diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 19d261cff..bbcef34cc 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -396,10 +396,12 @@ const DIRECTORY_BUDGET_COST: u64 = 4096; /// 文件同理:空文件与不足一块的小文件照样占 inode 和一个磁盘块。只按内容字节 /// 计费时,一个全是空文件的归档能让预算读数一直停在 0,条目上限就成了唯一防线。 const FILE_ENTRY_BUDGET_COST: u64 = DIRECTORY_BUDGET_COST; -/// 压缩体上限。解压预算只有在 ZipArchive 建起来之后才生效,而那时整个响应体 -/// 已经在内存里了,所以下载这一步需要自己的上限。技能仓库是 Markdown, -/// 128 MiB 的压缩包已经远超正常规模。 -const MAX_ARCHIVE_DOWNLOAD_BYTES: u64 = 128 * 1024 * 1024; +/// 压缩体上限。解压预算只有在 ZipArchive 建起来之后才生效,所以下载这一步需要 +/// 自己的上限。下载的是整个仓库归档,`docs/` 里的图片、GIF 常常比技能本身大 +/// 一个数量级(#8421:scientific-agent-skills 压缩包 235 MiB,技能只有 20 MiB), +/// 曾经的 128 MiB 会把这类仓库挡在门外。响应体流式落到临时文件而不进内存, +/// 上限对齐解压预算:压缩体超过解压后能容纳的总量,解压那一步也必然失败。 +const MAX_ARCHIVE_DOWNLOAD_BYTES: u64 = MAX_ARCHIVE_TOTAL_BYTES; /// 技能元数据 (从 SKILL.md 解析) #[derive(Debug, Clone, Deserialize)] @@ -3606,22 +3608,25 @@ impl SkillService { // 逐块读并卡住压缩体大小:`response.bytes()` 会先把攻击者控制的整个归档 // 收进内存,之后才轮到 ZipArchive 和解压预算——那时候堆已经被吃光了。 // 不能只信 Content-Length(可以撒谎或缺失),必须按实际收到的字节数算。 + // 归档写进匿名临时文件(关闭即删,失败路径也不留残留),内存只占一个块。 let mut response = response; - let mut body: Vec = Vec::new(); + let mut body = std::io::BufWriter::new(tempfile::tempfile()?); + let mut received: u64 = 0; while let Some(chunk) = response.chunk().await? { - if body.len().saturating_add(chunk.len()) as u64 > MAX_ARCHIVE_DOWNLOAD_BYTES { + received = received.saturating_add(chunk.len() as u64); + if received > MAX_ARCHIVE_DOWNLOAD_BYTES { let limit_mb = (MAX_ARCHIVE_DOWNLOAD_BYTES / 1024 / 1024).to_string(); return Err(anyhow::anyhow!(format_skill_error( - "ARCHIVE_TOO_LARGE", + "ARCHIVE_DOWNLOAD_TOO_LARGE", &[("limit_mb", &limit_mb)], - Some("checkZipContent"), + None, ))); } - body.extend_from_slice(&chunk); + std::io::Write::write_all(&mut body, &chunk)?; } - let cursor = std::io::Cursor::new(body); - let archive = zip::ZipArchive::new(cursor)?; + let file = body.into_inner().map_err(|e| e.into_error())?; + let archive = zip::ZipArchive::new(file)?; Self::extract_repo_archive(archive, dest) } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 556a722df..b6466cdde 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -3270,6 +3270,7 @@ "emptyArchive": "Downloaded archive is empty", "invalidRepoRef": "Invalid repository reference: {{owner}}/{{name}}", "archiveTooLarge": "Archive exceeds the {{limit_mb}} MB extraction limit; aborted", + "archiveDownloadTooLarge": "Repository archive download exceeds the {{limit_mb}} MB limit; aborted", "archiveTooManyEntries": "Archive has too many entries ({{count}}); the limit is {{limit}}", "downloadFailed": "Download failed: HTTP {{status}}", "allBranchesFailed": "All branches failed, tried: {{branches}}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 79dd3194a..d1e0de6eb 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3270,6 +3270,7 @@ "emptyArchive": "ダウンロードしたアーカイブが空です", "invalidRepoRef": "リポジトリの指定が不正です:{{owner}}/{{name}}", "archiveTooLarge": "アーカイブが展開上限 {{limit_mb}} MB を超えたため中止しました", + "archiveDownloadTooLarge": "リポジトリのアーカイブがダウンロード上限 {{limit_mb}} MB を超えたため中止しました", "archiveTooManyEntries": "アーカイブのエントリ数が多すぎます({{count}})。上限は {{limit}} です", "downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}", "allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index b169a25d0..653ff4059 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -3271,6 +3271,7 @@ "emptyArchive": "下載的壓縮檔為空", "invalidRepoRef": "倉庫位址不合法:{{owner}}/{{name}}", "archiveTooLarge": "壓縮檔解壓後超過 {{limit_mb}} MB 上限,已中止", + "archiveDownloadTooLarge": "倉庫壓縮檔下載超過 {{limit_mb}} MB 上限,已中止", "archiveTooManyEntries": "壓縮檔項目過多({{count}}),上限 {{limit}}", "downloadFailed": "下載失敗:HTTP {{status}}", "allBranchesFailed": "所有分支下載失敗,嘗試了:{{branches}}", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index d29411d3f..c88754b06 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -3270,6 +3270,7 @@ "emptyArchive": "下载的压缩包为空", "invalidRepoRef": "仓库地址不合法:{{owner}}/{{name}}", "archiveTooLarge": "压缩包解压后超过 {{limit_mb}} MB 上限,已中止", + "archiveDownloadTooLarge": "仓库压缩包下载超过 {{limit_mb}} MB 上限,已中止", "archiveTooManyEntries": "压缩包条目过多({{count}}),上限 {{limit}}", "downloadFailed": "下载失败:HTTP {{status}}", "allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}", diff --git a/src/lib/errors/skillErrorParser.ts b/src/lib/errors/skillErrorParser.ts index 1af6de944..32e587f25 100644 --- a/src/lib/errors/skillErrorParser.ts +++ b/src/lib/errors/skillErrorParser.ts @@ -39,6 +39,7 @@ function getErrorI18nKey(code: string): string { EMPTY_ARCHIVE: "skills.error.emptyArchive", INVALID_REPO_REF: "skills.error.invalidRepoRef", ARCHIVE_TOO_LARGE: "skills.error.archiveTooLarge", + ARCHIVE_DOWNLOAD_TOO_LARGE: "skills.error.archiveDownloadTooLarge", ARCHIVE_TOO_MANY_ENTRIES: "skills.error.archiveTooManyEntries", GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed", NO_SKILLS_IN_ZIP: "skills.error.noSkillsInZip", diff --git a/tests/lib/skillErrorReason.test.ts b/tests/lib/skillErrorReason.test.ts index c48a7eb81..a0adaaa3d 100644 --- a/tests/lib/skillErrorReason.test.ts +++ b/tests/lib/skillErrorReason.test.ts @@ -28,6 +28,17 @@ describe("skillErrorReason", () => { ); }); + it("tells an oversized download apart from the extraction limit", () => { + // #8421:下载体超限曾复用 ARCHIVE_TOO_LARGE,被说成「解压后超过上限」 + const error = JSON.stringify({ + code: "ARCHIVE_DOWNLOAD_TOO_LARGE", + context: { limit_mb: "512" }, + }); + expect(skillErrorReason(error, t)).toBe( + 'skills.error.archiveDownloadTooLarge:{"limit_mb":"512"}', + ); + }); + it("keeps the first line of an unstructured error", () => { expect(skillErrorReason("connection reset\nstack…", t)).toBe( "connection reset",