diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs index ea75d9994..4b07512a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs @@ -19,17 +19,26 @@ //! **未**复用 `collect_project_export_package_dir_files`:它没有「按排除规则剪枝」的钩子,会先 //! 遍历整个 `node_modules` 再交给调用方过滤;而且它对非普通条目是静默忽略,本模块要求失败关闭。 //! -//! ## 排除规则(口径与服务端校验器同批,逐条见 `bundle_excludes_relative_path`) +//! ## 排除规则(**以服务端校验器为权威**,两侧必须逐条覆盖) +//! +//! 规则集拆成下面这些 `const` 而不是散在 `matches!` 里:`scripts/check-project-bundle-policy-parity.mjs` +//! 会逐 token 比对两侧,机器门禁靠的就是这些常量(改规则必须同时改两侧,否则红)。 +//! +//! 服务端权威实现:`server-rs/crates/module-game-distribution/src/project_bundle.rs` +//! (`reject_forbidden_path` / `is_sensitive_file_name` / `MAX_PROJECT_*`)。 //! //! | 类别 | 规则 | 理由 | //! | --- | --- | --- | -//! | 依赖与版本库 | 任意层级 `node_modules` / `.git` / `.svn` | 依赖可重装,版本库不是工程内容 | -//! | 构建产物与 IDE | 项目根首层 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` / `.godot`;`game/` 首层 `dist` / `build` | 产物可重建;`.godot` 是编辑器缓存(模板包指南同样排除);AGC 网页脚手架构建输出落在 `game/dist` | -//! | 凭据与隐私 | 任意层级 `.env` / `.env.*` / `*.pem` / `*.key` / `*.p12` / `*.pfx` / `.npmrc` / `.netrc` | 源码外发绝不能带凭据 | -//! | AGC 自有数据 | 项目根首层 `.agent` / `exports` / `memory` | 见 `bundle_excludes_agc_owned_path` 的注释 | +//! | 依赖与版本库 | 任意层级 `node_modules` / `.git` / `.svn` | 依赖可重装;版本库元数据泄漏提交历史与远端地址 | +//! | AGC 平台保留名 | 任意层级 `.agent` | 项目身份 + Agent 运行数据 + 本机改编来源记录,见 `bundle_excludes_agc_owned_dir` | +//! | 构建产物 | 项目根首层 `dist` / `build` / `library` / `temp` / `local`(服务端权威);**客户端额外**:`game/` 首层 `dist` / `build` | 产物可重建;AGC 网页脚手架的 vite `outDir` 就落在 `game/dist`,源码包不该带 | +//! | IDE / 编辑器 | 项目根首层 `.idea` / `.vscode`(服务端权威);**客户端额外** `.godot` | 编辑器缓存,模板包指南同样排除 | +//! | 凭据与隐私 | 任意层级 前缀 `.env` / `id_rsa` / `id_ed25519`、后缀 `.pem` / `.key` / `.p12` / `.pfx` / `.map` / `.zip`、全名 `.npmrc` / `.netrc` / `.git-credentials` | 凭据外发不可撤销;`.map` 可反推源码与内部路径;`.zip` 是嵌套压缩包,解包阶段不递归校验等于绕过整份清单 | +//! | AGC 生成物 | **客户端额外** 项目根首层 `exports` / `memory` | 见 `bundle_excludes_agc_owned_dir` | //! -//! 规模上限与模板包一致(条目 ≤ 4096、单文件 ≤ 256 MiB、解压后总量 ≤ 512 MiB),超限**失败关闭** -//! 并点名违规条目,不静默截断。 +//! 规模上限**与服务端逐项相等**(服务端是安全边界):压缩包 ≤ 200 MiB、展开总量 ≤ 500 MiB、 +//! 单文件 ≤ 64 MiB、条目 ≤ 10 000、单文件 ≤ 包体 × 100。超限**失败关闭**并点名违规条目, +//! 不静默截断——客户端先拦,就不会出现「传到一半被服务端 422 拒掉」。 //! //! **本轮不接上传**:上行路由族落地后由发布链路在「授权非禁止」时调用本模块。在接线之前非 test //! 构建没有调用方,因此整模块带 `#![allow(dead_code)]`;接线时删除该属性。 @@ -38,12 +47,34 @@ use super::*; use std::io::Write; -/// 条目数上限:与模板包(`TEMPLATE_ARCHIVE_MAX_FILES`)同量级,待与服务端校验器比对后定稿。 -pub(crate) const PROJECT_BUNDLE_MAX_FILES: usize = 4_096; -/// 单文件(解压后)上限。 -pub(crate) const PROJECT_BUNDLE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; -/// 解压后总量上限。 -pub(crate) const PROJECT_BUNDLE_MAX_TOTAL_BYTES: u64 = 512 * 1024 * 1024; +/// 压缩包(zip)体积上限:与服务端 `MAX_PROJECT_BUNDLE_BYTES` 相等——反代放行量按 200 MiB 校准, +/// 超过它的包根本到不了 api-server,客户端必须提前拦。 +pub(crate) const PROJECT_BUNDLE_MAX_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024; +/// 展开后总量上限:与服务端 `MAX_PROJECT_EXPANDED_BYTES` 相等。 +pub(crate) const PROJECT_BUNDLE_MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; +/// 单文件(解压后)上限:与服务端 `MAX_PROJECT_FILE_BYTES` 相等。 +pub(crate) const PROJECT_BUNDLE_MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; +/// 条目数上限:与服务端 `MAX_PROJECT_FILE_COUNT` 相等。 +pub(crate) const PROJECT_BUNDLE_MAX_FILES: usize = 10_000; +/// 单文件相对包体的倍数上限:与服务端 `MAX_PROJECT_COMPRESSION_RATIO` 相等(zip 炸弹防线)。 +pub(crate) const PROJECT_BUNDLE_MAX_COMPRESSION_RATIO: u64 = 100; + +/// 任意层级都排除的目录名(依赖、版本库元数据、平台保留名)。 +const BUNDLE_EXCLUDED_ANY_LEVEL_DIRS: &[&str] = &["node_modules", ".git", ".svn", ".agent"]; +/// 项目根首层排除的构建产物目录(服务端权威口径)。 +const BUNDLE_EXCLUDED_ROOT_BUILD_DIRS: &[&str] = &["dist", "build", "library", "temp", "local"]; +/// 项目根首层排除的 IDE / 编辑器目录(`.godot` 是客户端额外项)。 +const BUNDLE_EXCLUDED_ROOT_IDE_DIRS: &[&str] = &[".idea", ".vscode", ".godot"]; +/// **客户端额外项**:`game/` 首层排除的构建产物(AGC 网页脚手架 vite `outDir` 落点)。 +const BUNDLE_EXCLUDED_GAME_BUILD_DIRS: &[&str] = &["dist", "build"]; +/// **客户端额外项**:项目根首层排除的 AGC 生成目录(`exports` 试玩包、`memory` Agent 记忆)。 +const BUNDLE_EXCLUDED_ROOT_AGC_DIRS: &[&str] = &["exports", "memory"]; +/// 任意层级按**前缀**排除的文件名(`.env*` / SSH 私钥名)。 +const BUNDLE_EXCLUDED_FILE_PREFIXES: &[&str] = &[".env", "id_rsa", "id_ed25519"]; +/// 任意层级按**后缀**排除的文件名:凭据、源码映射、嵌套压缩包。 +const BUNDLE_EXCLUDED_FILE_SUFFIXES: &[&str] = &[".pem", ".key", ".p12", ".pfx", ".map", ".zip"]; +/// 任意层级按**全名**排除的文件名。 +const BUNDLE_EXCLUDED_FILE_NAMES: &[&str] = &[".npmrc", ".netrc", ".git-credentials"]; /// ZIP 里给每个条目写的固定时间戳:ZIP 能表示的最早时刻(1980-01-01 00:00:00)。 /// 同内容必须得到同一份字节,所以绝不写「打包时刻」。 @@ -60,12 +91,14 @@ pub(crate) enum ProjectBundleScope { GameDirectory, } -/// 规模上限。默认值就是合同常量;测试用小上限跑同一段逻辑,不另写一条检查路径。 +/// 规模上限。默认值就是合同常量(与服务端逐项相等);测试用小上限跑同一段逻辑,不另写检查路径。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct ProjectBundleLimits { pub(crate) max_files: usize, pub(crate) max_file_bytes: u64, - pub(crate) max_total_bytes: u64, + pub(crate) max_expanded_bytes: u64, + pub(crate) max_archive_bytes: u64, + pub(crate) max_compression_ratio: u64, } impl Default for ProjectBundleLimits { @@ -73,7 +106,9 @@ impl Default for ProjectBundleLimits { Self { max_files: PROJECT_BUNDLE_MAX_FILES, max_file_bytes: PROJECT_BUNDLE_MAX_FILE_BYTES, - max_total_bytes: PROJECT_BUNDLE_MAX_TOTAL_BYTES, + max_expanded_bytes: PROJECT_BUNDLE_MAX_EXPANDED_BYTES, + max_archive_bytes: PROJECT_BUNDLE_MAX_ARCHIVE_BYTES, + max_compression_ratio: PROJECT_BUNDLE_MAX_COMPRESSION_RATIO, } } } @@ -133,6 +168,24 @@ pub(crate) fn build_project_bundle_with_limits( return Err("工程源包里没有可打包的文件(内容都被排除规则挡掉了)".to_string()); } let bytes = write_project_bundle_zip(&entries)?; + // 压缩包体积:服务端按反代放行量(200 MiB)卡这一条,客户端必须提前拦,否则白传一趟。 + if bytes.len() as u64 > limits.max_archive_bytes { + return Err(format!( + "工程源包压缩后超过上限({} > {} 字节);请删掉不必要的大文件后重试", + bytes.len(), + limits.max_archive_bytes + )); + } + // 压缩比:与服务端同一判据(单文件声明大小 ≤ 包体 × 倍数),防 zip 炸弹。 + let archive_bytes = bytes.len() as u64; + for entry in &entries { + if entry.size > archive_bytes.saturating_mul(limits.max_compression_ratio) { + return Err(format!( + "工程源包条目压缩比超过上限({} 字节的解压内容 > 包体 {} 字节 × {}):{}", + entry.size, archive_bytes, limits.max_compression_ratio, entry.entry_path + )); + } + } Ok(ProjectBundle { sha256: sha256_hex(&bytes), bytes, @@ -157,6 +210,9 @@ fn collect_project_bundle_entries( ) -> Result, String> { let mut entries = Vec::new(); let mut total_bytes: u64 = 0; + // 大小写折叠后的条目名:服务端拒绝折叠后重名的包(解包结果取决于文件系统大小写敏感度), + // 客户端提前失败关闭,不让作者传到一半才吃到 422。 + let mut case_folded_entry_paths = std::collections::BTreeSet::new(); let mut directories = vec![walk_root.to_path_buf()]; while let Some(directory) = directories.pop() { let mut children = fs::read_dir(&directory) @@ -195,10 +251,10 @@ fn collect_project_bundle_entries( )); } total_bytes = total_bytes.saturating_add(size); - if total_bytes > limits.max_total_bytes { + if total_bytes > limits.max_expanded_bytes { return Err(format!( "工程源包解压后总量超过上限(>{} 字节,累计到 {project_relative})", - limits.max_total_bytes + limits.max_expanded_bytes )); } if entries.len() + 1 > limits.max_files { @@ -210,8 +266,14 @@ fn collect_project_bundle_entries( // 条目名过一遍解压侧的同一道门禁,再统一成 `/` 分隔;两边只有一份路径规则。 let entry_path = crate::template_library::safe_archive_relative_path(&project_relative) .map_err(|_| format!("工程源包条目路径无效:{project_relative}"))?; + let entry_path = entry_path.to_string_lossy().replace('\\', "/"); + if !case_folded_entry_paths.insert(entry_path.to_ascii_lowercase()) { + return Err(format!( + "工程源包里有大小写折叠后重名的条目(服务端会拒收):{entry_path}" + )); + } entries.push(ProjectBundleEntry { - entry_path: entry_path.to_string_lossy().replace('\\', "/"), + entry_path, project_relative, absolute: path, size, @@ -265,6 +327,9 @@ fn write_project_bundle_zip(entries: &[ProjectBundleEntry]) -> Result, S } /// 是否排除某个**项目根相对**路径(`/` 分隔、已规范化、大小写不敏感比较)。 +/// +/// 规则集来自模块头部的 `const`(`BUNDLE_EXCLUDED_*`):`scripts/check-project-bundle-policy-parity.mjs` +/// 会与服务端校验器逐 token 比对,所以这里只做「查表」,不再内联字面量。 fn bundle_excludes_relative_path(relative_path: &str) -> bool { let components = relative_path.split('/').collect::>(); let Some(file_name) = components.last().copied() else { @@ -272,53 +337,50 @@ fn bundle_excludes_relative_path(relative_path: &str) -> bool { }; let first = components[0].to_ascii_lowercase(); let second = components.get(1).map(|value| value.to_ascii_lowercase()); - // 依赖与版本库:任意层级,命中即剪枝(`node_modules` 可能上万条,不能先收集再过滤)。 + // 依赖、版本库与平台保留名:任意层级命中即剪枝(`node_modules` 可能上万条,不能先收集再过滤)。 if components.iter().any(|component| { - matches!( - component.to_ascii_lowercase().as_str(), - "node_modules" | ".git" | ".svn" - ) + BUNDLE_EXCLUDED_ANY_LEVEL_DIRS + .iter() + .any(|excluded| component.eq_ignore_ascii_case(excluded)) }) { return true; } - // 构建产物与 IDE / 编辑器目录:项目根首层,外加 AGC 网页脚手架的 `game/dist`、`game/build`。 - if matches!( - first.as_str(), - "dist" | "build" | "library" | "temp" | "local" | ".idea" | ".vscode" | ".godot" - ) { - return true; - } - if first == "game" - && second - .as_deref() - .is_some_and(|value| matches!(value, "dist" | "build")) + if BUNDLE_EXCLUDED_ROOT_BUILD_DIRS + .iter() + .chain(BUNDLE_EXCLUDED_ROOT_IDE_DIRS) + .chain(BUNDLE_EXCLUDED_ROOT_AGC_DIRS) + .any(|excluded| first == *excluded) { return true; } - bundle_excludes_credential_file(file_name) || bundle_excludes_agc_owned_path(&first) + // 客户端额外项:AGC 网页脚手架的构建输出落在 `game/dist` / `game/build`。 + if first == "game" + && second.as_deref().is_some_and(|value| { + BUNDLE_EXCLUDED_GAME_BUILD_DIRS + .iter() + .any(|excluded| value == *excluded) + }) + { + return true; + } + bundle_excludes_sensitive_file_name(file_name) } -/// 凭据与隐私类文件名:任意层级都要排除(源码外发不能带 `.env` 与各类私钥)。 -fn bundle_excludes_credential_file(file_name: &str) -> bool { +/// 凭据、隐私与嵌套压缩包:按文件名判定,任意层级都要排除。 +/// +/// 与服务端 `is_sensitive_file_name` 逐条对应(前缀 / 后缀 / 全名三类),并额外覆盖 `.zip` +/// ——服务端在 `reject_forbidden_path` 里以同样的后缀形式拒收嵌套压缩包。 +fn bundle_excludes_sensitive_file_name(file_name: &str) -> bool { let name = file_name.to_ascii_lowercase(); - name == ".env" - || name.starts_with(".env.") - || name == ".npmrc" - || name == ".netrc" - || [".pem", ".key", ".p12", ".pfx"] + BUNDLE_EXCLUDED_FILE_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) + || BUNDLE_EXCLUDED_FILE_SUFFIXES .iter() .any(|suffix| name.ends_with(suffix)) -} - -/// AGC 自有、与「可编辑游戏工程」无关的项目根目录。 -/// -/// - `.agent`:项目身份(`manifest.json`)、Agent 运行数据(`agent.db`、会话、日志、checkpoint) -/// 与本机改编来源记录。身份必须由收到方自己建项生成(模板包指南里同一条理由);来源记录 -/// 更必须是收到方**自己取件时**写入的事实——把父作品的记录原样带过去等于伪造血缘声明。 -/// - `exports`:AGC 生成的试玩包(`exports/playtest-package-*.zip`),是产物不是源码。 -/// - `memory`:Agent 的记忆与策划产物(`memory/agents/**`),构建与运行游戏都不需要它。 -fn bundle_excludes_agc_owned_path(first_component_lowercase: &str) -> bool { - matches!(first_component_lowercase, ".agent" | "exports" | "memory") + || BUNDLE_EXCLUDED_FILE_NAMES + .iter() + .any(|excluded| name == *excluded) } #[cfg(test)] @@ -435,35 +497,70 @@ mod tests { } #[test] - fn bundle_excludes_credentials_at_any_level() { + fn bundle_excludes_credentials_and_privacy_files_at_any_level() { let root = test_root("credentials"); write_fixture_project(&root); write_file(&root, ".env", b"TOKEN=1"); write_file(&root, ".env.local", b"TOKEN=2"); + // 服务端用 `starts_with(".env")`,`.envrc` 这类同样落在前缀里,客户端必须一起排。 + write_file(&root, ".envrc", b"export TOKEN=3"); write_file(&root, ".npmrc", b"//registry.test/:_authToken=x"); write_file(&root, ".netrc", b"machine x"); + write_file(&root, ".git-credentials", b"https://user:token@host"); + write_file(&root, "deploy/id_rsa", b"private key"); + write_file(&root, "deploy/id_ed25519.pub", b"public key"); write_file(&root, "game/certs/dev.pem", b"pem"); write_file(&root, "game/certs/key.KEY", b"key"); write_file(&root, "assets/signing.p12", b"p12"); write_file(&root, "game/ios/export.pfx", b"pfx"); + // 源码映射会暴露原始源码与内部路径,服务端按隐私类拒收。 + write_file(&root, "game/dist/app.js.map", b"{\"version\":3}"); + write_file(&root, "game/src/app.js.map", b"{\"version\":3}"); let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); let entries = entry_set(&bundle); for excluded in [ ".env", ".env.local", + ".envrc", ".npmrc", ".netrc", + ".git-credentials", + "deploy/id_rsa", + "deploy/id_ed25519.pub", "game/certs/dev.pem", "game/certs/key.KEY", "assets/signing.p12", "game/ios/export.pfx", + "game/dist/app.js.map", + "game/src/app.js.map", ] { assert!(!entries.contains(excluded), "{excluded} 不应进包"); } assert!(entries.contains("game/index.html")); } + /// 嵌套压缩包:解包阶段不会递归校验包里的包,服务端一律拒收,客户端必须一样排掉。 + #[test] + fn bundle_excludes_nested_archives_at_any_level() { + let root = test_root("nested-archive"); + write_fixture_project(&root); + write_file(&root, "assets/sprites.zip", b"PK\x03\x04"); + write_file(&root, "game/vendor/tool.ZIP", b"PK\x03\x04"); + write_file(&root, "game/levels/pack.zip", b"PK\x03\x04"); + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + let entries = entry_set(&bundle); + for excluded in [ + "assets/sprites.zip", + "game/vendor/tool.ZIP", + "game/levels/pack.zip", + ] { + assert!(!entries.contains(excluded), "{excluded} 不应进包"); + } + assert!(entries.contains("assets/hero.png")); + } + #[test] fn bundle_excludes_agent_state_playtest_packages_and_memory() { let root = test_root("agc-owned"); @@ -475,6 +572,8 @@ mod tests { br#"{"gameId":"game_parent"}"#, ); write_file(&root, ".agent/conversations/project.jsonl", b"{}\n"); + // 平台保留名按「任意层级」对齐服务端:嵌套的项目状态同样不能外发。 + write_file(&root, "game/.agent/state.json", b"{}"); write_file(&root, "exports/playtest-package-1.zip", b"PK\x03\x04"); write_file(&root, "exports/README.md", b"# playtest"); write_file(&root, "memory/agents/design.md", b"agent memory"); @@ -485,6 +584,7 @@ mod tests { ".agent/manifest.json", ".agent/fork-source.json", ".agent/conversations/project.jsonl", + "game/.agent/state.json", "exports/playtest-package-1.zip", "exports/README.md", "memory/agents/design.md", @@ -492,7 +592,7 @@ mod tests { assert!(!entries.contains(excluded), "{excluded} 不应进包"); } // 身份与来源记录都由收到方自己建项/取件时生成,不能从包里继承。 - assert!(entries.iter().all(|entry| !entry.starts_with(".agent/"))); + assert!(entries.iter().all(|entry| !entry.contains(".agent/"))); } #[test] @@ -660,15 +760,15 @@ mod tests { } #[test] - fn bundle_fails_closed_when_total_bytes_exceed_limit() { - let root = test_root("limit-total-bytes"); + fn bundle_fails_closed_when_expanded_bytes_exceed_limit() { + let root = test_root("limit-expanded-bytes"); write_file(&root, "game/index.html", b"0123456789"); write_file(&root, "game/extra.js", b"0123456789"); let error = build_project_bundle_with_limits( &root, ProjectBundleScope::WholeProject, ProjectBundleLimits { - max_total_bytes: 15, + max_expanded_bytes: 15, ..ProjectBundleLimits::default() }, ) @@ -679,7 +779,7 @@ mod tests { &root, ProjectBundleScope::WholeProject, ProjectBundleLimits { - max_total_bytes: 20, + max_expanded_bytes: 20, ..ProjectBundleLimits::default() }, ) @@ -687,14 +787,69 @@ mod tests { } #[test] - fn contract_limits_match_the_template_package_order_of_magnitude() { - // 与服务端校验器比对后定稿的锚点:写死在测试里,改常量必须是有意识的决定。 - assert_eq!(PROJECT_BUNDLE_MAX_FILES, 4_096); - assert_eq!(PROJECT_BUNDLE_MAX_FILE_BYTES, 256 * 1024 * 1024); - assert_eq!(PROJECT_BUNDLE_MAX_TOTAL_BYTES, 512 * 1024 * 1024); + fn bundle_fails_closed_when_archive_bytes_exceed_limit() { + let root = test_root("limit-archive-bytes"); + write_file(&root, "game/index.html", b""); + let error = build_project_bundle_with_limits( + &root, + ProjectBundleScope::WholeProject, + ProjectBundleLimits { + max_archive_bytes: 16, + ..ProjectBundleLimits::default() + }, + ) + .expect_err("压缩包超过上限必须失败"); + assert!(error.contains("压缩后超过上限"), "{error}"); + // 上限给足时必须能打出来(证明上面那条是上限在拦,不是别的原因)。 + assert!(build_project_bundle_with_limits( + &root, + ProjectBundleScope::WholeProject, + ProjectBundleLimits { + max_archive_bytes: 4 * 1024, + ..ProjectBundleLimits::default() + }, + ) + .is_ok()); + } + + #[test] + fn bundle_fails_closed_when_compression_ratio_exceeds_limit() { + let root = test_root("limit-compression-ratio"); + // 4 KiB 的高度可压缩内容:压缩后约几百字节,倍数显然大于 1。 + write_file(&root, "game/levels/big.txt", &vec![b'a'; 4 * 1024]); + let error = build_project_bundle_with_limits( + &root, + ProjectBundleScope::WholeProject, + ProjectBundleLimits { + max_compression_ratio: 1, + ..ProjectBundleLimits::default() + }, + ) + .expect_err("压缩比超过上限必须失败"); + assert!(error.contains("压缩比超过上限"), "{error}"); + // 默认倍数(100)下同一份内容必须能正常打包:源码的压缩比远低于上限。 + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject) + .expect("default ratio must accept a compressible source file"); + assert_eq!(bundle.file_count, 1); + } + + #[test] + fn contract_limits_match_the_server_validator_exactly() { + // 锚点:与服务端 `MAX_PROJECT_*` 逐项相等。这些值由 + // `scripts/check-project-bundle-policy-parity.mjs` 机器比对,改数值必须是有意识的决定。 + assert_eq!(PROJECT_BUNDLE_MAX_ARCHIVE_BYTES, 200 * 1024 * 1024); + assert_eq!(PROJECT_BUNDLE_MAX_EXPANDED_BYTES, 500 * 1024 * 1024); + assert_eq!(PROJECT_BUNDLE_MAX_FILE_BYTES, 64 * 1024 * 1024); + assert_eq!(PROJECT_BUNDLE_MAX_FILES, 10_000); + assert_eq!(PROJECT_BUNDLE_MAX_COMPRESSION_RATIO, 100); + let limits = ProjectBundleLimits::default(); + assert_eq!(limits.max_archive_bytes, PROJECT_BUNDLE_MAX_ARCHIVE_BYTES); + assert_eq!(limits.max_expanded_bytes, PROJECT_BUNDLE_MAX_EXPANDED_BYTES); + assert_eq!(limits.max_file_bytes, PROJECT_BUNDLE_MAX_FILE_BYTES); + assert_eq!(limits.max_files, PROJECT_BUNDLE_MAX_FILES); assert_eq!( - ProjectBundleLimits::default().max_files, - PROJECT_BUNDLE_MAX_FILES + limits.max_compression_ratio, + PROJECT_BUNDLE_MAX_COMPRESSION_RATIO ); } diff --git a/package.json b/package.json index 43f20797b..e8edf38ac 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", "check:generated-bindings": "node scripts/check-generated-bindings.mjs", "check:game-distribution-dto-parity": "node scripts/check-game-distribution-dto-parity.mjs", + "check:project-bundle-policy-parity": "node scripts/check-project-bundle-policy-parity.mjs", "check:game-distribution-media-e2e": "node scripts/check-game-distribution-media-e2e.mjs", "check:game-distribution-owner-isolation": "node scripts/check-game-distribution-owner-isolation.mjs", "check:game-distribution-upload-safety": "node scripts/check-game-distribution-upload-safety.mjs", @@ -138,7 +139,7 @@ "check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && npm run check:module-runtime-artifact && node scripts/check-server-rs-ddd-boundaries.mjs", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", - "lint": "npm run check:encoding && npm run check:doc-index && npm run check:npm-workspaces && npm run check:git-hooks && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:generated-bindings && npm run check:game-distribution-dto-parity && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run check:nginx-spa-routes && npm run check:pingora-route-parity && npm run lint:eslint && npm run typecheck", + "lint": "npm run check:encoding && npm run check:doc-index && npm run check:npm-workspaces && npm run check:git-hooks && npm run check:rustfmt && npm run check:spacetime-schema && npm run check:generated-bindings && npm run check:game-distribution-dto-parity && npm run check:project-bundle-policy-parity && npm run check:production-ops && npm run check:preview-deployer && npm run check:maintenance-page && npm run check:nginx-spa-routes && npm run check:pingora-route-parity && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "format:rust": "cargo fmt --all --manifest-path server-rs/Cargo.toml && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "format": "prettier --write . && npm run format:rust", diff --git a/scripts/check-project-bundle-policy-parity.mjs b/scripts/check-project-bundle-policy-parity.mjs new file mode 100644 index 000000000..0252ff33c --- /dev/null +++ b/scripts/check-project-bundle-policy-parity.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +// 检查 AGC 工程源包打包器(客户端)与服务端校验器的排除规则 / 规模上限是否逐条一致。 +// +// 为什么需要它:同一套「源码包能装什么」的规则有两份实现——客户端 Rust 打包器 +// (`apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs`)负责**先拦**,服务端校验器 +// (`server-rs/crates/module-game-distribution/src/project_bundle.rs`)负责**最终把关**。两份已经 +// 真实漂移过一次(凭据清单缺 `.map` / 单文件上限比服务端宽 4 倍,客户端会打出服务端必然 422 的包)。 +// +// 比对口径(服务端是权威): +// 1. 规模上限:一一对应且必须**相等**(客户端更宽 = 白传一趟被拒;客户端更严 = 合法工程打不出来); +// 2. 规则 token:服务端每一条都必须在客户端存在(客户端可以先拦,绝不能漏拦); +// 3. 客户端额外项(`game/dist`、`game/build`、`.godot`、`exports`、`memory`):只打印,不算失败, +// 它们是客户端策略,不改变服务端会接受什么。 +// +// 抽不到 token / 数值一律报错退出:正则失配导致的「空集合」绝不能被当成绿灯。 + +import fs from 'node:fs'; + +const CLIENT_FILE = 'apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs'; +const SERVER_FILE = 'server-rs/crates/module-game-distribution/src/project_bundle.rs'; + +// [服务端常量名, 客户端常量名] +const LIMIT_PAIRS = [ + ['MAX_PROJECT_BUNDLE_BYTES', 'PROJECT_BUNDLE_MAX_ARCHIVE_BYTES'], + ['MAX_PROJECT_EXPANDED_BYTES', 'PROJECT_BUNDLE_MAX_EXPANDED_BYTES'], + ['MAX_PROJECT_FILE_BYTES', 'PROJECT_BUNDLE_MAX_FILE_BYTES'], + ['MAX_PROJECT_FILE_COUNT', 'PROJECT_BUNDLE_MAX_FILES'], + ['MAX_PROJECT_COMPRESSION_RATIO', 'PROJECT_BUNDLE_MAX_COMPRESSION_RATIO'], +]; + +function readFile(path) { + if (!fs.existsSync(path)) { + throw new Error(`文件不存在:${path}`); + } + return fs.readFileSync(path, 'utf8'); +} + +/// 取出一个函数的正文(从 `fn NAME` 到下一个顶层 `fn `),用于限定 token 的抓取范围。 +function functionBody(source, name) { + const start = source.indexOf(`fn ${name}`); + if (start < 0) { + throw new Error(`找不到函数 ${name}`); + } + const rest = source.slice(start); + const next = rest.indexOf('\nfn ', 1); + return next < 0 ? rest : rest.slice(0, next); +} + +/// 把 Rust 里的正整数字面量表达式(只允许数字、下划线与乘号)算成数值。 +function evaluateInteger(expression, label) { + const normalized = expression.replaceAll('_', '').trim(); + if (!/^[\d*\s]+$/u.test(normalized)) { + throw new Error(`${label} 的取值不是可解析的整数表达式:${expression}`); + } + return normalized + .split('*') + .map((part) => part.trim()) + .filter(Boolean) + .reduce((product, part) => product * Number(part), 1); +} + +/// 抓取 `const NAME: 类型 = 表达式;` 的数值。 +function rustConstant(source, name, file) { + const match = new RegExp( + `const\\s+${name}\\s*:\\s*\\w+\\s*=\\s*([\\d_*\\s]+);`, + ).exec(source); + if (!match) { + throw new Error(`${file} 里找不到常量 ${name}`); + } + return evaluateInteger(match[1], `${file} 的 ${name}`); +} + +/// 抓取 `const NAME: &[&str] = &[...];` / `[&str; N] = [...]` 里的字符串集合。 +function rustStringArray(source, name, file) { + const match = new RegExp( + `const\\s+${name}\\s*:\\s*(?:&)?\\[&str(?:;\\s*\\d+)?\\]\\s*=\\s*(?:&)?\\[([\\s\\S]*?)\\];`, + ).exec(source); + if (!match) { + throw new Error(`${file} 里找不到字符串数组常量 ${name}`); + } + return [...match[1].matchAll(/"([^"]*)"/gu)].map((entry) => entry[1]); +} + +/// 抓取某段代码里所有 `X.starts_with("...")` / `ends_with` / `==` 的字符串字面量。 +function stringLiteralsAfter(source, method) { + const pattern = + method === '==' + ? /(?:^|\W)\w+\s*==\s*"([^"]*)"/gu + : new RegExp(`\\w+\\.${method}\\("([^"]*)"\\)`, 'gu'); + return [...source.matchAll(pattern)].map((entry) => entry[1]); +} + +function unique(values) { + return [...new Set(values)].sort(); +} + +function reportExtras(label, serverTokens, clientTokens) { + const known = new Set(serverTokens); + const extras = clientTokens.filter((token) => !known.has(token)); + if (extras.length > 0) { + console.log(` · ${label}:客户端额外项(允许)→ ${extras.join(', ')}`); + } +} + +function requireCovered(label, serverTokens, clientTokens, failures) { + if (serverTokens.length === 0) { + throw new Error(`${label}:服务端 token 抽取为空,比对不可信`); + } + const client = new Set(clientTokens); + for (const token of unique(serverTokens)) { + if (!client.has(token)) { + failures.push(`${label}:服务端有「${token}」,客户端没有(客户端会漏拦该内容)`); + } + } +} + +const failures = []; +const client = readFile(CLIENT_FILE); +const server = readFile(SERVER_FILE); + +// 1. 规模上限:逐项相等。 +const clientLimits = new Map(); +for (const [serverName, clientName] of LIMIT_PAIRS) { + const clientValue = rustConstant(client, clientName, CLIENT_FILE); + const serverValue = rustConstant(server, serverName, SERVER_FILE); + clientLimits.set(clientName, clientValue); + if (clientValue !== serverValue) { + failures.push( + `规模上限不一致:服务端 ${serverName}=${serverValue},客户端 ${clientName}=${clientValue}`, + ); + } +} + +// 2. 规则 token:服务端每一条都必须在客户端存在。 +const serverRejectBlock = functionBody(server, 'reject_forbidden_path'); +const serverSensitiveBlock = functionBody(server, 'is_sensitive_file_name'); + +const serverAnyLevelDirs = unique(stringLiteralsAfter(serverRejectBlock, 'eq_ignore_ascii_case')); +const serverRootBuildDirs = unique(rustStringArray(server, 'ROOT_BUILD_DIRS', SERVER_FILE)); +const serverRootIdeDirs = unique(rustStringArray(server, 'ROOT_IDE_DIRS', SERVER_FILE)); +const serverPrefixes = unique(stringLiteralsAfter(serverSensitiveBlock, 'starts_with')); +const serverSuffixes = unique([ + ...stringLiteralsAfter(serverSensitiveBlock, 'ends_with'), + ...stringLiteralsAfter(serverRejectBlock, 'ends_with'), +]); +const serverNames = unique(stringLiteralsAfter(serverSensitiveBlock, '==')); + +const clientAnyLevelDirs = unique( + rustStringArray(client, 'BUNDLE_EXCLUDED_ANY_LEVEL_DIRS', CLIENT_FILE), +); +const clientRootBuildDirs = unique( + rustStringArray(client, 'BUNDLE_EXCLUDED_ROOT_BUILD_DIRS', CLIENT_FILE), +); +const clientRootIdeDirs = unique( + rustStringArray(client, 'BUNDLE_EXCLUDED_ROOT_IDE_DIRS', CLIENT_FILE), +); +const clientGameBuildDirs = unique( + rustStringArray(client, 'BUNDLE_EXCLUDED_GAME_BUILD_DIRS', CLIENT_FILE), +); +const clientRootAgcDirs = unique( + rustStringArray(client, 'BUNDLE_EXCLUDED_ROOT_AGC_DIRS', CLIENT_FILE), +); +const clientPrefixes = unique(rustStringArray(client, 'BUNDLE_EXCLUDED_FILE_PREFIXES', CLIENT_FILE)); +const clientSuffixes = unique(rustStringArray(client, 'BUNDLE_EXCLUDED_FILE_SUFFIXES', CLIENT_FILE)); +const clientNames = unique(rustStringArray(client, 'BUNDLE_EXCLUDED_FILE_NAMES', CLIENT_FILE)); + +requireCovered( + '任意层级目录', + serverAnyLevelDirs, + clientAnyLevelDirs, + failures, +); +requireCovered( + '项目根首层构建产物目录', + serverRootBuildDirs, + clientRootBuildDirs, + failures, +); +requireCovered('项目根首层 IDE 目录', serverRootIdeDirs, clientRootIdeDirs, failures); +requireCovered('文件名前缀(凭据/隐私)', serverPrefixes, clientPrefixes, failures); +requireCovered('文件名后缀(凭据/隐私/嵌套压缩包)', serverSuffixes, clientSuffixes, failures); +requireCovered('文件名全名(凭据)', serverNames, clientNames, failures); + +// 客户端的目录 token 合并比对:任何一类里已经排掉即算覆盖。 +const clientAllDirs = unique([ + ...clientAnyLevelDirs, + ...clientRootBuildDirs, + ...clientRootIdeDirs, + ...clientGameBuildDirs, + ...clientRootAgcDirs, +]); + +console.log( + `[check:project-bundle-policy-parity] 服务端规则:任意层级目录 ${serverAnyLevelDirs.join('/')};` + + `根级构建 ${serverRootBuildDirs.join('/')};根级 IDE ${serverRootIdeDirs.join('/')};` + + `前缀 ${serverPrefixes.join('/')};后缀 ${serverSuffixes.join('/')};全名 ${serverNames.join('/')}`, +); +console.log( + `[check:project-bundle-policy-parity] 客户端规则:任意层级目录 ${clientAnyLevelDirs.join('/')};` + + `根级构建 ${clientRootBuildDirs.join('/')};根级 IDE ${clientRootIdeDirs.join('/')};` + + `game 构建 ${clientGameBuildDirs.join('/')};AGC 生成目录 ${clientRootAgcDirs.join('/')};` + + `前缀 ${clientPrefixes.join('/')};后缀 ${clientSuffixes.join('/')};全名 ${clientNames.join('/')}`, +); +reportExtras('目录', [...serverAnyLevelDirs, ...serverRootBuildDirs, ...serverRootIdeDirs], clientAllDirs); +reportExtras('文件名前缀', serverPrefixes, clientPrefixes); +reportExtras('文件名后缀', serverSuffixes, clientSuffixes); +reportExtras('文件名全名', serverNames, clientNames); + +if (failures.length > 0) { + console.error('[check:project-bundle-policy-parity] 不一致:'); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); +} +console.log( + `[check:project-bundle-policy-parity] OK:${LIMIT_PAIRS.length} 项规模上限相等,` + + `服务端规则 ${serverAnyLevelDirs.length + serverRootBuildDirs.length + serverRootIdeDirs.length + serverPrefixes.length + serverSuffixes.length + serverNames.length} 条全部在客户端覆盖。`, +);