Files
Genarrative/scripts/check-project-bundle-policy-parity.mjs
T
suzmii 9403c3ee32
Project CI / Backend tests (pull_request) Failing after 20s
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 / 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 / Native shell tests (pull_request) Has been cancelled
test(游戏共创): 一致性门禁补「路径形状」维度,并同步校验器文档
- `scripts/check-project-bundle-policy-parity.mjs` 新增第 3 维:服务端 `normalize_archive_path` 的拒绝形状必须被客户端打包器的三个常量逐 token 覆盖——**哨兵段**(`.` / `..`)与**路径段结尾字符**(空格 / 点)分别用 `rustStringArray` / `rustCharArray` 抽取,**路径段禁止字符**同时抓 `.contains('x')` 与 `matches!(expr, 'a' | 'b')` 两种写法;再单独钉「两侧都必须显式拒 `/` 开头与 `\`」两条独立分支(它们靠常量表覆盖不到)。**空集合一律 throw**,沿用既有「抽不到不许当绿灯」的写法
- 修掉我自己在第一版里写错的两处(由补强过程中的实测发现,并已用临时副本验证修法):① `BUNDLE_FORBIDDEN_PATH_SEGMENTS` 是 `&[&str]` 而非 `&[char]`,须用 `rustStringArray`(`".."` 也无法用 char 字面量表达);② 反斜杠分支的正则写成了 4 个字面反斜杠,而源码是 `contains('\\')`(2 个),已改为 `/contains\('\\\\'\)/u`
- **「故意破坏会红」已验证**:临时从客户端 `BUNDLE_FORBIDDEN_PATH_CHARS` 去掉 `'?'` 与 `'*'` → 门禁立即报两条「路径段禁止字符:服务端有「?」/「*」,客户端没有」,随后已原样恢复
- 文档 `§3.5.2/§3.2.3`:校验器清单同步到新规则(新增目录/前缀/后缀/全名 + 内容嗅探及其取舍理由、嵌套包扩展名并集与 magic 嗅探、读取层封顶与压缩比口径更正、路径形状维度),并新增**产品口径「知情同意」**一条——清单是黑名单,`docs/`、`*.pdf`、`notes.txt`、截图等不在任何拒绝集内会原样外发,发布面板必须写明「整个项目目录(除少数排除项)会原样公开」
- 门禁:policy parity 0(服务端 51 条全被客户端覆盖 + 路径形状维度 OK);`check:doc-index` 248 份 OK;`check:encoding` OK;`git diff --check` 0
2026-10-05 16:54:14 +08:00

302 lines
14 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]);
}
/// 抓取 `const NAME: &[char] = &['a', 'b'];` / `[char; N] = [...]` 里的字符字面量(按源码文本比较)。
function rustCharArray(source, name, file) {
const match = new RegExp(
`const\\s+${name}\\s*:\\s*(?:&)?\\[char(?:;\\s*\\d+)?\\]\\s*=\\s*(?:&)?\\[([\\s\\S]*?)\\];`,
).exec(source);
if (!match) {
throw new Error(`${file} 里找不到字符数组常量 ${name}`);
}
return [...match[1].matchAll(/'((?:\\.|[^'\\])*)'/gu)].map((entry) => entry[1]);
}
/// 抓取某段代码里匹配给定正则的字符字面量(用于 `.ends_with('x')` / `.contains('x')` /
/// `matches!(expr, 'a' | 'b')` 这三种路径形状写法)。比较的是**源码文本**(例如反斜杠是 `\\`),
/// 因此两侧必须用同一种写法。
function charLiteralsMatching(source, pattern) {
return [...source.matchAll(pattern)].flatMap((entry) =>
[...entry[0].matchAll(/'((?:\\.|[^'\\])*)'/gu)].map((literal) => literal[1]),
);
}
/// `matches!(<expr>, 'a' | 'b' | ...)`:只取第二个参数里的字符字面量。
function matchesMacroChars(source) {
return charLiteralsMatching(source, /matches!\([^,]*,\s*[^)]*\)/gu);
}
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);
// 3. 路径形状规则:服务端 `normalize_archive_path` 的拒绝形状,必须被客户端打包器的
// `BUNDLE_FORBIDDEN_*` 常量逐 token 覆盖。
//
// 为什么单独加这一维:过去只比目录名/文件名/数值,于是「客户端能打出、服务端必拒」的路径
// 形状完全没人管——macOS/Linux 上 `src/a?.ts`、`x.`、`a//b` 这类名字客户端放行、服务端 422,
// 作者白传一趟。服务端 `normalize_archive_path` 是权威:段命中哨兵(`.` / `..`)、段以空格或
// 点结尾、段含 `:<>"|?*` 或反斜杠、路径以 `/` 开头,一律拒。
//
// 客户端对应的检查在打包器里(`bundle_entry_path_shape_error`),规则以三个常量表达,
// 因此这里比对的是「服务端的字面量」⊆「客户端的常量」。抽不到一律报错。
const SERVER_PACKAGE_FILE = 'server-rs/crates/module-game-distribution/src/package.rs';
const serverPackage = readFile(SERVER_PACKAGE_FILE);
const serverPathBlock = functionBody(serverPackage, 'normalize_archive_path');
const clientPathBlock = functionBody(client, 'bundle_entry_path_shape_error');
const serverPathSegments = unique(stringLiteralsAfter(serverPathBlock, '=='));
const serverPathSuffixChars = unique(
charLiteralsMatching(serverPathBlock, /\.ends_with\('(?:\\.|[^'\\])*'\)/gu),
);
const serverPathForbiddenChars = unique([
...charLiteralsMatching(serverPathBlock, /\.contains\('(?:\\.|[^'\\])*'\)/gu),
...matchesMacroChars(serverPathBlock),
]);
const clientPathSegments = unique(
rustStringArray(client, 'BUNDLE_FORBIDDEN_PATH_SEGMENTS', CLIENT_FILE),
);
const clientPathSuffixChars = unique(
rustCharArray(client, 'BUNDLE_FORBIDDEN_PATH_SUFFIX_CHARS', CLIENT_FILE),
);
const clientPathForbiddenChars = unique(
rustCharArray(client, 'BUNDLE_FORBIDDEN_PATH_CHARS', CLIENT_FILE),
);
requireCovered('路径段哨兵', serverPathSegments, clientPathSegments, failures);
requireCovered('路径段结尾字符', serverPathSuffixChars, clientPathSuffixChars, failures);
requireCovered('路径段禁止字符', serverPathForbiddenChars, clientPathForbiddenChars, failures);
// 两侧都必须拒「以 / 开头的绝对路径」与「反斜杠」:这两条在服务端是独立分支,
// 客户端如果只靠常量表覆盖不到(`/` 是分隔符、不能进禁止字符集),所以单独钉一次。
for (const [label, block] of [
['服务端 normalize_archive_path', serverPathBlock],
['客户端 bundle_entry_path_shape_error', clientPathBlock],
]) {
if (!/starts_with\('\/'\)/u.test(block)) {
failures.push(`${label} 必须显式拒绝以 / 开头的绝对路径(starts_with('/'))`);
}
if (!/contains\('\\\\'\)/u.test(block)) {
failures.push(`${label} 必须显式拒绝反斜杠路径(contains('\\\\'))`);
}
}
console.log(
`[check:project-bundle-policy-parity] 路径形状:哨兵 ${serverPathSegments.join('/')};` +
`结尾字符 ${serverPathSuffixChars.join(' ')};禁止字符 ${serverPathForbiddenChars.join(' ')}`,
);
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} 条全部在客户端覆盖。`,
);