From 9be75bf55547ed2eb135329cab7dc1feb6a317ec Mon Sep 17 00:00:00 2001 From: MistEO <18511905+MistEO@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:01:28 +0800 Subject: [PATCH 1/2] fix(update): validate gzip footer after tar extraction --- src-tauri/src/commands/update.rs | 110 +++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 395a6297..7aec4cc7 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -201,6 +201,16 @@ fn extract_tar_gz(tar_path: &str, dest_dir: &str) -> Result<(), ExtractError> { } })?; + // tar 在结束块处停止;读完 gzip 才会校验尾部的 CRC 和解压长度。 + std::io::copy(&mut archive.into_inner(), &mut std::io::sink()).map_err(|e| { + let message = format!("校验 tar.gz 失败: {}", e); + if is_corrupt_data_error(&e) { + ExtractError::corrupt(message) + } else { + ExtractError::other(message) + } + })?; + info!("extract_tar_gz success"); Ok(()) } @@ -877,6 +887,106 @@ pub fn current_exe_exists() -> bool { } } +#[cfg(test)] +mod gzip_tests { + use super::*; + use std::io::Write; + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "mxu-gzip-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + Self(root) + } + + fn extract(&self, bytes: &[u8]) -> Result<(), ExtractError> { + let package = self.0.join("package.tgz"); + std::fs::write(&package, bytes).unwrap(); + extract_zip( + package.to_string_lossy().into_owned(), + self.0.join("extracted").to_string_lossy().into_owned(), + ) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn package() -> Vec { + let mut tar = tar::Builder::new(Vec::new()); + let mut header = tar::Header::new_gnu(); + let content = b"resource content"; + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, "resource.txt", &content[..]) + .unwrap(); + let mut bytes = tar.into_inner().unwrap(); + // Real writers can pad tar beyond its end-of-archive blocks. + // Ensure unpack stops before the gzip decoder reaches the footer. + bytes.resize(bytes.len() + 32 * 1024, 0); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + gz.write_all(&bytes).unwrap(); + gz.finish().unwrap() + } + + #[test] + fn valid_padded_gzip_extracts_resource() { + let fixture = Fixture::new(); + fixture.extract(&package()).unwrap(); + assert_eq!( + std::fs::read(fixture.0.join("extracted/resource.txt")).unwrap(), + b"resource content" + ); + } + + #[test] + fn corrupted_gzip_footer_rejects_an_otherwise_complete_tar() { + // CRC-32 and ISIZE occupy the last eight bytes of a gzip member. + for offset in [8, 4] { + let fixture = Fixture::new(); + let mut bytes = package(); + let index = bytes.len() - offset; + bytes[index] ^= 1; + let error = fixture.extract(&bytes).unwrap_err(); + assert!(error.corrupt, "{}", error.message); + // Entries can be written before footer validation fails. The command + // must still reject the package so the caller cannot apply this tree. + assert_eq!( + std::fs::read(fixture.0.join("extracted/resource.txt")).unwrap(), + b"resource content" + ); + } + } + + #[test] + fn missing_gzip_footer_is_corrupt() { + let fixture = Fixture::new(); + let mut bytes = package(); + bytes.truncate(bytes.len() - 8); + assert!(fixture.extract(&bytes).unwrap_err().corrupt); + } + + #[test] + fn extraction_destination_failure_remains_an_io_error() { + let fixture = Fixture::new(); + std::fs::write(fixture.0.join("extracted"), b"existing file").unwrap(); + assert!(!fixture.extract(&package()).unwrap_err().corrupt); + } +} + #[cfg(test)] mod bundle_update_tests { use super::*; From 33e72a08bae360a2688da9d17b3b388a1217b0f9 Mon Sep 17 00:00:00 2001 From: MistEO <18511905+MistEO@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:55:51 +0800 Subject: [PATCH 2/2] fix(update): localize gzip validation errors --- src-tauri/src/commands/update.rs | 26 +++- src/components/InstallConfirmModal.tsx | 5 +- src/i18n/locales/en-US.ts | 1 + src/i18n/locales/ja-JP.ts | 1 + src/i18n/locales/ko-KR.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + src/i18n/locales/zh-TW.ts | 1 + src/services/updateService.ts | 11 +- tests/gzipInstallError.test.ts | 199 +++++++++++++++++++++++++ 9 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 tests/gzipInstallError.test.ts diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 7aec4cc7..d4b73de9 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -62,6 +62,9 @@ pub struct ExtractError { message: String, /// 是否为更新包本身损坏(截断、校验失败等),损坏的包需要重新下载 corrupt: bool, + /// 可由前端本地化的错误标识;message 保留原始诊断。 + #[serde(skip_serializing_if = "Option::is_none")] + code: Option<&'static str>, } impl ExtractError { @@ -69,6 +72,7 @@ impl ExtractError { Self { message, corrupt: true, + code: None, } } @@ -76,6 +80,7 @@ impl ExtractError { Self { message, corrupt: false, + code: None, } } } @@ -203,11 +208,11 @@ fn extract_tar_gz(tar_path: &str, dest_dir: &str) -> Result<(), ExtractError> { // tar 在结束块处停止;读完 gzip 才会校验尾部的 CRC 和解压长度。 std::io::copy(&mut archive.into_inner(), &mut std::io::sink()).map_err(|e| { - let message = format!("校验 tar.gz 失败: {}", e); - if is_corrupt_data_error(&e) { - ExtractError::corrupt(message) - } else { - ExtractError::other(message) + warn!("gzip footer validation failed: {}", e); + ExtractError { + message: e.to_string(), + corrupt: is_corrupt_data_error(&e), + code: Some("gzipValidationFailed"), } })?; @@ -962,6 +967,9 @@ mod gzip_tests { bytes[index] ^= 1; let error = fixture.extract(&bytes).unwrap_err(); assert!(error.corrupt, "{}", error.message); + let payload = serde_json::to_value(&error).unwrap(); + assert_eq!(payload["code"], "gzipValidationFailed"); + assert!(payload["message"].as_str().is_some_and(|s| !s.is_empty())); // Entries can be written before footer validation fails. The command // must still reject the package so the caller cannot apply this tree. assert_eq!( @@ -976,14 +984,18 @@ mod gzip_tests { let fixture = Fixture::new(); let mut bytes = package(); bytes.truncate(bytes.len() - 8); - assert!(fixture.extract(&bytes).unwrap_err().corrupt); + let error = fixture.extract(&bytes).unwrap_err(); + assert!(error.corrupt); + assert_eq!(error.code, Some("gzipValidationFailed")); } #[test] fn extraction_destination_failure_remains_an_io_error() { let fixture = Fixture::new(); std::fs::write(fixture.0.join("extracted"), b"existing file").unwrap(); - assert!(!fixture.extract(&package()).unwrap_err().corrupt); + let error = fixture.extract(&package()).unwrap_err(); + assert!(!error.corrupt); + assert!(serde_json::to_value(error).unwrap().get("code").is_none()); } } diff --git a/src/components/InstallConfirmModal.tsx b/src/components/InstallConfirmModal.tsx index 8932c1de..33c9008c 100644 --- a/src/components/InstallConfirmModal.tsx +++ b/src/components/InstallConfirmModal.tsx @@ -26,7 +26,10 @@ import { loggers } from '@/utils/logger'; // 将安装/重启错误转换为界面显示的文案(兜底更新的提示直接使用其 message) function formatInstallError(error: unknown, t: TFunction): string { if (error instanceof UpdateInstallError) { - return t(`mirrorChyan.installErrors.${error.code}`, { detail: error.detail }); + const detail = error.detailCode + ? t(`mirrorChyan.installErrors.${error.detailCode}`) + : error.detail; + return t(`mirrorChyan.installErrors.${error.code}`, { detail }); } return error instanceof Error ? error.message : String(error); } diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index fc7419ed..6a45da16 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -777,6 +777,7 @@ export default { installComplete: 'Installation Complete', installFailed: 'Installation Failed', installErrors: { + gzipValidationFailed: 'Failed to validate the gzip archive', packageMissing: 'Update package not found. Please download it again.', packageCorrupt: 'The update package is corrupted. Please download it again. ({{detail}})', extractFailed: diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 2b4bda01..8186de26 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -773,6 +773,7 @@ export default { installComplete: 'インストール完了', installFailed: 'インストールに失敗しました', installErrors: { + gzipValidationFailed: 'gzip アーカイブの検証に失敗しました', packageMissing: '更新パッケージが見つかりません。再度ダウンロードしてください', packageCorrupt: '更新パッケージが破損しています。再度ダウンロードしてください({{detail}})', extractFailed: diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index 5c8f1872..7fbbbcef 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -759,6 +759,7 @@ export default { installComplete: '설치 완료', installFailed: '설치 실패', installErrors: { + gzipValidationFailed: 'gzip 압축 파일 검증에 실패했습니다', packageMissing: '업데이트 패키지를 찾을 수 없습니다. 다시 다운로드해 주세요', packageCorrupt: '업데이트 패키지가 손상되었습니다. 다시 다운로드해 주세요 ({{detail}})', extractFailed: diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 87d21c24..38a78291 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -737,6 +737,7 @@ export default { installComplete: '安装完成', installFailed: '安装失败', installErrors: { + gzipValidationFailed: 'gzip 压缩包校验失败', packageMissing: '更新包不存在,请重新下载', packageCorrupt: '更新包已损坏,请重新下载({{detail}})', extractFailed: '解压更新包失败,可能被安全软件拦截({{detail}})', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 6b8b99da..28256ffe 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -733,6 +733,7 @@ export default { installComplete: '安裝完成', installFailed: '安裝失敗', installErrors: { + gzipValidationFailed: 'gzip 壓縮檔驗證失敗', packageMissing: '更新包不存在,請重新下載', packageCorrupt: '更新包已損毀,請重新下載({{detail}})', extractFailed: '解壓縮更新包失敗,可能遭安全軟體攔截({{detail}})', diff --git a/src/services/updateService.ts b/src/services/updateService.ts index d858ef43..c3a9b844 100644 --- a/src/services/updateService.ts +++ b/src/services/updateService.ts @@ -986,6 +986,7 @@ export function isExecutableInstaller(filePath: string): boolean { // Rust 端 extract_zip 失败时返回的错误 interface ExtractErrorPayload { message: string; + code?: string; /** 更新包本身损坏(截断、校验失败等) */ corrupt: boolean; } @@ -1021,12 +1022,14 @@ export type UpdateInstallErrorCode = export class UpdateInstallError extends Error { public readonly code: UpdateInstallErrorCode; public readonly detail: string; + public readonly detailCode?: 'gzipValidationFailed'; - constructor(code: UpdateInstallErrorCode, detail = '') { + constructor(code: UpdateInstallErrorCode, detail = '', detailCode?: 'gzipValidationFailed') { super(detail ? `${code}: ${detail}` : code); this.name = 'UpdateInstallError'; this.code = code; this.detail = detail; + this.detailCode = detailCode; } } @@ -1125,13 +1128,15 @@ export async function installUpdate(options: InstallUpdateOptions): Promise {}); + const detailCode = + isExtractError(error) && error.code === 'gzipValidationFailed' ? error.code : undefined; if (isExtractError(error) && error.corrupt) { // 包已损坏,移走并放弃,避免每次都拿同一个坏包重试 await moveToOldFolder(zipPath); discardPendingPackage(); - throw new UpdateInstallError('packageCorrupt', error.message); + throw new UpdateInstallError('packageCorrupt', error.message, detailCode); } - throw new UpdateInstallError('extractFailed', getErrorMessage(error)); + throw new UpdateInstallError('extractFailed', getErrorMessage(error), detailCode); } // 解压成功后再备份配置,避免每次失败的尝试都轮换掉旧备份 diff --git a/tests/gzipInstallError.test.ts b/tests/gzipInstallError.test.ts new file mode 100644 index 00000000..bd9d37d4 --- /dev/null +++ b/tests/gzipInstallError.test.ts @@ -0,0 +1,199 @@ +import { after, before, beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import ts from 'typescript'; +import { createServer, type ViteDevServer } from 'vite'; +import type { TFunction } from 'i18next'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const storage = new Map(); +const pendingKey = 'mxu-pending-update'; +const zipPath = '/fixture/cache/update.tgz'; +const rawDiagnostic = 'RAW_GZIP_DIAGNOSTIC: checksum mismatch'; +const operations: { command: string; args: Record }[] = []; +let extractionFailure: unknown; +let server: ViteDevServer; +let service: typeof import('../src/services/updateService.ts'); +let store: typeof import('../src/stores/appStore.ts'); +let localization: typeof import('../src/i18n/index.ts'); +let logging: typeof import('../src/utils/logger.ts'); +let formatInstallError: (error: unknown, t: TFunction) => string; + +before(async () => { + Object.assign(globalThis, { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + window: Object.assign(new EventTarget(), { + location: new URL('http://localhost/'), + __TAURI_INTERNALS__: { + invoke: async (command: string, args: Record) => { + operations.push({ command, args }); + switch (command) { + case 'validate_update_target': + case 'cleanup_extract_dir': + case 'move_file_to_old': + return; + case 'plugin:fs|exists': + assert.equal(args.path, zipPath); + return true; + case 'plugin:path|dirname': + assert.equal(args.path, zipPath); + return '/fixture/cache'; + case 'extract_zip': + throw extractionFailure; + default: + throw new Error(`Unexpected native operation: ${command}`); + } + }, + }, + }), + }); + server = await createServer({ + root, + configFile: false, + cacheDir: join(root, '.cache', 'gzip-install-error-tests'), + optimizeDeps: { noDiscovery: true, include: [] }, + resolve: { alias: { '@': join(root, 'src') } }, + server: { middlewareMode: true, hmr: false }, + appType: 'custom', + }); + service = await server.ssrLoadModule('/src/services/updateService.ts'); + store = await server.ssrLoadModule('/src/stores/appStore.ts'); + localization = await server.ssrLoadModule('/src/i18n/index.ts'); + logging = await server.ssrLoadModule('/src/utils/logger.ts'); + + // Execute the production formatter without mounting the desktop modal. The actual + // install service, error class, store and locale resources remain loaded above. + const file = 'src/components/InstallConfirmModal.tsx'; + const source = ts.createSourceFile( + file, + readFileSync(join(root, file), 'utf8'), + ts.ScriptTarget.Latest, + true, + ); + const formatter = source.statements.find( + (node): node is ts.FunctionDeclaration => + ts.isFunctionDeclaration(node) && node.name?.text === 'formatInstallError', + ); + assert.ok(formatter, 'Missing production install-error formatter'); + const code = ts.transpileModule(`${formatter.getText(source)}; return formatInstallError;`, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, + }).outputText; + formatInstallError = new Function('UpdateInstallError', code)(service.UpdateInstallError); +}); + +after(async () => { + await server?.close(); +}); + +beforeEach(() => { + storage.clear(); + storage.set(pendingKey, 'pending update'); + operations.length = 0; + store.useAppStore.setState({ downloadStatus: 'completed', downloadSavePath: zipPath }); +}); + +async function failedInstall() { + let failure: unknown; + await assert.rejects( + service.installUpdate({ zipPath, targetDir: '/fixture/app', newVersion: '2.0.0' }), + (error) => { + failure = error; + return error instanceof service.UpdateInstallError; + }, + ); + assert.ok(failure instanceof service.UpdateInstallError); + return failure; +} + +for (const corrupt of [true, false]) { + test(`gzip validation localizes the actual install failure in every locale (corrupt=${corrupt})`, async (context) => { + extractionFailure = { code: 'gzipValidationFailed', message: rawDiagnostic, corrupt }; + const logged: unknown[][] = []; + context.mock.method(logging.loggers.app, 'error', (...args: unknown[]) => logged.push(args)); + const error = await failedInstall(); + assert.equal(error.code, corrupt ? 'packageCorrupt' : 'extractFailed'); + assert.equal(error.detailCode, 'gzipValidationFailed'); + assert.equal(error.detail, rawDiagnostic, 'original diagnostics remain available to logging'); + assert.ok(logged.some((args) => args.includes(extractionFailure))); + + for (const [language, expectedDetail] of [ + ['en-US', 'Failed to validate the gzip archive'], + ['zh-CN', 'gzip 压缩包校验失败'], + ['zh-TW', 'gzip 壓縮檔驗證失敗'], + ['ja-JP', 'gzip アーカイブの検証に失敗しました'], + ['ko-KR', 'gzip 압축 파일 검증에 실패했습니다'], + ]) { + await localization.default.changeLanguage(language); + const message = formatInstallError(error, localization.default.t); + assert.ok(message.includes(expectedDetail), `${language}: ${message}`); + assert.notEqual( + message, + expectedDetail, + 'the existing outer installation message is retained', + ); + assert.ok( + !message.includes(rawDiagnostic), + 'raw backend diagnostics stay out of localized UI', + ); + assert.ok(!message.includes('gzipValidationFailed'), 'translation keys must resolve'); + } + + const expectedCommands = [ + 'validate_update_target', + 'plugin:fs|exists', + 'plugin:path|dirname', + 'cleanup_extract_dir', + 'extract_zip', + 'cleanup_extract_dir', + ...(corrupt ? ['move_file_to_old'] : []), + ]; + assert.deepEqual( + operations.map(({ command }) => command), + expectedCommands, + ); + if (corrupt) { + assert.deepEqual(operations.at(-1)?.args, { filePath: zipPath }); + assert.equal(storage.has(pendingKey), false); + assert.equal(store.useAppStore.getState().downloadStatus, 'idle'); + assert.equal(store.useAppStore.getState().downloadSavePath, null); + } else { + assert.equal(storage.get(pendingKey), 'pending update'); + assert.equal(store.useAppStore.getState().downloadStatus, 'completed'); + assert.equal(store.useAppStore.getState().downloadSavePath, zipPath); + await failedInstall(); + assert.equal(operations.filter(({ command }) => command === 'extract_zip').length, 2); + assert.ok(!operations.some(({ command }) => command === 'move_file_to_old')); + } + }); +} + +test('legacy payloads, plain errors and unrecognized codes retain their original detail', async () => { + await localization.default.changeLanguage('en-US'); + for (const failure of [ + { message: 'legacy diagnostic', corrupt: false }, + { message: 'legacy diagnostic', corrupt: false, code: 'futureValidationFailed' }, + 'legacy diagnostic', + new Error('legacy diagnostic'), + ]) { + extractionFailure = failure; + const error = await failedInstall(); + assert.equal(error.code, 'extractFailed'); + assert.equal(error.detailCode, undefined); + assert.equal(error.detail, 'legacy diagnostic'); + assert.equal( + formatInstallError(error, localization.default.t), + 'Failed to extract the update package. It may have been blocked by security software. (legacy diagnostic)', + ); + } + assert.equal( + formatInstallError(new Error('ordinary failure'), localization.default.t), + 'ordinary failure', + ); + assert.equal(formatInstallError('plain failure', localization.default.t), 'plain failure'); +});