Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions server/src/__tests__/7z-extract-compress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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),
Expand Down Expand Up @@ -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', () => {
Expand Down
40 changes: 26 additions & 14 deletions server/src/modules/terminal/TerminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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') {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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'
})
Expand Down Expand Up @@ -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']))
Expand All @@ -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'
})

Expand Down Expand Up @@ -1723,4 +1735,4 @@ export class TerminalManager {
}
}
}
}
}
40 changes: 40 additions & 0 deletions server/src/utils/filenameEncoding.ts
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,
}
}
125 changes: 117 additions & 8 deletions server/src/utils/zipToolsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve symlinks when merging extracted files

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 throw EISDIR. 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 👍 / 👎.

Comment on lines +19 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve symlink entries when merging extracted files

mergeDirectoryContents treats every non-directory entry as a regular file and calls fs.copyFile, which dereferences file symlinks and throws EISDIR for directory symlinks. That means ZIPs containing symlinks can now fail during finalize, or silently lose symlink semantics by turning links into plain files, whereas direct extraction previously kept the extractor’s output intact.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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-'))
}

/**
* 支持的操作系统平台列表
Expand Down Expand Up @@ -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()
Expand All @@ -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 回退`)
}
}

/**
Expand Down
Loading