diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 44f15e348..42b350395 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -365,7 +365,7 @@ pub(crate) project_bundle_sha256: Option, - **嵌套包识别**:扩展名并集 `.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` → 解压到 `/forks///`(与模板安装同一套路径门禁与私有 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` 等)待产品定口径 | ⏳ 待补 | diff --git a/scripts/check-project-bundle-policy-parity.mjs b/scripts/check-project-bundle-policy-parity.mjs index 8a6603e9b..5b7e9a862 100644 --- a/scripts/check-project-bundle-policy-parity.mjs +++ b/scripts/check-project-bundle-policy-parity.mjs @@ -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) {