3ae0d129ef
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
367 lines
17 KiB
JavaScript
367 lines
17 KiB
JavaScript
#!/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!(<expr>, '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} 条全部在客户端覆盖。`,
|
||
);
|