From b15d7142e472eef94411a4cf6c5e2c8a34af657e Mon Sep 17 00:00:00 2001 From: hengwuming Date: Tue, 7 Apr 2026 20:32:55 +0800 Subject: [PATCH 1/3] fix(server): harden filename encoding handling --- .../src/modules/terminal/TerminalManager.ts | 40 +++++---- server/src/utils/filenameEncoding.ts | 40 +++++++++ server/src/utils/zipToolsManager.ts | 82 +++++++++++++++++-- 3 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 server/src/utils/filenameEncoding.ts diff --git a/server/src/modules/terminal/TerminalManager.ts b/server/src/modules/terminal/TerminalManager.ts index 4ce2ab3..4004b87 100755 --- a/server/src/modules/terminal/TerminalManager.ts +++ b/server/src/modules/terminal/TerminalManager.ts @@ -12,6 +12,7 @@ import { exec } from 'child_process' import { TerminalSessionManager, PersistedTerminalSession } from './TerminalSessionManager.js' import { ConfigManager } from '../config/ConfigManager.js' import { ptyManager } from '../../utils/ptyManager.js' +import { buildUtf8LocaleEnv } from '../../utils/filenameEncoding.js' const execAsync = promisify(exec) @@ -200,6 +201,12 @@ export class TerminalManager { '-size', `${cols},${rows}`, '-coder', 'UTF-8' ] + + const terminalEnv = buildUtf8LocaleEnv({ + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor' + }) // 根据操作系统设置默认shell if (os.platform() === 'win32') { @@ -216,8 +223,14 @@ export class TerminalManager { if (sudoExists) { // 如果sudo存在,使用sudo切换用户,使用简化的方式 args.push('-cmd', JSON.stringify([ - 'sudo', '-u', defaultUser, '/bin/bash', '-c', - `cd "${workingDirectory}" && /bin/bash --login` + 'sudo', '-u', defaultUser, + 'env', + 'LANG=zh_CN.UTF-8', + 'LANGUAGE=zh_CN:zh', + 'LC_ALL=zh_CN.UTF-8', + 'LC_CTYPE=zh_CN.UTF-8', + '/bin/bash', '-c', + `cd "${workingDirectory}" && exec /bin/bash --login` ])) this.logger.info(`使用sudo切换到默认用户启动终端: ${defaultUser},工作目录: ${workingDirectory}`) } else { @@ -228,7 +241,8 @@ export class TerminalManager { // 使用su命令切换用户,使用简化的方式 args.push('-cmd', JSON.stringify([ 'su', defaultUser, '-c', - `cd "${workingDirectory}" && /bin/bash --login` + 'export LANG=zh_CN.UTF-8 LANGUAGE=zh_CN:zh LC_ALL=zh_CN.UTF-8 LC_CTYPE=zh_CN.UTF-8; ' + + `cd "${workingDirectory}" && exec /bin/bash --login` ])) this.logger.info(`使用su切换到默认用户启动终端: ${defaultUser},工作目录: ${workingDirectory}`) } else { @@ -254,11 +268,7 @@ export class TerminalManager { const ptyProcess = spawn(this.ptyPath, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: workingDirectory, - env: { - ...process.env, - TERM: 'xterm-256color', - COLORTERM: 'truecolor' - }, + env: terminalEnv, // Linux下创建新的进程组,确保信号正确传递 detached: os.platform() !== 'win32' }) @@ -1492,6 +1502,12 @@ export class TerminalManager { '-size', '100,30', // 使用默认大小 '-coder', 'UTF-8' ] + + const terminalEnv = buildUtf8LocaleEnv({ + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor' + }) // 使用默认bash,不切换用户 args.push('-cmd', JSON.stringify(['/bin/bash', '--login'])) @@ -1503,11 +1519,7 @@ export class TerminalManager { const ptyProcess = spawn(this.ptyPath, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: workingDirectory, - env: { - ...process.env, - TERM: 'xterm-256color', - COLORTERM: 'truecolor' - }, + env: terminalEnv, detached: os.platform() !== 'win32' }) @@ -1723,4 +1735,4 @@ export class TerminalManager { } } } -} \ No newline at end of file +} diff --git a/server/src/utils/filenameEncoding.ts b/server/src/utils/filenameEncoding.ts new file mode 100644 index 0000000..d34558f --- /dev/null +++ b/server/src/utils/filenameEncoding.ts @@ -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 { + 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, + } +} diff --git a/server/src/utils/zipToolsManager.ts b/server/src/utils/zipToolsManager.ts index 2629eb5..2818893 100644 --- a/server/src/utils/zipToolsManager.ts +++ b/server/src/utils/zipToolsManager.ts @@ -4,6 +4,26 @@ 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 mergeDirectoryContents(sourceDir: string, targetDir: string): Promise { + 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 mergeDirectoryContents(sourcePath, targetPath) + continue + } + + await fs.copyFile(sourcePath, targetPath) + } +} /** * 支持的操作系统平台列表 @@ -396,11 +416,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 { const toolPath = await this.getZipToolsPath() @@ -409,15 +430,60 @@ class ZipToolsManager { // 确保目标目录存在 await fs.mkdir(targetDir, { recursive: true }) + const tempBaseName = `.gsm3-zip-extract-${Date.now()}` + 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 = path.join(path.dirname(targetDir), `${tempBaseName}-${index}`) + + await fs.rm(tempTargetDir, { recursive: true, force: true }) + await fs.mkdir(tempTargetDir, { recursive: true }) + + const args = [ + '-mode', '2', + '-zipPath', zipFileName, + '-distDirPath', path.resolve(tempTargetDir), + '-code', encoding, + ] - await this.executeZipTools(toolPath, args, zipDir) + 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 + } + + 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 mergeDirectoryContents(chosenTempDir, targetDir) + await fs.rm(chosenTempDir, { recursive: true, force: true }) + + if (sawCorruptedNames) { + logger.warn(`ZIP 文件 ${zipPath} 在 UTF-8 解压下出现损坏文件名,已尝试使用 GBK 回退`) + } } /** From b25819be7a1d788a198314ce69109082f99c5421 Mon Sep 17 00:00:00 2001 From: hengwuming Date: Tue, 7 Apr 2026 21:16:55 +0800 Subject: [PATCH 2/3] fix(server): avoid zip extract temp dir collisions --- .../src/__tests__/7z-extract-compress.test.ts | 26 +++++++++++++++++++ server/src/utils/zipToolsManager.ts | 10 +++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/7z-extract-compress.test.ts b/server/src/__tests__/7z-extract-compress.test.ts index 70938f9..d1d402d 100644 --- a/server/src/__tests__/7z-extract-compress.test.ts +++ b/server/src/__tests__/7z-extract-compress.test.ts @@ -9,6 +9,7 @@ import { ZipToolsManager } from '../utils/zipToolsManager.js' import path from 'path' +import fs from 'fs/promises' // mock logger jest.mock('../utils/logger.js', () => ({ @@ -20,10 +21,18 @@ jest.mock('../utils/logger.js', () => ({ }, })) +jest.mock('../utils/filenameEncoding.js', () => ({ + directoryContainsCorruptedNames: jest.fn().mockResolvedValue(false), +})) + // mock fs/promises(避免真实文件系统操作) jest.mock('fs/promises', () => ({ access: jest.fn().mockResolvedValue(undefined), mkdir: jest.fn().mockResolvedValue(undefined), + mkdtemp: jest.fn(async (prefix: string) => `${prefix}mock-temp-dir`), + rm: jest.fn().mockResolvedValue(undefined), + readdir: jest.fn().mockResolvedValue([]), + copyFile: jest.fn().mockResolvedValue(undefined), stat: jest.fn().mockResolvedValue({ size: 1024 }), unlink: jest.fn().mockResolvedValue(undefined), chmod: jest.fn().mockResolvedValue(undefined), @@ -118,6 +127,23 @@ describe('extract7z / compress7z 参数构建', () => { }) }) + describe('extractZip', () => { + beforeEach(() => { + jest.spyOn(manager, 'getZipToolsPath').mockResolvedValue('/mock/path/to/file_zip_linux_x64') + }) + + it('应通过 mkdtemp 创建唯一的临时解压目录,避免并发冲突', async () => { + const zipPath = '/data/test/archive.zip' + const targetDir = '/data/test/output' + + await manager.extractZip(zipPath, targetDir) + + expect(fs.mkdtemp).toHaveBeenCalledWith( + path.join(path.dirname(targetDir), '.gsm3-zip-extract-') + ) + }) + }) + // --- compress7z 参数验证 --- describe('compress7z', () => { diff --git a/server/src/utils/zipToolsManager.ts b/server/src/utils/zipToolsManager.ts index 2818893..7f990a0 100644 --- a/server/src/utils/zipToolsManager.ts +++ b/server/src/utils/zipToolsManager.ts @@ -25,6 +25,10 @@ async function mergeDirectoryContents(sourceDir: string, targetDir: string): Pro } } +async function createZipExtractTempDir(targetDir: string): Promise { + return fs.mkdtemp(path.join(path.dirname(targetDir), '.gsm3-zip-extract-')) +} + /** * 支持的操作系统平台列表 * 二进制文件名直接使用 process.platform 值(win32, linux, darwin) @@ -430,17 +434,13 @@ class ZipToolsManager { // 确保目标目录存在 await fs.mkdir(targetDir, { recursive: true }) - const tempBaseName = `.gsm3-zip-extract-${Date.now()}` let chosenTempDir = '' let sawCorruptedNames = false let fallbackError: Error | null = null for (let index = 0; index < ZIP_FILENAME_ENCODINGS.length; index++) { const encoding = ZIP_FILENAME_ENCODINGS[index] - const tempTargetDir = path.join(path.dirname(targetDir), `${tempBaseName}-${index}`) - - await fs.rm(tempTargetDir, { recursive: true, force: true }) - await fs.mkdir(tempTargetDir, { recursive: true }) + const tempTargetDir = await createZipExtractTempDir(targetDir) const args = [ '-mode', '2', From 0549ee2f6449c0891c667440ff3c913365687050 Mon Sep 17 00:00:00 2001 From: hengwuming Date: Tue, 7 Apr 2026 21:32:42 +0800 Subject: [PATCH 3/3] fix(server): avoid duplicate zip extract copy --- .../src/__tests__/7z-extract-compress.test.ts | 28 +++++++++++ server/src/utils/zipToolsManager.ts | 49 +++++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/7z-extract-compress.test.ts b/server/src/__tests__/7z-extract-compress.test.ts index d1d402d..2a5241f 100644 --- a/server/src/__tests__/7z-extract-compress.test.ts +++ b/server/src/__tests__/7z-extract-compress.test.ts @@ -32,6 +32,7 @@ jest.mock('fs/promises', () => ({ mkdtemp: jest.fn(async (prefix: string) => `${prefix}mock-temp-dir`), rm: jest.fn().mockResolvedValue(undefined), readdir: jest.fn().mockResolvedValue([]), + rename: jest.fn().mockResolvedValue(undefined), copyFile: jest.fn().mockResolvedValue(undefined), stat: jest.fn().mockResolvedValue({ size: 1024 }), unlink: jest.fn().mockResolvedValue(undefined), @@ -142,6 +143,33 @@ describe('extract7z / compress7z 参数构建', () => { path.join(path.dirname(targetDir), '.gsm3-zip-extract-') ) }) + + it('解压成功后应优先通过 rename 移动内容,而不是再次整树 copy', async () => { + const zipPath = '/data/test/archive.zip' + const targetDir = '/data/test/output' + const tempDir = path.join(path.dirname(targetDir), '.gsm3-zip-extract-mock-temp-dir') + + ;(fs.readdir as jest.Mock).mockImplementation(async (dirPath: string) => { + if (dirPath === tempDir) { + return [ + { + name: 'large.zip.entry', + isDirectory: () => false, + }, + ] + } + + return [] + }) + + await manager.extractZip(zipPath, targetDir) + + expect(fs.rename).toHaveBeenCalledWith( + path.join(tempDir, 'large.zip.entry'), + path.join(targetDir, 'large.zip.entry') + ) + expect(fs.copyFile).not.toHaveBeenCalled() + }) }) // --- compress7z 参数验证 --- diff --git a/server/src/utils/zipToolsManager.ts b/server/src/utils/zipToolsManager.ts index 7f990a0..5d2e19d 100644 --- a/server/src/utils/zipToolsManager.ts +++ b/server/src/utils/zipToolsManager.ts @@ -8,7 +8,7 @@ import { directoryContainsCorruptedNames } from './filenameEncoding.js' const ZIP_FILENAME_ENCODINGS = ['utf-8', 'gbk'] as const -async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise { +async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise { await fs.mkdir(targetDir, { recursive: true }) const entries = await fs.readdir(sourceDir, { withFileTypes: true }) @@ -17,7 +17,7 @@ async function mergeDirectoryContents(sourceDir: string, targetDir: string): Pro const targetPath = path.join(targetDir, entry.name) if (entry.isDirectory()) { - await mergeDirectoryContents(sourcePath, targetPath) + await copyDirectoryContents(sourcePath, targetPath) continue } @@ -25,6 +25,49 @@ async function mergeDirectoryContents(sourceDir: string, targetDir: string): Pro } } +async function moveExtractedEntry(sourcePath: string, targetPath: string, isDirectory: boolean): Promise { + 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 { + 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 { return fs.mkdtemp(path.join(path.dirname(targetDir), '.gsm3-zip-extract-')) } @@ -478,7 +521,7 @@ class ZipToolsManager { throw fallbackError ?? new Error(`ZIP 解压失败: ${zipPath}`) } - await mergeDirectoryContents(chosenTempDir, targetDir) + await moveDirectoryContents(chosenTempDir, targetDir) await fs.rm(chosenTempDir, { recursive: true, force: true }) if (sawCorruptedNames) {