test(游戏共创): 一致性门禁补「路径形状」维度,并同步校验器文档
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
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
This commit is contained in:
@@ -279,6 +279,8 @@ pub(crate) project_bundle_sha256: Option<String>,
|
||||
|
||||
对象键:`{GAME_DISTRIBUTION_OBJECT_PREFIX}{game_id}/{version_id}.project.zip`(即 `agc/project-snapshots/v1/game-distribution/{game_id}/{version_id}.project.zip`)——与发行包同前缀族便于生命周期统一,靠 `.project.zip` 后缀区分资产,避免同一(作品, 版本)的两份资产互相覆盖或串用读取缓存。对象键只在服务端使用,投影里只回 `projectBundleBytes` / `projectBundleSha256`。
|
||||
|
||||
- **知情同意(2026-10-05 复核后新增,必须落到面板文案)**:校验清单是**黑名单**——作者工程里的 `docs/`、`*.pdf`、`notes.txt`、截图、素材源文件等**不在任何拒绝集内,会被原样外发**。因此发布面板必须写明「**整个项目目录(除少数排除项)会原样公开**」,让作者自己决定要不要把某个文件留在工程里。这不是技术漏洞,而是知情同意要求;技术侧能保证的是「凭据类内容尽量被拦」,不能保证「除源码外什么都不外发」。
|
||||
|
||||
### 3.3 状态与流转
|
||||
|
||||
| 对象 | 字段 | 取值 | 合法迁移 | 触发方 | 并发控制 |
|
||||
@@ -358,7 +360,12 @@ pub(crate) project_bundle_sha256: Option<String>,
|
||||
|
||||
- **打包(AGC,已落地)**:客户端 `project_bundle` 模块做**确定性打包**(条目排序 + 固定时间戳,同内容同摘要),排除 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)与根 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode`;客户端自身另有总量上限(512 MiB)。
|
||||
- **服务端复核(已落地,**不信任客户端**)**:`module-game-distribution/src/project_bundle.rs` 的 `validate_project_bundle_zip` 独立校验上传内容——上限与发行包**逐项相等**(压缩包 200 MiB / 展开 500 MiB / 单文件 64 MiB / 条目 10 000 / 压缩比 100,理由是两者共用同一条上传链路,反代与 Pingora 的放行量就是按 200 MiB 校准的);额外拦凭据与隐私文件(`.env`、`.env*`、`*.pem`、`*.key`、`*.p12`、`*.pfx`、`.npmrc`、`.netrc`、`.git-credentials`、`id_rsa*`、`id_ed25519*`、`*.map`)、嵌套 `.zip`、符号链接、加密条目与不安全路径;**不要求**根 `index.html`(源码包没有入口约定)。
|
||||
- **上传(AGC,已落地)**:发布链路在「授权非 `forbidden`」时随版本上传工程源包(失败**不阻断**发布,按可重试处理);面板明确提示「未上传工程包,你的作品只能被他人试玩与参考(不能直接重新发布)」。是否有工程包**不改变**授权是否开放(见 §3.2.3 的产品口径)。客户端规则与服务端对齐,并有机器门禁 `npm run check-project-bundle-policy-parity` 钉住两端排除清单/上限一致。
|
||||
- **上传(AGC,已落地)**:发布链路在「授权非 `forbidden`」时随版本上传工程源包(失败**不阻断**发布,按可重试处理);面板明确提示「未上传工程包,你的作品只能被他人试玩与参考(不能直接重新发布)」。是否有工程包**不改变**授权是否开放(见 §3.2.3 的产品口径)。客户端规则与服务端对齐,并有机器门禁 `npm run check-project-bundle-policy-parity` 钉住两端排除清单/上限一致(含路径形状维度,见下)。
|
||||
- **凭据/依赖识别补强(2026-10-05 对抗性复核后)**:除上面那份清单,还拦任意层级的 `.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 私钥块标记、AWS `AKIA…`、GitHub `ghp_` / `github_pat_`、Slack `xox*`)——这是唯一能兜住「未知凭据文件名」的形状;更宽的前缀启发(例如 `sk-`)**故意不做**,因为误报会直接阻断作者发布,收益与代价不成比例。
|
||||
- **嵌套包识别**:扩展名并集 `.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 覆盖,抽不到即报错)。
|
||||
- **下载与建项(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`,客户端据此决定建项形态。
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user