From 83f0155f3c4ad34bb07e4a5d148a9ffdb1dcccdf Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 16 Oct 2026 18:53:52 +0800 Subject: [PATCH] fix(skills): spool repo archives to disk and lift the 128 MiB download cap Skill discovery, install, check-updates and update all download the whole GitHub repo ZIP. The compressed body was collected into a Vec in memory and capped at 128 MiB, so repos whose docs/ images dwarf the skills themselves (K-Dense-AI/scientific-agent-skills: 235 MiB ZIP, 277 MiB uncompressed, 20 MiB of skills) failed before extraction even though they fit the 512 MiB extraction budget and 30k entry cap. The download cap also reused ARCHIVE_TOO_LARGE, so the UI blamed the extraction limit. Stream the body chunk by chunk into an anonymous temp file (deleted on drop, including error paths), still counting actually received bytes, and open the ZipArchive from that file. With memory no longer the constraint, align the download cap with the extraction budget, since a larger compressed body could not be extracted anyway. Report the download cap under its own ARCHIVE_DOWNLOAD_TOO_LARGE code with four-locale text. Extraction budget, entry cap and path/symlink protections are unchanged. Refs #8421 --- src-tauri/src/services/skill.rs | 27 ++++++++++++++++----------- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/errors/skillErrorParser.ts | 1 + tests/lib/skillErrorReason.test.ts | 11 +++++++++++ 7 files changed, 32 insertions(+), 11 deletions(-) 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",