63e6c6345e
- 新模块 `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)]`。
714 lines
31 KiB
Rust
714 lines
31 KiB
Rust
//! 工程源包(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}");
|
||
}
|
||
}
|