From 8ce4649081bb62e0a082e817d50e3d93dc4a608e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 15:24:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20M?= =?UTF-8?q?2b=20=E5=B7=A5=E7=A8=8B=E6=BA=90=E5=8C=85=E4=B8=93=E5=B1=9E=20z?= =?UTF-8?q?ip=20=E6=A0=A1=E9=AA=8C=E5=99=A8=EF=BC=88=E5=9D=97=20B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `server-rs/crates/module-game-distribution/src/project_bundle.rs`:`validate_project_bundle_zip(bytes) -> Result` · 复用 `crate::package::normalize_archive_path`(不复制路径安全逻辑),路径不安全统一归 `InvalidPath` · 拒绝清单:空包 / 非 zip / 条目数 > 10 000 / 符号链接 / 加密条目 / 嵌套 `.zip` / 完全重复与大小写折叠重复路径 / `node_modules`(任意层级)/ `.git`、`.svn`(任意层级)/ `.agent`(任意层级:避免把上一个作品的改编来源记录带给下一个人)/ 根级 `dist`、`build`、`library`、`temp`、`local`(子目录同名允许,与模板包规则一致)/ 根级 `.idea`、`.vscode` / 凭据与隐私:`.env`、`.env*`、`*.pem`、`*.key`、`*.p12`、`*.pfx`、`.npmrc`、`.netrc`、`.git-credentials`、`id_rsa*`、`id_ed25519*`、`*.map` / 单文件 > 64 MiB / 累计 > 500 MiB / 单文件 > 包体 × 100 · 规模上限与发行包**逐项相等**(200 MiB / 500 MiB / 64 MiB / 10 000 / 100):两者共用同一条上传链路,反代与 Pingora 的放行量就是按 200 MiB 校准的,放宽就得同步放开多处部署配置;单测用 `assert_eq!` 钉住这条等价关系 · 不要求根 `index.html`(源码包没有入口约定),非空即可 - `lib.rs` 只追加 `mod project_bundle;` 与 `pub use project_bundle::{...}`(+6 行);`package.rs` **零改动**,发行包行为与测试不受影响 - 测试 16 条:合法源码包通过(故意不含 `index.html`)、上述每条拒绝规则各一条、「子目录里的 `dist/` 允许」反例、常量与发行包逐项相等、manifest 的 sha256/字节数与输入一致 - 门禁:`cargo test -p module-game-distribution` 69 passed(含本校验器 16 条);wasm build 0;`cargo fmt --all -- --check` 0 --- .../module-game-distribution/src/lib.rs | 6 + .../src/project_bundle.rs | 677 ++++++++++++++++++ 2 files changed, 683 insertions(+) create mode 100644 server-rs/crates/module-game-distribution/src/project_bundle.rs diff --git a/server-rs/crates/module-game-distribution/src/lib.rs b/server-rs/crates/module-game-distribution/src/lib.rs index bd622e73b..60e4918ad 100644 --- a/server-rs/crates/module-game-distribution/src/lib.rs +++ b/server-rs/crates/module-game-distribution/src/lib.rs @@ -5,6 +5,7 @@ mod errors; mod events; mod lineage; mod package; +mod project_bundle; mod release; mod reviews; @@ -39,6 +40,11 @@ pub use package::{ MAX_COMPRESSION_RATIO, MAX_EXPANDED_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_PACKAGE_BYTES, ReleaseFileManifest, ReleasePackageError, ReleasePackageManifest, validate_release_zip, }; +pub use project_bundle::{ + MAX_PROJECT_BUNDLE_BYTES, MAX_PROJECT_COMPRESSION_RATIO, MAX_PROJECT_EXPANDED_BYTES, + MAX_PROJECT_FILE_BYTES, MAX_PROJECT_FILE_COUNT, ProjectBundleError, ProjectBundleFileManifest, + ProjectBundleManifest, validate_project_bundle_zip, +}; pub use release::{ MAX_RELEASE_ASSET_BYTES, ReleaseAssetError, extract_release_asset, normalize_release_asset_path, release_asset_content_type, diff --git a/server-rs/crates/module-game-distribution/src/project_bundle.rs b/server-rs/crates/module-game-distribution/src/project_bundle.rs new file mode 100644 index 000000000..435ca4c9c --- /dev/null +++ b/server-rs/crates/module-game-distribution/src/project_bundle.rs @@ -0,0 +1,677 @@ +use std::{ + collections::HashSet, + io::{Cursor, Read}, +}; + +use sha2::{Digest, Sha256}; + +use crate::package::normalize_archive_path; + +/// 工程源包(源码包)压缩体积上限。取值与发行包 `MAX_PACKAGE_BYTES` 逐字节一致: +/// 两者共用同一条上传链路,反代的放行量(Nginx `client_max_body_size`、Pingora +/// `MAX_API_BODY_BYTES`)就是按 200 MiB 校准的,比这更大的包根本到不了 `api-server`。 +/// 所以这里既不能比发行包更宽(否则要同步放开多处部署配置),也没有理由更严。 +pub const MAX_PROJECT_BUNDLE_BYTES: u64 = 200 * 1024 * 1024; +/// 展开后总量上限,与发行包同口径(压缩包上限的 2.5 倍):纯文本 / JSON 占比高的 +/// 源码包压缩比很高,包体小而展开量大,必须留出解压余量。 +pub const MAX_PROJECT_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; +/// 单文件上限与发行包一致;源码里没有需要突破 64 MiB 的单个文件。 +pub const MAX_PROJECT_FILE_BYTES: u64 = 64 * 1024 * 1024; +/// 条目数上限与发行包一致;10 000 个文件对任何手写工程都绰绰有余。 +pub const MAX_PROJECT_FILE_COUNT: usize = 10_000; +/// 单文件声明大小相对整包体积的倍数上限,用于拦截 zip 炸弹(一个几 KB 的包声明 +/// 解压出几 GB):与发行包一致,正常源码的压缩比远低于 100 倍。 +pub const MAX_PROJECT_COMPRESSION_RATIO: u64 = 100; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectBundleFileManifest { + pub path: String, + pub size_bytes: u64, + pub sha256: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectBundleManifest { + pub bundle_bytes: u64, + pub bundle_sha256: String, + pub files: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProjectBundleError { + EmptyBundle, + BundleTooLarge, + InvalidArchive, + TooManyFiles, + InvalidPath, + SymlinkNotAllowed, + EncryptedFileNotAllowed, + NestedArchiveNotAllowed, + DependencyDirectoryNotAllowed, + VersionControlDirectoryNotAllowed, + LocalStateDirectoryNotAllowed, + BuildArtifactNotAllowed, + IdeDirectoryNotAllowed, + SensitiveFileNotAllowed, + FileTooLarge, + ExpandedBundleTooLarge, + CompressionRatioTooHigh, + ReadFailed, +} + +/// 校验一个工程源包(zip)。规则与发行包不同:**不要求**根 `index.html`,也 +/// **不拒绝** `*.map` 之外的构建目录(只拦根级构建产物),因为这些是源码工程 +/// 的正常组成部分;反过来,源码包必须额外拦掉依赖目录、版本库元数据、AGC 本地 +/// 状态目录与凭据文件。非空且通过全部拒绝清单即返回 manifest。 +pub fn validate_project_bundle_zip( + bytes: &[u8], +) -> Result { + if bytes.is_empty() { + return Err(ProjectBundleError::EmptyBundle); + } + let bundle_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if bundle_bytes > MAX_PROJECT_BUNDLE_BYTES { + return Err(ProjectBundleError::BundleTooLarge); + } + + let mut archive = + zip::ZipArchive::new(Cursor::new(bytes)).map_err(|_| ProjectBundleError::InvalidArchive)?; + if archive.len() == 0 { + // 只有 EOCD、没有任何条目的 zip 也是空包:源码工程至少得有一个文件。 + return Err(ProjectBundleError::EmptyBundle); + } + if archive.len() > MAX_PROJECT_FILE_COUNT { + return Err(ProjectBundleError::TooManyFiles); + } + + let mut paths = HashSet::with_capacity(archive.len()); + let mut case_folded_paths = HashSet::with_capacity(archive.len()); + let mut files = Vec::with_capacity(archive.len()); + let mut expanded_bytes = 0_u64; + for index in 0..archive.len() { + let (path, declared_size, is_dir) = { + // `by_index` 对加密条目直接报「需要口令」,拿不到元数据;这里先用 + // `by_index_raw` 只读头部判掉加密与符号链接,再决定是否解压取内容。 + let file = archive + .by_index_raw(index) + .map_err(|_| ProjectBundleError::InvalidArchive)?; + if file.encrypted() { + return Err(ProjectBundleError::EncryptedFileNotAllowed); + } + if file.is_symlink() { + return Err(ProjectBundleError::SymlinkNotAllowed); + } + let enclosed = file + .enclosed_name() + .ok_or(ProjectBundleError::InvalidPath)?; + let path = + normalize_archive_path(&enclosed).map_err(|_| ProjectBundleError::InvalidPath)?; + (path, file.size(), file.is_dir()) + }; + // 完全重复或大小写折叠后重复的条目:解包结果取决于解压器的遍历顺序, + // 在大小写不敏感的文件系统(Windows / macOS)上还会互相覆盖。 + if !paths.insert(path.clone()) || !case_folded_paths.insert(path.to_ascii_lowercase()) { + return Err(ProjectBundleError::InvalidPath); + } + reject_forbidden_path(&path)?; + if is_dir { + continue; + } + if declared_size > MAX_PROJECT_FILE_BYTES { + return Err(ProjectBundleError::FileTooLarge); + } + expanded_bytes = expanded_bytes.saturating_add(declared_size); + if expanded_bytes > MAX_PROJECT_EXPANDED_BYTES { + return Err(ProjectBundleError::ExpandedBundleTooLarge); + } + if declared_size > bundle_bytes.saturating_mul(MAX_PROJECT_COMPRESSION_RATIO) { + return Err(ProjectBundleError::CompressionRatioTooHigh); + } + + let mut content = Vec::with_capacity(usize::try_from(declared_size).unwrap_or(0)); + archive + .by_index(index) + .map_err(|_| ProjectBundleError::InvalidArchive)? + .read_to_end(&mut content) + .map_err(|_| ProjectBundleError::ReadFailed)?; + if u64::try_from(content.len()).unwrap_or(u64::MAX) != declared_size { + return Err(ProjectBundleError::ReadFailed); + } + files.push(ProjectBundleFileManifest { + path, + size_bytes: declared_size, + sha256: hex::encode(Sha256::digest(&content)), + }); + } + + let bundle_digest = Sha256::digest(bytes); + Ok(ProjectBundleManifest { + bundle_bytes, + bundle_sha256: hex::encode(bundle_digest), + files, + }) +} + +/// 拒绝清单里与体积无关的部分:依赖 / 版本库 / 本地状态 / 构建产物 / IDE / 凭据。 +fn reject_forbidden_path(path: &str) -> Result<(), ProjectBundleError> { + let mut root = ""; + let mut is_first = true; + let mut has_dependency_dir = false; + let mut has_version_control_dir = false; + let mut has_local_state_dir = false; + for part in path.split('/') { + if is_first { + root = part; + is_first = false; + } + has_dependency_dir |= part.eq_ignore_ascii_case("node_modules"); + has_version_control_dir |= + part.eq_ignore_ascii_case(".git") || part.eq_ignore_ascii_case(".svn"); + has_local_state_dir |= part.eq_ignore_ascii_case(".agent"); + } + + // 依赖目录出现在任意层级都拒绝:它是安装产物,体积大且可由 package.json 还原, + // 随包外发只会污染下一手工程,也拖垮上传与解包。 + if has_dependency_dir { + return Err(ProjectBundleError::DependencyDirectoryNotAllowed); + } + // 版本库元数据(.git / .svn)带上会泄漏提交历史、分支名与远端地址。 + if has_version_control_dir { + return Err(ProjectBundleError::VersionControlDirectoryNotAllowed); + } + // AGC 的本地状态目录(.agent)记录着本作品的改编来源与本地会话信息,随包外发 + // 会把上一个作者的工作痕迹带给下一个使用者。 + if has_local_state_dir { + return Err(ProjectBundleError::LocalStateDirectoryNotAllowed); + } + + // 构建产物与 IDE 只拦**根级**:根 dist / build / library / temp / local 是 + // 构建输出(与模板包同规则),子目录里的 dist/ 是工程自己的目录布局,必须允许。 + const ROOT_BUILD_DIRS: [&str; 5] = ["dist", "build", "library", "temp", "local"]; + if ROOT_BUILD_DIRS + .iter() + .any(|name| root.eq_ignore_ascii_case(name)) + { + return Err(ProjectBundleError::BuildArtifactNotAllowed); + } + const ROOT_IDE_DIRS: [&str; 2] = [".idea", ".vscode"]; + if ROOT_IDE_DIRS + .iter() + .any(|name| root.eq_ignore_ascii_case(name)) + { + return Err(ProjectBundleError::IdeDirectoryNotAllowed); + } + + // 凭据与隐私按**文件名**判定(任意层级):这些文件一旦外发就是不可撤销的泄漏。 + // 大小写折叠后再比:包也会被大小写不敏感的文件系统解包,`.ENV` 与 `.env` 等价。 + let file_name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase(); + if is_sensitive_file_name(&file_name) { + return Err(ProjectBundleError::SensitiveFileNotAllowed); + } + // 嵌套压缩包:解包阶段不会递归校验包里的包,等于绕过整份拒绝清单。 + if file_name.ends_with(".zip") { + return Err(ProjectBundleError::NestedArchiveNotAllowed); + } + Ok(()) +} + +fn is_sensitive_file_name(name: &str) -> bool { + name.starts_with(".env") // .env / .env.local / .env.production … + || name.ends_with(".pem") + || name.ends_with(".key") + || name.ends_with(".p12") + || name.ends_with(".pfx") + || name == ".npmrc" + || name == ".netrc" + || name == ".git-credentials" + || name.starts_with("id_rsa") + || name.starts_with("id_ed25519") + || name.ends_with(".map") // 源码映射会暴露原始源码与路径 +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use zip::{ZipWriter, write::SimpleFileOptions}; + + use super::*; + + fn archive(files: &[(&str, &[u8])]) -> Vec { + let mut output = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(&mut output); + for (path, content) in files { + writer + .start_file(*path, SimpleFileOptions::default()) + .expect("zip entry"); + writer.write_all(content).expect("zip content"); + } + writer.finish().expect("finish zip"); + output.into_inner() + } + + /// 用 deflate 逐块写入「声明体积很大、内容全为零」的文件:包体极小(零的压缩比 + /// 极高),但中央目录里的解压后声明大小是真实的,用来低成本触达规模类检查。 + fn zeros_archive(entries: &[(&str, u64)]) -> Vec { + let mut output = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(&mut output); + let chunk = vec![0_u8; 1024 * 1024]; + for (path, size) in entries { + writer + .start_file(*path, SimpleFileOptions::default()) + .expect("zip entry"); + let mut remaining = *size; + while remaining > 0 { + let take = + usize::try_from(remaining.min(chunk.len() as u64)).unwrap_or(chunk.len()); + writer.write_all(&chunk[..take]).expect("zip content"); + remaining -= take as u64; + } + } + writer.finish().expect("finish zip"); + output.into_inner() + } + + /// 存储型(不压缩)大包:用于触达包体上限;预留容量避免 Vec 扩容时双倍占用。 + fn stored_zeros_archive(path: &str, size: u64) -> Vec { + let mut output = Cursor::new(Vec::with_capacity( + usize::try_from(size).unwrap_or(0).saturating_add(4096), + )); + let mut writer = ZipWriter::new(&mut output); + writer + .start_file( + path, + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored), + ) + .expect("zip entry"); + let chunk = vec![0_u8; 1024 * 1024]; + let mut remaining = size; + while remaining > 0 { + let take = usize::try_from(remaining.min(chunk.len() as u64)).unwrap_or(chunk.len()); + writer.write_all(&chunk[..take]).expect("zip content"); + remaining -= take as u64; + } + writer.finish().expect("finish zip"); + output.into_inner() + } + + /// 手工给单条目 zip 打上「加密」标志位:zip crate 的写入端产不出加密条目 + /// (需要 `aes-crypto` / ZipCrypto 特性),而校验器只看头部元数据、不解密, + /// 直接把通用位标记的 bit 0 置上即可覆盖这条拒绝分支。 + fn encrypted_archive(path: &str, content: &[u8]) -> Vec { + let mut bytes = archive(&[(path, content)]); + assert_eq!(&bytes[0..4], b"PK\x03\x04", "local file header"); + // 本地文件头:magic(4) + version needed(2) + general purpose flag(2) + bytes[6] |= 0b1; + // 中央目录项:magic(4) + version made by(2) + version needed(2) + flag(2) + let central = bytes + .windows(4) + .position(|window| window == b"PK\x01\x02") + .expect("central directory header"); + bytes[central + 8] |= 0b1; + bytes + } + + /// xorshift64:测试里不需要密码学强度,只要不可压缩(deflate 基本压不动)。 + fn incompressible_bytes(len: usize) -> Vec { + let mut state = 0x9E37_79B9_7F4A_7C15_u64; + let mut output = Vec::with_capacity(len); + while output.len() < len { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + output.extend_from_slice(&state.to_le_bytes()); + } + output.truncate(len); + output + } + + #[test] + fn accepts_source_bundle_without_index_html() { + let bytes = archive(&[ + ("package.json", br#"{"name":"demo"}"#), + ("vite.config.ts", b"export default {}"), + ("src/main.ts", b"console.log(1)"), + ("public/logo.png", b"\x89PNG\r\n\x1a\n"), + ]); + let manifest = validate_project_bundle_zip(&bytes).expect("valid source bundle"); + // 源码包不要求根 index.html:上面这四个文件里故意没有它。 + assert_eq!(manifest.bundle_bytes, bytes.len() as u64); + assert_eq!(manifest.bundle_sha256, hex::encode(Sha256::digest(&bytes))); + assert_eq!( + manifest + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(), + vec![ + "package.json", + "vite.config.ts", + "src/main.ts", + "public/logo.png" + ] + ); + assert!(manifest.files.iter().all(|file| file.path != "index.html")); + assert_eq!(manifest.files[0].size_bytes, 15); + assert_eq!( + manifest.files[0].sha256, + hex::encode(Sha256::digest(br#"{"name":"demo"}"#)) + ); + } + + #[test] + fn rejects_empty_and_non_zip_input() { + assert_eq!( + validate_project_bundle_zip(&[]), + Err(ProjectBundleError::EmptyBundle) + ); + assert_eq!( + validate_project_bundle_zip(b"definitely not a zip"), + Err(ProjectBundleError::InvalidArchive) + ); + // 只有 EOCD、零条目的 zip 同样是空包。 + assert_eq!( + validate_project_bundle_zip(&archive(&[])), + Err(ProjectBundleError::EmptyBundle) + ); + } + + /// 201 MiB 存储型单条目:包体一旦越过 200 MiB 就必须在解析条目前被拒。 + #[test] + fn rejects_bundle_over_size_limit() { + let bytes = stored_zeros_archive("src/big.bin", 201 * 1024 * 1024); + assert!(bytes.len() as u64 > MAX_PROJECT_BUNDLE_BYTES); + assert_eq!( + validate_project_bundle_zip(&bytes), + Err(ProjectBundleError::BundleTooLarge) + ); + } + + #[test] + fn rejects_too_many_entries() { + let mut output = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(&mut output); + for index in 0..MAX_PROJECT_FILE_COUNT + 1 { + writer + .start_file( + format!("src/module_{index}.ts"), + SimpleFileOptions::default(), + ) + .expect("zip entry"); + writer.write_all(b"export {};").expect("zip content"); + } + writer.finish().expect("finish zip"); + assert_eq!( + validate_project_bundle_zip(&output.into_inner()), + Err(ProjectBundleError::TooManyFiles) + ); + } + + #[test] + fn rejects_unsafe_and_duplicate_paths() { + for unsafe_path in [ + "../escape.txt", + "/etc/passwd", + "src\\main.ts", + "src/*.ts", + "C:/evil.txt", + ] { + assert_eq!( + validate_project_bundle_zip(&archive(&[ + ("package.json", b"x"), + (unsafe_path, b"x") + ])), + Err(ProjectBundleError::InvalidPath), + "path {unsafe_path:?} must be rejected" + ); + } + // 逐字节同名的两条会被 zip crate 的中央目录索引合并(同名后写覆盖),校验器 + // 根本看不到第二条;这里覆盖的是「归一化后同名」的重复:目录条目 + // `src/main.ts/` 归一化后与文件 `src/main.ts` 完全相同。 + let mut duplicate_output = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut duplicate_output); + writer + .start_file("src/main.ts", SimpleFileOptions::default()) + .expect("zip entry"); + writer.write_all(b"a").expect("zip content"); + writer + .add_directory("src/main.ts/", SimpleFileOptions::default()) + .expect("zip dir entry"); + writer.finish().expect("finish zip"); + } + assert_eq!( + validate_project_bundle_zip(&duplicate_output.into_inner()), + Err(ProjectBundleError::InvalidPath) + ); + let case_folded_duplicate = archive(&[("src/main.ts", b"a"), ("SRC/Main.ts", b"b")]); + assert_eq!( + validate_project_bundle_zip(&case_folded_duplicate), + Err(ProjectBundleError::InvalidPath) + ); + } + + /// 符号链接条目必须整体拒绝:解包器不能跟随链接把内容写到包外。 + #[test] + fn rejects_symlink_entries() { + let mut output = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut output); + writer + .start_file( + "package.json", + SimpleFileOptions::default().unix_permissions(0o644), + ) + .expect("entry"); + writer.write_all(b"{}").expect("content"); + // 只能用 `add_symlink`:`unix_permissions` 会把 mode 掩成 `0o777`, + // 正常写入路径补的是 `S_IFREG`,造不出 `S_IFLNK` 条目。 + writer + .add_symlink("escape", "/tmp", SimpleFileOptions::default()) + .expect("symlink entry"); + writer.finish().expect("finish zip"); + } + assert_eq!( + validate_project_bundle_zip(&output.into_inner()), + Err(ProjectBundleError::SymlinkNotAllowed) + ); + } + + #[test] + fn rejects_encrypted_entries() { + let bytes = encrypted_archive("src/main.ts", b"console.log(1)"); + assert_eq!( + validate_project_bundle_zip(&bytes), + Err(ProjectBundleError::EncryptedFileNotAllowed) + ); + } + + #[test] + fn rejects_nested_archives() { + assert_eq!( + validate_project_bundle_zip(&archive(&[ + ("package.json", b"{}"), + ("src/vendor.zip", b"PK\x03\x04") + ])), + Err(ProjectBundleError::NestedArchiveNotAllowed) + ); + } + + #[test] + fn rejects_dependency_and_vcs_and_local_state_directories() { + // 依赖目录:任意层级都拒绝。 + for path in [ + "node_modules/lodash/index.js", + "src/deep/node_modules/pkg/x.js", + ] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, b"x")])), + Err(ProjectBundleError::DependencyDirectoryNotAllowed), + "path {path:?}" + ); + } + // 版本库元数据:任意层级都拒绝。 + for path in [".git/HEAD", "src/.svn/entries"] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, b"x")])), + Err(ProjectBundleError::VersionControlDirectoryNotAllowed), + "path {path:?}" + ); + } + // AGC 本地状态:任意层级都拒绝。 + for path in [".agent/session.json", "src/deep/.agent/state.json"] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, b"x")])), + Err(ProjectBundleError::LocalStateDirectoryNotAllowed), + "path {path:?}" + ); + } + } + + #[test] + fn rejects_root_build_and_ide_directories_but_allows_nested_ones() { + for path in [ + "dist/main.js", + "build/out.js", + "library/chunk.js", + "temp/scratch.ts", + "local/notes.md", + ] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, b"x")])), + Err(ProjectBundleError::BuildArtifactNotAllowed), + "path {path:?}" + ); + } + for path in [".idea/workspace.xml", ".vscode/settings.json"] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, b"x")])), + Err(ProjectBundleError::IdeDirectoryNotAllowed), + "path {path:?}" + ); + } + // 反例:子目录里的 dist/ / build/ 是工程自己的布局,必须放行; + // 根级但名字只是前缀(build.ts)的文件也不是构建目录。 + let nested = archive(&[ + ("package.json", b"{}"), + ("src/dist/main.js", b"x"), + ("packages/app/build/index.js", b"x"), + ("build.ts", b"export {}"), + ]); + let manifest = validate_project_bundle_zip(&nested).expect("nested dist allowed"); + assert_eq!(manifest.files.len(), 4); + } + + #[test] + fn rejects_sensitive_files_at_any_level() { + for path in [ + ".env", + ".env.production", + "config/server.pem", + "certs/tls.key", + "certs/keystore.p12", + "certs/app.pfx", + ".npmrc", + ".netrc", + ".git-credentials", + "deploy/id_rsa", + "deploy/id_ed25519.pub", + "public/app.js.map", + ] { + assert_eq!( + validate_project_bundle_zip(&archive(&[("package.json", b"{}"), (path, b"x")])), + Err(ProjectBundleError::SensitiveFileNotAllowed), + "path {path:?}" + ); + } + } + + #[test] + fn rejects_oversized_single_file() { + // 65 MiB 零内容 deflate 后包体很小,但中央目录声明了真实解压大小。 + let bytes = zeros_archive(&[("src/big.bin", 65 * 1024 * 1024)]); + assert_eq!( + validate_project_bundle_zip(&bytes), + Err(ProjectBundleError::FileTooLarge) + ); + } + + #[test] + fn rejects_excessive_expanded_size() { + // 每文件声明 63 MiB(未越单文件上限),累计第 8 个越过 500 MiB 展开上限。 + // 零内容 deflate 后包体只有几百 KB,需要一块不可压缩的压舱石把包体抬到 + // 声明大小 / 100 之上,否则会先撞上压缩比检查而不是展开量检查。 + let ballast = incompressible_bytes(1024 * 1024); + let mut output = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(&mut output); + writer + .start_file( + "assets/ballast.bin", + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored), + ) + .expect("zip entry"); + writer.write_all(&ballast).expect("zip content"); + let chunk = vec![0_u8; 1024 * 1024]; + for index in 0..8 { + writer + .start_file(format!("src/blob_{index}.ts"), SimpleFileOptions::default()) + .expect("zip entry"); + let mut remaining = 63 * 1024 * 1024_u64; + while remaining > 0 { + let take = + usize::try_from(remaining.min(chunk.len() as u64)).unwrap_or(chunk.len()); + writer.write_all(&chunk[..take]).expect("zip content"); + remaining -= take as u64; + } + } + writer.finish().expect("finish zip"); + assert_eq!( + validate_project_bundle_zip(&output.into_inner()), + Err(ProjectBundleError::ExpandedBundleTooLarge) + ); + } + + #[test] + fn rejects_excessive_compression_ratio() { + // 1 MiB 零内容 deflate 成 1 KB 上下,声明大小远超包体的 100 倍。 + let bytes = zeros_archive(&[("src/bomb.bin", 1024 * 1024)]); + assert_eq!( + validate_project_bundle_zip(&bytes), + Err(ProjectBundleError::CompressionRatioTooHigh) + ); + } + + /// 数据区损坏(中央目录与声明大小都正常,只有 deflate 流本身是垃圾)时必须在 + /// 解压阶段报 `ReadFailed`,而不是 panic 或静默产出错误清单。 + #[test] + fn reports_read_failure_for_corrupt_entry_data() { + let mut bytes = archive(&[("package.json", b"{\"name\":\"demo\"}")]); + let name_len = usize::from(u16::from_le_bytes([bytes[26], bytes[27]])); + let extra_len = usize::from(u16::from_le_bytes([bytes[28], bytes[29]])); + let data_start = 30 + name_len + extra_len; + for byte in &mut bytes[data_start..data_start + 8] { + *byte = 0xFF; + } + assert_eq!( + validate_project_bundle_zip(&bytes), + Err(ProjectBundleError::ReadFailed) + ); + } + + /// 口径钉住:工程源包与发行包共用同一条上传链路,五项限值必须逐字节一致。 + /// 反代放行量按 200 MiB 校准,任何一侧单独调整都会让合法包在到达服务前被拒。 + #[test] + fn pins_limits_to_release_package_limits() { + assert_eq!(MAX_PROJECT_BUNDLE_BYTES, crate::package::MAX_PACKAGE_BYTES); + assert_eq!( + MAX_PROJECT_EXPANDED_BYTES, + crate::package::MAX_EXPANDED_BYTES + ); + assert_eq!(MAX_PROJECT_FILE_BYTES, crate::package::MAX_FILE_BYTES); + assert_eq!(MAX_PROJECT_FILE_COUNT, crate::package::MAX_FILE_COUNT); + assert_eq!( + MAX_PROJECT_COMPRESSION_RATIO, + crate::package::MAX_COMPRESSION_RATIO + ); + } +}