feat(s5): add static gate

This commit is contained in:
zizi 2026-06-05 14:49:19 +08:00
parent 617a75b19a
commit 1acc959180
4 changed files with 1472 additions and 3 deletions

View 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 仍是 unknownstatic 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;
}

View 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;
}

View 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-closedAST 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_FAILS4 正确拒绝 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 DevToolImportEvidencescope 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 通过,不是已验证平台配置。

View File

@ -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": `