From 1acc95918088cccd19d951b4c292a8fcdc31b87d Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 5 Jun 2026 14:49:19 +0800 Subject: [PATCH] feat(s5): add static gate --- apps/api/src/modules/static-gate/index.ts | 775 ++++++++++++++++++ .../modules/static-gate/static-gate.spec.ts | 511 ++++++++++++ docs/memorys/2026-06-05-S5Task4静态门禁.md | 149 ++++ scripts/check-scope.mjs | 40 +- 4 files changed, 1472 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/modules/static-gate/index.ts create mode 100644 apps/api/src/modules/static-gate/static-gate.spec.ts create mode 100644 docs/memorys/2026-06-05-S5Task4静态门禁.md diff --git a/apps/api/src/modules/static-gate/index.ts b/apps/api/src/modules/static-gate/index.ts new file mode 100644 index 00000000..31a96863 --- /dev/null +++ b/apps/api/src/modules/static-gate/index.ts @@ -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; + readonly projectConfig: Record; + 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 { + 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 { + 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 { + 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 { + 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 { + const declaredAssetPaths = new Set(); + 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 { + const result: ProjectCodeFile[] = []; + await collectProjectCodeFilesInto(projectRoot, "", result); + return result; +} + +async function collectProjectCodeFilesInto(projectRoot: string, relativeRoot: string, result: ProjectCodeFile[]): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): 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 { + 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; +} diff --git a/apps/api/src/modules/static-gate/static-gate.spec.ts b/apps/api/src/modules/static-gate/static-gate.spec.ts new file mode 100644 index 00000000..3be8c79f --- /dev/null +++ b/apps/api/src/modules/static-gate/static-gate.spec.ts @@ -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; + }[] = [ + { + 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; + const projectConfig = JSON.parse(await context.readProjectFile("project.config.json")) as Record; + 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>; + +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>; + readonly inputFor: (targetResult: MiniGameTargetConversionResult, overrides?: { readonly source?: string; readonly project?: MiniGameProject }) => StaticGateInput; + readonly projectPath: (relativePath: string) => string; + readonly removeProjectFile: (relativePath: string) => Promise; + readonly readProjectFile: (relativePath: string) => Promise; + readonly writeProjectFile: (relativePath: string, content: string) => Promise; + readonly readAssetManifest: () => Promise<{ id: string; entries: Array<{ path: string; hash: string; sizeBytes: number; type: string }> }>; + readonly readConversionReport: () => Promise>; + readonly projectSizeBytes: () => Promise; +}> { + 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, + 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 { + 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; +} diff --git a/docs/memorys/2026-06-05-S5Task4静态门禁.md b/docs/memorys/2026-06-05-S5Task4静态门禁.md new file mode 100644 index 00000000..40c9216c --- /dev/null +++ b/docs/memorys/2026-06-05-S5Task4静态门禁.md @@ -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 通过,不是已验证平台配置。 diff --git a/scripts/check-scope.mjs b/scripts/check-scope.mjs index 7e07f7b0..24c514a9 100644 --- a/scripts/check-scope.mjs +++ b/scripts/check-scope.mjs @@ -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": `