Skip to content
Open
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
122 changes: 122 additions & 0 deletions src-tauri/src/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,25 @@ pub struct ExtractError {
message: String,
/// 是否为更新包本身损坏(截断、校验失败等),损坏的包需要重新下载
corrupt: bool,
/// 可由前端本地化的错误标识;message 保留原始诊断。
#[serde(skip_serializing_if = "Option::is_none")]
code: Option<&'static str>,
}

impl ExtractError {
fn corrupt(message: String) -> Self {
Self {
message,
corrupt: true,
code: None,
}
}

fn other(message: String) -> Self {
Self {
message,
corrupt: false,
code: None,
}
}
}
Expand Down Expand Up @@ -201,6 +206,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| {
warn!("gzip footer validation failed: {}", e);
ExtractError {
message: e.to_string(),
corrupt: is_corrupt_data_error(&e),
code: Some("gzipValidationFailed"),
}
})?;

info!("extract_tar_gz success");
Ok(())
}
Expand Down Expand Up @@ -877,6 +892,113 @@ 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<u8> {
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);
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!(
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);
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();
let error = fixture.extract(&package()).unwrap_err();
assert!(!error.corrupt);
assert!(serde_json::to_value(error).unwrap().get("code").is_none());
}
}

#[cfg(test)]
mod bundle_update_tests {
use super::*;
Expand Down
5 changes: 4 additions & 1 deletion src/components/InstallConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,7 @@ export default {
installComplete: 'インストール完了',
installFailed: 'インストールに失敗しました',
installErrors: {
gzipValidationFailed: 'gzip アーカイブの検証に失敗しました',
packageMissing: '更新パッケージが見つかりません。再度ダウンロードしてください',
packageCorrupt: '更新パッケージが破損しています。再度ダウンロードしてください({{detail}})',
extractFailed:
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ko-KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,7 @@ export default {
installComplete: '설치 완료',
installFailed: '설치 실패',
installErrors: {
gzipValidationFailed: 'gzip 압축 파일 검증에 실패했습니다',
packageMissing: '업데이트 패키지를 찾을 수 없습니다. 다시 다운로드해 주세요',
packageCorrupt: '업데이트 패키지가 손상되었습니다. 다시 다운로드해 주세요 ({{detail}})',
extractFailed:
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@ export default {
installComplete: '安装完成',
installFailed: '安装失败',
installErrors: {
gzipValidationFailed: 'gzip 压缩包校验失败',
packageMissing: '更新包不存在,请重新下载',
packageCorrupt: '更新包已损坏,请重新下载({{detail}})',
extractFailed: '解压更新包失败,可能被安全软件拦截({{detail}})',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,7 @@ export default {
installComplete: '安裝完成',
installFailed: '安裝失敗',
installErrors: {
gzipValidationFailed: 'gzip 壓縮檔驗證失敗',
packageMissing: '更新包不存在,請重新下載',
packageCorrupt: '更新包已損毀,請重新下載({{detail}})',
extractFailed: '解壓縮更新包失敗,可能遭安全軟體攔截({{detail}})',
Expand Down
11 changes: 8 additions & 3 deletions src/services/updateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,7 @@ export function isExecutableInstaller(filePath: string): boolean {
// Rust 端 extract_zip 失败时返回的错误
interface ExtractErrorPayload {
message: string;
code?: string;
/** 更新包本身损坏(截断、校验失败等) */
corrupt: boolean;
}
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -1125,13 +1128,15 @@ export async function installUpdate(options: InstallUpdateOptions): Promise<bool
log.error('解压更新包失败:', error);
await invoke('cleanup_extract_dir', { extractDir }).catch(() => {});

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);
}

// 解压成功后再备份配置,避免每次失败的尝试都轮换掉旧备份
Expand Down
Loading
Loading