feat(s5): add static gate
This commit is contained in:
parent
617a75b19a
commit
1acc959180
775
apps/api/src/modules/static-gate/index.ts
Normal file
775
apps/api/src/modules/static-gate/index.ts
Normal file
@ -0,0 +1,775 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import type { AssetManifest, AssetManifestEntry } from "../game-package/index.js";
|
||||
import type { ConversionReport, MiniGameProject, MiniGameTarget } from "../minigame-conversion/index.js";
|
||||
|
||||
export type StaticGateStatus = "static_validated" | "failed_with_diagnostics";
|
||||
export type StaticGateFailureReasonCode =
|
||||
| "MINIGAME_ENTRY_MISSING"
|
||||
| "MINIGAME_CONFIG_MISSING"
|
||||
| "MINIGAME_ADAPTER_MISSING"
|
||||
| "MINIGAME_ASSET_MANIFEST_MISSING"
|
||||
| "CONVERSION_REPORT_MISSING"
|
||||
| "CONVERSION_REPORT_INVALID"
|
||||
| "CONVERSION_REPORT_RESOURCE_BUDGET_MISSING"
|
||||
| "PACKAGE_RESOURCE_BUDGET_EXCEEDED"
|
||||
| "EMPTY_MINIGAME_SHELL"
|
||||
| "FORBIDDEN_DOM_BOM_API"
|
||||
| "WEB_ONLY_SDK_USAGE"
|
||||
| "FORBIDDEN_NODE_API"
|
||||
| "FORBIDDEN_DYNAMIC_CODE_EVAL"
|
||||
| "DYNAMIC_IMPORT_FORBIDDEN"
|
||||
| "EXTERNAL_SCRIPT_FORBIDDEN"
|
||||
| "RESOURCE_HASH_MISMATCH"
|
||||
| "RESOURCE_SIZE_MISMATCH"
|
||||
| "DIRECT_PLATFORM_API_IN_GAME_LOGIC_FORBIDDEN"
|
||||
| "KUAISHOU_UNKNOWN_RISK_NOTE_MISSING"
|
||||
| "MINIGAME_PROJECT_CONFIG_INVALID";
|
||||
|
||||
export type StaticGateDiagnostic = {
|
||||
readonly severity: "info" | "warning" | "important" | "critical";
|
||||
readonly reasonCode: StaticGateFailureReasonCode | "KUAISHOU_GLOBAL_AND_CONFIG_SHAPE_UNKNOWN";
|
||||
readonly path?: string;
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
export type StaticGateInput = {
|
||||
readonly project: MiniGameProject;
|
||||
readonly sourceLogic: {
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type StaticGatePassResult = {
|
||||
readonly status: "static_validated";
|
||||
readonly reasonCode?: undefined;
|
||||
readonly target: MiniGameTarget;
|
||||
readonly diagnostics: readonly StaticGateDiagnostic[];
|
||||
readonly resourceBudget: ConversionReport["resourceBudget"];
|
||||
};
|
||||
|
||||
export type StaticGateFailureResult = {
|
||||
readonly status: "failed_with_diagnostics";
|
||||
readonly reasonCode: StaticGateFailureReasonCode;
|
||||
readonly target: MiniGameTarget;
|
||||
readonly diagnostics: readonly StaticGateDiagnostic[];
|
||||
};
|
||||
|
||||
export type StaticGateResult = StaticGatePassResult | StaticGateFailureResult;
|
||||
|
||||
type StaticGateServiceOptions = {
|
||||
readonly root?: string;
|
||||
};
|
||||
|
||||
type ProjectCodeFile = {
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
};
|
||||
|
||||
type ParsedProjectFiles = {
|
||||
readonly gameJson: Record<string, unknown>;
|
||||
readonly projectConfig: Record<string, unknown>;
|
||||
readonly assetManifest: AssetManifest;
|
||||
readonly conversionReport: ConversionReport;
|
||||
};
|
||||
|
||||
const SOURCE_FILE_PATTERN = /\.(?:js|mjs|cjs)$/i;
|
||||
const KUAISHOU_UNKNOWN_RISK_CODE = "KUAISHOU_GLOBAL_AND_CONFIG_SHAPE_UNKNOWN" as const;
|
||||
const MINI_GAME_TARGETS = ["wechat_minigame", "douyin_minigame", "kuaishou_minigame"] as const;
|
||||
const CONVERSION_REPORT_SEVERITIES = ["info", "warning", "important", "critical"] as const;
|
||||
const WEB_ONLY_SDK_PATTERNS = [
|
||||
/^phaser(?:\/|$)/i,
|
||||
/^gdevelop(?:\/|$)/i,
|
||||
/^ct-js(?:\/|$)/i,
|
||||
/^cocos(?:\/|$)/i,
|
||||
/^@cocos\//i,
|
||||
/^laya(?:\/|$)/i,
|
||||
/^layaair(?:\/|$)/i,
|
||||
/^pixi\.js(?:\/|$)/i,
|
||||
/^@pixi\//i
|
||||
] as const;
|
||||
const NODE_BUILTIN_IMPORTS = new Set([
|
||||
"assert",
|
||||
"buffer",
|
||||
"child_process",
|
||||
"cluster",
|
||||
"crypto",
|
||||
"dns",
|
||||
"events",
|
||||
"fs",
|
||||
"http",
|
||||
"https",
|
||||
"net",
|
||||
"os",
|
||||
"path",
|
||||
"process",
|
||||
"readline",
|
||||
"stream",
|
||||
"tls",
|
||||
"url",
|
||||
"util",
|
||||
"vm",
|
||||
"worker_threads",
|
||||
"zlib"
|
||||
]);
|
||||
|
||||
export class StaticGateService {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(options: StaticGateServiceOptions = {}) {
|
||||
this.root = path.resolve(options.root ?? process.cwd());
|
||||
}
|
||||
|
||||
async validateProject(input: StaticGateInput): Promise<StaticGateResult> {
|
||||
let projectRoot: string;
|
||||
try {
|
||||
projectRoot = this.resolveRepositoryPath(input.project.target, input.project.rootPath);
|
||||
} catch (error) {
|
||||
if (error instanceof StaticGateInternalError) return error.result;
|
||||
throw error;
|
||||
}
|
||||
let entryPath: string;
|
||||
let adapterPath: string;
|
||||
let assetManifestPath: string;
|
||||
let conversionReportPath: string;
|
||||
try {
|
||||
entryPath = normalizeProjectFieldPath(input.project.target, input.project.rootPath, projectRoot, "entryFile", input.project.entryFile);
|
||||
adapterPath = normalizeProjectFieldPath(input.project.target, input.project.rootPath, projectRoot, "adapterPath", input.project.adapterPath);
|
||||
assetManifestPath = normalizeProjectFieldPath(
|
||||
input.project.target,
|
||||
input.project.rootPath,
|
||||
projectRoot,
|
||||
"assetManifestPath",
|
||||
input.project.assetManifestPath
|
||||
);
|
||||
conversionReportPath = normalizeProjectFieldPath(
|
||||
input.project.target,
|
||||
input.project.rootPath,
|
||||
projectRoot,
|
||||
"conversionReportPath",
|
||||
input.project.conversionReportPath
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof StaticGateInternalError) return error.result;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// static gate 只读取项目树并返回诊断;开发者工具导入证据属于 Task5,不能在这里写入或冒充。
|
||||
if (!(await fileExists(resolveProjectPath(input.project.target, projectRoot, entryPath, "entryFile")))) {
|
||||
return failed(input.project.target, "MINIGAME_ENTRY_MISSING", entryPath, "小游戏项目缺少声明入口文件。");
|
||||
}
|
||||
if (
|
||||
!(await fileExists(resolveProjectPath(input.project.target, projectRoot, "game.json", "configFile"))) ||
|
||||
!(await fileExists(resolveProjectPath(input.project.target, projectRoot, "project.config.json", "projectConfigFile")))
|
||||
) {
|
||||
return failed(input.project.target, "MINIGAME_CONFIG_MISSING", "project.config.json", "小游戏项目缺少 game.json 或 project.config.json。");
|
||||
}
|
||||
|
||||
if (!(await fileExists(resolveProjectPath(input.project.target, projectRoot, adapterPath, "adapterPath")))) {
|
||||
return failed(input.project.target, "MINIGAME_ADAPTER_MISSING", adapterPath, "小游戏项目缺少 platform adapter/runtime 入口。");
|
||||
}
|
||||
|
||||
const runtimePath = "js/runtime-sdk.js";
|
||||
if (!(await fileExists(resolveProjectPath(input.project.target, projectRoot, runtimePath, "runtimePath")))) {
|
||||
return failed(input.project.target, "MINIGAME_ADAPTER_MISSING", runtimePath, "小游戏项目缺少 runtime shim。");
|
||||
}
|
||||
|
||||
if (!(await fileExists(resolveProjectPath(input.project.target, projectRoot, assetManifestPath, "assetManifestPath")))) {
|
||||
return failed(input.project.target, "MINIGAME_ASSET_MANIFEST_MISSING", assetManifestPath, "小游戏项目缺少 AssetManifest。");
|
||||
}
|
||||
|
||||
if (!(await fileExists(resolveProjectPath(input.project.target, projectRoot, conversionReportPath, "conversionReportPath")))) {
|
||||
return failed(input.project.target, "CONVERSION_REPORT_MISSING", conversionReportPath, "小游戏项目缺少 conversion-report.json。");
|
||||
}
|
||||
|
||||
let parsed: ParsedProjectFiles;
|
||||
try {
|
||||
parsed = await this.parseProjectFiles(input.project, projectRoot, assetManifestPath, conversionReportPath);
|
||||
} catch (error) {
|
||||
if (error instanceof StaticGateInternalError) return error.result;
|
||||
throw error;
|
||||
}
|
||||
const configResult = this.validateProjectConfig(input.project, parsed);
|
||||
if (configResult) return configResult;
|
||||
|
||||
const shellResult = await this.validateNonEmptyShell(input.project.target, projectRoot);
|
||||
if (shellResult) return shellResult;
|
||||
|
||||
// 同时扫描源 GameLogicModule 与生成项目代码,避免最终产物绕过源侧扫描。
|
||||
const sourceScanResult = scanSource(input.project.target, input.sourceLogic.path, input.sourceLogic.content, { gameLogicSource: true });
|
||||
if (sourceScanResult) return sourceScanResult;
|
||||
|
||||
const projectCodeFiles = await collectProjectCodeFiles(projectRoot);
|
||||
for (const file of projectCodeFiles) {
|
||||
const scanResult = scanSource(input.project.target, file.path, file.content, { gameLogicSource: file.path === "js/game-logic-module.js" });
|
||||
if (scanResult) return scanResult;
|
||||
}
|
||||
|
||||
const resourceResult = await this.validateResources(input.project.target, projectRoot, parsed.assetManifest);
|
||||
if (resourceResult) return resourceResult;
|
||||
|
||||
const diagnostics = this.kuaishouRiskDiagnostics(input.project.target, parsed);
|
||||
if (input.project.target === "kuaishou_minigame" && diagnostics.length === 0) {
|
||||
return failed(
|
||||
input.project.target,
|
||||
"KUAISHOU_UNKNOWN_RISK_NOTE_MISSING",
|
||||
"project.config.json",
|
||||
"快手小游戏 global/config shape 仍是 unknown,static gate 不能静默通过缺少风险提示的项目。"
|
||||
);
|
||||
}
|
||||
|
||||
// ConversionReport.resourceBudget 是 S5 包体审计链路的输入;缺失、伪造或超限都必须 fail closed。
|
||||
const budgetResult = await this.validateResourceBudget(input.project.target, projectRoot, parsed.conversionReport);
|
||||
if (budgetResult) return budgetResult;
|
||||
|
||||
return {
|
||||
status: "static_validated",
|
||||
target: input.project.target,
|
||||
diagnostics,
|
||||
resourceBudget: parsed.conversionReport.resourceBudget
|
||||
};
|
||||
}
|
||||
|
||||
private async parseProjectFiles(
|
||||
project: MiniGameProject,
|
||||
projectRoot: string,
|
||||
assetManifestPath: string,
|
||||
conversionReportPath: string
|
||||
): Promise<ParsedProjectFiles> {
|
||||
try {
|
||||
return {
|
||||
gameJson: parseJsonObject(await readFile(resolveProjectPath(project.target, projectRoot, "game.json", "configFile"), "utf8")),
|
||||
projectConfig: parseJsonObject(
|
||||
await readFile(resolveProjectPath(project.target, projectRoot, "project.config.json", "projectConfigFile"), "utf8")
|
||||
),
|
||||
assetManifest: parseAssetManifest(
|
||||
await readFile(resolveProjectPath(project.target, projectRoot, assetManifestPath, "assetManifestPath"), "utf8")
|
||||
),
|
||||
conversionReport: parseConversionReport(
|
||||
project.target,
|
||||
await readFile(resolveProjectPath(project.target, projectRoot, conversionReportPath, "conversionReportPath"), "utf8")
|
||||
)
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof StaticGateInternalError) throw error;
|
||||
throw new StaticGateInternalError(
|
||||
failed(project.target, "MINIGAME_PROJECT_CONFIG_INVALID", "project.config.json", error instanceof Error ? error.message : String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private validateProjectConfig(project: MiniGameProject, parsed: ParsedProjectFiles): StaticGateFailureResult | null {
|
||||
if (parsed.gameJson.entry !== project.entryFile || parsed.projectConfig.entryFile !== project.entryFile) {
|
||||
return failed(project.target, "MINIGAME_ENTRY_MISSING", "project.config.json", "项目配置未声明可验证的 game.js 入口。");
|
||||
}
|
||||
if (parsed.gameJson.platformTarget !== project.target || parsed.projectConfig.target !== project.target || parsed.conversionReport.target !== project.target) {
|
||||
return failed(project.target, "MINIGAME_PROJECT_CONFIG_INVALID", "project.config.json", "项目配置、报告与 MiniGameProject target 不一致。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async validateResourceBudget(
|
||||
target: MiniGameTarget,
|
||||
projectRoot: string,
|
||||
report: ConversionReport
|
||||
): Promise<StaticGateFailureResult | null> {
|
||||
const budget = report.resourceBudget;
|
||||
if (!isRecord(budget) || !isFiniteNonNegativeNumber(budget.maxBytes) || !isFiniteNonNegativeNumber(budget.actualBytes) || typeof budget.passed !== "boolean") {
|
||||
return failed(target, "CONVERSION_REPORT_RESOURCE_BUDGET_MISSING", "conversion-report.json", "ConversionReport 缺少可验证的 resourceBudget。");
|
||||
}
|
||||
|
||||
const actualBytes = await directorySizeBytes(projectRoot);
|
||||
if (budget.actualBytes !== actualBytes) {
|
||||
return failed(target, "PACKAGE_RESOURCE_BUDGET_EXCEEDED", "conversion-report.json", "ConversionReport.resourceBudget.actualBytes 与真实项目目录大小不一致。");
|
||||
}
|
||||
if (!budget.passed || actualBytes > budget.maxBytes) {
|
||||
return failed(target, "PACKAGE_RESOURCE_BUDGET_EXCEEDED", "conversion-report.json", "小游戏项目包体超过 ConversionReport.resourceBudget.maxBytes。");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async validateNonEmptyShell(target: MiniGameTarget, projectRoot: string): Promise<StaticGateFailureResult | null> {
|
||||
const requiredRuntimeFiles = ["game.js", "js/runtime-sdk.js", "js/game-logic-module.js"];
|
||||
for (const relativePath of requiredRuntimeFiles) {
|
||||
const content = await readFile(resolveProjectPath(target, projectRoot, relativePath, relativePath), "utf8");
|
||||
if (content.trim().length === 0) {
|
||||
return failed(target, "EMPTY_MINIGAME_SHELL", relativePath, "小游戏项目只有空壳文件,不能进入 static_validated。");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async validateResources(
|
||||
target: MiniGameTarget,
|
||||
projectRoot: string,
|
||||
assetManifest: AssetManifest
|
||||
): Promise<StaticGateFailureResult | null> {
|
||||
const declaredAssetPaths = new Set<string>();
|
||||
for (const [index, entry] of assetManifest.entries.entries()) {
|
||||
let relativePath: string;
|
||||
try {
|
||||
relativePath = normalizeAssetManifestEntryPath(target, entry.path, index);
|
||||
} catch (error) {
|
||||
if (error instanceof StaticGateInternalError) return error.result;
|
||||
throw error;
|
||||
}
|
||||
declaredAssetPaths.add(relativePath);
|
||||
const absolutePath = resolveProjectPath(target, projectRoot, relativePath, `assetManifest.entries.${index}.path`);
|
||||
if (!(await fileExists(absolutePath))) {
|
||||
return failed(target, "RESOURCE_HASH_MISMATCH", `assetManifest.entries.${index}.path`, "AssetManifest 声明的资源文件不存在。");
|
||||
}
|
||||
const content = await readFile(absolutePath);
|
||||
const actualHash = checksumBytes(content);
|
||||
if (actualHash !== entry.hash) {
|
||||
return failed(target, "RESOURCE_HASH_MISMATCH", `assetManifest.entries.${index}.hash`, "AssetManifest 声明的 hash 与实际资源不一致。");
|
||||
}
|
||||
if (content.byteLength !== entry.sizeBytes) {
|
||||
return failed(target, "RESOURCE_SIZE_MISMATCH", `assetManifest.entries.${index}.sizeBytes`, "AssetManifest 声明的 sizeBytes 与实际资源不一致。");
|
||||
}
|
||||
}
|
||||
for (const actualAssetPath of await collectAssetFiles(projectRoot)) {
|
||||
if (!declaredAssetPaths.has(actualAssetPath)) {
|
||||
return failed(target, "RESOURCE_HASH_MISMATCH", actualAssetPath, "小游戏项目 assets 目录存在未被 AssetManifest 声明的资源。");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private kuaishouRiskDiagnostics(target: MiniGameTarget, parsed: ParsedProjectFiles): StaticGateDiagnostic[] {
|
||||
if (target !== "kuaishou_minigame") return [];
|
||||
|
||||
const riskNotes = [
|
||||
...stringArray(parsed.gameJson.riskNotes),
|
||||
...stringArray(parsed.projectConfig.riskNotes),
|
||||
...diagnosticReasonCodes(parsed.conversionReport.diagnostics)
|
||||
];
|
||||
if (!riskNotes.includes(KUAISHOU_UNKNOWN_RISK_CODE)) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
severity: "warning",
|
||||
reasonCode: KUAISHOU_UNKNOWN_RISK_CODE,
|
||||
path: "project.config.json",
|
||||
message: "快手小游戏 global/config shape 当前仍缺少真实源码、官方文档或导入证据,static gate 只允许带风险提示通过。"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private resolveRepositoryPath(target: MiniGameTarget, repositoryPath: string): string {
|
||||
assertRepositoryRelativePath(target, repositoryPath);
|
||||
const resolved = path.resolve(this.root, repositoryPath);
|
||||
if (resolved !== this.root && !resolved.startsWith(`${this.root}${path.sep}`)) {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "MINIGAME_PROJECT_CONFIG_INVALID", "rootPath", "MiniGameProject.rootPath 逃逸 static gate 仓储根目录。")
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
class StaticGateInternalError extends Error {
|
||||
readonly result: StaticGateFailureResult;
|
||||
|
||||
constructor(result: StaticGateFailureResult) {
|
||||
super(result.diagnostics[0]?.message ?? result.reasonCode);
|
||||
this.name = "StaticGateInternalError";
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
function scanSource(
|
||||
target: MiniGameTarget,
|
||||
sourcePath: string,
|
||||
source: string,
|
||||
options: { readonly gameLogicSource: boolean }
|
||||
): StaticGateFailureResult | null {
|
||||
const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS);
|
||||
let failure: StaticGateFailureResult | null = null;
|
||||
|
||||
const failOnce = (reasonCode: StaticGateFailureReasonCode, message: string): void => {
|
||||
failure ??= failed(target, reasonCode, sourcePath, message);
|
||||
};
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (failure) return;
|
||||
|
||||
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
||||
const moduleSpecifier = node.moduleSpecifier;
|
||||
if (moduleSpecifier && ts.isStringLiteralLike(moduleSpecifier)) {
|
||||
inspectModuleSpecifier(moduleSpecifier.text, failOnce);
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(node)) {
|
||||
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||
failOnce("DYNAMIC_IMPORT_FORBIDDEN", "小游戏转换产物禁止动态 import。");
|
||||
return;
|
||||
}
|
||||
const calledMember = staticMemberName(node.expression);
|
||||
if (calledMember?.receiver === "module" && calledMember.property === "require") {
|
||||
failOnce("FORBIDDEN_NODE_API", "小游戏转换产物禁止 module.require。");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
calledMember?.property === "Function" &&
|
||||
(calledMember.receiver === "globalThis" || calledMember.receiver === "window")
|
||||
) {
|
||||
failOnce("FORBIDDEN_DYNAMIC_CODE_EVAL", "小游戏转换产物禁止 globalThis/window.Function 动态代码。");
|
||||
return;
|
||||
}
|
||||
if (isIdentifierCall(node, "Function")) {
|
||||
failOnce("FORBIDDEN_DYNAMIC_CODE_EVAL", "小游戏转换产物禁止 Function 动态代码。");
|
||||
return;
|
||||
}
|
||||
if (isIdentifierCall(node, "require")) {
|
||||
failOnce("FORBIDDEN_NODE_API", "小游戏转换产物禁止 require。");
|
||||
return;
|
||||
}
|
||||
if (isIdentifierCall(node, "eval")) {
|
||||
failOnce("FORBIDDEN_DYNAMIC_CODE_EVAL", "小游戏转换产物禁止 eval。");
|
||||
return;
|
||||
}
|
||||
if ((isIdentifierCall(node, "setTimeout") || isIdentifierCall(node, "setInterval")) && isStringLiteralArgument(node, 0)) {
|
||||
failOnce("FORBIDDEN_DYNAMIC_CODE_EVAL", "小游戏转换产物禁止 setTimeout/setInterval 字符串代码。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const accessedMember = staticMemberName(node);
|
||||
if (accessedMember?.receiver === "globalThis" && (accessedMember.property === "document" || accessedMember.property === "window")) {
|
||||
failOnce("FORBIDDEN_DOM_BOM_API", "小游戏转换产物禁止 globalThis.document/window 等 DOM/BOM API。");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
accessedMember?.receiver === "globalThis" &&
|
||||
(accessedMember.property === "process" || accessedMember.property === "Buffer")
|
||||
) {
|
||||
failOnce("FORBIDDEN_NODE_API", "小游戏转换产物禁止 globalThis.process/Buffer 等 Node 全局对象。");
|
||||
return;
|
||||
}
|
||||
if (options.gameLogicSource && accessedMember?.receiver === "globalThis" && isPlatformGlobalName(accessedMember.property)) {
|
||||
failOnce("DIRECT_PLATFORM_API_IN_GAME_LOGIC_FORBIDDEN", "GameLogicModule 不能通过 globalThis 直接调用 wx/tt/ks 平台全局对象。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "Function") {
|
||||
failOnce("FORBIDDEN_DYNAMIC_CODE_EVAL", "小游戏转换产物禁止 new Function。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isIdentifier(node)) {
|
||||
if (node.text === "document" || node.text === "window") {
|
||||
failOnce("FORBIDDEN_DOM_BOM_API", "小游戏转换产物禁止 document/window 等 DOM/BOM API。");
|
||||
return;
|
||||
}
|
||||
if (node.text === "process" || node.text === "Buffer") {
|
||||
failOnce("FORBIDDEN_NODE_API", "小游戏转换产物禁止 process/Buffer 等 Node 全局对象。");
|
||||
return;
|
||||
}
|
||||
if (options.gameLogicSource && isPlatformGlobalName(node.text)) {
|
||||
failOnce("DIRECT_PLATFORM_API_IN_GAME_LOGIC_FORBIDDEN", "GameLogicModule 不能直接调用 wx/tt/ks 平台全局对象。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return failure;
|
||||
}
|
||||
|
||||
function inspectModuleSpecifier(moduleSpecifier: string, fail: (reasonCode: StaticGateFailureReasonCode, message: string) => void): void {
|
||||
if (/^(?:https?:)?\/\//i.test(moduleSpecifier)) {
|
||||
fail("EXTERNAL_SCRIPT_FORBIDDEN", "小游戏项目禁止引用远程启动脚本。");
|
||||
return;
|
||||
}
|
||||
if (moduleSpecifier.startsWith("node:") || NODE_BUILTIN_IMPORTS.has(moduleSpecifier.split("/")[0] ?? "")) {
|
||||
fail("FORBIDDEN_NODE_API", "小游戏转换产物禁止 Node builtin import。");
|
||||
return;
|
||||
}
|
||||
if (WEB_ONLY_SDK_PATTERNS.some((pattern) => pattern.test(moduleSpecifier))) {
|
||||
fail("WEB_ONLY_SDK_USAGE", "小游戏转换产物禁止 Phaser/GDevelop/ct-js/Cocos/Laya/Pixi 等 Web-only runtime SDK。");
|
||||
}
|
||||
}
|
||||
|
||||
function isIdentifierCall(node: ts.CallExpression, name: string): boolean {
|
||||
return ts.isIdentifier(node.expression) && node.expression.text === name;
|
||||
}
|
||||
|
||||
function isStringLiteralArgument(node: ts.CallExpression, index: number): boolean {
|
||||
const argument = node.arguments[index];
|
||||
return argument !== undefined && (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument));
|
||||
}
|
||||
|
||||
function staticMemberName(node: ts.Node): { readonly receiver: string; readonly property: string } | null {
|
||||
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
||||
return { receiver: node.expression.text, property: node.name.text };
|
||||
}
|
||||
if (
|
||||
ts.isElementAccessExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.argumentExpression !== undefined &&
|
||||
(ts.isStringLiteral(node.argumentExpression) || ts.isNoSubstitutionTemplateLiteral(node.argumentExpression))
|
||||
) {
|
||||
return { receiver: node.expression.text, property: node.argumentExpression.text };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPlatformGlobalName(value: string): boolean {
|
||||
return value === "wx" || value === "tt" || value === "ks";
|
||||
}
|
||||
|
||||
function failed(target: MiniGameTarget, reasonCode: StaticGateFailureReasonCode, diagnosticPath: string, message: string): StaticGateFailureResult {
|
||||
// 所有失败统一返回可审计诊断形状,调用方可直接把 reasonCode/path 交给转换报告或人工排查。
|
||||
return {
|
||||
status: "failed_with_diagnostics",
|
||||
reasonCode,
|
||||
target,
|
||||
diagnostics: [
|
||||
{
|
||||
severity: "critical",
|
||||
reasonCode,
|
||||
path: diagnosticPath,
|
||||
message
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
async function collectProjectCodeFiles(projectRoot: string): Promise<ProjectCodeFile[]> {
|
||||
const result: ProjectCodeFile[] = [];
|
||||
await collectProjectCodeFilesInto(projectRoot, "", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectProjectCodeFilesInto(projectRoot: string, relativeRoot: string, result: ProjectCodeFile[]): Promise<void> {
|
||||
const entries = await readdir(path.join(projectRoot, relativeRoot), { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const relativePath = relativeRoot.length === 0 ? entry.name : `${relativeRoot}/${entry.name}`;
|
||||
const absolutePath = path.join(projectRoot, relativePath);
|
||||
if (entry.isDirectory()) {
|
||||
await collectProjectCodeFilesInto(projectRoot, relativePath, result);
|
||||
} else if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) {
|
||||
result.push({
|
||||
path: relativePath,
|
||||
content: await readFile(absolutePath, "utf8")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAssetFiles(projectRoot: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
try {
|
||||
await collectAssetFilesInto(projectRoot, "assets", result);
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === "ENOENT") return result;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectAssetFilesInto(projectRoot: string, relativeRoot: string, result: string[]): Promise<void> {
|
||||
const entries = await readdir(resolveProjectPathForTrustedRelativeRoot(projectRoot, relativeRoot), { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const relativePath = `${relativeRoot}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
await collectAssetFilesInto(projectRoot, relativePath, result);
|
||||
} else if (entry.isFile()) {
|
||||
result.push(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySizeBytes(root: string): Promise<number> {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
let total = 0;
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
total += await directorySizeBytes(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
total += (await stat(entryPath)).size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(filePath)).isFile();
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProjectFieldPath(
|
||||
target: MiniGameTarget,
|
||||
projectRootPath: string,
|
||||
projectRoot: string,
|
||||
fieldName: string,
|
||||
fieldValue: string
|
||||
): string {
|
||||
const relativeValue = fieldValue.startsWith(`${projectRootPath}/`) ? fieldValue.slice(projectRootPath.length + 1) : fieldValue;
|
||||
assertRepositoryRelativePath(target, relativeValue, fieldName);
|
||||
resolveProjectPath(target, projectRoot, relativeValue, fieldName);
|
||||
return relativeValue;
|
||||
}
|
||||
|
||||
function normalizeAssetManifestEntryPath(target: MiniGameTarget, value: string, index: number): string {
|
||||
const fieldName = `assetManifest.entries.${index}.path`;
|
||||
assertRepositoryRelativePath(target, value, fieldName);
|
||||
if (!value.startsWith("assets/")) {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "MINIGAME_PROJECT_CONFIG_INVALID", fieldName, "AssetManifest.entries[].path 必须位于 assets/ 目录。")
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveProjectPath(target: MiniGameTarget, projectRoot: string, relativePath: string, fieldName: string): string {
|
||||
assertRepositoryRelativePath(target, relativePath, fieldName);
|
||||
const resolved = path.resolve(projectRoot, relativePath);
|
||||
if (resolved !== projectRoot && !resolved.startsWith(`${projectRoot}${path.sep}`)) {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "MINIGAME_PROJECT_CONFIG_INVALID", fieldName, "MiniGameProject 字段解析后逃逸项目根目录。")
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveProjectPathForTrustedRelativeRoot(projectRoot: string, relativePath: string): string {
|
||||
const resolved = path.resolve(projectRoot, relativePath);
|
||||
if (resolved !== projectRoot && !resolved.startsWith(`${projectRoot}${path.sep}`)) {
|
||||
throw new Error(`trusted relative path escaped projectRoot: ${relativePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function parseJsonObject(content: string): Record<string, unknown> {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error("JSON content must be an object.");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseAssetManifest(content: string): AssetManifest {
|
||||
const parsed = parseJsonObject(content);
|
||||
if (typeof parsed.id !== "string" || !Array.isArray(parsed.entries)) throw new Error("AssetManifest shape is invalid.");
|
||||
const entries = parsed.entries.map((entry, index): AssetManifestEntry => {
|
||||
if (!isRecord(entry)) throw new Error(`AssetManifest entries.${index} is invalid.`);
|
||||
if (typeof entry.path !== "string" || typeof entry.hash !== "string" || !isFiniteNonNegativeNumber(entry.sizeBytes)) {
|
||||
throw new Error(`AssetManifest entries.${index} fields are invalid.`);
|
||||
}
|
||||
if (!isAssetManifestEntryType(entry.type)) throw new Error(`AssetManifest entries.${index}.type is invalid.`);
|
||||
return {
|
||||
path: entry.path,
|
||||
hash: entry.hash,
|
||||
sizeBytes: entry.sizeBytes,
|
||||
type: entry.type
|
||||
};
|
||||
});
|
||||
return { id: parsed.id, entries };
|
||||
}
|
||||
|
||||
function parseConversionReport(target: MiniGameTarget, content: string): ConversionReport {
|
||||
const parsed = parseJsonObject(content);
|
||||
validateConversionReportShape(target, parsed);
|
||||
return parsed as unknown as ConversionReport;
|
||||
}
|
||||
|
||||
function validateConversionReportShape(target: MiniGameTarget, report: Record<string, unknown>): void {
|
||||
// ConversionReport 是 static gate、下载和运营侧共同消费的审计证据;字段缺失或 P0 值非法必须先 fail closed。
|
||||
if (!isMiniGameTarget(report.target)) {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "CONVERSION_REPORT_INVALID", "conversion-report.json", "ConversionReport.target 缺失或非法。")
|
||||
);
|
||||
}
|
||||
if (report.status !== "packaged") {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "CONVERSION_REPORT_INVALID", "conversion-report.json", "ConversionReport.status 必须是 packaged。")
|
||||
);
|
||||
}
|
||||
if (!isConversionReportSeverity(report.severity)) {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "CONVERSION_REPORT_INVALID", "conversion-report.json", "ConversionReport.severity 缺失或非法。")
|
||||
);
|
||||
}
|
||||
if (report.evidenceLevel !== "generated") {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "CONVERSION_REPORT_INVALID", "conversion-report.json", "ConversionReport.evidenceLevel 必须是 generated。")
|
||||
);
|
||||
}
|
||||
if (!isNonEmptyString(report.checksum)) {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "CONVERSION_REPORT_INVALID", "conversion-report.json", "ConversionReport.checksum 缺失或非法。")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRepositoryRelativePath(target: MiniGameTarget, value: string, diagnosticPath = "path"): void {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value === "." ||
|
||||
value.includes("\0") ||
|
||||
value.includes("\\") ||
|
||||
path.isAbsolute(value) ||
|
||||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)
|
||||
) {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "MINIGAME_PROJECT_CONFIG_INVALID", diagnosticPath, "项目路径必须是受控仓储相对路径,不能是空路径、绝对路径、URL 或协议路径。")
|
||||
);
|
||||
}
|
||||
if (value.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
throw new StaticGateInternalError(
|
||||
failed(target, "MINIGAME_PROJECT_CONFIG_INVALID", diagnosticPath, "项目路径不能包含空段、当前目录或目录穿越片段。")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function diagnosticReasonCodes(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((item) => (isRecord(item) && typeof item.reasonCode === "string" ? [item.reasonCode] : []));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function isMiniGameTarget(value: unknown): value is MiniGameTarget {
|
||||
return typeof value === "string" && (MINI_GAME_TARGETS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isConversionReportSeverity(value: unknown): value is ConversionReport["severity"] {
|
||||
return typeof value === "string" && (CONVERSION_REPORT_SEVERITIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isAssetManifestEntryType(value: unknown): value is AssetManifestEntry["type"] {
|
||||
return value === "image" || value === "audio" || value === "json" || value === "script";
|
||||
}
|
||||
|
||||
function checksumBytes(value: Uint8Array): string {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error;
|
||||
}
|
||||
511
apps/api/src/modules/static-gate/static-gate.spec.ts
Normal file
511
apps/api/src/modules/static-gate/static-gate.spec.ts
Normal file
@ -0,0 +1,511 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AssetManifest, GamePackage } from "../game-package/index.js";
|
||||
import {
|
||||
FileSystemMiniGameProjectRepository,
|
||||
MiniGameProjectBuilderService,
|
||||
type MiniGameProject,
|
||||
type MiniGameTarget,
|
||||
type MiniGameTargetConversionResult
|
||||
} from "../minigame-conversion/index.js";
|
||||
import { StaticGateService, type StaticGateInput, type StaticGateResult } from "./index.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("S5 static gate", () => {
|
||||
it("passes Task3 generated projects for wechat, douyin, and Kuaishou with explicit unknown risk note", async () => {
|
||||
const context = await createStaticGateContext(["wechat_minigame", "douyin_minigame", "kuaishou_minigame"]);
|
||||
|
||||
for (const targetResult of context.result.targets) {
|
||||
const result = await context.gate.validateProject(context.inputFor(targetResult));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "static_validated",
|
||||
target: targetResult.project.target
|
||||
});
|
||||
if (targetResult.project.target === "kuaishou_minigame") {
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
severity: "warning",
|
||||
reasonCode: "KUAISHOU_GLOBAL_AND_CONFIG_SHAPE_UNKNOWN",
|
||||
path: "project.config.json",
|
||||
message: expect.stringContaining("快手小游戏")
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const invalidCases: readonly {
|
||||
readonly name: string;
|
||||
readonly expectedReasonCode: StaticGateResult["reasonCode"];
|
||||
readonly target?: MiniGameTarget;
|
||||
readonly mutate: (context: StaticGateContext) => Promise<StaticGateInput>;
|
||||
}[] = [
|
||||
{
|
||||
name: "missing entry",
|
||||
expectedReasonCode: "MINIGAME_ENTRY_MISSING",
|
||||
mutate: async (context) => {
|
||||
await context.removeProjectFile("game.js");
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "missing config",
|
||||
expectedReasonCode: "MINIGAME_CONFIG_MISSING",
|
||||
mutate: async (context) => {
|
||||
await context.removeProjectFile("project.config.json");
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "missing adapter",
|
||||
expectedReasonCode: "MINIGAME_ADAPTER_MISSING",
|
||||
mutate: async (context) => {
|
||||
await context.removeProjectFile("js/platform-adapter.js");
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "document/window usage",
|
||||
expectedReasonCode: "FORBIDDEN_DOM_BOM_API",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport function badDom() { window.addEventListener("resize", () => document.title); }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "web-only SDK usage",
|
||||
expectedReasonCode: "WEB_ONLY_SDK_USAGE",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nimport Phaser from "phaser";\nexport const badSdk = Phaser;\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Node builtin import",
|
||||
expectedReasonCode: "FORBIDDEN_NODE_API",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nimport fs from "fs";\nexport const badNode = fs;\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "node:* import",
|
||||
expectedReasonCode: "FORBIDDEN_NODE_API",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nimport nodePath from "node:path";\nexport const badNodeProtocol = nodePath;\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "require/process/Buffer usage",
|
||||
expectedReasonCode: "FORBIDDEN_NODE_API",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport function badNodeGlobals() { return Buffer.from(process.cwd() + require("fs").existsSync(".")).toString("hex"); }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "eval/new Function usage",
|
||||
expectedReasonCode: "FORBIDDEN_DYNAMIC_CODE_EVAL",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport function badEval() { eval("1"); return new Function("return 1")(); }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "setTimeout(string)/setInterval(string) usage",
|
||||
expectedReasonCode: "FORBIDDEN_DYNAMIC_CODE_EVAL",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport function badTimer() { setTimeout("danger()", 0); setInterval("danger()", 0); }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "dynamic import",
|
||||
expectedReasonCode: "DYNAMIC_IMPORT_FORBIDDEN",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport async function badImport() { return import("./late-bound.js"); }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "external script",
|
||||
expectedReasonCode: "EXTERNAL_SCRIPT_FORBIDDEN",
|
||||
mutate: async (context) => {
|
||||
await context.writeProjectFile("game.js", 'import "https://cdn.example.com/minigame-sdk.js";\n');
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "resource hash mismatch",
|
||||
expectedReasonCode: "RESOURCE_HASH_MISMATCH",
|
||||
mutate: async (context) => {
|
||||
await context.writeProjectFile("assets/facility.png", "tampered-asset");
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "undeclared asset file",
|
||||
expectedReasonCode: "RESOURCE_HASH_MISMATCH",
|
||||
mutate: async (context) => {
|
||||
await context.writeProjectFile("assets/undeclared.json", "{\"hidden\":true}\n");
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "absolute AssetManifest resource path",
|
||||
expectedReasonCode: "MINIGAME_PROJECT_CONFIG_INVALID",
|
||||
mutate: async (context) => {
|
||||
const manifest = await context.readAssetManifest();
|
||||
manifest.entries[0] = {
|
||||
...manifest.entries[0],
|
||||
path: path.join(context.root, "outside.png")
|
||||
};
|
||||
await context.writeProjectFile("asset-manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "AssetManifest resource path without assets prefix",
|
||||
expectedReasonCode: "MINIGAME_PROJECT_CONFIG_INVALID",
|
||||
mutate: async (context) => {
|
||||
const manifest = await context.readAssetManifest();
|
||||
manifest.entries[0] = {
|
||||
...manifest.entries[0],
|
||||
path: "facility.png"
|
||||
};
|
||||
await context.writeProjectFile("asset-manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "package over budget",
|
||||
expectedReasonCode: "PACKAGE_RESOURCE_BUDGET_EXCEEDED",
|
||||
mutate: async (context) => {
|
||||
const report = await context.readConversionReport();
|
||||
report.resourceBudget = {
|
||||
maxBytes: 1,
|
||||
actualBytes: await context.projectSizeBytes(),
|
||||
passed: false
|
||||
};
|
||||
await context.writeProjectFile("conversion-report.json", `${JSON.stringify(report, null, 2)}\n`);
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "conversion report actualBytes forged below real directory size",
|
||||
expectedReasonCode: "PACKAGE_RESOURCE_BUDGET_EXCEEDED",
|
||||
mutate: async (context) => {
|
||||
const report = await context.readConversionReport();
|
||||
report.resourceBudget = {
|
||||
maxBytes: 4 * 1024 * 1024,
|
||||
actualBytes: 1,
|
||||
passed: true
|
||||
};
|
||||
await context.writeProjectFile("conversion-report.json", `${JSON.stringify(report, null, 2)}\n`);
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "empty shell",
|
||||
expectedReasonCode: "EMPTY_MINIGAME_SHELL",
|
||||
mutate: async (context) => {
|
||||
await context.writeProjectFile("game.js", "\n");
|
||||
await context.writeProjectFile("js/runtime-sdk.js", "\n");
|
||||
await context.writeProjectFile("js/game-logic-module.js", "\n");
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "conversion report missing resourceBudget",
|
||||
expectedReasonCode: "CONVERSION_REPORT_RESOURCE_BUDGET_MISSING",
|
||||
mutate: async (context) => {
|
||||
const report = await context.readConversionReport();
|
||||
delete report.resourceBudget;
|
||||
await context.writeProjectFile("conversion-report.json", `${JSON.stringify(report, null, 2)}\n`);
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
},
|
||||
...(["target", "status", "severity", "evidenceLevel", "checksum"] as const).map((fieldName) => ({
|
||||
name: `conversion report missing ${fieldName}`,
|
||||
expectedReasonCode: "CONVERSION_REPORT_INVALID" as const,
|
||||
mutate: async (context: StaticGateContext) => {
|
||||
const report = await context.readConversionReport();
|
||||
delete report[fieldName];
|
||||
await context.writeProjectFile("conversion-report.json", `${JSON.stringify(report, null, 2)}\n`);
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
})),
|
||||
{
|
||||
name: "direct wx/tt/ks call from GameLogicModule",
|
||||
expectedReasonCode: "DIRECT_PLATFORM_API_IN_GAME_LOGIC_FORBIDDEN",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport function badPlatformGlobal() { wx.createCanvas(); tt.getSystemInfoSync(); ks.onShow(() => undefined); }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "computed document/window from globalThis",
|
||||
expectedReasonCode: "FORBIDDEN_DOM_BOM_API",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport function badComputedDom() { return globalThis["document"].title + globalThis["window"].innerWidth; }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "computed wx/tt/ks from GameLogicModule",
|
||||
expectedReasonCode: "DIRECT_PLATFORM_API_IN_GAME_LOGIC_FORBIDDEN",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport function badComputedPlatformGlobal() { return globalThis["wx"].createCanvas() || globalThis["tt"].getSystemInfoSync() || globalThis["ks"].onShow(() => undefined); }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "module.require Node builtin",
|
||||
expectedReasonCode: "FORBIDDEN_NODE_API",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport function badModuleRequire() { return module.require("fs").readFileSync("package.json", "utf8"); }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "computed Function dynamic eval",
|
||||
expectedReasonCode: "FORBIDDEN_DYNAMIC_CODE_EVAL",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
source: `${context.sourceLogic}\nexport function badComputedFunction() { return globalThis.Function("return 1")() + window.Function("return 2")(); }\n`
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "MiniGameProject adapterPath escapes project root after root prefix trim",
|
||||
expectedReasonCode: "MINIGAME_PROJECT_CONFIG_INVALID",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
project: {
|
||||
...context.targetResult.project,
|
||||
adapterPath: `${context.targetResult.project.rootPath}/../outside/platform-adapter.js`
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "MiniGameProject assetManifestPath uses URL",
|
||||
expectedReasonCode: "MINIGAME_PROJECT_CONFIG_INVALID",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
project: {
|
||||
...context.targetResult.project,
|
||||
assetManifestPath: "https://cdn.example.com/asset-manifest.json"
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "MiniGameProject conversionReportPath uses backslash",
|
||||
expectedReasonCode: "MINIGAME_PROJECT_CONFIG_INVALID",
|
||||
mutate: async (context) =>
|
||||
context.inputFor(context.targetResult, {
|
||||
project: {
|
||||
...context.targetResult.project,
|
||||
conversionReportPath: "reports\\conversion-report.json"
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
name: "Kuaishou unknown config accepted without risk note",
|
||||
target: "kuaishou_minigame",
|
||||
expectedReasonCode: "KUAISHOU_UNKNOWN_RISK_NOTE_MISSING",
|
||||
mutate: async (context) => {
|
||||
const gameJson = JSON.parse(await context.readProjectFile("game.json")) as Record<string, unknown>;
|
||||
const projectConfig = JSON.parse(await context.readProjectFile("project.config.json")) as Record<string, unknown>;
|
||||
gameJson.riskNotes = [];
|
||||
projectConfig.riskNotes = [];
|
||||
const report = await context.readConversionReport();
|
||||
report.diagnostics = [];
|
||||
await context.writeProjectFile("game.json", `${JSON.stringify(gameJson, null, 2)}\n`);
|
||||
await context.writeProjectFile("project.config.json", `${JSON.stringify(projectConfig, null, 2)}\n`);
|
||||
await context.writeProjectFile("conversion-report.json", `${JSON.stringify(report, null, 2)}\n`);
|
||||
return context.inputFor(context.targetResult);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
it.each(invalidCases)("fails closed for $name", async ({ expectedReasonCode, mutate, target }) => {
|
||||
const context = await createStaticGateContext([target ?? "wechat_minigame"]);
|
||||
const result = await context.gate.validateProject(await mutate(context));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "failed_with_diagnostics",
|
||||
reasonCode: expectedReasonCode,
|
||||
target: target ?? "wechat_minigame"
|
||||
});
|
||||
expect(result.diagnostics[0]).toMatchObject({
|
||||
severity: "critical",
|
||||
reasonCode: expectedReasonCode
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type StaticGateContext = Awaited<ReturnType<typeof createStaticGateContext>>;
|
||||
|
||||
async function createStaticGateContext(targets: readonly MiniGameTarget[]): Promise<{
|
||||
readonly root: string;
|
||||
readonly repository: FileSystemMiniGameProjectRepository;
|
||||
readonly gate: StaticGateService;
|
||||
readonly sourceLogic: string;
|
||||
readonly targetResult: MiniGameTargetConversionResult;
|
||||
readonly result: Awaited<ReturnType<MiniGameProjectBuilderService["convertGamePackage"]>>;
|
||||
readonly inputFor: (targetResult: MiniGameTargetConversionResult, overrides?: { readonly source?: string; readonly project?: MiniGameProject }) => StaticGateInput;
|
||||
readonly projectPath: (relativePath: string) => string;
|
||||
readonly removeProjectFile: (relativePath: string) => Promise<void>;
|
||||
readonly readProjectFile: (relativePath: string) => Promise<string>;
|
||||
readonly writeProjectFile: (relativePath: string, content: string) => Promise<void>;
|
||||
readonly readAssetManifest: () => Promise<{ id: string; entries: Array<{ path: string; hash: string; sizeBytes: number; type: string }> }>;
|
||||
readonly readConversionReport: () => Promise<Record<string, unknown>>;
|
||||
readonly projectSizeBytes: () => Promise<number>;
|
||||
}> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "huijing-s5-static-gate-"));
|
||||
tempRoots.push(root);
|
||||
const repository = new FileSystemMiniGameProjectRepository({ root });
|
||||
const builder = new MiniGameProjectBuilderService(repository);
|
||||
const gate = new StaticGateService({ root });
|
||||
const context = await createConversionInput(repository);
|
||||
const result = await builder.convertGamePackage({
|
||||
gamePackage: context.gamePackage,
|
||||
assetManifest: context.assetManifest,
|
||||
targets
|
||||
});
|
||||
const targetResult = result.targets[0]!;
|
||||
|
||||
const projectPath = (relativePath: string) => path.join(repository.resolveRepositoryPath(targetResult.project.rootPath), relativePath);
|
||||
|
||||
return {
|
||||
root,
|
||||
repository,
|
||||
gate,
|
||||
sourceLogic: context.sourceLogic,
|
||||
targetResult,
|
||||
result,
|
||||
inputFor: (targetResultForInput, overrides = {}) => ({
|
||||
project: overrides.project ?? targetResultForInput.project,
|
||||
sourceLogic: {
|
||||
path: context.gamePackage.profiles.web.manifest.logicArtifactPath,
|
||||
content: overrides.source ?? context.sourceLogic
|
||||
}
|
||||
}),
|
||||
projectPath,
|
||||
removeProjectFile: async (relativePath) => rm(projectPath(relativePath), { force: true }),
|
||||
readProjectFile: async (relativePath) => readFile(projectPath(relativePath), "utf8"),
|
||||
writeProjectFile: async (relativePath, content) => {
|
||||
const targetPath = projectPath(relativePath);
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, content);
|
||||
},
|
||||
readAssetManifest: async () =>
|
||||
JSON.parse(await readFile(projectPath("asset-manifest.json"), "utf8")) as {
|
||||
id: string;
|
||||
entries: Array<{ path: string; hash: string; sizeBytes: number; type: string }>;
|
||||
},
|
||||
readConversionReport: async () => JSON.parse(await readFile(projectPath("conversion-report.json"), "utf8")) as Record<string, unknown>,
|
||||
projectSizeBytes: async () => directorySizeBytes(repository.resolveRepositoryPath(targetResult.project.rootPath))
|
||||
};
|
||||
}
|
||||
|
||||
async function createConversionInput(repository: FileSystemMiniGameProjectRepository): Promise<{
|
||||
readonly gamePackage: GamePackage;
|
||||
readonly assetManifest: AssetManifest;
|
||||
readonly sourceLogic: string;
|
||||
}> {
|
||||
const gameVersionId = "version-s5-static-gate";
|
||||
const packagePath = `dist/packages/${gameVersionId}/web`;
|
||||
const sourceLogic = [
|
||||
'import * as RuntimeSdk from "@huijing/runtime-sdk";',
|
||||
"",
|
||||
"export function init(sdk, config) {",
|
||||
" void RuntimeSdk;",
|
||||
" void sdk;",
|
||||
" return { status: \"running\", gameVersionId: config.gameVersionId };",
|
||||
"}",
|
||||
"export function update(state) { return state; }",
|
||||
"export function render() { return []; }",
|
||||
"export function handleInput(state) { return state; }",
|
||||
"export function onPause() {}",
|
||||
"export function onResume() {}",
|
||||
""
|
||||
].join("\n");
|
||||
const assets = {
|
||||
"assets/facility.png": "fake-png-bytes",
|
||||
"assets/balance.json": "{\"score\":12}\n"
|
||||
};
|
||||
const assetManifest = {
|
||||
id: `asset-manifest-${gameVersionId}`,
|
||||
entries: [
|
||||
{ path: "assets/facility.png", hash: checksumText(assets["assets/facility.png"]), sizeBytes: Buffer.byteLength(assets["assets/facility.png"]), type: "image" },
|
||||
{ path: "assets/balance.json", hash: checksumText(assets["assets/balance.json"]), sizeBytes: Buffer.byteLength(assets["assets/balance.json"]), type: "json" }
|
||||
]
|
||||
} satisfies AssetManifest;
|
||||
const assetManifestText = `${JSON.stringify(assetManifest, null, 2)}\n`;
|
||||
const manifestPayload = {
|
||||
entry: "runtime/index.js",
|
||||
configPath: "config.json",
|
||||
logicArtifactPath: "logic/logic.mjs",
|
||||
assetManifestPath: "asset-manifest.json",
|
||||
runtimeVersion: "web-runtime-mvp-v1"
|
||||
};
|
||||
const webManifest = {
|
||||
...manifestPayload,
|
||||
checksum: checksumText(JSON.stringify(manifestPayload))
|
||||
};
|
||||
const gamePackage = {
|
||||
id: `game-package-${gameVersionId}`,
|
||||
gameVersionId,
|
||||
profiles: {
|
||||
web: {
|
||||
status: "built",
|
||||
artifactPath: packagePath,
|
||||
manifest: webManifest
|
||||
},
|
||||
wechat_minigame: { status: "pending_conversion" },
|
||||
douyin_minigame: { status: "pending_conversion" },
|
||||
kuaishou_minigame: { status: "pending_conversion" }
|
||||
},
|
||||
assetManifestId: assetManifest.id,
|
||||
artifactChecksums: {
|
||||
[assetManifest.id]: checksumText(assetManifestText),
|
||||
[webManifest.logicArtifactPath]: checksumText(sourceLogic),
|
||||
[webManifest.assetManifestPath]: checksumText(assetManifestText),
|
||||
"manifest.json": webManifest.checksum
|
||||
}
|
||||
} satisfies GamePackage;
|
||||
|
||||
await repository.writeTextFile(`${packagePath}/logic/logic.mjs`, sourceLogic);
|
||||
await repository.writeTextFile(`${packagePath}/asset-manifest.json`, assetManifestText);
|
||||
await repository.writeTextFile(`${packagePath}/manifest.json`, `${JSON.stringify(webManifest, null, 2)}\n`);
|
||||
await repository.writeTextFile(`${packagePath}/assets/facility.png`, assets["assets/facility.png"]);
|
||||
await repository.writeTextFile(`${packagePath}/assets/balance.json`, assets["assets/balance.json"]);
|
||||
|
||||
return { gamePackage, assetManifest, sourceLogic };
|
||||
}
|
||||
|
||||
function checksumText(value: string): string {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
async function directorySizeBytes(root: string): Promise<number> {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
let total = 0;
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
total += await directorySizeBytes(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
total += (await stat(entryPath)).size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
149
docs/memorys/2026-06-05-S5Task4静态门禁.md
Normal file
149
docs/memorys/2026-06-05-S5Task4静态门禁.md
Normal file
@ -0,0 +1,149 @@
|
||||
# 2026-06-05 S5 Task4 静态门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S5 Task4 `Implement Static Gate` 已在独立 worktree `codex/s5-minigame-prep` 完成实现、controller 自测,并在两轮 review finding 修复后通过 fresh review gate:
|
||||
|
||||
- spec compliance review:首轮 FAIL,二轮 FAIL,三轮 PASS。
|
||||
- quality review:首轮 FAIL,二轮 FAIL,三轮 PASS。
|
||||
|
||||
可以提交 Task4。后续 S5 可进入 Task5 `Record DevTool Import Evidence`,但当前 Task4 不生成 `DevToolImportEvidence`,不创建 `docs/evidence/devtool-import/**`,也不声称开发者工具导入成功。
|
||||
|
||||
## 变更范围
|
||||
|
||||
- `apps/api/src/modules/static-gate/index.ts`
|
||||
- `apps/api/src/modules/static-gate/static-gate.spec.ts`
|
||||
- `scripts/check-scope.mjs`
|
||||
|
||||
未实现:
|
||||
|
||||
- S5 Task5 DevTool Import Evidence
|
||||
- `docs/evidence/devtool-import/**`
|
||||
- S6-S8 业务流
|
||||
|
||||
冻结合同未改:
|
||||
|
||||
- `packages/shared-contracts/src/game-package.ts`
|
||||
- `packages/shared-contracts/src/runtime-sdk-contract.ts`
|
||||
- `packages/shared-contracts/src/game-logic-module.ts`
|
||||
- `packages/shared-contracts/src/minigame-conversion.ts`
|
||||
|
||||
## 子代理与 Review Gate
|
||||
|
||||
| Role | Agent ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementer | `019e9651-c160-7ad2-9faf-d1af45c06121` | DONE | 无 |
|
||||
| spec review | `019e9669-bab1-78a0-884e-8e6f0eb564ac` | FAIL | AssetManifest 只做单向校验,未反扫 `assets/**` 未声明资源 |
|
||||
| quality review | `019e9669-bc02-7a22-8242-a22e5863f938` | FAIL | 资源路径和 project 字段路径未 fail-closed;AST computed/member 绕过;`actualBytes` 可伪造 |
|
||||
| fix implementer | `019e966c-6dd8-7070-a748-9cd27ca4c9e6` | DONE | 无 |
|
||||
| spec re-review | `019e9679-7cca-7b70-b02b-991943661495` | FAIL | ConversionReport 非 `resourceBudget` 必填字段未校验 |
|
||||
| quality re-review | `019e9679-7da2-7943-bfef-980ba797f4cf` | FAIL | ConversionReport 非 `resourceBudget` 必填字段未校验 |
|
||||
| local patch | main agent | DONE | 补 `target` 缺字段测试 |
|
||||
| final spec review | `019e9686-0feb-7410-8033-292040fc7d7d` | PASS | 无 Critical / Important |
|
||||
| final quality review | `019e9688-9313-7ff3-ac06-23423c610317` | PASS | 无 Critical / Important |
|
||||
|
||||
子代理没有写 `docs/memorys`,本文件为主代理留痕。
|
||||
|
||||
## 实现摘要
|
||||
|
||||
- 新增 `StaticGateService.validateProject()`:
|
||||
- 成功返回 `static_validated`。
|
||||
- 失败统一返回 `failed_with_diagnostics`、`reasonCode`、`target`、`diagnostics`。
|
||||
- 项目树检查:
|
||||
- 验证 `game.js`、`game.json`、`project.config.json`、adapter、`js/runtime-sdk.js`、`asset-manifest.json`、`conversion-report.json` 存在。
|
||||
- `entryFile`、`adapterPath`、`assetManifestPath`、`conversionReportPath` 均规范化到项目根内,拒绝空路径、绝对路径、URL/protocol、反斜杠、`.`、`..`、空段。
|
||||
- ConversionReport 检查:
|
||||
- `target/status/severity/evidenceLevel/checksum` 缺失或非法返回 `CONVERSION_REPORT_INVALID`。
|
||||
- `resourceBudget` 缺失返回 `CONVERSION_REPORT_RESOURCE_BUDGET_MISSING`。
|
||||
- `resourceBudget.actualBytes` 必须等于真实项目目录大小。
|
||||
- 超预算或 `passed=false` 返回 `PACKAGE_RESOURCE_BUDGET_EXCEEDED`。
|
||||
- 资源检查:
|
||||
- `AssetManifest.entries[].path` 必须是 `assets/` 下受控相对路径。
|
||||
- 声明资源必须存在,hash 和 size 必须一致。
|
||||
- 反扫 `assets/**`,未声明资源 fail closed。
|
||||
- 源码扫描:
|
||||
- 同时扫描 source `GameLogicModule` 和生成项目 JS。
|
||||
- 禁止 DOM/BOM、Web-only SDK、Node builtin、`node:*`、`require/process/Buffer`、`eval/new Function`、字符串 timer、dynamic import、external script。
|
||||
- 覆盖 `globalThis["document"]`、`globalThis["wx"]`、`module.require("fs")`、`globalThis.Function(...)` 等 computed/member 绕过。
|
||||
- `wx/tt/ks` 直调只在 GameLogicModule 源和生成 `js/game-logic-module.js` 中 fail,不误伤平台 adapter。
|
||||
- Kuaishou:
|
||||
- 仍保持 global/config shape unknown。
|
||||
- `kuaishou_minigame` 缺 `KUAISHOU_GLOBAL_AND_CONFIG_SHAPE_UNKNOWN` 风险提示时 fail closed。
|
||||
- Scope gate:
|
||||
- S5 放行 `apps/api/src/modules/static-gate/` 消费 Task4 必需术语。
|
||||
- S4 仍拒绝 S5 converter/static-gate 术语。
|
||||
- S5 仍不放行 Task5 `DevToolImportEvidence` / `DevToolImportBlocker` / `ChannelReadinessNote` 给 static gate。
|
||||
|
||||
## Controller 自测
|
||||
|
||||
已运行并通过:
|
||||
|
||||
```bash
|
||||
node --check scripts/check-scope.mjs
|
||||
pnpm --filter @huijing/api test -- static-gate
|
||||
pnpm --filter @huijing/api typecheck
|
||||
pnpm --filter @huijing/api lint
|
||||
pnpm check:s5-scope
|
||||
git diff --check
|
||||
```
|
||||
|
||||
关键输出:
|
||||
|
||||
```text
|
||||
Test Files 25 passed (25)
|
||||
Tests 280 passed (280)
|
||||
S5 scope check passed.
|
||||
```
|
||||
|
||||
`pnpm check:s4-scope` 已运行,结果为 EXPECTED_FAIL:S4 正确拒绝 S5 converter/static-gate/contract 术语。
|
||||
|
||||
测试中仍有既有 `pg@9` deprecation warning,但命令退出码为 0。
|
||||
|
||||
## Review Evidence
|
||||
|
||||
首轮 spec finding:
|
||||
|
||||
```text
|
||||
AssetManifest 只遍历声明项,未反扫 assets/**,额外未声明资源仍可通过。
|
||||
```
|
||||
|
||||
首轮 quality findings:
|
||||
|
||||
```text
|
||||
AssetManifest.entries[].path 未做受控路径校验。
|
||||
adapterPath/assetManifestPath/conversionReportPath 未重新校验,可能读取项目树外文件。
|
||||
AST scanner 漏 globalThis["document"]、globalThis["wx"]、module.require、globalThis.Function 等绕过。
|
||||
resourceBudget.actualBytes 可伪造。
|
||||
```
|
||||
|
||||
第二轮 review finding:
|
||||
|
||||
```text
|
||||
ConversionReport 缺 status/severity/evidenceLevel/checksum 仍可能通过 static gate。
|
||||
```
|
||||
|
||||
最终 re-review 确认:
|
||||
|
||||
```text
|
||||
ConversionReport target/status/severity/evidenceLevel/checksum/resourceBudget 字段已 fail-closed。
|
||||
AssetManifest 正反向完整性满足。
|
||||
路径边界、computed/member AST 扫描、actualBytes 对账已覆盖。
|
||||
未实现 Task5 DevToolImportEvidence,scope gate 仍保持 S4 拒绝 S5、S5 不放行 Task5/S6-S8。
|
||||
```
|
||||
|
||||
## Git Status Snapshot
|
||||
|
||||
写入本文档前 `git status --short --branch --untracked-files=all`:
|
||||
|
||||
```text
|
||||
## codex/s5-minigame-prep
|
||||
M scripts/check-scope.mjs
|
||||
?? apps/api/src/modules/static-gate/index.ts
|
||||
?? apps/api/src/modules/static-gate/static-gate.spec.ts
|
||||
```
|
||||
|
||||
## Residual Notes
|
||||
|
||||
- 当前 Task4 只是 static gate,不是开发者工具导入证据。
|
||||
- `ConversionReport.checksum` 当前校验为必填非空;如后续要作为防篡改证据,需要在后续任务中定义 checksum 计算规则和 mismatch 负例。
|
||||
- Kuaishou 仍是 unknown risk note 通过,不是已验证平台配置。
|
||||
@ -251,6 +251,12 @@ const s5Task3ApprovedTerms = new Set([
|
||||
"MiniGameProject",
|
||||
"ConversionReport"
|
||||
]);
|
||||
const s5Task4ApprovedTerms = new Set([
|
||||
...s5Task3ApprovedTerms,
|
||||
"GameLogicModule",
|
||||
"AssetManifest",
|
||||
"GamePackage"
|
||||
]);
|
||||
const s5InheritedS2S3S4Terms = new Set([
|
||||
...s2ApprovedTerms,
|
||||
...s3ApprovedTerms,
|
||||
@ -271,6 +277,10 @@ const s5Task3ApprovedTermAllowPathPrefixes = [
|
||||
"apps/api/src/modules/minigame-conversion/",
|
||||
"apps/api/src/modules/minigame-templates/"
|
||||
];
|
||||
const s5Task4ApprovedTermAllowPathPrefixes = [
|
||||
...s5Task3ApprovedTermAllowPathPrefixes,
|
||||
"apps/api/src/modules/static-gate/"
|
||||
];
|
||||
const s5ApprovedTermReferenceAllowExactPaths = new Set([
|
||||
...s5ApprovedTermAllowExactPaths,
|
||||
// 共享合同根出口可以暴露 S5 Task1 合同,但不能因此把 index.ts 整体变成 S5-owned path。
|
||||
@ -6235,20 +6245,25 @@ function hasS5Task3ConversionTermAllowPath(filePath) {
|
||||
return hasS5ContractTermAllowPath(filePath) || s5Task3ApprovedTermAllowPathPrefixes.some((prefix) => filePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
function hasS5Task4StaticGateTermAllowPath(filePath) {
|
||||
return hasS5ContractTermAllowPath(filePath) || s5Task4ApprovedTermAllowPathPrefixes.some((prefix) => filePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isAllowedS5ApprovedTermReference(filePath, term) {
|
||||
if (isAllowedS2ApprovedTermReference(filePath, term)) return true;
|
||||
if (isAllowedS3ApprovedTermReference(filePath, term)) return true;
|
||||
|
||||
// S5 可以消费 S4 runtime/package 合同术语,但 /events/batch 仍属于 S6-S8 行为路径,不能继承 S4 mock/smoke 例外。
|
||||
if (s5InheritedS2S3S4Terms.has(term) && term !== "/events/batch") {
|
||||
return hasS4ApprovedTermAllowPath(filePath) || hasS5RuntimeAdapterTermAllowPath(filePath);
|
||||
return hasS4ApprovedTermAllowPath(filePath) || hasS5RuntimeAdapterTermAllowPath(filePath) || hasS5Task4StaticGateTermAllowPath(filePath);
|
||||
}
|
||||
|
||||
if (!s5ApprovedTerms.has(term)) return false;
|
||||
if (hasS5ContractTermAllowPath(filePath)) return true;
|
||||
|
||||
// S5 Task3 只允许 converter/templates 消费转换、项目和报告术语;Task5 devtool import 与 channel readiness 仍不放行。
|
||||
return s5Task3ApprovedTerms.has(term) && hasS5Task3ConversionTermAllowPath(filePath);
|
||||
// S5 Task4 允许 static gate 消费转换项目、报告和上游包/逻辑术语;
|
||||
// Task5 devtool import 与 channel readiness 仍不放行,避免 static gate 冒充导入证据。
|
||||
return s5Task4ApprovedTerms.has(term) && hasS5Task4StaticGateTermAllowPath(filePath);
|
||||
}
|
||||
|
||||
function shouldSkipLaterPhaseTermViolation(filePath, term, phase) {
|
||||
@ -11451,6 +11466,25 @@ export const template = "GameLogicModule starts after runtime-sdk";
|
||||
}
|
||||
);
|
||||
|
||||
await withFixture(
|
||||
{
|
||||
"apps/api/src/modules/static-gate/index.ts": `
|
||||
import type { GamePackage, AssetManifest } from "../game-package/index.js";
|
||||
import type { GameLogicModule } from "../game-logic/index.js";
|
||||
export type StaticGateInput = { gamePackage: GamePackage; assetManifest: AssetManifest; logic: GameLogicModule; };
|
||||
export type StaticGateResult = { status: "failed_with_diagnostics" | "static_validated"; reasonCode?: string; };
|
||||
export type MiniGameProject = { target: "wechat_minigame"; };
|
||||
export type ConversionReport = { evidenceLevel: "static_gate_passed"; };
|
||||
`
|
||||
},
|
||||
async (root) => {
|
||||
const s4Violations = await findScopeViolations(root, "S4");
|
||||
const s5Violations = await findScopeViolations(root, "S5");
|
||||
assertHasReason(s4Violations, "LATER_PHASE_SCOPE_LEAK", "S4 rejects S5 Task4 static gate terms and paths");
|
||||
assertNoViolations(s5Violations, "S5 allows Task4 static gate to consume source/package/project contracts");
|
||||
}
|
||||
);
|
||||
|
||||
await withFixture(
|
||||
{
|
||||
"apps/api/src/modules/minigame-conversion/devtool-import.ts": `
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user