diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs index f50a21077..adf122338 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs @@ -36,19 +36,13 @@ pub(crate) fn verify_fork_source_bytes( bytes.len() )); } - let actual = sha256_hex(bytes); + let actual = crate::project_bundle::sha256_hex(bytes); if actual != expected_sha256.trim().to_ascii_lowercase() { return Err("改编来源发行包完整性校验失败,已放弃落盘".to_string()); } Ok(()) } -fn sha256_hex(bytes: &[u8]) -> String { - let mut hasher = sha2::Sha256::new(); - hasher.update(bytes); - format!("{:x}", hasher.finalize()) -} - /// 参考副本相对项目的路径:`reference//`。 /// /// 作品 ID 来自平台响应,只接受路径安全取值;拼接复用归档条目门禁(拒绝 `..`、盘符与 @@ -164,6 +158,7 @@ pub(crate) fn create_project_from_platform_fork_at( #[cfg(test)] mod tests { use super::*; + use crate::project_bundle::sha256_hex; use zip::write::SimpleFileOptions; fn test_root(label: &str) -> PathBuf { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 44d869b3e..8f6fe9a58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -120,6 +120,7 @@ mod process_identity; mod process_session; mod process_session_bridge; mod project; +mod project_bundle; mod project_snapshot; mod repository_context; mod resource_inspect; 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 new file mode 100644 index 000000000..ea75d9994 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs @@ -0,0 +1,713 @@ +//! 工程源包(project bundle):把当前项目打成**确定性 ZIP**,供 M2b「版本级工程源包」上传使用。 +//! +//! 与 `project/export.rs` 的发行包是两条路:发行包只装构建产物(`dist` 树 + 根 `assets` + +//! `exports/README.md`,见 `collect_project_export_package_files`),并且会做资源引用归一化; +//! 工程源包要的是**作者本地的可编辑工程**(源码 + 配置 + 素材),因此**不改写任何一个字节**, +//! 收到方解压后就是一份能继续改、能重新构建的工程(`docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §3.5.2)。 +//! +//! **ZIP 根目录 == 项目根**(与模板包同一条合同):条目名一律是**项目根相对**路径,与打包范围 +//! 无关;`ProjectBundleScope::GameDirectory` 只缩小内容范围(只收集 `game/` 下的内容),不改变 +//! 条目命名口径——因此收到方总是往项目根解。 +//! +//! 复用了哪些既有能力(避免第二套真相): +//! - 条目路径门禁用解压侧同一个 `template_library::safe_archive_relative_path`(拒绝对路径、 +//! `..`、盘符、反斜杠,并统一成 `/`),保证「打得出来就一定解得回去」; +//! - 根相对路径用 `project::relative_project_path`; +//! - 符号链接与 Windows 重解析点用 `image_inspect::metadata_is_windows_reparse_point`; +//! - 确定性 ZIP 写法沿用 `project/export.rs` 归一化发行包那套(先排序再逐文件写入)。 +//! +//! **未**复用 `collect_project_export_package_dir_files`:它没有「按排除规则剪枝」的钩子,会先 +//! 遍历整个 `node_modules` 再交给调用方过滤;而且它对非普通条目是静默忽略,本模块要求失败关闭。 +//! +//! ## 排除规则(口径与服务端校验器同批,逐条见 `bundle_excludes_relative_path`) +//! +//! | 类别 | 规则 | 理由 | +//! | --- | --- | --- | +//! | 依赖与版本库 | 任意层级 `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` 的注释 | +//! +//! 规模上限与模板包一致(条目 ≤ 4096、单文件 ≤ 256 MiB、解压后总量 ≤ 512 MiB),超限**失败关闭** +//! 并点名违规条目,不静默截断。 +//! +//! **本轮不接上传**:上行路由族落地后由发布链路在「授权非禁止」时调用本模块。在接线之前非 test +//! 构建没有调用方,因此整模块带 `#![allow(dead_code)]`;接线时删除该属性。 +#![allow(dead_code)] + +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 里给每个条目写的固定时间戳:ZIP 能表示的最早时刻(1980-01-01 00:00:00)。 +/// 同内容必须得到同一份字节,所以绝不写「打包时刻」。 +const PROJECT_BUNDLE_FIXED_ZIP_SECONDS: (u16, u8, u8, u8, u8, u8) = (1980, 1, 1, 0, 0, 0); +/// ZIP 里给每个条目写的固定权限位:普通文件 0o644,不受本机 umask 与可执行位影响。 +const PROJECT_BUNDLE_FILE_MODE: u32 = 0o644; + +/// 打包范围:只决定**收集哪些内容**,不改变条目命名(条目始终相对项目根)。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectBundleScope { + /// 整个项目(源码 + 配置 + 素材);M2b 上传用这一档。 + WholeProject, + /// 只收集 `game/` 下的内容(`game/index.html` 这类源码入口及其同级文件)。 + 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, +} + +impl Default for ProjectBundleLimits { + fn default() -> Self { + Self { + max_files: PROJECT_BUNDLE_MAX_FILES, + max_file_bytes: PROJECT_BUNDLE_MAX_FILE_BYTES, + max_total_bytes: PROJECT_BUNDLE_MAX_TOTAL_BYTES, + } + } +} + +/// 打好的工程源包。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectBundle { + pub(crate) bytes: Vec, + pub(crate) sha256: String, + pub(crate) file_count: usize, + /// 与 ZIP 条目一一对应(项目根相对、`/` 分隔、已排序)。 + pub(crate) entry_paths: Vec, +} + +/// 打包器与服务端摘要、Fork 取件校验共用的同一个十六进制摘要实现。 +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = sha2::Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +/// 把项目打成确定性工程源包(按合同上限)。 +pub(crate) fn build_project_bundle( + root: &Path, + scope: ProjectBundleScope, +) -> Result { + build_project_bundle_with_limits(root, scope, ProjectBundleLimits::default()) +} + +/// 同上,但上限可注入:只给测试用小上限跑同一段检查逻辑用。 +pub(crate) fn build_project_bundle_with_limits( + root: &Path, + scope: ProjectBundleScope, + limits: ProjectBundleLimits, +) -> Result { + if root.as_os_str().is_empty() || !root.is_absolute() { + return Err("工程源包打包需要项目根的绝对路径".to_string()); + } + let root_metadata = fs::symlink_metadata(root) + .map_err(|error| format!("读取项目根失败:{}: {error}", root.display()))?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err("工程源包的项目根必须是普通目录".to_string()); + } + let walk_root = match scope { + ProjectBundleScope::WholeProject => root.to_path_buf(), + ProjectBundleScope::GameDirectory => { + let game_root = root.join("game"); + if !game_root.is_dir() { + return Err("项目里没有 game/ 目录,无法只打包游戏目录".to_string()); + } + game_root + } + }; + + let entries = collect_project_bundle_entries(root, &walk_root, &limits)?; + if entries.is_empty() { + return Err("工程源包里没有可打包的文件(内容都被排除规则挡掉了)".to_string()); + } + let bytes = write_project_bundle_zip(&entries)?; + Ok(ProjectBundle { + sha256: sha256_hex(&bytes), + bytes, + file_count: entries.len(), + entry_paths: entries.into_iter().map(|entry| entry.entry_path).collect(), + }) +} + +/// 单个待打包条目:`entry_path` 是项目根相对的 ZIP 条目名,`project_relative` 用于排除规则。 +struct ProjectBundleEntry { + entry_path: String, + project_relative: String, + absolute: PathBuf, + size: u64, +} + +/// 递归收集(按排除规则剪枝),并按条目名排序——排序是确定性的一部分。 +fn collect_project_bundle_entries( + root: &Path, + walk_root: &Path, + limits: &ProjectBundleLimits, +) -> Result, String> { + let mut entries = Vec::new(); + let mut total_bytes: u64 = 0; + let mut directories = vec![walk_root.to_path_buf()]; + while let Some(directory) = directories.pop() { + let mut children = fs::read_dir(&directory) + .map_err(|error| format!("读取项目目录失败:{}: {error}", directory.display()))? + .collect::, _>>() + .map_err(|error| format!("读取项目目录项失败:{}: {error}", directory.display()))?; + // 目录项顺序不稳定,先按名字排序,保证同一份内容每次都按同一顺序处理。 + children.sort_by_key(|entry| entry.file_name()); + for child in children { + let path = child.path(); + let project_relative = crate::project::relative_project_path(root, &path)?; + if bundle_excludes_relative_path(&project_relative) { + continue; + } + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取项目条目失败:{project_relative}: {error}"))?; + if metadata.file_type().is_symlink() + || crate::image_inspect::metadata_is_windows_reparse_point(&metadata) + { + return Err(format!( + "工程源包不允许符号链接或重解析点:{project_relative}" + )); + } + if metadata.is_dir() { + directories.push(path); + continue; + } + if !metadata.is_file() { + return Err(format!("工程源包只接受普通文件与目录:{project_relative}")); + } + let size = metadata.len(); + if size > limits.max_file_bytes { + return Err(format!( + "工程源包单文件超过上限({size} > {} 字节):{project_relative}", + limits.max_file_bytes + )); + } + total_bytes = total_bytes.saturating_add(size); + if total_bytes > limits.max_total_bytes { + return Err(format!( + "工程源包解压后总量超过上限(>{} 字节,累计到 {project_relative})", + limits.max_total_bytes + )); + } + if entries.len() + 1 > limits.max_files { + return Err(format!( + "工程源包条目数超过上限(>{} 条,累计到 {project_relative})", + limits.max_files + )); + } + // 条目名过一遍解压侧的同一道门禁,再统一成 `/` 分隔;两边只有一份路径规则。 + let entry_path = crate::template_library::safe_archive_relative_path(&project_relative) + .map_err(|_| format!("工程源包条目路径无效:{project_relative}"))?; + entries.push(ProjectBundleEntry { + entry_path: entry_path.to_string_lossy().replace('\\', "/"), + project_relative, + absolute: path, + size, + }); + } + } + entries.sort_by(|left, right| left.entry_path.cmp(&right.entry_path)); + Ok(entries) +} + +/// 写确定性 ZIP:条目顺序已排好,时间戳与权限位固定,压缩方式固定。 +/// +/// 只写**文件**条目,不写目录条目:解压侧会按需补父目录(`extract_template_archive` 的行为), +/// 目录条目只会给 ZIP 引入额外的可变属性。 +fn write_project_bundle_zip(entries: &[ProjectBundleEntry]) -> Result, String> { + let modified = zip::DateTime::from_date_and_time( + PROJECT_BUNDLE_FIXED_ZIP_SECONDS.0, + PROJECT_BUNDLE_FIXED_ZIP_SECONDS.1, + PROJECT_BUNDLE_FIXED_ZIP_SECONDS.2, + PROJECT_BUNDLE_FIXED_ZIP_SECONDS.3, + PROJECT_BUNDLE_FIXED_ZIP_SECONDS.4, + PROJECT_BUNDLE_FIXED_ZIP_SECONDS.5, + ) + .map_err(|error| format!("工程源包固定时间戳无效:{error}"))?; + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .last_modified_time(modified) + .unix_permissions(PROJECT_BUNDLE_FILE_MODE); + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for entry in entries { + let content = fs::read(&entry.absolute).map_err(|error| { + format!("读取工程源包文件失败:{}: {error}", entry.project_relative) + })?; + if content.len() as u64 != entry.size { + return Err(format!( + "工程源包条目在打包期间发生变化:{}", + entry.project_relative + )); + } + writer + .start_file(entry.entry_path.as_str(), options) + .map_err(|error| format!("写入工程源包条目失败:{}: {error}", entry.entry_path))?; + writer + .write_all(&content) + .map_err(|error| format!("写入工程源包内容失败:{}: {error}", entry.entry_path))?; + } + let cursor = writer + .finish() + .map_err(|error| format!("完成工程源包失败:{error}"))?; + Ok(cursor.into_inner()) +} + +/// 是否排除某个**项目根相对**路径(`/` 分隔、已规范化、大小写不敏感比较)。 +fn bundle_excludes_relative_path(relative_path: &str) -> bool { + let components = relative_path.split('/').collect::>(); + let Some(file_name) = components.last().copied() else { + return false; + }; + let first = components[0].to_ascii_lowercase(); + let second = components.get(1).map(|value| value.to_ascii_lowercase()); + // 依赖与版本库:任意层级,命中即剪枝(`node_modules` 可能上万条,不能先收集再过滤)。 + if components.iter().any(|component| { + matches!( + component.to_ascii_lowercase().as_str(), + "node_modules" | ".git" | ".svn" + ) + }) { + 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")) + { + return true; + } + bundle_excludes_credential_file(file_name) || bundle_excludes_agc_owned_path(&first) +} + +/// 凭据与隐私类文件名:任意层级都要排除(源码外发不能带 `.env` 与各类私钥)。 +fn bundle_excludes_credential_file(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"] + .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") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + fn test_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let root = std::env::temp_dir().join(format!( + "agc-project-bundle-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("create temp root"); + root + } + + fn write_file(root: &Path, relative: &str, content: &[u8]) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent"); + } + fs::write(&path, content).expect("write fixture file"); + } + + /// 一份典型 AGC 网页工程:`game/` 源码 + 根 `assets/` + 少量要排除的东西。 + fn write_fixture_project(root: &Path) { + write_file(root, "game/index.html", b""); + write_file( + root, + "game/package.json", + br#"{"dependencies":{"phaser":"4.2.1"}}"#, + ); + write_file(root, "game/vite.config.js", b"export default {};"); + write_file(root, "assets/hero.png", b"png-bytes"); + write_file(root, ".gitignore", b"node_modules\n"); + } + + fn entry_set(bundle: &ProjectBundle) -> BTreeSet { + bundle.entry_paths.iter().cloned().collect() + } + + #[test] + fn bundle_excludes_dependency_and_vcs_directories_at_any_level() { + let root = test_root("vcs"); + write_fixture_project(&root); + write_file(&root, "node_modules/phaser/package.json", b"{}"); + write_file( + &root, + "game/node_modules/left-pad/index.js", + b"module.exports = 1;", + ); + write_file(&root, ".git/config", b"[core]"); + write_file(&root, "game/.git/HEAD", b"ref: refs/heads/main"); + write_file(&root, "docs/.svn/entries", b"x"); + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + let entries = entry_set(&bundle); + assert!(entries.contains("game/index.html")); + assert!(entries.contains("assets/hero.png")); + assert!(entries.contains(".gitignore")); + for excluded in [ + "node_modules/phaser/package.json", + "game/node_modules/left-pad/index.js", + ".git/config", + "game/.git/HEAD", + "docs/.svn/entries", + ] { + assert!(!entries.contains(excluded), "{excluded} 不应进包"); + } + } + + #[test] + fn bundle_excludes_root_build_and_editor_directories_and_game_build_output() { + let root = test_root("build"); + write_fixture_project(&root); + for excluded in [ + "dist/index.html", + "build/game.js", + "library/imports.json", + "temp/cache.bin", + "local/settings.json", + ".idea/workspace.xml", + ".vscode/settings.json", + ".godot/editor_state.cfg", + // AGC 网页脚手架的构建输出落在 game/ 下:产物可重建,不该随源码外发。 + "game/dist/index.html", + "game/build/game.js", + ] { + write_file(&root, excluded, b"generated"); + } + write_file(&root, "game/src/main.js", b"console.log('kept');"); + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + let entries = entry_set(&bundle); + assert!(entries.contains("game/src/main.js")); + for excluded in [ + "dist/index.html", + "build/game.js", + "library/imports.json", + "temp/cache.bin", + "local/settings.json", + ".idea/workspace.xml", + ".vscode/settings.json", + ".godot/editor_state.cfg", + "game/dist/index.html", + "game/build/game.js", + ] { + assert!(!entries.contains(excluded), "{excluded} 不应进包"); + } + } + + #[test] + fn bundle_excludes_credentials_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"); + write_file(&root, ".npmrc", b"//registry.test/:_authToken=x"); + write_file(&root, ".netrc", b"machine x"); + 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"); + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + let entries = entry_set(&bundle); + for excluded in [ + ".env", + ".env.local", + ".npmrc", + ".netrc", + "game/certs/dev.pem", + "game/certs/key.KEY", + "assets/signing.p12", + "game/ios/export.pfx", + ] { + assert!(!entries.contains(excluded), "{excluded} 不应进包"); + } + assert!(entries.contains("game/index.html")); + } + + #[test] + fn bundle_excludes_agent_state_playtest_packages_and_memory() { + let root = test_root("agc-owned"); + write_fixture_project(&root); + write_file(&root, ".agent/manifest.json", br#"{"schemaVersion":"v1"}"#); + write_file( + &root, + ".agent/fork-source.json", + br#"{"gameId":"game_parent"}"#, + ); + write_file(&root, ".agent/conversations/project.jsonl", b"{}\n"); + 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"); + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + let entries = entry_set(&bundle); + for excluded in [ + ".agent/manifest.json", + ".agent/fork-source.json", + ".agent/conversations/project.jsonl", + "exports/playtest-package-1.zip", + "exports/README.md", + "memory/agents/design.md", + ] { + assert!(!entries.contains(excluded), "{excluded} 不应进包"); + } + // 身份与来源记录都由收到方自己建项/取件时生成,不能从包里继承。 + assert!(entries.iter().all(|entry| !entry.starts_with(".agent/"))); + } + + #[test] + fn bundle_is_deterministic_across_runs_and_creation_order() { + let first_root = test_root("deterministic-a"); + write_fixture_project(&first_root); + write_file(&first_root, "game/src/a.js", b"a"); + write_file(&first_root, "game/src/b.js", b"b"); + + // 同内容、不同创建顺序:条目必须按路径排序,摘要必须一致。 + let second_root = test_root("deterministic-b"); + write_file(&second_root, "game/src/b.js", b"b"); + write_file(&second_root, "game/src/a.js", b"a"); + write_file(&second_root, "game/index.html", b""); + write_file(&second_root, "game/vite.config.js", b"export default {};"); + write_file( + &second_root, + "game/package.json", + br#"{"dependencies":{"phaser":"4.2.1"}}"#, + ); + write_file(&second_root, "assets/hero.png", b"png-bytes"); + write_file(&second_root, ".gitignore", b"node_modules\n"); + + let first = build_project_bundle(&first_root, ProjectBundleScope::WholeProject) + .expect("first bundle"); + let second = build_project_bundle(&second_root, ProjectBundleScope::WholeProject) + .expect("second bundle"); + assert_eq!(first.sha256, second.sha256); + assert_eq!(first.bytes, second.bytes); + // 5 个基线文件 + 2 个 game/src 文件。 + assert_eq!(first.file_count, 7); + assert_eq!(first.entry_paths, second.entry_paths); + let mut sorted = first.entry_paths.clone(); + sorted.sort(); + assert_eq!(first.entry_paths, sorted, "条目必须按路径排序"); + + // 同一份目录打两次也必须同摘要(时间戳与权限位固定,不写打包时刻)。 + let again = build_project_bundle(&first_root, ProjectBundleScope::WholeProject) + .expect("repeat bundle"); + assert_eq!(again.sha256, first.sha256); + + // 改一个字节 → 摘要必须变,别把「确定性」做成「永远同一个摘要」。 + write_file(&first_root, "game/src/a.js", b"changed"); + let changed = build_project_bundle(&first_root, ProjectBundleScope::WholeProject) + .expect("changed bundle"); + assert_ne!(changed.sha256, first.sha256); + } + + #[test] + fn bundle_entries_stay_relative_and_forward_slashed() { + let root = test_root("entry-paths"); + write_fixture_project(&root); + write_file(&root, "game/src/deep/nested/mod.js", b"x"); + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + for entry in &bundle.entry_paths { + assert!(!entry.starts_with('/'), "{entry} 不能是绝对路径"); + assert!(!entry.contains('\\'), "{entry} 必须用 / 分隔"); + assert!(!entry.contains(".."), "{entry} 不能上跳"); + assert!( + !entry.contains(':'), + "{entry} 不能带盘符或冒号(与解压侧门禁一致)" + ); + // 解压侧同一道门禁必须原样接受这些条目名。 + crate::template_library::safe_archive_relative_path(entry) + .unwrap_or_else(|error| panic!("{entry} 应被解压侧接受:{error}")); + } + } + + #[test] + fn bundle_survives_the_existing_extractor_round_trip() { + let root = test_root("round-trip"); + write_fixture_project(&root); + write_file( + &root, + "game/src/game-scene.js", + b"export const hello = 'world';", + ); + + let bundle = build_project_bundle(&root, ProjectBundleScope::WholeProject).expect("bundle"); + let destination = test_root("round-trip-out"); + let written = + crate::template_library::extract_template_archive(&bundle.bytes, &destination) + .expect("工程源包必须能被既有解压器解开"); + assert_eq!(written, bundle.file_count); + for entry in &bundle.entry_paths { + assert!(destination.join(entry).is_file(), "{entry} 解压后应存在"); + } + assert_eq!( + fs::read(destination.join("game/src/game-scene.js")).unwrap(), + b"export const hello = 'world';" + ); + } + + #[test] + fn game_directory_scope_only_collects_game_content() { + let root = test_root("game-scope"); + write_fixture_project(&root); + write_file(&root, "exports/README.md", b"# playtest"); + + let bundle = + build_project_bundle(&root, ProjectBundleScope::GameDirectory).expect("game bundle"); + let entries = entry_set(&bundle); + assert!(entries.contains("game/index.html")); + assert!(entries.contains("game/package.json")); + assert!(!entries.iter().any(|entry| entry.starts_with("assets/"))); + // 条目仍然相对项目根:收到方总是往项目根解。 + assert!(entries.iter().all(|entry| entry.starts_with("game/"))); + // game/ 下只有 index.html / package.json / vite.config.js 三个文件。 + assert_eq!(bundle.file_count, 3); + } + + #[test] + fn game_directory_scope_requires_a_game_directory() { + let root = test_root("no-game-dir"); + write_file(&root, "assets/hero.png", b"png"); + let error = build_project_bundle(&root, ProjectBundleScope::GameDirectory) + .expect_err("缺少 game/ 必须失败"); + assert!(error.contains("game/"), "{error}"); + } + + #[test] + fn empty_bundle_fails_closed() { + let root = test_root("empty"); + write_file(&root, "node_modules/only/index.js", b"x"); + let error = build_project_bundle(&root, ProjectBundleScope::WholeProject) + .expect_err("空包必须失败"); + assert!(error.contains("没有可打包的文件"), "{error}"); + } + + #[test] + fn bundle_fails_closed_when_file_count_exceeds_limit() { + let root = test_root("limit-files"); + 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_files: 1, + ..ProjectBundleLimits::default() + }, + ) + .expect_err("超出条目数必须失败"); + assert!(error.contains("条目数超过上限"), "{error}"); + } + + #[test] + fn bundle_fails_closed_when_single_file_exceeds_limit() { + let root = test_root("limit-file-bytes"); + write_file(&root, "game/index.html", b"0123456789"); + let error = build_project_bundle_with_limits( + &root, + ProjectBundleScope::WholeProject, + ProjectBundleLimits { + max_file_bytes: 9, + ..ProjectBundleLimits::default() + }, + ) + .expect_err("超出单文件上限必须失败"); + assert!( + error.contains("单文件超过上限") && error.contains("game/index.html"), + "错误必须点名违规条目:{error}" + ); + } + + #[test] + fn bundle_fails_closed_when_total_bytes_exceed_limit() { + let root = test_root("limit-total-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, + ..ProjectBundleLimits::default() + }, + ) + .expect_err("超出总量上限必须失败"); + assert!(error.contains("总量超过上限"), "{error}"); + // 失败关闭而不是静默截断:这里必须真的报错,而不是给出一个被裁过的包。 + assert!(build_project_bundle_with_limits( + &root, + ProjectBundleScope::WholeProject, + ProjectBundleLimits { + max_total_bytes: 20, + ..ProjectBundleLimits::default() + }, + ) + .is_ok()); + } + + #[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); + assert_eq!( + ProjectBundleLimits::default().max_files, + PROJECT_BUNDLE_MAX_FILES + ); + } + + #[cfg(unix)] + #[test] + fn bundle_rejects_symbolic_links() { + use std::os::unix::fs::symlink; + let root = test_root("symlink"); + write_fixture_project(&root); + symlink(root.join("game/index.html"), root.join("game/linked.html")) + .expect("create symlink"); + let error = build_project_bundle(&root, ProjectBundleScope::WholeProject) + .expect_err("符号链接必须失败关闭"); + assert!(error.contains("符号链接"), "{error}"); + } +}