Files
Genarrative/scripts/check-project-bundle-policy-parity.mjs
T
suzmii 524ca890c6 feat(游戏共创): 工程源包规则与服务端对齐,并加机器 parity 门禁
规则对齐(以服务端为权威,`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`、
压缩包上限、压缩比上限各自一条;合同常量锚点改为与服务端逐项相等)。
2026-10-05 17:30:48 +08:00

220 lines
9.3 KiB
JavaScript
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.
#!/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} 条全部在客户端覆盖。`,
);