diff --git a/server/src/__tests__/7z-extract-compress.test.ts b/server/src/__tests__/7z-extract-compress.test.ts index 70938f9..2a5241f 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,19 @@ 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([]), + rename: jest.fn().mockResolvedValue(undefined), + copyFile: jest.fn().mockResolvedValue(undefined), stat: jest.fn().mockResolvedValue({ size: 1024 }), unlink: jest.fn().mockResolvedValue(undefined), chmod: jest.fn().mockResolvedValue(undefined), @@ -118,6 +128,50 @@ 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-') + ) + }) + + 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 参数验证 --- describe('compress7z', () => { 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..5d2e19d 100644 --- a/server/src/utils/zipToolsManager.ts +++ b/server/src/utils/zipToolsManager.ts @@ -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 { + 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) + } +} + +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-')) +} /** * 支持的操作系统平台列表 @@ -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 { 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 回退`) + } } /**