Files
Genarrative/scripts/check-project-bundle-policy-parity.mjs
suzmii 3ae0d129ef
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
test(游戏共创): 一致性门禁把内容嗅探与 magic bytes 纳入比对(补最后一处规则空白)
- `scripts/check-project-bundle-policy-parity.mjs` 新增第 4 维:**凭据内容嗅探与嵌套包 magic bytes**。复核指出这两套「换名字也拦得住」的规则只靠常量表表达,过去门禁对 `sniff|PEM|AKIA|magic|secret` 零命中(只有看得见的规则才有守卫),本次补齐:
  · `SECRET_CONTENT_TEXT_EXTENSIONS` / `SECRET_CONTENT_SIGNATURES`(字符串数组集合相等)、`SECRET_CONTENT_MAX_SNIFF_BYTES` / `NESTED_ARCHIVE_SNIFF_BYTES`(整数相等)、`SECRET_AWS_ACCESS_KEY_PREFIX` / `PEM_PRIVATE_KEY_LINE_PREFIX` / `PEM_PRIVATE_KEY_MARKER`(字符串逐字相等)——新增 `rustStringConst` helper 抓 `&str` 常量
  · `nested_archive_format` 里的 `b"..."` magic 字面量按「服务端 ⊆ 客户端」比对(客户端可以先拦、不能漏拦),并用新的 `byteStringLiterals` helper 抽取;两侧空集合一律 throw
- **「故意改一侧会红」已验证(两处,均已原样恢复)**:① 从客户端 `BUNDLE_FORBIDDEN_PATH_CHARS` 去掉 `'?'`/`'*'` → 报两条「路径段禁止字符…客户端没有」;② 从客户端 `SECRET_CONTENT_SIGNATURES` 去掉 `ghp_` → 报「嗅探规则不一致:服务端 …=ghp_|github_pat_|…,客户端 …=github_pat_|…」
- 文档 §3.5.2 与 §5.2 同步门禁的四个覆盖维度(目录/前缀/后缀/全名 + 上限、路径形状、内容嗅探特征表、magic bytes),并把 §5.2 的门禁行从「P1-a」扩为「P1-a + 嗅探维度」
- 门禁:`check-project-bundle-policy-parity` 0(服务端 51 条规则全被客户端覆盖;嗅探:`ghp_/github_pat_/xoxa-/xoxb-/xoxp-` + `AKIA` + PEM 标记;magic:7z / PK / Rar! / gzip / ustar;窗口 512 字节两侧一致);`check:doc-index` 248 份 OK;`check:encoding` 5305 files OK;`git diff --check` 0
2026-10-05 17:42:46 +08:00

367 lines
17 KiB
JavaScript
Raw Permalink 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);
}
/// 抓取 `const NAME: &str = "...";` 的字面量(两侧必须完全一致的那类常量)。
function rustStringConst(source, name, file) {
const match = new RegExp(`const\\s+${name}\\s*:\\s*&str\\s*=\\s*"([^"]*)"\\s*;`).exec(
source,
);
if (!match) {
throw new Error(`${file} 里找不到字符串常量 ${name}`);
}
return match[1];
}
/// 抓取某段代码里所有 `b"..."` 字节串字面量的**源码文本**(magic bytes 比对用)。
function byteStringLiterals(source) {
return unique([...source.matchAll(/b"((?:\\.|[^"\\])*)"/gu)].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);
// 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(' ')}`,
);
// 4. 凭据内容嗅探与嵌套包 magic bytes:这两套规则集也必须两端一致。
//
// 为什么单独一维(复核 §12 的最后一处空白):内容嗅探与 magic 嗅探是「换名字也拦得住」的那一层,
// 但它们只靠常量表表达;常量表一旦只改一侧,门禁过去完全看不见。这里把六个常量做成
// **必须逐字相等**的对,并把 `nested_archive_format` 里的 `b"..."` magic 字面量做成
// 「服务端 ⊆ 客户端」(客户端可以先拦,绝不能漏拦),空集合一律报错。
const SECRET_EQUAL_PAIRS = [
// [服务端常量, 客户端常量, 抓取方式];抓取方式:array=字符串数组集合相等,int=整数相等,str=字符串相等
['SECRET_CONTENT_TEXT_EXTENSIONS', 'SECRET_CONTENT_TEXT_EXTENSIONS', 'array'],
['SECRET_CONTENT_MAX_SNIFF_BYTES', 'SECRET_CONTENT_MAX_SNIFF_BYTES', 'int'],
['SECRET_CONTENT_SIGNATURES', 'SECRET_CONTENT_SIGNATURES', 'array'],
['SECRET_AWS_ACCESS_KEY_PREFIX', 'SECRET_AWS_ACCESS_KEY_PREFIX', 'str'],
['PEM_PRIVATE_KEY_LINE_PREFIX', 'PEM_PRIVATE_KEY_LINE_PREFIX', 'str'],
['PEM_PRIVATE_KEY_MARKER', 'PEM_PRIVATE_KEY_MARKER', 'str'],
['NESTED_ARCHIVE_SNIFF_BYTES', 'NESTED_ARCHIVE_SNIFF_BYTES', 'int'],
];
for (const [serverName, clientName, kind] of SECRET_EQUAL_PAIRS) {
let serverValue;
let clientValue;
if (kind === 'array') {
serverValue = rustStringArray(server, serverName, SERVER_FILE).sort().join('|');
clientValue = rustStringArray(client, clientName, CLIENT_FILE).sort().join('|');
} else if (kind === 'int') {
serverValue = rustConstant(server, serverName, SERVER_FILE);
clientValue = rustConstant(client, clientName, CLIENT_FILE);
} else {
serverValue = rustStringConst(server, serverName, SERVER_FILE);
clientValue = rustStringConst(client, clientName, CLIENT_FILE);
}
if (serverValue !== clientValue) {
failures.push(
`嗅探规则不一致:服务端 ${serverName}=${serverValue},客户端 ${clientName}=${clientValue}`,
);
}
}
const serverMagicBytes = byteStringLiterals(functionBody(server, 'nested_archive_format'));
const clientMagicBytes = byteStringLiterals(functionBody(client, 'nested_archive_format'));
requireCovered('嵌套包 magic bytes', serverMagicBytes, clientMagicBytes, failures);
if (clientMagicBytes.length === 0) {
throw new Error('嵌套包 magic bytes:客户端 token 抽取为空,比对不可信');
}
console.log(
`[check:project-bundle-policy-parity] 嗅探:内容特征 ${rustStringArray(server, 'SECRET_CONTENT_SIGNATURES', SERVER_FILE).join('/')} + ${rustStringConst(server, 'SECRET_AWS_ACCESS_KEY_PREFIX', SERVER_FILE)}… + PEM 标记;` +
`magic bytes ${serverMagicBytes.join(' ')};嗅探窗口 ${rustConstant(server, 'NESTED_ARCHIVE_SNIFF_BYTES', SERVER_FILE)} 字节`,
);
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} 条全部在客户端覆盖。`,
);