test(游戏共创): 一致性门禁把内容嗅探与 magic bytes 纳入比对(补最后一处规则空白)
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

- `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
This commit is contained in:
2026-10-05 17:42:46 +08:00
parent 15024dab0a
commit 3ae0d129ef
2 changed files with 67 additions and 2 deletions
@@ -365,7 +365,7 @@ pub(crate) project_bundle_sha256: Option<String>,
- **嵌套包识别**:扩展名并集 `.zip` / `.tar` / `.gz` / `.tgz` / `.7z` / `.rar` / `.jar` / `.whl` / `.nupkg`,**并对每个条目按 magic bytes 嗅探**(`PK\x03\x04`、`7z`、`Rar!`、gzip、tar 的 `ustar`)——改名成 `.dat` 也拦。客户端打包器同款检查,命中即报错(指明路径与格式),不静默跳过。
- **规模检查的读取层封顶**:实际读取按**该条目声明大小**封顶(只允许多读 1 字节用于判溢出,短读同样算失败),预分配同样收紧到硬上限。因此「声明说谎的 deflate 炸弹」在**读**这一层就失败关闭,不会先解完再比对;**同一处修法同步到发行包校验器**(那里原有同样的写法)。
- **压缩比口径更正**:分母是**该条目的压缩字节**(不是整包字节),而且它**不是 zip 炸弹防线**——真正的约束是单文件 64 MiB 与累计展开 500 MiB 两条上限,压缩比只是补充。文档与代码注释都按这个语义写。
- **路径形状维度(一致性门禁新增)**:客户端过去只过 `template_library::safe_archive_relative_path`(不查结尾点/空格与 `<>"|?*`),于是 macOS/Linux 上 `src/a?.ts`、`x.` 这类名字**客户端能打、服务端必 422**(白传一趟)。现在客户端打包器自带一份与服务端逐字符对齐的路径形状检查,且 `check-project-bundle-policy-parity` 新增该维度(服务端的哨兵段 / 结尾字符 / 禁止字符必须被客户端的常量逐 token 覆盖,抽不到即报错)。
- **路径形状与嗅探维度(一致性门禁新增)**:客户端过去只过 `template_library::safe_archive_relative_path`(不查结尾点/空格与 `<>"|?*`),于是 macOS/Linux 上 `src/a?.ts`、`x.` 这类名字**客户端能打、服务端必 422**(白传一趟)。现在客户端打包器自带一份与服务端逐字符对齐的路径形状检查,且 `check-project-bundle-policy-parity` 覆盖四类:① 目录/前缀/后缀/全名 + 5 项上限;② **路径形状**(哨兵段 / 结尾字符 / 禁止字符 + 两侧必须显式拒 `/` 开头与 `\`);③ **内容嗅探特征表**(`SECRET_CONTENT_SIGNATURES` / `AWS 前缀` / PEM 标记 / 文本扩展名白名单 / 嗅探窗口,两侧逐字相等);④ **嵌套包 magic bytes**(服务端 ⊆ 客户端)。抽不到一律报错,不做「抽不到就绿」。
- **下载与建项(AGC,已落地)**:`fork-source` 返回 `source: 'project'` 时走 `GET /games/{gameId}/fork-source/project`(`package` 时走原路径)→ 校验 `sha256` 与 `bytes` → 解压到 `<app_data>/forks/<gameId>/<versionId>/`(与模板安装同一套路径门禁与私有 DACL 写入)→ 按来源分支建项(源码形态可直接改源码/重跑构建;成品包形态是可玩参考副本)→ 写 `manifest.forkedFrom`。来源记录 `.agent/fork-source.json` 升到 **v2**(记录取件形态与对应摘要),**兼容 v1**(旧记录仍可读);取件成功提示按两种形态分别如实措辞。
- **优先级(已落地)**:同一版本同时存在工程源包与成品包时,`fork-source` 优先返回工程源包并标 `source: 'project'`;只有工程包缺失(或行上「字节数 > 0 但摘要为空」这种半写状态)才回落 `package`,客户端据此决定建项形态。
@@ -540,7 +540,7 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的
| 客户端 | AGC 打包器(确定性打包,**24 条单测**,含路径形状检查)、发布时上传(失败不阻断,且**上传前按 `max_archive_bytes` 预检**)、按 `source` 分支的取件建项、来源记录 v2(兼容 v1);`npx vitest run apps/ai-game-creator-shell/tests` 1961 passed(补强前一次的运行;本次未改前端 TS);AGC 定向 `cargo test -- project_bundle` **24 passed** | ✅ 已验证 |
| 校验器对抗性补强(P0-a/b/c) | 凭据识别补强(目录 `.aws` / `.ssh` / `.kube` / `.docker` / `.gnupg` / `.terraform` / `.secrets`;前缀 `credentials` / `id_ecdsa` / `id_dsa` / `terraform.tfstate` / `service-account`;后缀 `.jks` / `.keystore` / `.ppk` / `.p8` / `.kdbx` / `.der`;全名 `.htpasswd` / `.pgpass`,外加小文本内容嗅探 PEM / `AKIA…` / `ghp_` / `xox*`);嵌套包扩展名并集 + magic bytes 嗅探(改名也拦);读取层按声明大小封顶 + 压缩比分母改为条目压缩字节;新增错误变体 `CredentialDirectoryNotAllowed` / `SecretContentDetected` | ✅ 已验证(19 条校验器单测;**发行包回归** `cargo test -p api-server game_distribution` 60 passed,含新增「声明说谎 → `ReadFailed`」) |
| 执行级交叉证据(P1-b) | 客户端 crate 用 dev-dependency 依赖 `module-game-distribution`:新测试用**真实打包器**产出字节喂服务端 `validate_project_bundle_zip` 断言接受(并逐项比对摘要 / 字节数 / 条目顺序),再手造含 `.env` / `node_modules` 的包断言服务端独立拒绝 | ✅ 已验证(AGC `cargo test -- project_bundle` 24 passed) |
| 一致性门禁(P1-a) | `check-project-bundle-policy-parity` 新增**路径形状**维度:服务端 `normalize_archive_path` 的哨兵段 / 结尾字符 / 禁止字符必须被客户端常量逐 token 覆盖,并单独钉「两侧都必须显式拒 `/` 开头与 `\`」;空集合一律报错 | ✅ 已验证(服务端 51 条规则全被客户端覆盖;「故意改一侧会红」已实测) |
| 一致性门禁(P1-a + 嗅探维度) | `check-project-bundle-policy-parity` 现已覆盖四类:目录/前缀/后缀/全名 + 5 项规模上限;**路径形状**(哨兵段 / 结尾字符 / 禁止字符,并单独钉两侧都必须显式拒 `/` 开头与 `\`);**内容嗅探特征表**(`SECRET_CONTENT_SIGNATURES` / `SECRET_AWS_ACCESS_KEY_PREFIX` / PEM 标记 / 文本扩展名白名单 / 嗅探窗口,逐字相等);**嵌套包 magic bytes**(服务端 ⊆ 客户端)。空集合一律报错 | ✅ 已验证(服务端 51 条规则全被客户端覆盖;「故意改一侧会红」两次实测:路径禁止字符、内容特征表) |
| 已知留白 | 未纳入的归档格式(`.bz2` / `.xz` / `.zst`)与数据库转储(`*.db` / `*.sql`)、未纳入的凭据目录(`.gcloud` / `.azure` / `.password-store`)、CP437 条目名与 `external_attributes == 0` 的条目、以及「爆炸包峰值内存」未做真实量测 | ⏳ 已知限制(详见 §3.5.2 与里程碑「已知留白」) |
| 已公开版本的补传(backfill) | **未做**(按产品口径不做:阶段门与发行包确认一致,公开后不接受内容写入),文档三处已如实写明 | ⏭ 明确不做 |
| 真实边界(>200 MiB / 10 001 条目 / 半包续传的极端组合)与结构化埋点事件 | 未跑/未接:边界靠常量与单测钉住,结构化埋点(`game_fork_source_downloaded` 等)待产品定口径 | ⏳ 待补 |
@@ -115,6 +115,22 @@ 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();
}
@@ -288,6 +304,55 @@ console.log(
`结尾字符 ${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) {