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`、
压缩包上限、压缩比上限各自一条;合同常量锚点改为与服务端逐项相等)。
This commit is contained in:
@@ -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