feat(游戏共创): 工程源包规则与服务端对齐,并加机器 parity 门禁
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
规则对齐(以服务端为权威,`module-game-distribution/src/project_bundle.rs`):
- 凭据/隐私两侧补齐:客户端新增 `.git-credentials`、`id_rsa*`、`id_ed25519*`、`*.map`,
并把 `.env` 放宽成服务端同形的 `starts_with(".env")`(`.envrc` 之类一并排掉)。
反向无需补:客户端没有服务端缺的凭据类规则,比对一次后两侧凭据集合完全一致。
- 新增被漏掉的嵌套压缩包规则:服务端对任意层级 `*.zip` 一律拒收(解包阶段不递归校验,
等于绕过整份清单),客户端原来会把它打进包里 → 现在同样排除。
- `.agent` 由「项目根首层」对齐到**任意层级**(服务端口径;平台保留名两边一致比更宽松重要)。
- 客户端额外项保留并逐项写明理由:`game/dist`、`game/build`(AGC 脚手架构建输出)、
`.godot`(编辑器缓存,模板包指南同规则)、`exports`/`memory`(AGC 生成物与 Agent 记忆)。
- 规模上限统一到服务端数值:压缩包 ≤ 200 MiB(新增)、展开 ≤ 500 MiB、单文件 ≤ 64 MiB、
条目 ≤ 10 000、单文件 ≤ 包体 × 100(新增)。客户端先拦,不再出现「传到一半被 422 拒掉」。
- 顺手补上服务端已有的「大小写折叠后重名条目」防线:客户端提前失败关闭。
可机器比对的常量:规则集拆成 `BUNDLE_EXCLUDED_*` 常量、上限拆成 `PROJECT_BUNDLE_MAX_*`,
新增 `scripts/check-project-bundle-policy-parity.mjs` 逐 token/逐数值比对两侧(服务端每条规则
必须在客户端存在,客户端额外项只打印不算失败;抽不到 token 直接报错,避免假绿),并照既有
DTO parity 的形态接到 `package.json` 的 `check:*` 与 `lint` 链。
测试:客户端 17 条(新增 `.git-credentials`/`id_rsa`/`*.map`/`.envrc`/`.zip`/任意层级 `.agent`、
压缩包上限、压缩比上限各自一条;合同常量锚点改为与服务端逐项相等)。
This commit is contained in:
@@ -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<Vec<ProjectBundleEntry>, 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<Vec<u8>, 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::<Vec<_>>();
|
||||
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"<html></html>");
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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} 条全部在客户端覆盖。`,
|
||||
);
|
||||
Reference in New Issue
Block a user