Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project_bundle.rs
T
suzmii 63e6c6345e feat(游戏共创): 新增工程源包确定性打包器(M2b 客户端半,暂不接上传)
- 新模块 `src-tauri/src/project_bundle.rs`:`build_project_bundle(root, scope)` 产出
  `(bytes, sha256, file_count, entry_paths)`,口径见 `docs/【技术方案】游戏共创与作品Fork-…` §3.5.2。
- 确定性:条目按路径排序、固定时间戳(ZIP 下限 1980-01-01)、固定权限位 0o644、固定 deflate,
  只写文件条目不写目录条目;同内容必然得到同一 sha256(有测试钉住,含「改一字节即变」)。
- 路径安全:条目名一律项目根相对并过解压侧同一道门禁 `template_library::safe_archive_relative_path`;
  符号链接 / Windows 重解析点、非普通条目一律失败关闭。ZIP 根 == 项目根(与模板包同一合同)。
- 排除规则:依赖与 VCS 任意层级(`node_modules`/`.git`/`.svn`)、项目根首层构建产物与 IDE
  (`dist`/`build`/`library`/`temp`/`local`/`.idea`/`.vscode`/`.godot`)、任意层级凭据
  (`.env`/`.env.*`/`*.pem`/`*.key`/`*.p12`/`*.pfx`/`.npmrc`/`.netrc`)、AGC 自有目录
  (`.agent`/`exports`/`memory`,理由逐条写在模块注释里)。
- 带上限常量:条目 ≤ 4096、单文件 ≤ 256 MiB、展开总量 ≤ 512 MiB;超限失败关闭并点名违规条目,
  不静默截断(测试用小上限跑同一段逻辑,不另写一条检查路径)。
- 复用而非另起一套:`safe_archive_relative_path`(解压侧)、`project::relative_project_path`、
  `image_inspect::metadata_is_windows_reparse_point`、`export.rs` 的排序 + 逐文件写 ZIP 写法;
  摘要实现抽出 `project_bundle::sha256_hex`,`game_fork` 的取件校验改为复用它(少一份副本)。
- 打包范围:`WholeProject`(默认,M2b 用)与 `GameDirectory`(只收集 `game/`,条目命名不变)。
- 未接上传:上行路由族未定,模块整体带 `#![allow(dead_code)]` 并在注释里写明接线时删除。
- 测试 14 条:每类排除规则、确定性(两次/乱序/改字节)、条目路径不变量 + 解压器往返、
  两个打包范围、空包与三类超限失败关闭、合同常量锚点;符号链接用例按平台 `#[cfg(unix)]`。
2026-10-05 17:30:47 +08:00

714 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 工程源包(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<u8>,
pub(crate) sha256: String,
pub(crate) file_count: usize,
/// 与 ZIP 条目一一对应(项目根相对、`/` 分隔、已排序)。
pub(crate) entry_paths: Vec<String>,
}
/// 打包器与服务端摘要、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<ProjectBundle, String> {
build_project_bundle_with_limits(root, scope, ProjectBundleLimits::default())
}
/// 同上,但上限可注入:只给测试用小上限跑同一段检查逻辑用。
pub(crate) fn build_project_bundle_with_limits(
root: &Path,
scope: ProjectBundleScope,
limits: ProjectBundleLimits,
) -> Result<ProjectBundle, String> {
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<Vec<ProjectBundleEntry>, 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::<Result<Vec<_>, _>>()
.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<Vec<u8>, 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::<Vec<_>>();
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"<html></html>");
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<String> {
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"<html></html>");
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}");
}
}