-
Notifications
You must be signed in to change notification settings - Fork 73
fix(server): 修复服务端文件名编码与终端乱码问题 #79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import fs from 'fs/promises' | ||
| import path from 'path' | ||
|
|
||
| const REPLACEMENT_CHARACTER = '\uFFFD' | ||
| const UTF8_LOCALE = 'zh_CN.UTF-8' | ||
|
|
||
| export function filenameContainsReplacementCharacters(filename: string): boolean { | ||
| return filename.includes(REPLACEMENT_CHARACTER) | ||
| } | ||
|
|
||
| export async function directoryContainsCorruptedNames(rootPath: string): Promise<boolean> { | ||
| const pendingDirs = [rootPath] | ||
|
|
||
| while (pendingDirs.length > 0) { | ||
| const currentDir = pendingDirs.pop()! | ||
| const entries = await fs.readdir(currentDir, { withFileTypes: true }) | ||
|
|
||
| for (const entry of entries) { | ||
| if (filenameContainsReplacementCharacters(entry.name)) { | ||
| return true | ||
| } | ||
|
|
||
| if (entry.isDirectory()) { | ||
| pendingDirs.push(path.join(currentDir, entry.name)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return false | ||
| } | ||
|
|
||
| export function buildUtf8LocaleEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { | ||
| return { | ||
| ...baseEnv, | ||
| LANG: UTF8_LOCALE, | ||
| LANGUAGE: 'zh_CN:zh', | ||
| LC_ALL: UTF8_LOCALE, | ||
| LC_CTYPE: UTF8_LOCALE, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,73 @@ import fs from 'fs/promises' | |
| import { createWriteStream } from 'fs' | ||
| import { pipeline } from 'stream/promises' | ||
| import logger from './logger.js' | ||
| import { directoryContainsCorruptedNames } from './filenameEncoding.js' | ||
|
|
||
| const ZIP_FILENAME_ENCODINGS = ['utf-8', 'gbk'] as const | ||
|
|
||
| async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise<void> { | ||
| await fs.mkdir(targetDir, { recursive: true }) | ||
| const entries = await fs.readdir(sourceDir, { withFileTypes: true }) | ||
|
|
||
| for (const entry of entries) { | ||
| const sourcePath = path.join(sourceDir, entry.name) | ||
| const targetPath = path.join(targetDir, entry.name) | ||
|
|
||
| if (entry.isDirectory()) { | ||
| await copyDirectoryContents(sourcePath, targetPath) | ||
| continue | ||
| } | ||
|
|
||
| await fs.copyFile(sourcePath, targetPath) | ||
|
Comment on lines
+19
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 符号链接问题可以暂时先不管 |
||
| } | ||
| } | ||
|
|
||
| async function moveExtractedEntry(sourcePath: string, targetPath: string, isDirectory: boolean): Promise<void> { | ||
| try { | ||
| await fs.rename(sourcePath, targetPath) | ||
| return | ||
| } catch (error: any) { | ||
| if (error?.code === 'EXDEV') { | ||
| if (isDirectory) { | ||
| await copyDirectoryContents(sourcePath, targetPath) | ||
| await fs.rm(sourcePath, { recursive: true, force: true }) | ||
| } else { | ||
| await fs.copyFile(sourcePath, targetPath) | ||
| await fs.rm(sourcePath, { force: true }) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| if (isDirectory && ['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error?.code)) { | ||
| await moveDirectoryContents(sourcePath, targetPath) | ||
| await fs.rm(sourcePath, { recursive: true, force: true }) | ||
| return | ||
| } | ||
|
|
||
| if (!isDirectory && ['EEXIST', 'EPERM'].includes(error?.code)) { | ||
| await fs.rm(targetPath, { force: true }) | ||
| await fs.rename(sourcePath, targetPath) | ||
| return | ||
| } | ||
|
|
||
| throw error | ||
| } | ||
| } | ||
|
|
||
| async function moveDirectoryContents(sourceDir: string, targetDir: string): Promise<void> { | ||
| await fs.mkdir(targetDir, { recursive: true }) | ||
| const entries = await fs.readdir(sourceDir, { withFileTypes: true }) | ||
|
|
||
| for (const entry of entries) { | ||
| const sourcePath = path.join(sourceDir, entry.name) | ||
| const targetPath = path.join(targetDir, entry.name) | ||
| await moveExtractedEntry(sourcePath, targetPath, entry.isDirectory()) | ||
| } | ||
| } | ||
|
|
||
| async function createZipExtractTempDir(targetDir: string): Promise<string> { | ||
| return fs.mkdtemp(path.join(path.dirname(targetDir), '.gsm3-zip-extract-')) | ||
| } | ||
|
|
||
| /** | ||
| * 支持的操作系统平台列表 | ||
|
|
@@ -396,11 +463,12 @@ class ZipToolsManager { | |
|
|
||
| /** | ||
| * 执行 ZIP 解压操作 | ||
| * 命令: file_zip -mode 2 -zipPath {文件名} -distDirPath {目标目录} -code utf-8 | ||
| * 命令: file_zip -mode 2 -zipPath {文件名} -distDirPath {目标目录} -code {encoding} | ||
| * cwd 设置为 ZIP 文件所在目录 | ||
| * | ||
| * 注意参数格式(Go flag 包仅支持单横线前缀): | ||
| * -mode / -zipPath / -distDirPath / -code 均使用单横线 + 空格分隔值 | ||
| * 优先尝试 UTF-8,若检测到损坏文件名或首次解压失败则回退到 GBK | ||
| */ | ||
| async extractZip(zipPath: string, targetDir: string): Promise<void> { | ||
| const toolPath = await this.getZipToolsPath() | ||
|
|
@@ -409,15 +477,56 @@ class ZipToolsManager { | |
|
|
||
| // 确保目标目录存在 | ||
| await fs.mkdir(targetDir, { recursive: true }) | ||
| let chosenTempDir = '' | ||
| let sawCorruptedNames = false | ||
| let fallbackError: Error | null = null | ||
|
|
||
| const args = [ | ||
| '-mode', '2', | ||
| '-zipPath', zipFileName, | ||
| '-distDirPath', path.resolve(targetDir), | ||
| '-code', 'utf-8', | ||
| ] | ||
| for (let index = 0; index < ZIP_FILENAME_ENCODINGS.length; index++) { | ||
| const encoding = ZIP_FILENAME_ENCODINGS[index] | ||
| const tempTargetDir = await createZipExtractTempDir(targetDir) | ||
|
|
||
| const args = [ | ||
| '-mode', '2', | ||
| '-zipPath', zipFileName, | ||
| '-distDirPath', path.resolve(tempTargetDir), | ||
| '-code', encoding, | ||
| ] | ||
|
|
||
| try { | ||
| await this.executeZipTools(toolPath, args, zipDir) | ||
| } catch (error: any) { | ||
| await fs.rm(tempTargetDir, { recursive: true, force: true }) | ||
| fallbackError = error instanceof Error ? error : new Error(String(error)) | ||
| logger.warn(`ZIP 解压尝试失败 (${encoding}): ${fallbackError.message}`) | ||
| continue | ||
| } | ||
|
|
||
| await this.executeZipTools(toolPath, args, zipDir) | ||
| const hasCorruptedNames = await directoryContainsCorruptedNames(tempTargetDir) | ||
| if (!hasCorruptedNames) { | ||
| chosenTempDir = tempTargetDir | ||
| break | ||
| } | ||
|
|
||
| sawCorruptedNames = true | ||
| logger.warn(`ZIP 解压检测到损坏文件名,准备使用下一种编码重试: ${zipPath} (${encoding})`) | ||
|
|
||
| if (index === ZIP_FILENAME_ENCODINGS.length - 1) { | ||
| chosenTempDir = tempTargetDir | ||
| } else { | ||
| await fs.rm(tempTargetDir, { recursive: true, force: true }) | ||
| } | ||
| } | ||
|
|
||
| if (!chosenTempDir) { | ||
| throw fallbackError ?? new Error(`ZIP 解压失败: ${zipPath}`) | ||
| } | ||
|
|
||
| await moveDirectoryContents(chosenTempDir, targetDir) | ||
| await fs.rm(chosenTempDir, { recursive: true, force: true }) | ||
|
|
||
| if (sawCorruptedNames) { | ||
| logger.warn(`ZIP 文件 ${zipPath} 在 UTF-8 解压下出现损坏文件名,已尝试使用 GBK 回退`) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new merge step copies every non-directory entry with
fs.copyFile, which does not preserve symlinks; symlink-to-file entries become regular files and symlink-to-directory entries can throwEISDIR. This means ZIPs containing symlinks (common in Unix-oriented packages) may now fail to extract or silently lose link structure, a regression from the previous direct extraction path.Useful? React with 👍 / 👎.