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