#!/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!(, '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} 条全部在客户端覆盖。`, );