391 lines
14 KiB
JavaScript
391 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import {
|
|
analyzeLive2DMinJsSource,
|
|
buildCallResolutionReviewCatalog,
|
|
buildFunctionNameReviewCatalog,
|
|
buildLegacyFunctionRenameCatalog,
|
|
type Live2DCallResolutionDecisionCatalog,
|
|
type Live2DFunctionNameDecisionCatalog,
|
|
type Live2DFunctionReviewPacketValidation,
|
|
type LegacyFunctionRenameEvidence,
|
|
validateLive2DFunctionReviewPacket,
|
|
} from "../src/tools/deobfuscation.js";
|
|
|
|
interface CliOptions {
|
|
callDecisionCatalogPath: string;
|
|
callReviewCatalogOutputPath: string;
|
|
catalogOutputPath: string;
|
|
decisionCatalogPath: string;
|
|
formattedSourcePath: string;
|
|
internalRootSourcePath: string;
|
|
outputPath: string;
|
|
renameMapPaths: string[];
|
|
reviewCatalogOutputPath: string;
|
|
reviewPacketPath: string | null;
|
|
sourcePath: string;
|
|
}
|
|
|
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const workspaceRoot = path.resolve(
|
|
process.env.KT_WORKSPACE_ROOT || path.join(dirname, "../../.."),
|
|
);
|
|
const defaultSourcePath =
|
|
"Vue/kt-blog-web/public/live2d/wordpress-moc/live2d.min.js";
|
|
const defaultOutputPath =
|
|
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/source-structure-audit.json";
|
|
const defaultFormattedSourcePath =
|
|
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/formatted.js";
|
|
const defaultCatalogOutputPath =
|
|
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/function-name-catalog.json";
|
|
const defaultReviewCatalogOutputPath =
|
|
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/function-name-review-catalog.generated.json";
|
|
const defaultDecisionCatalogPath =
|
|
"docs/live2d-deobfuscation/function-name-decisions.json";
|
|
const defaultCallDecisionCatalogPath =
|
|
"docs/live2d-deobfuscation/call-resolutions.json";
|
|
const defaultCallReviewCatalogOutputPath =
|
|
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/call-review-catalog.generated.json";
|
|
const defaultInternalRootSourcePath =
|
|
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/core-only-pass1.js";
|
|
const defaultPublicRenameMapPath =
|
|
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/renames-public-roots.json";
|
|
const defaultInternalRenameMapPath =
|
|
".kt-workspace/deobfuscate/blog-live2d-cubism2-minjs/live2d.min/renames-internal-roots.json";
|
|
|
|
/**
|
|
* Resolves a CLI path against the KT workspace so the command behaves identically from local and NAS checkouts.
|
|
* @param value Absolute path or KT-workspace-relative path.
|
|
* @returns Normalized absolute path.
|
|
*/
|
|
function resolveWorkspacePath(value: string): string {
|
|
return path.isAbsolute(value) ? value : path.resolve(workspaceRoot, value);
|
|
}
|
|
|
|
/**
|
|
* Parses the bounded source/output CLI surface for the reproducible Live2D source audit.
|
|
* @param argv Command-line arguments after the executable and script path.
|
|
* @returns Resolved source and generated-evidence paths.
|
|
*/
|
|
function parseArgs(argv: string[]): CliOptions {
|
|
const args = new Map<string, string>();
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const item = argv[index];
|
|
if (!item.startsWith("--")) {
|
|
continue;
|
|
}
|
|
const [key, inlineValue] = item.slice(2).split("=", 2);
|
|
if (inlineValue !== undefined) {
|
|
if (key === "review-packet" && inlineValue.trim().length === 0) {
|
|
throw new Error("--review-packet requires a workspace-relative or absolute path");
|
|
}
|
|
args.set(key, inlineValue);
|
|
continue;
|
|
}
|
|
const next = argv[index + 1];
|
|
if (key === "review-packet" && (!next || next.startsWith("--"))) {
|
|
throw new Error("--review-packet requires a workspace-relative or absolute path");
|
|
}
|
|
if (next && !next.startsWith("--")) {
|
|
args.set(key, next);
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
if (args.has("help") || args.has("h")) {
|
|
printHelp();
|
|
process.exit(0);
|
|
}
|
|
|
|
return {
|
|
callDecisionCatalogPath: resolveWorkspacePath(
|
|
args.get("call-decisions") ?? defaultCallDecisionCatalogPath,
|
|
),
|
|
callReviewCatalogOutputPath: resolveWorkspacePath(
|
|
args.get("call-review-catalog-output") ??
|
|
defaultCallReviewCatalogOutputPath,
|
|
),
|
|
catalogOutputPath: resolveWorkspacePath(
|
|
args.get("catalog-output") ?? defaultCatalogOutputPath,
|
|
),
|
|
decisionCatalogPath: resolveWorkspacePath(
|
|
args.get("decisions") ?? defaultDecisionCatalogPath,
|
|
),
|
|
formattedSourcePath: resolveWorkspacePath(
|
|
args.get("formatted-source") ?? defaultFormattedSourcePath,
|
|
),
|
|
internalRootSourcePath: resolveWorkspacePath(
|
|
args.get("internal-root-source") ?? defaultInternalRootSourcePath,
|
|
),
|
|
outputPath: resolveWorkspacePath(args.get("output") ?? defaultOutputPath),
|
|
renameMapPaths: [
|
|
resolveWorkspacePath(defaultPublicRenameMapPath),
|
|
resolveWorkspacePath(defaultInternalRenameMapPath),
|
|
],
|
|
reviewCatalogOutputPath: resolveWorkspacePath(
|
|
args.get("review-catalog-output") ?? defaultReviewCatalogOutputPath,
|
|
),
|
|
reviewPacketPath: args.has("review-packet")
|
|
? resolveWorkspacePath(args.get("review-packet") ?? "")
|
|
: null,
|
|
sourcePath: resolveWorkspacePath(args.get("source") ?? defaultSourcePath),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Prints the stable command contract without exposing generated evidence outside `.kt-workspace` by default.
|
|
*/
|
|
function printHelp(): void {
|
|
console.log(`KT Live2D min.js 源码结构审计
|
|
|
|
用法:
|
|
pnpm run audit-live2d-minjs -- [--source <path>] [--output <path>] [--formatted-source <path>] [--internal-root-source <path>] [--catalog-output <path>] [--review-catalog-output <path>] [--decisions <path>] [--call-decisions <path>] [--call-review-catalog-output <path>] [--review-packet <path>]
|
|
|
|
默认输入:
|
|
${defaultSourcePath}
|
|
|
|
默认输出:
|
|
${defaultOutputPath}
|
|
|
|
函数名目录:
|
|
${defaultCatalogOutputPath}
|
|
|
|
函数名复审骨架:
|
|
${defaultReviewCatalogOutputPath}
|
|
|
|
长期复审决策:
|
|
${defaultDecisionCatalogPath}
|
|
|
|
调用复审骨架:
|
|
${defaultCallReviewCatalogOutputPath}
|
|
|
|
长期调用决策:
|
|
${defaultCallDecisionCatalogPath}
|
|
|
|
可选 Context Packet 门禁:
|
|
--review-packet <path> 校验函数表的 order/name/byte span/line span/SyntaxKind/functionId/snippetSha256
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Reads the compact durable function decision catalog; semantic validation runs in the catalog builder.
|
|
* @param decisionCatalogPath Versioned JSON path under `docs/live2d-deobfuscation`.
|
|
* @returns Parsed reviewed decision catalog.
|
|
*/
|
|
function readFunctionNameDecisions(
|
|
decisionCatalogPath: string,
|
|
): Live2DFunctionNameDecisionCatalog {
|
|
const parsed = JSON.parse(
|
|
readFileSync(decisionCatalogPath, "utf8"),
|
|
) as Live2DFunctionNameDecisionCatalog;
|
|
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.decisions)) {
|
|
throw new Error(`Invalid Live2D function decision catalog: ${decisionCatalogPath}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/**
|
|
* Reads the compact durable call decision catalog; source/caller/target validation runs in the catalog builder.
|
|
* @param decisionCatalogPath Versioned JSON path under `docs/live2d-deobfuscation`.
|
|
* @returns Parsed reviewed-only call decision catalog.
|
|
*/
|
|
function readCallResolutionDecisions(
|
|
decisionCatalogPath: string,
|
|
): Live2DCallResolutionDecisionCatalog {
|
|
const parsed = JSON.parse(
|
|
readFileSync(decisionCatalogPath, "utf8"),
|
|
) as Live2DCallResolutionDecisionCatalog;
|
|
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.decisions)) {
|
|
throw new Error(`Invalid Live2D call decision catalog: ${decisionCatalogPath}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/**
|
|
* Reads one historical direct `symbol@offset -> semanticName` map and rejects nested/ambiguous schemas.
|
|
* @param renameMapPath Rename evidence file created by the initial public/internal root passes.
|
|
* @returns Typed evidence source for raw-source function ID migration.
|
|
*/
|
|
function readLegacyRenameMap(
|
|
renameMapPath: string,
|
|
checkpointPath: string,
|
|
): LegacyFunctionRenameEvidence {
|
|
const parsed = JSON.parse(readFileSync(renameMapPath, "utf8")) as unknown;
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
throw new Error(`Legacy rename map must be an object: ${renameMapPath}`);
|
|
}
|
|
const renames: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
if (typeof value !== "string") {
|
|
throw new Error(`Legacy rename map values must be strings: ${renameMapPath}#${key}`);
|
|
}
|
|
renames[key] = value;
|
|
}
|
|
return {
|
|
checkpointAudit: analyzeLive2DMinJsSource(
|
|
readFileSync(checkpointPath, "utf8"),
|
|
{
|
|
allowCoreOnly: true,
|
|
sourcePath: path.relative(workspaceRoot, checkpointPath).replaceAll("\\", "/"),
|
|
},
|
|
),
|
|
evidencePath: path.relative(workspaceRoot, renameMapPath).replaceAll("\\", "/"),
|
|
renames,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reads the immutable min.js source, writes the AST-derived inventory/call graph, and prints a bounded summary.
|
|
*/
|
|
function main(): void {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.reviewPacketPath) {
|
|
const requiredPacketEvidencePaths = [
|
|
options.decisionCatalogPath,
|
|
options.formattedSourcePath,
|
|
options.internalRootSourcePath,
|
|
...options.renameMapPaths,
|
|
];
|
|
const missingPacketEvidencePaths = requiredPacketEvidencePaths.filter(
|
|
(requiredPath) => !existsSync(requiredPath),
|
|
);
|
|
if (missingPacketEvidencePaths.length > 0) {
|
|
throw new Error(
|
|
`Live2D review-packet validation requires current decision/catalog evidence: ${missingPacketEvidencePaths.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
const source = readFileSync(options.sourcePath, "utf8");
|
|
const audit = analyzeLive2DMinJsSource(source, {
|
|
sourcePath: path.relative(workspaceRoot, options.sourcePath).replaceAll("\\", "/"),
|
|
});
|
|
mkdirSync(path.dirname(options.outputPath), { recursive: true });
|
|
writeFileSync(options.outputPath, `${JSON.stringify(audit, null, 2)}\n`, "utf8");
|
|
let functionCatalogSummary: {
|
|
candidate: number;
|
|
candidateOwnerCounts: Record<string, number>;
|
|
entries: number;
|
|
pending: number;
|
|
pendingOwnerCounts: Record<string, number>;
|
|
reviewed: number;
|
|
skipped: number;
|
|
total: number;
|
|
} | null = null;
|
|
let callCatalogSummary: {
|
|
decisions: number;
|
|
pending: number;
|
|
reviewed: number;
|
|
total: number;
|
|
} | null = null;
|
|
let reviewPacketValidation: Live2DFunctionReviewPacketValidation | null = null;
|
|
if (
|
|
existsSync(options.formattedSourcePath) &&
|
|
existsSync(options.internalRootSourcePath) &&
|
|
options.renameMapPaths.every(existsSync)
|
|
) {
|
|
const functionCatalog = buildLegacyFunctionRenameCatalog(
|
|
audit,
|
|
[
|
|
readLegacyRenameMap(
|
|
options.renameMapPaths[0],
|
|
options.formattedSourcePath,
|
|
),
|
|
readLegacyRenameMap(
|
|
options.renameMapPaths[1],
|
|
options.internalRootSourcePath,
|
|
),
|
|
],
|
|
);
|
|
mkdirSync(path.dirname(options.catalogOutputPath), { recursive: true });
|
|
writeFileSync(
|
|
options.catalogOutputPath,
|
|
`${JSON.stringify(functionCatalog, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
const reviewCatalog = buildFunctionNameReviewCatalog(
|
|
audit,
|
|
functionCatalog,
|
|
existsSync(options.decisionCatalogPath)
|
|
? readFunctionNameDecisions(options.decisionCatalogPath)
|
|
: undefined,
|
|
);
|
|
mkdirSync(path.dirname(options.reviewCatalogOutputPath), { recursive: true });
|
|
writeFileSync(
|
|
options.reviewCatalogOutputPath,
|
|
`${JSON.stringify(reviewCatalog, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
const callDecisionCatalog = existsSync(options.callDecisionCatalogPath)
|
|
? readCallResolutionDecisions(options.callDecisionCatalogPath)
|
|
: undefined;
|
|
const callReviewCatalog = buildCallResolutionReviewCatalog(
|
|
audit,
|
|
reviewCatalog,
|
|
callDecisionCatalog,
|
|
);
|
|
mkdirSync(path.dirname(options.callReviewCatalogOutputPath), {
|
|
recursive: true,
|
|
});
|
|
writeFileSync(
|
|
options.callReviewCatalogOutputPath,
|
|
`${JSON.stringify(callReviewCatalog, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
callCatalogSummary = {
|
|
decisions: callDecisionCatalog?.decisions.length ?? 0,
|
|
pending: callReviewCatalog.summary.pending,
|
|
reviewed: callReviewCatalog.summary.reviewed,
|
|
total: callReviewCatalog.summary.total,
|
|
};
|
|
if (options.reviewPacketPath) {
|
|
reviewPacketValidation = validateLive2DFunctionReviewPacket(
|
|
readFileSync(options.reviewPacketPath, "utf8"),
|
|
reviewCatalog,
|
|
source,
|
|
);
|
|
}
|
|
functionCatalogSummary = {
|
|
candidate: reviewCatalog.summary.candidate,
|
|
candidateOwnerCounts: reviewCatalog.summary.candidateOwnerCounts,
|
|
entries: functionCatalog.entries.length,
|
|
pending: reviewCatalog.summary.pending,
|
|
pendingOwnerCounts: reviewCatalog.summary.pendingOwnerCounts,
|
|
reviewed: reviewCatalog.summary.reviewed,
|
|
skipped: functionCatalog.skipped.length,
|
|
total: reviewCatalog.summary.total,
|
|
};
|
|
}
|
|
if (options.reviewPacketPath && reviewPacketValidation === null) {
|
|
throw new Error(
|
|
"Live2D review-packet validation requires the formatted source, internal root source, and rename maps",
|
|
);
|
|
}
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
core: audit.core,
|
|
callCatalog: callCatalogSummary,
|
|
functionCatalog: functionCatalogSummary,
|
|
ok: audit.summary.parseDiagnosticCount === 0,
|
|
outputPath: options.outputPath,
|
|
reviewPacket: reviewPacketValidation,
|
|
sourcePath: options.sourcePath,
|
|
sourceSha256: audit.sourceSha256,
|
|
summary: audit.summary,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
}
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
}
|