Files
reverse-engineering-anything/scripts/verify/architecture-guards.mjs
T
morluto 89640a446a fix(parsers): stop reading every RangeError as a format failure (#10602)
* refactor(parsers): report format failures through one ParseFailure algebra

Add ParseFailure (malformed, truncated, unsupported, limit, with format,
offset and field) and its one carrier, ParseFailureError, in the domain.
BinaryReader now names truncation and the field it read, and its native
format and resource failures are ParseFailureError subclasses.

Providers projected a format failure with their own instanceof ladder,
and some dropped the selected path from the invalid_format message.
parseFailureAnalysisError now owns that projection for the native,
Go, devicetree, V8, Windows Shortcut and Electron fuse providers.
The V8 code cache, Windows Shortcut and Electron fuse format classes
are removed in favour of the algebra.

The architecture guard counts raw Buffer and DataView reads, plain
Error throws in parser folders and plain JSON.parse of parser input
against baselines that can only shrink.

* fix(parsers): stop reading every RangeError as a format failure

Mach-O, Swift and Objective-C metadata parsers threw RangeError for a
truncated or malformed record, so their catch sites treated any
RangeError as a partial decode: a call stack overflow or a host string
limit became an "unresolved" metadata reason, and the IL2CPP Mach-O
path reported it as a malformed container.

Those parsers and the CIL method body reader now throw ParseFailureError.
hostRangeErrorKind is the one interpreter of a host RangeError (stack
overflow, host capacity, raw out-of-bounds read), and
parseFailureFromCause admits only a parse failure or a raw
out-of-bounds read as evidence about the input. Fixup stream catch-alls
rethrow host limits. The architecture guard counts the remaining direct
instanceof RangeError checks.

* fix(parsers): preserve Apple metadata failure ownership

* test(architecture): shrink parser failure baseline

* fix: preserve uncertainty for host range errors
2026-11-15 10:03:26 +08:00

366 lines
13 KiB
JavaScript

import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, readdir, writeFile } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import {
compareCountedBaseline,
recordLoweredBaseline,
} from "./counted-baseline.mjs";
// Architecture guards for the strict-identity / best-effort-inference split.
// - Composite dedup keys must not NUL-join variable-length or free-form
// MIDDLE fields: ["a\0b"] collapses with ["a", "b"] and drops a finding.
// The text check below only flags one spelling; it is not a complete audit.
// - Transparent wrappers unwrap in exactly one place
// (javascriptAstValues.unwrapJavaScriptExpression); direct isTS* unwrapping
// elsewhere drifts (the `satisfies` class of bug).
const root = fileURLToPath(new URL("../../", import.meta.url));
const listFiles = async (dir) => {
const output = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) output.push(...(await listFiles(path)));
else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".spec.ts") &&
!entry.name.endsWith(".fixture.ts")
)
output.push(path);
}
return output;
};
const stripComments = (content) =>
content
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n")
.replace(/\/\*[\s\S]*?\*\//gu, "");
const sources = await listFiles(join(root, "src"));
const files = await Promise.all(
sources.map(async (path) => ({
path: relative(root, path).split(sep).join("/"),
content: stripComments(await readFile(path, "utf8")),
})),
);
const failures = [];
const check = (label, selected, pattern, allow = []) => {
const offenders = selected.filter(
(file) => pattern.test(file.content) && !allow.includes(file.path),
);
if (offenders.length > 0)
failures.push(
`${label}:\n${offenders.map((file) => ` ${file.path}`).join("\n")}`,
);
};
// Prerequisite inspection lifetime is an application contract shared by adapters.
check(
"provider-owned inspection lifetime (use src/application/ProviderInspectionScope)",
files,
/providers\/ProviderInspectionScope\.js/,
);
check("NUL-joined list key (use compositeKey)", files, /\.join\(["']\\0["']\)/);
check(
"direct transparent-wrapper unwrap outside javascriptAstValues (use unwrapJavaScriptExpression)",
files,
/isTSAsExpression|isTSSatisfiesExpression|isTSNonNullExpression|isTSTypeAssertion|isTSInstantiationExpression|isParenthesizedExpression/,
["src/domain/javascript/javascriptAstValues.ts"],
);
// A producer that pins both the soft and hard ceiling of every resource takes
// the vector from one owner. prlimitCeilingArguments is the command-less prefix
// of prlimitArgumentVector; both live in src/process/LinuxResourceLimits.ts.
// Re-audited 2026-10-22 with util-linux 2.39.3: a bare value sets both soft and
// hard, the same as soft:hard when the numbers are equal. `soft:` leaves the
// inherited hard ceiling unchanged.
// FirmwareCommand and WakaruCommand call prlimitArgumentVector and keep their
// own file-size and CPU budgets (120 CPU seconds; firmware file size is the
// call output budget or the 8 MiB report allowance; Wakaru file size is the
// 128 MiB output allowance). Their 1 GiB address space is
// BOUNDED_PRODUCER_LIMITS.address_space_bytes. PcapNativeBound calls
// prlimitCeilingArguments because PcapCommand appends the producer command.
// Its CPU ceiling stays 300 seconds and it has no resident-input budget;
// address space and file size use those shared fields.
// selectEvmWorkerLimits stays a separate selector: Result failures, unit
// checks, and a refusal when soft exceeds hard. It must not call
// clampLinuxResourceLimits.
check(
"inline paired prlimit ceilings (use prlimitArgumentVector from src/process/LinuxResourceLimits)",
files,
/--core=0:0/,
["src/process/LinuxResourceLimits.ts"],
);
check(
"bare prlimit value (a bare value sets both ceilings; use prlimitArgumentVector)",
files,
/--core=0(?!:)/,
);
// 2026-10-22: soft-only `resource:` options retain the inherited hard ceiling.
// EVMole budgets differ (3 GiB address space, 16 MiB file, 30 CPU seconds), so
// this lane must not call prlimitArgumentVector.
check(
"soft-only prlimit ceilings (paired producers use prlimitArgumentVector)",
files,
/--core=0:(?!0)/,
["src/evm/EvmoleInterfaceProvider.ts"],
);
// Resident input is the input_bytes field of BOUNDED_PRODUCER_LIMITS. CamelCase
// inputBytes values with a different policy (an overridable pwntools admission,
// historical capture intake) are not this budget.
check(
"inline resident-input budget (use BOUNDED_PRODUCER_LIMITS from src/domain/producerResourceLimits)",
files,
/input_bytes:\s*32 \* 1024 \* 1024/,
["src/domain/producerResourceLimits.ts"],
);
// Fixed-arity backtick keys audited 2026-10-10: every prefix field is NUL-free
// (hex digests, file paths, enums, numbers, or compositeKey JSON which escapes
// NUL), with free-form source strings only in final position. New backtick-\0
// keys must either meet that shape (and extend this list with justification)
// or use compositeKey.
check(
"new backtick-NUL composite key (audit against the rule above)",
files,
/`[^`\n]*\\0/,
[
// Bundle fingerprint: fixed packaged paths (no NUL/newline) and 64 hex
// digest characters per record, never guide contents. Preserve v1 locators.
"src/application/GuidanceAccess.ts",
"src/application/javascript/ElectronBoundaryAnalysis.ts",
"src/application/javascript/ElectronBoundaryGraphContext.ts",
"src/browser/CdpCaptureCompleteness.ts",
"src/browser/CdpCaptureEventHandlers.ts",
"src/browser/CdpPageCapture.ts",
"src/browser/WebSourceMapFetcher.ts",
"src/domain/javascript/electronStaticAnalysisBrowser.ts",
"src/domain/javascript/electronStaticAnalysisIpc.ts",
"src/domain/javascript/javascriptApplicationChangeGraph.ts",
"src/domain/javascript/javascriptApplicationVersionItems.ts",
"src/domain/javascript/javascriptApplicationVersionKeys.ts",
"src/domain/javascript/javascriptExportShapeSelection.ts",
"src/domain/javascript/javascriptFeatureSeed.ts",
"src/domain/javascript/javascriptFeatureTraversal.ts",
"src/domain/javascript/javascriptNativeHandoff.ts",
"src/domain/javascript/javascriptRuntimeReconciliationMatching.ts",
"src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts",
"src/domain/javascript/javascriptStaticAnalysisBundler.ts",
"src/domain/javascript/javascriptStaticAnalysisCalls.ts",
"src/domain/javascript/javascriptStaticAnalysisFindings.ts",
"src/domain/localPath.ts",
"src/domain/process/processTraceEvaluation.ts",
"src/domain/process/processTraceSpecification.ts",
"src/domain/unicodeCodePointOrder.ts",
"src/domain/webBundleAnalyzerInspection.ts",
],
);
// Mach-O load-command codes have one table; a local copy drifts from it.
check(
"local Mach-O load-command code (import LC from src/domain/apple/machoLoadCommandCodes)",
files,
/\bLC_[A-Z0-9_]+\s*=\s*(?:0x[0-9a-f]+|\d+)\b/iu,
["src/domain/apple/machoLoadCommandCodes.ts"],
);
// Counted baselines record the remaining copies of a pattern that has one
// owner. A file may not gain copies and a baseline may not grow; when copies
// are removed, `--shrink-baselines` lowers the recorded counts (and never
// raises them) so the next removal cannot be undone silently.
const baselinePath = join(
root,
"scripts/verify/architecture-guard-baselines.json",
);
const baselines = JSON.parse(await readFile(baselinePath, "utf8"));
const shrinkBaselines = process.argv.includes("--shrink-baselines");
let baselinesShrunk = false;
const checkCounted = (label, key, selected, pattern, owners) => {
const counts = new Map();
for (const file of selected) {
if (owners.includes(file.path)) continue;
const count = file.content.match(pattern)?.length ?? 0;
if (count > 0) counts.set(file.path, count);
}
const compared = compareCountedBaseline(
counts,
baselines[key] ?? {},
shrinkBaselines,
);
const lines = (differences) =>
differences
.map(
({ path, count, allowed }) =>
` ${path}: ${count} (baseline ${allowed})`,
)
.join("\n");
if (compared.grown.length > 0)
failures.push(
`${label}; new copies are not allowed:\n${lines(compared.grown)}`,
);
if (recordLoweredBaseline(baselines, key, compared)) baselinesShrunk = true;
if (compared.stale.length > 0)
failures.push(
`${label}; copies were removed, so lower the baseline with \`node scripts/verify/architecture-guards.mjs --shrink-baselines\`:\n${lines(compared.stale)}`,
);
};
// Cancellation reasons are required by `cancelledBy` in analysisErrorCore; a
// direct construction can omit or replace the caller's reason.
checkCounted(
"direct AnalysisCancelledError construction (use cancelledBy from src/domain/analysisErrorCore)",
"analysis-cancelled-constructor",
files,
/new AnalysisCancelledError\(/gu,
["src/domain/analysisErrorCore.ts"],
);
// Byte-format parsers read untrusted input. Bounds, truncation and failure
// kind belong to BinaryReader (src/native/metadata/BinaryContainer.ts) and
// the ParseFailure algebra (src/domain/parseFailure.ts), not to each parser.
const PARSER_FOLDERS = [
"src/android/",
"src/apkSignature/",
"src/artifacts/",
"src/dotnet/",
"src/electron/",
"src/erlang/",
"src/evm/",
"src/firmware/",
"src/flutter/",
"src/go/",
"src/gpu/",
"src/il2cpp/",
"src/model/",
"src/native/",
"src/python/",
"src/rust/",
"src/v8/",
"src/wasm/",
...[
"android",
"apkSignature",
"apple",
"electron",
"erlang",
"evm",
"firmware",
"flutter",
"go",
"gpu",
"il2cpp",
"managed",
"model",
"native",
"python",
"rust",
"v8",
"wasm",
"windows",
].map((folder) => `src/domain/${folder}/`),
];
const parserFiles = files.filter((file) =>
PARSER_FOLDERS.some((folder) => file.path.startsWith(folder)),
);
// An unchecked Buffer or DataView read throws a bare RangeError past the end,
// which a catch site cannot tell from a host stack or string limit.
checkCounted(
"raw Buffer or DataView numeric read (use BinaryReader from src/native/metadata/BinaryContainer)",
"raw-buffer-read",
files,
/\.(?:read(?:Big)?U?Int(?:8|16|32|64)?(?:LE|BE)?|readBigUint64(?:LE|BE)|readUint(?:8|16|32)(?:LE|BE)?|read(?:Float|Double)(?:LE|BE)|get(?:Big)?(?:Uint|Int)(?:8|16|32|64)|getFloat(?:32|64))\(/gu,
["src/native/metadata/BinaryContainer.ts"],
);
checkCounted(
"plain Error, TypeError or RangeError thrown by a parser (throw a ParseFailureError from src/domain/parseFailure)",
"parser-plain-throw",
parserFiles,
/throw new (?:Error|TypeError|RangeError)\(/gu,
[],
);
// A per-format failure class restates the ParseFailure algebra under its own
// spelling and needs its own catch-site translation; parsers throw a
// ParseFailureError (or a subclass of it) instead.
checkCounted(
"per-format failure class extending Error (throw a ParseFailureError from src/domain/parseFailure)",
"parser-format-failure-class",
files,
/class \w*(?:Format|Parse)(?:Failure|Error) extends Error\b/gu,
["src/domain/parseFailure.ts"],
);
// JSON.parse keeps the last of repeated member names without saying so; a
// parser of artifact content must state its duplicate-member policy
// (src/domain/jsonDuplicateMember.ts finds a repeated name).
checkCounted(
"plain JSON.parse of parser input (state the duplicate-member policy; see src/domain/jsonDuplicateMember)",
"parser-json-parse",
parserFiles,
/JSON\.parse\(/gu,
[],
);
// A RangeError can mean a stack overflow, a host string or buffer limit, a
// raw read or write past a buffer, or whatever its thrower intended. Only
// known host limits are classified; parsers throw a ParseFailureError.
checkCounted(
"direct RangeError interpretation (use hostRangeErrorKind or parseFailureFromCause from src/domain/parseFailure)",
"range-error-interpretation",
files,
/instanceof RangeError/gu,
["src/domain/parseFailure.ts"],
);
if (baselinesShrunk) {
const sorted = Object.fromEntries(
Object.entries(baselines).map(([key, counts]) => [
key,
Object.fromEntries(
Object.entries(counts).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
),
),
]),
);
await writeFile(baselinePath, `${JSON.stringify(sorted, null, 2)}\n`);
console.log(`Lowered counted baselines in ${relative(root, baselinePath)}.`);
}
if (failures.length > 0) {
console.error(`Architecture guards failed:\n\n${failures.join("\n\n")}\n`);
process.exit(1);
}
// CI runs this file before `npm ci`. The numeric-limit parser needs
// typescript6, so that check waits until dependencies exist. `npm run lint`
// runs it after install.
const typescript6 = new URL(
"../../node_modules/typescript6/package.json",
import.meta.url,
);
if (!existsSync(typescript6)) {
console.log(
"Numeric REA_ limit check waits until dependencies are installed.",
);
} else {
const numericLimits = spawnSync(
process.execPath,
[fileURLToPath(new URL("./rea-numeric-limits.mjs", import.meta.url))],
{ stdio: "inherit" },
);
if (numericLimits.status !== 0) process.exit(numericLimits.status ?? 1);
}
console.log(`Architecture guards passed across ${files.length} source files.`);