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
This commit is contained in:
2026-10-05 16:54:14 +08:00
parent 10d1fcbc5f
commit 88368d0418
2 changed files with 90 additions and 1 deletions
@@ -90,6 +90,31 @@ function stringLiteralsAfter(source, method) {
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();
}
@@ -206,6 +231,63 @@ 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) {