From 3e61a63a104859e1c8ca3bcdc6bad0511d1f1735 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:05:43 +0000 Subject: [PATCH] refactor: split oversized analysis workflows --- scripts/lib/managed-conformance-config.mjs | 102 ++ .../managed-conformance-manifest-parser.mjs | 209 +++ scripts/lib/managed-conformance-manifest.mjs | 340 ++++ scripts/lib/managed-conformance-oracles.mjs | 307 ++++ scripts/lib/managed-conformance-support.mjs | 48 + scripts/lib/managed-pe-fixture.mjs | 21 +- scripts/verify-managed-conformance.mjs | 915 +---------- src/application/CrossVersionInvestigation.ts | 45 +- .../CrossVersionInvestigationCollections.ts | 45 + .../JavaScriptArtifactGraphBundlers.ts | 309 ++++ .../JavaScriptArtifactGraphStructure.ts | 301 +--- src/domain/analysisErrorPresentation.ts | 280 ++++ src/domain/analysisErrorProjection.ts | 369 +++++ src/domain/errors.ts | 616 +------ src/domain/reconstructionCoverage.ts | 415 +---- .../reconstructionCoverageEvaluation.ts | 411 +++++ src/dotnet/ManagedMemberInspector.ts | 1444 +---------------- src/dotnet/ManagedMemberInspectorCore.ts | 477 ++++++ src/dotnet/ManagedMemberInstructionDecoder.ts | 470 ++++++ src/dotnet/ManagedMemberRows.ts | 224 +++ src/dotnet/ManagedMethodBodyReader.ts | 193 +++ tests/fixtures/managedPe.ts | 46 +- 22 files changed, 3985 insertions(+), 3602 deletions(-) create mode 100644 scripts/lib/managed-conformance-config.mjs create mode 100644 scripts/lib/managed-conformance-manifest-parser.mjs create mode 100644 scripts/lib/managed-conformance-manifest.mjs create mode 100644 scripts/lib/managed-conformance-oracles.mjs create mode 100644 scripts/lib/managed-conformance-support.mjs create mode 100644 src/application/CrossVersionInvestigationCollections.ts create mode 100644 src/application/JavaScriptArtifactGraphBundlers.ts create mode 100644 src/domain/analysisErrorPresentation.ts create mode 100644 src/domain/analysisErrorProjection.ts create mode 100644 src/domain/reconstructionCoverageEvaluation.ts create mode 100644 src/dotnet/ManagedMemberInspectorCore.ts create mode 100644 src/dotnet/ManagedMemberInstructionDecoder.ts create mode 100644 src/dotnet/ManagedMemberRows.ts create mode 100644 src/dotnet/ManagedMethodBodyReader.ts diff --git a/scripts/lib/managed-conformance-config.mjs b/scripts/lib/managed-conformance-config.mjs new file mode 100644 index 0000000000..126222071a --- /dev/null +++ b/scripts/lib/managed-conformance-config.mjs @@ -0,0 +1,102 @@ +export const inspectionLimits = { + referenceOffset: 0, + referenceLimit: 100, + resourceOffset: 0, + resourceLimit: 100, + attributeOffset: 0, + attributeLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, +}; + +export const memberLimits = { + typeOffset: 0, + typeLimit: 100, + methodOffset: 0, + methodLimit: 100, + fieldOffset: 0, + fieldLimit: 100, + memberRefOffset: 0, + memberRefLimit: 100, + edgeOffset: 0, + edgeLimit: 100, + instructionAnchorLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, + maxMethodBodyBytes: 1024 * 1024, + maxMethodInstructions: 1_000, +}; + +export const nativeBoundaryLimits = { + moduleRefOffset: 0, + moduleRefLimit: 100, + importOffset: 0, + importLimit: 100, + implementationOffset: 0, + implementationLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, +}; + +export const applicationGraphLimits = { + max_types: 100, + max_methods: 100, + max_fields: 100, + max_pinvoke_imports: 100, + max_native_implementations: 100, +}; + +export const comparisonLimits = { + max_method_matches: 100, + max_field_matches: 100, + max_candidates: 20, +}; + +export const defaultIlBody = Buffer.from([ + 0x32, 0x02, 0x7b, 0x01, 0x00, 0x00, 0x04, 0x28, 0x01, 0x00, 0x00, 0x0a, 0x2a, +]); + +export function functionDossier(name) { + const emptyPage = { + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, + }; + return { + procedure: { + address: "0x401000", + name, + classification: { + external: false, + thunk: false, + thunk_target: null, + provenance: "synthetic-provider", + }, + signature: null, + locals: [], + }, + pseudocode: { + text: "", + total_chars: 0, + returned_chars: 0, + truncated: false, + next_offset: null, + }, + assembly: emptyPage, + comments: emptyPage, + callers: emptyPage, + callees: emptyPage, + incoming_references: emptyPage, + outgoing_references: emptyPage, + referenced_strings: emptyPage, + referenced_names: emptyPage, + basic_blocks: emptyPage, + instruction_scan: { scanned: 0, truncated: false }, + limitations: [], + }; +} diff --git a/scripts/lib/managed-conformance-manifest-parser.mjs b/scripts/lib/managed-conformance-manifest-parser.mjs new file mode 100644 index 0000000000..c007568587 --- /dev/null +++ b/scripts/lib/managed-conformance-manifest-parser.mjs @@ -0,0 +1,209 @@ +export function parseManagedAppManifest(rawManifest) { + const manifest = object(rawManifest, "manifest"); + ensure( + manifest.schema_version === 1, + "manifest.schema_version must be exactly 1", + ); + const target = object(manifest.target, "manifest.target"); + const methods = array(manifest.methods, "manifest.methods"); + ensure(manifest.methods.length > 0, "manifest.methods must not be empty"); + return { + schema_version: 1, + label: optionalString(manifest.label, "manifest.label"), + target: { + path: string(target.path, "manifest.target.path"), + sha256: digest(target.sha256, "manifest.target.sha256"), + mvid: optionalCliMetadataGuid(target.mvid, "manifest.target.mvid"), + assembly_name: optionalString( + target.assembly_name, + "manifest.target.assembly_name", + ), + runtime_family: optionalString( + target.runtime_family, + "manifest.target.runtime_family", + ), + managed_architecture: optionalString( + target.managed_architecture, + "manifest.target.managed_architecture", + ), + }, + methods: methods.map((method, index) => + parseManagedAppManifestMethod(method, index), + ), + application_graph: parseManagedAppManifestGraph(manifest.application_graph), + }; +} + +function parseManagedAppManifestGraph(rawGraph) { + if (rawGraph === undefined) return undefined; + const graph = object(rawGraph, "manifest.application_graph"); + const expectedKinds = + graph.expected_node_kinds === undefined + ? [] + : array( + graph.expected_node_kinds, + "manifest.application_graph.expected_node_kinds", + ).map((kind, index) => + string( + kind, + `manifest.application_graph.expected_node_kinds[${String(index)}]`, + ), + ); + const traces = + graph.feature_traces === undefined + ? [] + : array( + graph.feature_traces, + "manifest.application_graph.feature_traces", + ).map((trace, index) => parseManagedAppManifestTrace(trace, index)); + ensure( + expectedKinds.length > 0 || traces.length > 0, + "manifest.application_graph must declare expected_node_kinds or feature_traces", + ); + return { + expected_node_kinds: expectedKinds, + feature_traces: traces, + }; +} + +function parseManagedAppManifestTrace(rawTrace, index) { + const prefix = `manifest.application_graph.feature_traces[${String(index)}]`; + const trace = object(rawTrace, prefix); + const match = trace.match ?? "exact"; + ensure( + match === "exact" || match === "contains", + `${prefix}.match must be exact or contains`, + ); + const caseSensitive = trace.case_sensitive ?? true; + ensure( + typeof caseSensitive === "boolean", + `${prefix}.case_sensitive must be a boolean`, + ); + const minMatchedSeeds = trace.min_matched_seeds ?? 1; + ensure( + Number.isInteger(minMatchedSeeds) && minMatchedSeeds > 0, + `${prefix}.min_matched_seeds must be a positive integer`, + ); + return { + label: optionalString(trace.label, `${prefix}.label`), + method_token: methodToken(trace.method_token, `${prefix}.method_token`), + seed: string(trace.seed, `${prefix}.seed`), + match, + case_sensitive: caseSensitive, + min_matched_seeds: minMatchedSeeds, + }; +} + +function parseManagedAppManifestMethod(rawMethod, index) { + const prefix = `manifest.methods[${String(index)}]`; + const method = object(rawMethod, prefix); + const token = methodToken(method.token, `${prefix}.token`); + return { + label: optionalString(method.label, `${prefix}.label`), + token, + signature_sha256: digest( + method.signature_sha256, + `${prefix}.signature_sha256`, + ), + il_size: ilSize(method, prefix), + il_sha256: + method.il_sha256 === undefined + ? undefined + : digest(method.il_sha256, `${prefix}.il_sha256`), + normalized_il_sha256: digest( + method.normalized_il_sha256, + `${prefix}.normalized_il_sha256`, + ), + }; +} + +function ilSize(method, prefix) { + const hasIlSize = method.il_size !== undefined; + const hasIlLength = method.il_length !== undefined; + ensure( + hasIlSize || hasIlLength, + `${prefix}.il_size is required; il_length is accepted as a legacy alias`, + ); + if (hasIlSize && hasIlLength) + ensure( + method.il_size === method.il_length, + `${prefix}.il_size and ${prefix}.il_length disagree`, + ); + const value = hasIlSize ? method.il_size : method.il_length; + ensure( + Number.isInteger(value) && value >= 0, + `${prefix}.il_size must be a non-negative integer`, + ); + return value; +} + +function object(value, name) { + ensure( + value !== null && typeof value === "object" && !Array.isArray(value), + `${name} must be an object`, + ); + return value; +} + +function array(value, name) { + ensure(Array.isArray(value), `${name} must be an array`); + return value; +} + +function string(value, name) { + ensure( + typeof value === "string" && value.length > 0, + `${name} must be a non-empty string`, + ); + return value; +} + +function optionalString(value, name) { + if (value === undefined) return undefined; + return string(value, name); +} + +function digest(value, name) { + const text = string(value, name); + ensure(/^[a-f0-9]{64}$/u.test(text), `${name} must be a lowercase sha256`); + return text; +} + +function optionalCliMetadataGuid(value, name) { + if (value === undefined) return undefined; + if (value === null) return null; + const text = string(value, name); + ensure( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test( + text, + ), + `${name} must be a lowercase GUID`, + ); + return text; +} + +function metadataToken(value, name) { + const text = string(value, name); + ensure(/^0x[0-9a-f]{8}$/u.test(text), `${name} must be a metadata token`); + return text; +} + +function methodToken(value, name) { + const token = metadataToken(value, name); + ensure( + token.startsWith("0x06"), + `${name} must be a MethodDef token beginning with 0x06`, + ); + return token; +} + +export function methodTokenRow(token) { + const row = Number.parseInt(token.slice(4), 16); + ensure(row > 0, `method token ${token} has an invalid row id`); + return row; +} + +export function ensure(condition, message) { + if (!condition) + throw new Error(`managed app manifest verification failed: ${message}`); +} diff --git a/scripts/lib/managed-conformance-manifest.mjs b/scripts/lib/managed-conformance-manifest.mjs new file mode 100644 index 0000000000..74c456f054 --- /dev/null +++ b/scripts/lib/managed-conformance-manifest.mjs @@ -0,0 +1,340 @@ +import { basename, dirname, isAbsolute, resolve } from "node:path"; +import { readFile } from "node:fs/promises"; + +import { parseBinaryTarget } from "../../dist/domain/binaryTarget.js"; +import { inspectManagedArtifactBytes } from "../../dist/dotnet/ManagedArtifactInspector.js"; +import { inspectManagedMembersBytes } from "../../dist/dotnet/ManagedMemberInspector.js"; +import { projectManagedApplicationGraphEvidence } from "../../dist/application/ManagedApplicationGraphService.js"; +import { traceApplicationFeatureEvidence } from "../../dist/application/JavaScriptApplicationWorkflowService.js"; +import { createEvidence } from "../../dist/domain/evidence.js"; +import { MANAGED_STATIC_PROVIDER } from "../../dist/application/InvestigationProviders.js"; +import { + ensure, + methodTokenRow, + parseManagedAppManifest, +} from "./managed-conformance-manifest-parser.mjs"; + +export const createManagedManifestVerifier = (context) => ({ + verifyManagedAppManifest: (rawManifest, manifestPath) => + verifyManagedAppManifest(context, rawManifest, manifestPath), +}); + +async function verifyManagedAppManifest(context, rawManifest, manifestPath) { + const { appManifestInspectionLimits } = context; + const manifest = parseManagedAppManifest(rawManifest); + const targetPath = isAbsolute(manifest.target.path) + ? manifest.target.path + : resolve(dirname(manifestPath), manifest.target.path); + const parsedTarget = await parseBinaryTarget(targetPath); + ensure( + parsedTarget.ok, + `target ${targetPath} could not be parsed as a binary target`, + ); + const target = parsedTarget.value; + ensure( + target.sha256 === manifest.target.sha256, + `target sha256 mismatch: expected ${manifest.target.sha256}, observed ${target.sha256}`, + ); + const bytes = await readFile(targetPath); + const artifact = inspectManagedArtifactBytes( + bytes, + target, + appManifestInspectionLimits, + ); + verifyManifestTarget(manifest, artifact); + const methodVerification = verifyManifestMethods( + context, + manifest, + bytes, + target, + ); + const { methods, inspectedMethods } = methodVerification; + let assertions = 1 + methodVerification.assertions; + const applicationGraph = + manifest.application_graph === undefined + ? undefined + : verifyManagedAppManifestApplicationGraph({ + context, + graphManifest: manifest.application_graph, + bytes, + target, + artifact, + manifestMethods: manifest.methods, + inspectedMethods, + }); + if (applicationGraph !== undefined) assertions += applicationGraph.assertions; + return { + label: manifest.label ?? null, + assertions, + target: { + file_name: basename(targetPath), + sha256: target.sha256, + mvid: artifact.module?.mvid ?? null, + assembly_name: artifact.assembly?.name ?? null, + runtime_family: artifact.classification.runtime_family, + managed_architecture: artifact.classification.managed_architecture, + }, + methods, + ...(applicationGraph === undefined + ? {} + : { application_graph: applicationGraph.summary }), + }; +} + +function verifyManifestTarget(manifest, artifact) { + ensure( + artifact.classification.status === "managed", + `target is ${artifact.classification.status}, expected managed`, + ); + if (manifest.target.mvid !== undefined) + ensure( + artifact.module?.mvid === manifest.target.mvid, + `target MVID mismatch: expected ${manifest.target.mvid}, observed ${artifact.module?.mvid ?? "null"}`, + ); + if (manifest.target.assembly_name !== undefined) + ensure( + artifact.assembly?.name === manifest.target.assembly_name, + `assembly name mismatch: expected ${manifest.target.assembly_name}, observed ${artifact.assembly?.name ?? "null"}`, + ); + if (manifest.target.runtime_family !== undefined) + ensure( + artifact.classification.runtime_family === manifest.target.runtime_family, + `runtime family mismatch: expected ${manifest.target.runtime_family}, observed ${artifact.classification.runtime_family}`, + ); + if (manifest.target.managed_architecture !== undefined) + ensure( + artifact.classification.managed_architecture === + manifest.target.managed_architecture, + `managed architecture mismatch: expected ${manifest.target.managed_architecture}, observed ${artifact.classification.managed_architecture}`, + ); +} + +function verifyManifestMethods(context, manifest, bytes, target) { + const methods = []; + let assertions = 0; + const inspectedMethods = new Map(); + for (const expectedMethod of manifest.methods) { + const method = inspectManagedMethodByToken( + context, + bytes, + target, + expectedMethod, + ); + ensure( + method.signature.raw_sha256 === expectedMethod.signature_sha256, + `method ${expectedMethod.token} signature sha256 mismatch: expected ${expectedMethod.signature_sha256}, observed ${method.signature.raw_sha256}`, + ); + ensure( + method.body.il_size === expectedMethod.il_size, + `method ${expectedMethod.token} IL size mismatch: expected ${String(expectedMethod.il_size)}, observed ${String(method.body.il_size)}`, + ); + if (expectedMethod.il_sha256 !== undefined) + ensure( + method.body.il_sha256 === expectedMethod.il_sha256, + `method ${expectedMethod.token} IL sha256 mismatch: expected ${expectedMethod.il_sha256}, observed ${method.body.il_sha256 ?? "null"}`, + ); + ensure( + method.body.normalized_il_sha256 === expectedMethod.normalized_il_sha256, + `method ${expectedMethod.token} normalized IL sha256 mismatch: expected ${expectedMethod.normalized_il_sha256}, observed ${method.body.normalized_il_sha256 ?? "null"}`, + ); + assertions += expectedMethod.il_sha256 === undefined ? 4 : 5; + inspectedMethods.set(expectedMethod.token, method); + methods.push({ + label: expectedMethod.label ?? null, + token: method.token, + declaring_type: method.declaring_type, + name: method.name, + signature_sha256: method.signature.raw_sha256, + il_size: method.body.il_size, + il_sha256: method.body.il_sha256, + normalized_il_sha256: method.body.normalized_il_sha256, + }); + } + return { methods, inspectedMethods, assertions }; +} + +function verifyManagedAppManifestApplicationGraph({ + context, + graphManifest, + bytes, + target, + artifact, + manifestMethods, + inspectedMethods, +}) { + const graphByToken = new Map(); + const projectionState = { + context, + graphByToken, + inspectedMethods, + bytes, + target, + artifact, + }; + const firstToken = manifestMethods[0]?.token; + ensure( + firstToken !== undefined, + "application graph verification requires at least one manifest method", + ); + const baseline = projectManifestMethod(projectionState, firstToken); + let assertions = 1; + for (const expectedKind of graphManifest.expected_node_kinds) { + ensure( + baseline.nodeKinds.includes(expectedKind), + `application graph is missing expected node kind ${expectedKind}`, + ); + assertions += 1; + } + + const traces = []; + for (const expectedTrace of graphManifest.feature_traces) { + const projection = projectManifestMethod( + projectionState, + expectedTrace.method_token, + ); + const traced = traceApplicationFeatureEvidence({ + application: projection.evidence, + native_observations: [], + seed: { + kind: "string", + value: expectedTrace.seed, + match: expectedTrace.match, + case_sensitive: expectedTrace.case_sensitive, + }, + direction: "incoming", + limits: { + max_seed_matches: 10, + max_depth: 4, + max_nodes: 100, + max_edges: 200, + max_paths: 20, + }, + }); + ensure( + traced.ok, + `application graph trace failed for method ${expectedTrace.method_token}`, + ); + const matched = traced.value.normalized_result.summary.matched_seeds; + ensure( + matched >= expectedTrace.min_matched_seeds, + `application graph trace ${expectedTrace.seed} matched ${String(matched)} seeds, expected at least ${String(expectedTrace.min_matched_seeds)}`, + ); + assertions += 1; + traces.push({ + label: expectedTrace.label ?? null, + method_token: expectedTrace.method_token, + seed: expectedTrace.seed, + matched_seeds: matched, + trace_evidence_id: traced.value.evidence_id, + }); + } + + return { + assertions, + summary: { + projections: [...graphByToken.values()].map((projection) => ({ + method_token: projection.method.token, + graph_evidence_id: projection.evidence.evidence_id, + node_kinds: projection.nodeKinds, + summary: projection.summary, + })), + expected_node_kinds: graphManifest.expected_node_kinds, + feature_traces: traces, + }, + }; +} + +const projectManifestMethod = (state, token) => { + const { + appManifestInspectionLimits, + appManifestMemberLimits, + applicationGraphLimits, + } = state.context; + const existing = state.graphByToken.get(token); + if (existing !== undefined) return existing; + const method = state.inspectedMethods.get(token); + ensure( + method !== undefined, + `application graph method ${token} must also be declared in manifest.methods`, + ); + const members = inspectManagedMethodPageByToken( + state.context, + state.bytes, + state.target, + token, + ); + const artifactEvidence = createEvidence( + state.target, + MANAGED_STATIC_PROVIDER, + { + operation: "inspect_managed_artifact", + parameters: appManifestInspectionLimits, + result: state.artifact, + rawResult: null, + limitations: state.artifact.limitations, + locations: [{ kind: "artifact-path", path: state.target.path }], + }, + ); + const memberEvidence = createEvidence(state.target, MANAGED_STATIC_PROVIDER, { + operation: "inspect_managed_members", + parameters: { + ...appManifestMemberLimits, + methodOffset: methodTokenRow(token) - 1, + }, + result: members, + rawResult: null, + limitations: members.limitations, + locations: [{ kind: "artifact-path", path: state.target.path }], + }); + const projected = projectManagedApplicationGraphEvidence({ + managed_artifact: artifactEvidence, + managed_members: memberEvidence, + limits: applicationGraphLimits, + }); + ensure( + projected.ok, + `application graph projection failed for method ${token}`, + ); + const projection = { + method, + evidence: projected.value, + nodeKinds: [ + ...new Set( + projected.value.normalized_result.graph.nodes.map(({ kind }) => kind), + ), + ].sort(), + summary: projected.value.normalized_result.summary, + }; + state.graphByToken.set(token, projection); + return projection; +}; + +function inspectManagedMethodByToken(context, bytes, target, expectedMethod) { + const members = inspectManagedMethodPageByToken( + context, + bytes, + target, + expectedMethod.token, + ); + const method = members.methods.items.find( + ({ token }) => token === expectedMethod.token, + ); + ensure( + method !== undefined, + `method ${expectedMethod.token} was not returned by the member inspector`, + ); + ensure( + method.body.status === "present", + `method ${expectedMethod.token} body status is ${method.body.status}, expected present`, + ); + return method; +} + +function inspectManagedMethodPageByToken(context, bytes, target, token) { + const { appManifestMemberLimits } = context; + const row = methodTokenRow(token); + return inspectManagedMembersBytes(bytes, target, { + ...appManifestMemberLimits, + methodOffset: row - 1, + }); +} diff --git a/scripts/lib/managed-conformance-oracles.mjs b/scripts/lib/managed-conformance-oracles.mjs new file mode 100644 index 0000000000..cd6ae48082 --- /dev/null +++ b/scripts/lib/managed-conformance-oracles.mjs @@ -0,0 +1,307 @@ +import { execFile } from "node:child_process"; +import { constants } from "node:fs"; +import { access, readFile } from "node:fs/promises"; +import { basename, isAbsolute, join, resolve } from "node:path"; +import assert from "node:assert/strict"; +import { promisify } from "node:util"; + +import { inspectManagedArtifactBytes } from "../../dist/dotnet/ManagedArtifactInspector.js"; +import { inspectManagedMembersBytes } from "../../dist/dotnet/ManagedMemberInspector.js"; +import { importManagedReconstructionEvidence } from "../../dist/application/ManagedReconstructionService.js"; +import { createEvidence } from "../../dist/domain/evidence.js"; +import { MANAGED_STATIC_PROVIDER } from "../../dist/application/InvestigationProviders.js"; +import { createManagedManifestVerifier } from "./managed-conformance-manifest.mjs"; + +const execFileAsync = promisify(execFile); + +export const createManagedConformanceOracleSupport = (context) => { + const verifier = createManagedManifestVerifier(context); + const support = { ...context, ...verifier }; + return { + runManagedAppManifestSelfTest: () => runManagedAppManifestSelfTest(support), + runOptionalManagedAppManifest: () => runOptionalManagedAppManifest(support), + runOptionalIlspyOracle: () => runOptionalIlspyOracle(support), + }; +}; + +async function runManagedAppManifestSelfTest(context) { + const sample = await context.fixture("manifest-self-test.exe", { + methodName: "PinnedSemanticSlice", + ilBody: context.defaultIlBody, + }); + const artifact = inspectManagedArtifactBytes( + sample.bytes, + sample.target, + context.inspectionLimits, + ); + const members = inspectManagedMembersBytes( + sample.bytes, + sample.target, + context.memberLimits, + ); + const method = members.methods.items[0]; + assert.ok(method); + const summary = await context.verifyManagedAppManifest( + { + schema_version: 1, + label: "source-owned manifest verifier self-test", + target: { + path: sample.path, + sha256: sample.target.sha256, + mvid: artifact.module?.mvid, + assembly_name: artifact.assembly?.name, + runtime_family: artifact.classification.runtime_family, + managed_architecture: artifact.classification.managed_architecture, + }, + methods: [ + { + label: "pinned-semantic-slice", + token: method.token, + signature_sha256: method.signature.raw_sha256, + il_size: method.body.il_size, + il_sha256: method.body.il_sha256, + normalized_il_sha256: method.body.normalized_il_sha256, + }, + ], + application_graph: { + expected_node_kinds: [ + "artifact", + "managed-assembly", + "managed-module", + "managed-type", + "managed-method", + ], + feature_traces: [ + { + label: "trace pinned method by name", + method_token: method.token, + seed: "PinnedSemanticSlice", + match: "exact", + min_matched_seeds: 1, + }, + ], + }, + }, + join(context.workspace, "source-owned-managed-app-manifest.json"), + ); + return { + verified: true, + assertions: summary.assertions, + methods: summary.methods.length, + applicationGraph: summary.application_graph ?? null, + target: summary.target, + }; +} + +async function runOptionalManagedAppManifest(context) { + const manifestPath = process.env.REA_MANAGED_APP_MANIFEST_PATH; + if (manifestPath === undefined) return null; + if (manifestPath.trim().length === 0) + throw new Error("REA_MANAGED_APP_MANIFEST_PATH is set but empty"); + const resolvedManifestPath = resolve(process.cwd(), manifestPath); + const text = await readFile(resolvedManifestPath, "utf8"); + let manifest; + try { + manifest = JSON.parse(text); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `managed app manifest ${resolvedManifestPath} is not valid JSON: ${message}`, + ); + } + return { + configured: true, + ...(await context.verifyManagedAppManifest(manifest, resolvedManifestPath)), + }; +} + +async function runOptionalIlspyOracle(context) { + const ilspyPath = process.env.REA_ILSPY_CMD_PATH; + if (ilspyPath === undefined) return null; + ensureIlspy( + ilspyPath.trim().length > 0, + "REA_ILSPY_CMD_PATH is set but empty", + ); + ensureIlspy( + isAbsolute(ilspyPath), + "REA_ILSPY_CMD_PATH must be an absolute path", + ); + await ensureExecutable(ilspyPath); + const versionOutput = await runIlspy(ilspyPath, ["--version"], 64 * 1024); + const version = ilspyVersion(versionOutput); + const executableSha256 = context.sha256(await readFile(ilspyPath)); + const sample = await context.fixture("ilspy-oracle.exe", { + methodName: "PinnedSemanticSlice", + }); + const artifact = inspectManagedArtifactBytes( + sample.bytes, + sample.target, + context.inspectionLimits, + ); + const members = inspectManagedMembersBytes( + sample.bytes, + sample.target, + context.memberLimits, + ); + const method = members.methods.items[0]; + ensureIlspy(method !== undefined, "source-owned fixture has no method"); + const listOutput = await runIlspy( + ilspyPath, + ["--disable-updatecheck", "-l", "c", sample.path], + 256 * 1024, + ); + ensureIlspy( + listOutput.includes("Class Fixture.Program"), + "ilspycmd class listing did not include Fixture.Program", + ); + const csharp = await runIlspy( + ilspyPath, + ["--disable-updatecheck", "--type", "Fixture.Program", sample.path], + 256 * 1024, + ); + ensureIlspy( + csharp.includes("PinnedSemanticSlice"), + "ilspycmd C# output did not include the pinned method name", + ); + ensureIlspy( + csharp.length <= 65_536, + "ilspycmd C# output exceeded the managed reconstruction import bound", + ); + const reconstructionImport = importIlspyReconstruction({ + context, + sample, + method, + members, + version, + executableSha256, + csharp, + }); + const imported = reconstructionImport.value.normalized_result; + return { + env: "REA_ILSPY_CMD_PATH", + configured: true, + version, + executable_sha256: executableSha256, + fixture: { + file_name: basename(sample.path), + sha256: sample.target.sha256, + mvid: artifact.module?.mvid ?? null, + assembly_name: artifact.assembly?.name ?? null, + method: { + token: method.token, + declaring_type: method.declaring_type, + name: method.name, + signature_sha256: method.signature.raw_sha256, + il_size: method.body.il_size, + normalized_il_sha256: method.body.normalized_il_sha256, + }, + }, + output: { + class_listing_sha256: context.sha256(Buffer.from(listOutput)), + decompiled_csharp_sha256: context.sha256(Buffer.from(csharp)), + decompiled_csharp_bytes: Buffer.byteLength(csharp), + imported_evidence_id: reconstructionImport.value.evidence_id, + reconstruction_id: imported.reconstruction_id, + canonical_observation: + imported.methods[0]?.validation.canonical_observation ?? null, + confidence_floor: + imported.methods[0]?.validation.confidence_floor ?? null, + }, + }; +} + +function importIlspyReconstruction({ + context, + sample, + method, + members, + version, + executableSha256, + csharp, +}) { + const memberEvidence = createEvidence( + sample.target, + MANAGED_STATIC_PROVIDER, + { + operation: "inspect_managed_members", + parameters: context.memberLimits, + result: members, + rawResult: null, + limitations: members.limitations, + locations: [{ kind: "artifact-path", path: sample.target.path }], + }, + ); + const reconstructionImport = importManagedReconstructionEvidence({ + static_members: memberEvidence, + decompiler: { + name: "ilspycmd", + version, + family: "ilspy", + executable_sha256: executableSha256, + options: ["--disable-updatecheck", "--type", "Fixture.Program"], + }, + methods: [ + { + token: method.token, + signature_sha256: method.signature.raw_sha256, + normalized_il_sha256: method.body.normalized_il_sha256, + reconstruction: { + kind: "decompiled-csharp", + language: "csharp", + text: csharp, + }, + }, + ], + notes: [ + "source-owned real ilspycmd oracle; output is imported as reconstruction inference", + ], + }); + ensureIlspy(reconstructionImport.ok, "managed reconstruction import failed"); + const imported = reconstructionImport.value.normalized_result; + ensureIlspy( + imported.summary.decompiled_csharp_methods === 1, + "managed reconstruction import did not record one decompiled C# method", + ); + return reconstructionImport; +} + +async function ensureExecutable(path) { + try { + await access(path, constants.X_OK); + } catch { + throw new Error(`managed ILSpy oracle failed: ${path} is not executable`); + } +} + +async function runIlspy(path, args, maxBuffer) { + try { + const { stdout } = await execFileAsync(path, args, { + timeout: 30_000, + maxBuffer, + env: { + ...process.env, + DOTNET_CLI_TELEMETRY_OPTOUT: "1", + }, + }); + return stdout; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`managed ILSpy oracle failed: ${message}`); + } +} + +function ilspyVersion(output) { + const version = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line.startsWith("ilspycmd:")); + ensureIlspy( + version !== undefined, + "ilspycmd --version did not identify ilspycmd", + ); + return version; +} + +function ensureIlspy(condition, message) { + if (!condition) throw new Error(`managed ILSpy oracle failed: ${message}`); +} diff --git a/scripts/lib/managed-conformance-support.mjs b/scripts/lib/managed-conformance-support.mjs new file mode 100644 index 0000000000..457aae1af1 --- /dev/null +++ b/scripts/lib/managed-conformance-support.mjs @@ -0,0 +1,48 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { parseBinaryTarget } from "../../dist/domain/binaryTarget.js"; +import { buildManagedPeFixture } from "./managed-pe-fixture.mjs"; +import { createManagedConformanceOracleSupport } from "./managed-conformance-oracles.mjs"; + +export const createManagedConformanceSupport = (context) => { + const fixtureBytes = async (name, bytes) => { + const path = join(context.workspace, name); + await writeFile(path, bytes); + const parsed = await parseBinaryTarget(path); + if (!parsed.ok) throw parsed.error; + return { bytes, path, target: parsed.value }; + }; + const fixture = (name, options) => + fixtureBytes(name, buildManagedPeFixture(options)); + const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); + const appManifestInspectionLimits = { + ...context.inspectionLimits, + maxMetadataBytes: 64 * 1024 * 1024, + maxTableRows: 250_000, + maxHeapItemBytes: 16 * 1024 * 1024, + }; + const appManifestMemberLimits = { + ...context.memberLimits, + typeLimit: 1, + methodLimit: 1, + fieldLimit: 1, + memberRefLimit: 1, + edgeLimit: 1, + maxMetadataBytes: 64 * 1024 * 1024, + maxTableRows: 250_000, + maxHeapItemBytes: 16 * 1024 * 1024, + maxMethodBodyBytes: 4 * 1024 * 1024, + maxMethodInstructions: 20_000, + }; + const base = { + ...context, + fixture, + fixtureBytes, + sha256, + appManifestInspectionLimits, + appManifestMemberLimits, + }; + return { ...base, ...createManagedConformanceOracleSupport(base) }; +}; diff --git a/scripts/lib/managed-pe-fixture.mjs b/scripts/lib/managed-pe-fixture.mjs index 01d388dde9..e505fcdd0f 100644 --- a/scripts/lib/managed-pe-fixture.mjs +++ b/scripts/lib/managed-pe-fixture.mjs @@ -232,10 +232,8 @@ export const alternateMvid = Buffer.from([ ]); /** Build a source-owned PE/CLI fixture without committing compiled binaries. */ -export const buildManagedPeFixture = (options = {}) => { - const strings = new StringHeap(); - const blobs = new BlobHeap(); - const methods = options.methods ?? [ +const managedFixtureMethods = (options) => + options.methods ?? [ { name: options.methodName ?? "Main", body: options.ilBody ?? defaultIlBody, @@ -243,6 +241,10 @@ export const buildManagedPeFixture = (options = {}) => { implFlags: options.methodImplFlags ?? 0, }, ]; + +const buildManagedMetadata = (options, methods) => { + const strings = new StringHeap(); + const blobs = new BlobHeap(); const stringsForMethods = methods.map((method) => strings.add(method.name)); const moduleName = strings.add("Fixture.dll"); const assemblyName = strings.add("Fixture.Managed"); @@ -320,6 +322,10 @@ export const buildManagedPeFixture = (options = {}) => { blobs.toBuffer(), ); if (options.corruptMetadataSignature === true) metadata.writeUInt32LE(0, 0); + return metadata; +}; + +const buildManagedImage = (options, methods, metadata) => { const resourceData = options.resourceData ?? Buffer.from("resource-data"); const resourceDirectory = Buffer.concat([ u32(resourceData.length), @@ -381,7 +387,12 @@ export const buildManagedPeFixture = (options = {}) => { return image; }; -/** Build a syntactically valid PE32 fixture with no CLI directory. */ +/** Build a source-owned PE/CLI fixture without committing compiled binaries. */ +export const buildManagedPeFixture = (options = {}) => { + const methods = managedFixtureMethods(options); + const metadata = buildManagedMetadata(options, methods); + return buildManagedImage(options, methods, metadata); +}; export const buildNativePeFixture = () => { const image = buildManagedPeFixture(); const cliDirectory = 0x84 + 20 + 96 + 14 * 8; diff --git a/scripts/verify-managed-conformance.mjs b/scripts/verify-managed-conformance.mjs index 7be0796c16..1af60797da 100644 --- a/scripts/verify-managed-conformance.mjs +++ b/scripts/verify-managed-conformance.mjs @@ -1,13 +1,8 @@ -import { createHash } from "node:crypto"; -import { execFile } from "node:child_process"; -import { constants } from "node:fs"; -import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { join } from "node:path"; import assert from "node:assert/strict"; -import { promisify } from "node:util"; -import { parseBinaryTarget } from "../dist/domain/binaryTarget.js"; import { inspectManagedArtifactBytes } from "../dist/dotnet/ManagedArtifactInspector.js"; import { inspectManagedMembersBytes } from "../dist/dotnet/ManagedMemberInspector.js"; import { inspectManagedNativeBoundariesBytes } from "../dist/dotnet/ManagedNativeBoundaryInspector.js"; @@ -22,94 +17,34 @@ import { MANAGED_STATIC_PROVIDER } from "../dist/application/InvestigationProvid import { createEvidence } from "../dist/domain/evidence.js"; import { alternateMvid, - buildManagedPeFixture, buildNativePeFixture, } from "./lib/managed-pe-fixture.mjs"; - -const inspectionLimits = { - referenceOffset: 0, - referenceLimit: 100, - resourceOffset: 0, - resourceLimit: 100, - attributeOffset: 0, - attributeLimit: 100, - maxMetadataBytes: 1024 * 1024, - maxTableRows: 1_000, - maxHeapItemBytes: 1024 * 1024, -}; - -const memberLimits = { - typeOffset: 0, - typeLimit: 100, - methodOffset: 0, - methodLimit: 100, - fieldOffset: 0, - fieldLimit: 100, - memberRefOffset: 0, - memberRefLimit: 100, - edgeOffset: 0, - edgeLimit: 100, - instructionAnchorLimit: 100, - maxMetadataBytes: 1024 * 1024, - maxTableRows: 1_000, - maxHeapItemBytes: 1024 * 1024, - maxMethodBodyBytes: 1024 * 1024, - maxMethodInstructions: 1_000, -}; - -const nativeBoundaryLimits = { - moduleRefOffset: 0, - moduleRefLimit: 100, - importOffset: 0, - importLimit: 100, - implementationOffset: 0, - implementationLimit: 100, - maxMetadataBytes: 1024 * 1024, - maxTableRows: 1_000, - maxHeapItemBytes: 1024 * 1024, -}; - -const applicationGraphLimits = { - max_types: 100, - max_methods: 100, - max_fields: 100, - max_pinvoke_imports: 100, - max_native_implementations: 100, -}; - -const appManifestInspectionLimits = { - ...inspectionLimits, - maxMetadataBytes: 64 * 1024 * 1024, - maxTableRows: 250_000, - maxHeapItemBytes: 16 * 1024 * 1024, -}; - -const appManifestMemberLimits = { - ...memberLimits, - typeLimit: 1, - methodLimit: 1, - fieldLimit: 1, - memberRefLimit: 1, - edgeLimit: 1, - maxMetadataBytes: 64 * 1024 * 1024, - maxTableRows: 250_000, - maxHeapItemBytes: 16 * 1024 * 1024, - maxMethodBodyBytes: 4 * 1024 * 1024, - maxMethodInstructions: 20_000, -}; - -const comparisonLimits = { - max_method_matches: 100, - max_field_matches: 100, - max_candidates: 20, -}; - -const defaultIlBody = Buffer.from([ - 0x32, 0x02, 0x7b, 0x01, 0x00, 0x00, 0x04, 0x28, 0x01, 0x00, 0x00, 0x0a, 0x2a, -]); +import { createManagedConformanceSupport } from "./lib/managed-conformance-support.mjs"; +import { + applicationGraphLimits, + comparisonLimits, + defaultIlBody, + functionDossier, + inspectionLimits, + memberLimits, + nativeBoundaryLimits, +} from "./lib/managed-conformance-config.mjs"; const workspace = await mkdtemp(join(tmpdir(), "rea-managed-conformance-")); -const execFileAsync = promisify(execFile); +const { + fixture, + fixtureBytes, + runManagedAppManifestSelfTest, + runOptionalManagedAppManifest, + runOptionalIlspyOracle, + sha256, +} = createManagedConformanceSupport({ + workspace, + inspectionLimits, + memberLimits, + applicationGraphLimits, + defaultIlBody, +}); try { const modern = await fixture("modern-anycpu.exe", { @@ -548,801 +483,3 @@ try { } finally { await rm(workspace, { recursive: true, force: true }); } - -async function fixture(name, options) { - return fixtureBytes(name, buildManagedPeFixture(options)); -} - -async function fixtureBytes(name, bytes) { - const path = join(workspace, name); - await writeFile(path, bytes); - const parsed = await parseBinaryTarget(path); - if (!parsed.ok) throw parsed.error; - return { bytes, path, target: parsed.value }; -} - -function sha256(bytes) { - return createHash("sha256").update(bytes).digest("hex"); -} - -async function runManagedAppManifestSelfTest() { - const sample = await fixture("manifest-self-test.exe", { - methodName: "PinnedSemanticSlice", - ilBody: defaultIlBody, - }); - const artifact = inspectManagedArtifactBytes( - sample.bytes, - sample.target, - inspectionLimits, - ); - const members = inspectManagedMembersBytes( - sample.bytes, - sample.target, - memberLimits, - ); - const method = members.methods.items[0]; - assert.ok(method); - const summary = await verifyManagedAppManifest( - { - schema_version: 1, - label: "source-owned manifest verifier self-test", - target: { - path: sample.path, - sha256: sample.target.sha256, - mvid: artifact.module?.mvid, - assembly_name: artifact.assembly?.name, - runtime_family: artifact.classification.runtime_family, - managed_architecture: artifact.classification.managed_architecture, - }, - methods: [ - { - label: "pinned-semantic-slice", - token: method.token, - signature_sha256: method.signature.raw_sha256, - il_size: method.body.il_size, - il_sha256: method.body.il_sha256, - normalized_il_sha256: method.body.normalized_il_sha256, - }, - ], - application_graph: { - expected_node_kinds: [ - "artifact", - "managed-assembly", - "managed-module", - "managed-type", - "managed-method", - ], - feature_traces: [ - { - label: "trace pinned method by name", - method_token: method.token, - seed: "PinnedSemanticSlice", - match: "exact", - min_matched_seeds: 1, - }, - ], - }, - }, - join(workspace, "source-owned-managed-app-manifest.json"), - ); - return { - verified: true, - assertions: summary.assertions, - methods: summary.methods.length, - applicationGraph: summary.application_graph ?? null, - target: summary.target, - }; -} - -async function runOptionalManagedAppManifest() { - const manifestPath = process.env.REA_MANAGED_APP_MANIFEST_PATH; - if (manifestPath === undefined) return null; - if (manifestPath.trim().length === 0) - throw new Error("REA_MANAGED_APP_MANIFEST_PATH is set but empty"); - const resolvedManifestPath = resolve(process.cwd(), manifestPath); - const text = await readFile(resolvedManifestPath, "utf8"); - let manifest; - try { - manifest = JSON.parse(text); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `managed app manifest ${resolvedManifestPath} is not valid JSON: ${message}`, - ); - } - return { - configured: true, - ...(await verifyManagedAppManifest(manifest, resolvedManifestPath)), - }; -} - -async function runOptionalIlspyOracle() { - const ilspyPath = process.env.REA_ILSPY_CMD_PATH; - if (ilspyPath === undefined) return null; - ensureIlspy( - ilspyPath.trim().length > 0, - "REA_ILSPY_CMD_PATH is set but empty", - ); - ensureIlspy( - isAbsolute(ilspyPath), - "REA_ILSPY_CMD_PATH must be an absolute path", - ); - await ensureExecutable(ilspyPath); - const versionOutput = await runIlspy(ilspyPath, ["--version"], 64 * 1024); - const version = ilspyVersion(versionOutput); - const executableSha256 = sha256(await readFile(ilspyPath)); - const sample = await fixture("ilspy-oracle.exe", { - methodName: "PinnedSemanticSlice", - }); - const artifact = inspectManagedArtifactBytes( - sample.bytes, - sample.target, - inspectionLimits, - ); - const members = inspectManagedMembersBytes( - sample.bytes, - sample.target, - memberLimits, - ); - const method = members.methods.items[0]; - ensureIlspy(method !== undefined, "source-owned fixture has no method"); - const listOutput = await runIlspy( - ilspyPath, - ["--disable-updatecheck", "-l", "c", sample.path], - 256 * 1024, - ); - ensureIlspy( - listOutput.includes("Class Fixture.Program"), - "ilspycmd class listing did not include Fixture.Program", - ); - const csharp = await runIlspy( - ilspyPath, - ["--disable-updatecheck", "--type", "Fixture.Program", sample.path], - 256 * 1024, - ); - ensureIlspy( - csharp.includes("PinnedSemanticSlice"), - "ilspycmd C# output did not include the pinned method name", - ); - ensureIlspy( - csharp.length <= 65_536, - "ilspycmd C# output exceeded the managed reconstruction import bound", - ); - const memberEvidence = createEvidence( - sample.target, - MANAGED_STATIC_PROVIDER, - { - operation: "inspect_managed_members", - parameters: memberLimits, - result: members, - rawResult: null, - limitations: members.limitations, - locations: [{ kind: "artifact-path", path: sample.target.path }], - }, - ); - const reconstructionImport = importManagedReconstructionEvidence({ - static_members: memberEvidence, - decompiler: { - name: "ilspycmd", - version, - family: "ilspy", - executable_sha256: executableSha256, - options: ["--disable-updatecheck", "--type", "Fixture.Program"], - }, - methods: [ - { - token: method.token, - signature_sha256: method.signature.raw_sha256, - normalized_il_sha256: method.body.normalized_il_sha256, - reconstruction: { - kind: "decompiled-csharp", - language: "csharp", - text: csharp, - }, - }, - ], - notes: [ - "source-owned real ilspycmd oracle; output is imported as reconstruction inference", - ], - }); - ensureIlspy(reconstructionImport.ok, "managed reconstruction import failed"); - const imported = reconstructionImport.value.normalized_result; - ensureIlspy( - imported.summary.decompiled_csharp_methods === 1, - "managed reconstruction import did not record one decompiled C# method", - ); - return { - env: "REA_ILSPY_CMD_PATH", - configured: true, - version, - executable_sha256: executableSha256, - fixture: { - file_name: basename(sample.path), - sha256: sample.target.sha256, - mvid: artifact.module?.mvid ?? null, - assembly_name: artifact.assembly?.name ?? null, - method: { - token: method.token, - declaring_type: method.declaring_type, - name: method.name, - signature_sha256: method.signature.raw_sha256, - il_size: method.body.il_size, - normalized_il_sha256: method.body.normalized_il_sha256, - }, - }, - output: { - class_listing_sha256: sha256(Buffer.from(listOutput)), - decompiled_csharp_sha256: sha256(Buffer.from(csharp)), - decompiled_csharp_bytes: Buffer.byteLength(csharp), - imported_evidence_id: reconstructionImport.value.evidence_id, - reconstruction_id: imported.reconstruction_id, - canonical_observation: - imported.methods[0]?.validation.canonical_observation ?? null, - confidence_floor: - imported.methods[0]?.validation.confidence_floor ?? null, - }, - }; -} - -async function ensureExecutable(path) { - try { - await access(path, constants.X_OK); - } catch { - throw new Error(`managed ILSpy oracle failed: ${path} is not executable`); - } -} - -async function runIlspy(path, args, maxBuffer) { - try { - const { stdout } = await execFileAsync(path, args, { - timeout: 30_000, - maxBuffer, - env: { - ...process.env, - DOTNET_CLI_TELEMETRY_OPTOUT: "1", - }, - }); - return stdout; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`managed ILSpy oracle failed: ${message}`); - } -} - -function ilspyVersion(output) { - const version = output - .split(/\r?\n/u) - .map((line) => line.trim()) - .find((line) => line.startsWith("ilspycmd:")); - ensureIlspy( - version !== undefined, - "ilspycmd --version did not identify ilspycmd", - ); - return version; -} - -function ensureIlspy(condition, message) { - if (!condition) throw new Error(`managed ILSpy oracle failed: ${message}`); -} - -async function verifyManagedAppManifest(rawManifest, manifestPath) { - const manifest = parseManagedAppManifest(rawManifest); - const targetPath = isAbsolute(manifest.target.path) - ? manifest.target.path - : resolve(dirname(manifestPath), manifest.target.path); - const parsedTarget = await parseBinaryTarget(targetPath); - ensure( - parsedTarget.ok, - `target ${targetPath} could not be parsed as a binary target`, - ); - const target = parsedTarget.value; - ensure( - target.sha256 === manifest.target.sha256, - `target sha256 mismatch: expected ${manifest.target.sha256}, observed ${target.sha256}`, - ); - const bytes = await readFile(targetPath); - const artifact = inspectManagedArtifactBytes( - bytes, - target, - appManifestInspectionLimits, - ); - ensure( - artifact.classification.status === "managed", - `target is ${artifact.classification.status}, expected managed`, - ); - if (manifest.target.mvid !== undefined) - ensure( - artifact.module?.mvid === manifest.target.mvid, - `target MVID mismatch: expected ${manifest.target.mvid}, observed ${artifact.module?.mvid ?? "null"}`, - ); - if (manifest.target.assembly_name !== undefined) - ensure( - artifact.assembly?.name === manifest.target.assembly_name, - `assembly name mismatch: expected ${manifest.target.assembly_name}, observed ${artifact.assembly?.name ?? "null"}`, - ); - if (manifest.target.runtime_family !== undefined) - ensure( - artifact.classification.runtime_family === manifest.target.runtime_family, - `runtime family mismatch: expected ${manifest.target.runtime_family}, observed ${artifact.classification.runtime_family}`, - ); - if (manifest.target.managed_architecture !== undefined) - ensure( - artifact.classification.managed_architecture === - manifest.target.managed_architecture, - `managed architecture mismatch: expected ${manifest.target.managed_architecture}, observed ${artifact.classification.managed_architecture}`, - ); - - const methods = []; - let assertions = 1; - const inspectedMethods = new Map(); - for (const expectedMethod of manifest.methods) { - const method = inspectManagedMethodByToken(bytes, target, expectedMethod); - ensure( - method.signature.raw_sha256 === expectedMethod.signature_sha256, - `method ${expectedMethod.token} signature sha256 mismatch: expected ${expectedMethod.signature_sha256}, observed ${method.signature.raw_sha256}`, - ); - ensure( - method.body.il_size === expectedMethod.il_size, - `method ${expectedMethod.token} IL size mismatch: expected ${String(expectedMethod.il_size)}, observed ${String(method.body.il_size)}`, - ); - if (expectedMethod.il_sha256 !== undefined) - ensure( - method.body.il_sha256 === expectedMethod.il_sha256, - `method ${expectedMethod.token} IL sha256 mismatch: expected ${expectedMethod.il_sha256}, observed ${method.body.il_sha256 ?? "null"}`, - ); - ensure( - method.body.normalized_il_sha256 === expectedMethod.normalized_il_sha256, - `method ${expectedMethod.token} normalized IL sha256 mismatch: expected ${expectedMethod.normalized_il_sha256}, observed ${method.body.normalized_il_sha256 ?? "null"}`, - ); - assertions += expectedMethod.il_sha256 === undefined ? 4 : 5; - inspectedMethods.set(expectedMethod.token, method); - methods.push({ - label: expectedMethod.label ?? null, - token: method.token, - declaring_type: method.declaring_type, - name: method.name, - signature_sha256: method.signature.raw_sha256, - il_size: method.body.il_size, - il_sha256: method.body.il_sha256, - normalized_il_sha256: method.body.normalized_il_sha256, - }); - } - const applicationGraph = - manifest.application_graph === undefined - ? undefined - : verifyManagedAppManifestApplicationGraph({ - graphManifest: manifest.application_graph, - bytes, - target, - artifact, - manifestMethods: manifest.methods, - inspectedMethods, - }); - if (applicationGraph !== undefined) assertions += applicationGraph.assertions; - return { - label: manifest.label ?? null, - assertions, - target: { - file_name: basename(targetPath), - sha256: target.sha256, - mvid: artifact.module?.mvid ?? null, - assembly_name: artifact.assembly?.name ?? null, - runtime_family: artifact.classification.runtime_family, - managed_architecture: artifact.classification.managed_architecture, - }, - methods, - ...(applicationGraph === undefined - ? {} - : { application_graph: applicationGraph.summary }), - }; -} - -function verifyManagedAppManifestApplicationGraph({ - graphManifest, - bytes, - target, - artifact, - manifestMethods, - inspectedMethods, -}) { - const graphByToken = new Map(); - const projectionForToken = (token) => { - const existing = graphByToken.get(token); - if (existing !== undefined) return existing; - const method = inspectedMethods.get(token); - ensure( - method !== undefined, - `application graph method ${token} must also be declared in manifest.methods`, - ); - const members = inspectManagedMethodPageByToken(bytes, target, token); - const artifactEvidence = createEvidence(target, MANAGED_STATIC_PROVIDER, { - operation: "inspect_managed_artifact", - parameters: appManifestInspectionLimits, - result: artifact, - rawResult: null, - limitations: artifact.limitations, - locations: [{ kind: "artifact-path", path: target.path }], - }); - const memberEvidence = createEvidence(target, MANAGED_STATIC_PROVIDER, { - operation: "inspect_managed_members", - parameters: { - ...appManifestMemberLimits, - methodOffset: methodTokenRow(token) - 1, - }, - result: members, - rawResult: null, - limitations: members.limitations, - locations: [{ kind: "artifact-path", path: target.path }], - }); - const projected = projectManagedApplicationGraphEvidence({ - managed_artifact: artifactEvidence, - managed_members: memberEvidence, - limits: applicationGraphLimits, - }); - ensure( - projected.ok, - `application graph projection failed for method ${token}`, - ); - const projection = { - method, - evidence: projected.value, - nodeKinds: [ - ...new Set( - projected.value.normalized_result.graph.nodes.map(({ kind }) => kind), - ), - ].sort(), - summary: projected.value.normalized_result.summary, - }; - graphByToken.set(token, projection); - return projection; - }; - - const firstToken = manifestMethods[0]?.token; - ensure( - firstToken !== undefined, - "application graph verification requires at least one manifest method", - ); - const baseline = projectionForToken(firstToken); - let assertions = 1; - for (const expectedKind of graphManifest.expected_node_kinds) { - ensure( - baseline.nodeKinds.includes(expectedKind), - `application graph is missing expected node kind ${expectedKind}`, - ); - assertions += 1; - } - - const traces = []; - for (const expectedTrace of graphManifest.feature_traces) { - const projection = projectionForToken(expectedTrace.method_token); - const traced = traceApplicationFeatureEvidence({ - application: projection.evidence, - native_observations: [], - seed: { - kind: "string", - value: expectedTrace.seed, - match: expectedTrace.match, - case_sensitive: expectedTrace.case_sensitive, - }, - direction: "incoming", - limits: { - max_seed_matches: 10, - max_depth: 4, - max_nodes: 100, - max_edges: 200, - max_paths: 20, - }, - }); - ensure( - traced.ok, - `application graph trace failed for method ${expectedTrace.method_token}`, - ); - const matched = traced.value.normalized_result.summary.matched_seeds; - ensure( - matched >= expectedTrace.min_matched_seeds, - `application graph trace ${expectedTrace.seed} matched ${String(matched)} seeds, expected at least ${String(expectedTrace.min_matched_seeds)}`, - ); - assertions += 1; - traces.push({ - label: expectedTrace.label ?? null, - method_token: expectedTrace.method_token, - seed: expectedTrace.seed, - matched_seeds: matched, - trace_evidence_id: traced.value.evidence_id, - }); - } - - return { - assertions, - summary: { - projections: [...graphByToken.values()].map((projection) => ({ - method_token: projection.method.token, - graph_evidence_id: projection.evidence.evidence_id, - node_kinds: projection.nodeKinds, - summary: projection.summary, - })), - expected_node_kinds: graphManifest.expected_node_kinds, - feature_traces: traces, - }, - }; -} - -function inspectManagedMethodByToken(bytes, target, expectedMethod) { - const members = inspectManagedMethodPageByToken( - bytes, - target, - expectedMethod.token, - ); - const method = members.methods.items.find( - ({ token }) => token === expectedMethod.token, - ); - ensure( - method !== undefined, - `method ${expectedMethod.token} was not returned by the member inspector`, - ); - ensure( - method.body.status === "present", - `method ${expectedMethod.token} body status is ${method.body.status}, expected present`, - ); - return method; -} - -function inspectManagedMethodPageByToken(bytes, target, token) { - const row = methodTokenRow(token); - return inspectManagedMembersBytes(bytes, target, { - ...appManifestMemberLimits, - methodOffset: row - 1, - }); -} - -function parseManagedAppManifest(rawManifest) { - const manifest = object(rawManifest, "manifest"); - ensure( - manifest.schema_version === 1, - "manifest.schema_version must be exactly 1", - ); - const target = object(manifest.target, "manifest.target"); - const methods = array(manifest.methods, "manifest.methods"); - ensure(manifest.methods.length > 0, "manifest.methods must not be empty"); - return { - schema_version: 1, - label: optionalString(manifest.label, "manifest.label"), - target: { - path: string(target.path, "manifest.target.path"), - sha256: digest(target.sha256, "manifest.target.sha256"), - mvid: optionalCliMetadataGuid(target.mvid, "manifest.target.mvid"), - assembly_name: optionalString( - target.assembly_name, - "manifest.target.assembly_name", - ), - runtime_family: optionalString( - target.runtime_family, - "manifest.target.runtime_family", - ), - managed_architecture: optionalString( - target.managed_architecture, - "manifest.target.managed_architecture", - ), - }, - methods: methods.map((method, index) => - parseManagedAppManifestMethod(method, index), - ), - application_graph: parseManagedAppManifestGraph(manifest.application_graph), - }; -} - -function parseManagedAppManifestGraph(rawGraph) { - if (rawGraph === undefined) return undefined; - const graph = object(rawGraph, "manifest.application_graph"); - const expectedKinds = - graph.expected_node_kinds === undefined - ? [] - : array( - graph.expected_node_kinds, - "manifest.application_graph.expected_node_kinds", - ).map((kind, index) => - string( - kind, - `manifest.application_graph.expected_node_kinds[${String(index)}]`, - ), - ); - const traces = - graph.feature_traces === undefined - ? [] - : array( - graph.feature_traces, - "manifest.application_graph.feature_traces", - ).map((trace, index) => parseManagedAppManifestTrace(trace, index)); - ensure( - expectedKinds.length > 0 || traces.length > 0, - "manifest.application_graph must declare expected_node_kinds or feature_traces", - ); - return { - expected_node_kinds: expectedKinds, - feature_traces: traces, - }; -} - -function parseManagedAppManifestTrace(rawTrace, index) { - const prefix = `manifest.application_graph.feature_traces[${String(index)}]`; - const trace = object(rawTrace, prefix); - const match = trace.match ?? "exact"; - ensure( - match === "exact" || match === "contains", - `${prefix}.match must be exact or contains`, - ); - const caseSensitive = trace.case_sensitive ?? true; - ensure( - typeof caseSensitive === "boolean", - `${prefix}.case_sensitive must be a boolean`, - ); - const minMatchedSeeds = trace.min_matched_seeds ?? 1; - ensure( - Number.isInteger(minMatchedSeeds) && minMatchedSeeds > 0, - `${prefix}.min_matched_seeds must be a positive integer`, - ); - return { - label: optionalString(trace.label, `${prefix}.label`), - method_token: methodToken(trace.method_token, `${prefix}.method_token`), - seed: string(trace.seed, `${prefix}.seed`), - match, - case_sensitive: caseSensitive, - min_matched_seeds: minMatchedSeeds, - }; -} - -function parseManagedAppManifestMethod(rawMethod, index) { - const prefix = `manifest.methods[${String(index)}]`; - const method = object(rawMethod, prefix); - const token = methodToken(method.token, `${prefix}.token`); - return { - label: optionalString(method.label, `${prefix}.label`), - token, - signature_sha256: digest( - method.signature_sha256, - `${prefix}.signature_sha256`, - ), - il_size: ilSize(method, prefix), - il_sha256: - method.il_sha256 === undefined - ? undefined - : digest(method.il_sha256, `${prefix}.il_sha256`), - normalized_il_sha256: digest( - method.normalized_il_sha256, - `${prefix}.normalized_il_sha256`, - ), - }; -} - -function ilSize(method, prefix) { - const hasIlSize = method.il_size !== undefined; - const hasIlLength = method.il_length !== undefined; - ensure( - hasIlSize || hasIlLength, - `${prefix}.il_size is required; il_length is accepted as a legacy alias`, - ); - if (hasIlSize && hasIlLength) - ensure( - method.il_size === method.il_length, - `${prefix}.il_size and ${prefix}.il_length disagree`, - ); - const value = hasIlSize ? method.il_size : method.il_length; - ensure( - Number.isInteger(value) && value >= 0, - `${prefix}.il_size must be a non-negative integer`, - ); - return value; -} - -function object(value, name) { - ensure( - value !== null && typeof value === "object" && !Array.isArray(value), - `${name} must be an object`, - ); - return value; -} - -function array(value, name) { - ensure(Array.isArray(value), `${name} must be an array`); - return value; -} - -function string(value, name) { - ensure( - typeof value === "string" && value.length > 0, - `${name} must be a non-empty string`, - ); - return value; -} - -function optionalString(value, name) { - if (value === undefined) return undefined; - return string(value, name); -} - -function digest(value, name) { - const text = string(value, name); - ensure(/^[a-f0-9]{64}$/u.test(text), `${name} must be a lowercase sha256`); - return text; -} - -function optionalCliMetadataGuid(value, name) { - if (value === undefined) return undefined; - if (value === null) return null; - const text = string(value, name); - ensure( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test( - text, - ), - `${name} must be a lowercase GUID`, - ); - return text; -} - -function metadataToken(value, name) { - const text = string(value, name); - ensure(/^0x[0-9a-f]{8}$/u.test(text), `${name} must be a metadata token`); - return text; -} - -function methodToken(value, name) { - const token = metadataToken(value, name); - ensure( - token.startsWith("0x06"), - `${name} must be a MethodDef token beginning with 0x06`, - ); - return token; -} - -function methodTokenRow(token) { - const row = Number.parseInt(token.slice(4), 16); - ensure(row > 0, `method token ${token} has an invalid row id`); - return row; -} - -function ensure(condition, message) { - if (!condition) - throw new Error(`managed app manifest verification failed: ${message}`); -} - -function functionDossier(name) { - const emptyPage = { - items: [], - total: 0, - returned: 0, - truncated: false, - next_offset: null, - }; - return { - procedure: { - address: "0x401000", - name, - classification: { - external: false, - thunk: false, - thunk_target: null, - provenance: "synthetic-provider", - }, - signature: null, - locals: [], - }, - pseudocode: { - text: "", - total_chars: 0, - returned_chars: 0, - truncated: false, - next_offset: null, - }, - assembly: emptyPage, - comments: emptyPage, - callers: emptyPage, - callees: emptyPage, - incoming_references: emptyPage, - outgoing_references: emptyPage, - referenced_strings: emptyPage, - referenced_names: emptyPage, - basic_blocks: emptyPage, - instruction_scan: { scanned: 0, truncated: false }, - limitations: [], - }; -} diff --git a/src/application/CrossVersionInvestigation.ts b/src/application/CrossVersionInvestigation.ts index 1db2517930..dfe7fe70c8 100644 --- a/src/application/CrossVersionInvestigation.ts +++ b/src/application/CrossVersionInvestigation.ts @@ -41,6 +41,12 @@ import { createChangedBehaviorEvidence, } from "./CrossVersionInvestigationEvidence.js"; import { selectCompletedInvestigationReplay } from "./CrossVersionInvestigationReplay.js"; +import { + investigationEvidenceIds as evidenceIds, + mergeInvestigationEvidence as mergeEvidence, + recordsForInvestigationRun as recordsForRun, + replaceInvestigationRun as replaceRun, +} from "./CrossVersionInvestigationCollections.js"; export interface CrossVersionInvestigationOutcome { readonly evidence: Evidence; @@ -497,42 +503,3 @@ const inventoryEvidence = ( ), ); }; - -const recordsForRun = ( - workspace: InvestigationWorkspace, - run: InvestigationRun, -): Evidence[] => { - const ids = new Set([ - ...run.left_inventory_evidence_ids, - ...run.right_inventory_evidence_ids, - ...(run.comparison_evidence_id === null - ? [] - : [run.comparison_evidence_id]), - ...(run.result_evidence_id === null ? [] : [run.result_evidence_id]), - ]); - return workspace.bundle.records.filter(({ evidence_id: id }) => ids.has(id)); -}; - -const mergeEvidence = ( - current: readonly Evidence[], - additions: readonly Evidence[], -): Evidence[] => { - const records = new Map( - current.map((record) => [record.evidence_id, record]), - ); - for (const record of additions) - if (!records.has(record.evidence_id)) - records.set(record.evidence_id, record); - return [...records.values()]; -}; - -const replaceRun = ( - runs: readonly InvestigationRun[], - replacement: InvestigationRun, -): InvestigationRun[] => [ - ...runs.filter(({ run_id: id }) => id !== replacement.run_id), - replacement, -]; - -const evidenceIds = (records: readonly Evidence[]): string[] => - records.map(({ evidence_id: id }) => id); diff --git a/src/application/CrossVersionInvestigationCollections.ts b/src/application/CrossVersionInvestigationCollections.ts new file mode 100644 index 0000000000..b60a81f8ac --- /dev/null +++ b/src/application/CrossVersionInvestigationCollections.ts @@ -0,0 +1,45 @@ +import type { Evidence } from "../domain/evidence.js"; +import type { + InvestigationRun, + InvestigationWorkspace, +} from "../domain/investigationWorkspace.js"; + +export const recordsForInvestigationRun = ( + workspace: InvestigationWorkspace, + run: InvestigationRun, +): Evidence[] => { + const ids = new Set([ + ...run.left_inventory_evidence_ids, + ...run.right_inventory_evidence_ids, + ...(run.comparison_evidence_id === null + ? [] + : [run.comparison_evidence_id]), + ...(run.result_evidence_id === null ? [] : [run.result_evidence_id]), + ]); + return workspace.bundle.records.filter(({ evidence_id: id }) => ids.has(id)); +}; + +export const mergeInvestigationEvidence = ( + current: readonly Evidence[], + additions: readonly Evidence[], +): Evidence[] => { + const records = new Map( + current.map((record) => [record.evidence_id, record]), + ); + for (const record of additions) + if (!records.has(record.evidence_id)) + records.set(record.evidence_id, record); + return [...records.values()]; +}; + +export const replaceInvestigationRun = ( + runs: readonly InvestigationRun[], + replacement: InvestigationRun, +): InvestigationRun[] => [ + ...runs.filter(({ run_id: id }) => id !== replacement.run_id), + replacement, +]; + +export const investigationEvidenceIds = ( + records: readonly Evidence[], +): string[] => records.map(({ evidence_id: id }) => id); diff --git a/src/application/JavaScriptArtifactGraphBundlers.ts b/src/application/JavaScriptArtifactGraphBundlers.ts new file mode 100644 index 0000000000..0eb26d6af0 --- /dev/null +++ b/src/application/JavaScriptArtifactGraphBundlers.ts @@ -0,0 +1,309 @@ +import type { ApplicationNode } from "../domain/javascriptApplicationGraph.js"; +import type { JavaScriptBundlerRegistration } from "../domain/javascriptStaticAnalysisTypes.js"; +import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import { + addAstContainsEdge, + artifactLocalIdentity, + chunkLookupKey, + javascriptAnalysisCoverage, + moduleLookupKey, + type JavaScriptArtifactGraphContext, + type JavaScriptArtifactGraphCoverage, +} from "./JavaScriptArtifactGraphContext.js"; +import { + astObservationEvidence, + staticInferenceEvidence, +} from "./JavaScriptArtifactGraphEvidence.js"; + +/** Project Webpack/Rspack chunk and factory literals recovered from AST. */ +export const addJavaScriptBundlerNodes = ( + context: JavaScriptArtifactGraphContext, +): void => { + for (const analyzed of context.analysis.files) { + const { file, javascript } = analyzed; + const asset = context.assetNodes.get(file.path); + if (javascript === null || asset === undefined) continue; + const coverage = javascriptAnalysisCoverage(javascript, context.input); + const records: BundlerGraphRecord[] = []; + for (const registration of javascript.bundler_registrations) { + const input = { + file, + registration, + coverage, + limitations: javascript.limitations, + }; + const chunk = createBundlerChunkNode(context, input); + addAstContainsEdge(context, { + source: asset, + target: chunk, + file, + range: registration.location, + coverage, + properties: { bundler: registration.bundler }, + }); + for (const key of registration.chunk_keys) + context.chunkNodes.set( + chunkLookupKey(file.path, registration.runtime, key), + chunk, + ); + records.push({ file, registration, chunk, coverage }); + addBundlerModuleNodes(context, { ...input, chunk }); + } + for (const record of records) addBundlerRuntimeEdges(context, record); + } +}; + +interface BundlerProjectionInput { + readonly file: JavaScriptArtifactFile; + readonly registration: JavaScriptBundlerRegistration; + readonly coverage: JavaScriptArtifactGraphCoverage; + readonly limitations: readonly string[]; +} + +interface BundlerModuleProjectionInput extends BundlerProjectionInput { + readonly chunk: ApplicationNode; +} + +const createBundlerChunkNode = ( + context: JavaScriptArtifactGraphContext, + input: BundlerProjectionInput, +): ApplicationNode => { + const { file, registration, coverage, limitations } = input; + const chunkKey = `${registration.runtime}:${registration.chunk_keys.join(",")}`; + return context.accumulator.addNode({ + kind: "javascript-chunk", + identity: artifactLocalIdentity(file.sha256, "bundler-chunk", chunkKey), + observations: [ + { + label: chunkKey, + properties: { + path: file.path, + bundler: registration.bundler, + runtime: registration.runtime, + chunk_keys: registration.chunk_keys, + omitted_chunk_keys: registration.omitted_chunk_keys, + unknown_chunk_keys: registration.unknown_chunk_keys, + runtime_require_name: registration.runtime_require_name, + runtime_module_cache_status: registration.runtime_module_cache_status, + entry_module_keys: registration.entry_module_keys, + omitted_entry_module_keys: registration.omitted_entry_module_keys, + unknown_entry_module_keys: registration.unknown_entry_module_keys, + async_chunk_keys: registration.async_chunk_keys, + omitted_async_chunk_keys: registration.omitted_async_chunk_keys, + unknown_async_chunk_keys: registration.unknown_async_chunk_keys, + module_count: registration.modules.length, + }, + evidence: astObservationEvidence({ + sha256: file.sha256, + path: file.path, + range: registration.location, + operation: "extract-bundler-registration", + coverage, + limitations, + }), + }, + ], + }); +}; + +const addBundlerModuleNodes = ( + context: JavaScriptArtifactGraphContext, + input: BundlerModuleProjectionInput, +): void => { + const { file, registration, chunk, coverage, limitations } = input; + for (const moduleValue of registration.modules) { + const module = context.accumulator.addNode({ + kind: "javascript-module", + identity: artifactLocalIdentity( + file.sha256, + `${registration.runtime}:module`, + moduleValue.module_key, + ), + observations: [ + { + label: moduleValue.module_key, + properties: { + path: file.path, + bundler: registration.bundler, + runtime: registration.runtime, + chunk_keys: registration.chunk_keys, + module_key: moduleValue.module_key, + factory_require_name: moduleValue.factory_require_name, + runtime_entry: registration.entry_module_keys.includes( + moduleValue.module_key, + ), + source_sha256: moduleValue.source_sha256, + structural_fingerprint_sha256: + moduleValue.structural_fingerprint_sha256, + structural_fingerprint_algorithm: + moduleValue.structural_fingerprint_algorithm, + structural_fingerprint_status: + moduleValue.structural_fingerprint_status, + exports: moduleValue.exports, + exports_truncated: moduleValue.exports_truncated, + }, + evidence: astObservationEvidence({ + sha256: file.sha256, + path: file.path, + range: moduleValue.location, + operation: "extract-bundler-module", + coverage, + limitations, + }), + }, + ], + }); + context.moduleNodes.set( + moduleLookupKey(file.path, moduleValue.module_key), + module, + ); + addAstContainsEdge(context, { + source: chunk, + target: module, + file, + range: moduleValue.location, + coverage, + properties: { + bundler: registration.bundler, + runtime: registration.runtime, + chunk_keys: registration.chunk_keys, + module_key: moduleValue.module_key, + }, + }); + } +}; + +interface BundlerGraphRecord { + readonly file: JavaScriptArtifactFile; + readonly registration: JavaScriptBundlerRegistration; + readonly chunk: ApplicationNode; + readonly coverage: JavaScriptArtifactGraphCoverage; +} + +const addBundlerRuntimeEdges = ( + context: JavaScriptArtifactGraphContext, + record: BundlerGraphRecord, +): void => { + const { file, registration, chunk, coverage } = record; + for (const moduleKey of registration.entry_module_keys) { + const resolved = context.moduleNodes.get( + moduleLookupKey(file.path, moduleKey), + ); + const target = + resolved ?? unresolvedBundlerModuleNode(context, record, moduleKey); + context.accumulator.addEdge({ + source_node_id: chunk.node_id, + target_node_id: target.node_id, + relation: "loads", + properties: { + kind: "bundler-entry-module", + bundler: registration.bundler, + runtime: registration.runtime, + module_key: moduleKey, + resolution_status: resolved === undefined ? "not-found" : "resolved", + }, + evidence: staticInferenceEvidence({ + sha256: file.sha256, + path: file.path, + range: registration.location, + operation: "resolve-bundler-entry-module", + coverage, + confidence: resolved === undefined ? "low" : "high", + }), + }); + } + for (const chunkKey of registration.async_chunk_keys) { + const lookup = chunkLookupKey(file.path, registration.runtime, chunkKey); + const resolved = context.chunkNodes.get(lookup); + const target = + resolved ?? unresolvedBundlerChunkNode(context, record, chunkKey); + context.accumulator.addEdge({ + source_node_id: chunk.node_id, + target_node_id: target.node_id, + relation: "imports", + properties: { + kind: "bundler-async-chunk", + bundler: registration.bundler, + runtime: registration.runtime, + chunk_key: chunkKey, + resolution_status: resolved === undefined ? "not-found" : "resolved", + }, + evidence: staticInferenceEvidence({ + sha256: file.sha256, + path: file.path, + range: registration.location, + operation: "resolve-bundler-async-chunk", + coverage, + confidence: resolved === undefined ? "low" : "high", + }), + }); + } +}; + +const unresolvedBundlerModuleNode = ( + context: JavaScriptArtifactGraphContext, + record: BundlerGraphRecord, + moduleKey: string, +): ApplicationNode => + context.accumulator.addNode({ + kind: "javascript-module", + identity: artifactLocalIdentity( + record.file.sha256, + `${record.registration.runtime}:unresolved-entry-module`, + moduleKey, + ), + observations: [ + { + label: moduleKey, + properties: { + semantic_role: "bundler-module-reference", + bundler: record.registration.bundler, + runtime: record.registration.runtime, + module_key: moduleKey, + resolution_status: "not-found", + }, + evidence: staticInferenceEvidence({ + sha256: record.file.sha256, + path: record.file.path, + range: record.registration.location, + operation: "retain-unresolved-bundler-entry-module", + coverage: record.coverage, + confidence: "low", + }), + }, + ], + }); + +const unresolvedBundlerChunkNode = ( + context: JavaScriptArtifactGraphContext, + record: BundlerGraphRecord, + chunkKey: string, +): ApplicationNode => + context.accumulator.addNode({ + kind: "javascript-chunk", + identity: artifactLocalIdentity( + record.file.sha256, + `${record.registration.runtime}:unresolved-async-chunk`, + chunkKey, + ), + observations: [ + { + label: chunkKey, + properties: { + semantic_role: "bundler-chunk-reference", + bundler: record.registration.bundler, + runtime: record.registration.runtime, + chunk_key: chunkKey, + resolution_status: "not-found", + }, + evidence: staticInferenceEvidence({ + sha256: record.file.sha256, + path: record.file.path, + range: record.registration.location, + operation: "retain-unresolved-bundler-async-chunk", + coverage: record.coverage, + confidence: "low", + }), + }, + ], + }); diff --git a/src/application/JavaScriptArtifactGraphStructure.ts b/src/application/JavaScriptArtifactGraphStructure.ts index 2b826edee6..2d75e2501a 100644 --- a/src/application/JavaScriptArtifactGraphStructure.ts +++ b/src/application/JavaScriptArtifactGraphStructure.ts @@ -1,6 +1,5 @@ import type { ArtifactInventorySnapshot } from "./ArtifactInventory.js"; import type { ApplicationNode } from "../domain/javascriptApplicationGraph.js"; -import type { JavaScriptBundlerRegistration } from "../domain/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactContainer, @@ -9,25 +8,20 @@ import type { import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js"; import { addArtifactContainsEdge, - addAstContainsEdge, addUnavailableStaticParseScope, artifactFileNodeKind, artifactLocalIdentity, - chunkLookupKey, createElectronRoleNode, - javascriptAnalysisCoverage, linkElectronRoleToAsset, - moduleLookupKey, type JavaScriptArtifactGraphContext, - type JavaScriptArtifactGraphCoverage, } from "./JavaScriptArtifactGraphContext.js"; import { artifactObservationEvidence, - astObservationEvidence, completeReconstructionCoverage, staticInferenceEvidence, } from "./JavaScriptArtifactGraphEvidence.js"; import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; +export { addJavaScriptBundlerNodes } from "./JavaScriptArtifactGraphBundlers.js"; interface PackageRoleInput { readonly packageNode: ApplicationNode; @@ -282,299 +276,6 @@ export const addJavaScriptPackageNodes = ( return roots; }; -/** Project Webpack/Rspack chunk and factory literals recovered from AST. */ -export const addJavaScriptBundlerNodes = ( - context: JavaScriptArtifactGraphContext, -): void => { - for (const analyzed of context.analysis.files) { - const { file, javascript } = analyzed; - const asset = context.assetNodes.get(file.path); - if (javascript === null || asset === undefined) continue; - const coverage = javascriptAnalysisCoverage(javascript, context.input); - const records: BundlerGraphRecord[] = []; - for (const registration of javascript.bundler_registrations) { - const input = { - file, - registration, - coverage, - limitations: javascript.limitations, - }; - const chunk = createBundlerChunkNode(context, input); - addAstContainsEdge(context, { - source: asset, - target: chunk, - file, - range: registration.location, - coverage, - properties: { bundler: registration.bundler }, - }); - for (const key of registration.chunk_keys) - context.chunkNodes.set( - chunkLookupKey(file.path, registration.runtime, key), - chunk, - ); - records.push({ file, registration, chunk, coverage }); - addBundlerModuleNodes(context, { ...input, chunk }); - } - for (const record of records) addBundlerRuntimeEdges(context, record); - } -}; - -interface BundlerProjectionInput { - readonly file: JavaScriptArtifactFile; - readonly registration: JavaScriptBundlerRegistration; - readonly coverage: JavaScriptArtifactGraphCoverage; - readonly limitations: readonly string[]; -} - -interface BundlerModuleProjectionInput extends BundlerProjectionInput { - readonly chunk: ApplicationNode; -} - -const createBundlerChunkNode = ( - context: JavaScriptArtifactGraphContext, - input: BundlerProjectionInput, -): ApplicationNode => { - const { file, registration, coverage, limitations } = input; - const chunkKey = `${registration.runtime}:${registration.chunk_keys.join(",")}`; - return context.accumulator.addNode({ - kind: "javascript-chunk", - identity: artifactLocalIdentity(file.sha256, "bundler-chunk", chunkKey), - observations: [ - { - label: chunkKey, - properties: { - path: file.path, - bundler: registration.bundler, - runtime: registration.runtime, - chunk_keys: registration.chunk_keys, - omitted_chunk_keys: registration.omitted_chunk_keys, - unknown_chunk_keys: registration.unknown_chunk_keys, - runtime_require_name: registration.runtime_require_name, - runtime_module_cache_status: registration.runtime_module_cache_status, - entry_module_keys: registration.entry_module_keys, - omitted_entry_module_keys: registration.omitted_entry_module_keys, - unknown_entry_module_keys: registration.unknown_entry_module_keys, - async_chunk_keys: registration.async_chunk_keys, - omitted_async_chunk_keys: registration.omitted_async_chunk_keys, - unknown_async_chunk_keys: registration.unknown_async_chunk_keys, - module_count: registration.modules.length, - }, - evidence: astObservationEvidence({ - sha256: file.sha256, - path: file.path, - range: registration.location, - operation: "extract-bundler-registration", - coverage, - limitations, - }), - }, - ], - }); -}; - -const addBundlerModuleNodes = ( - context: JavaScriptArtifactGraphContext, - input: BundlerModuleProjectionInput, -): void => { - const { file, registration, chunk, coverage, limitations } = input; - for (const moduleValue of registration.modules) { - const module = context.accumulator.addNode({ - kind: "javascript-module", - identity: artifactLocalIdentity( - file.sha256, - `${registration.runtime}:module`, - moduleValue.module_key, - ), - observations: [ - { - label: moduleValue.module_key, - properties: { - path: file.path, - bundler: registration.bundler, - runtime: registration.runtime, - chunk_keys: registration.chunk_keys, - module_key: moduleValue.module_key, - factory_require_name: moduleValue.factory_require_name, - runtime_entry: registration.entry_module_keys.includes( - moduleValue.module_key, - ), - source_sha256: moduleValue.source_sha256, - structural_fingerprint_sha256: - moduleValue.structural_fingerprint_sha256, - structural_fingerprint_algorithm: - moduleValue.structural_fingerprint_algorithm, - structural_fingerprint_status: - moduleValue.structural_fingerprint_status, - exports: moduleValue.exports, - exports_truncated: moduleValue.exports_truncated, - }, - evidence: astObservationEvidence({ - sha256: file.sha256, - path: file.path, - range: moduleValue.location, - operation: "extract-bundler-module", - coverage, - limitations, - }), - }, - ], - }); - context.moduleNodes.set( - moduleLookupKey(file.path, moduleValue.module_key), - module, - ); - addAstContainsEdge(context, { - source: chunk, - target: module, - file, - range: moduleValue.location, - coverage, - properties: { - bundler: registration.bundler, - runtime: registration.runtime, - chunk_keys: registration.chunk_keys, - module_key: moduleValue.module_key, - }, - }); - } -}; - -interface BundlerGraphRecord { - readonly file: JavaScriptArtifactFile; - readonly registration: JavaScriptBundlerRegistration; - readonly chunk: ApplicationNode; - readonly coverage: JavaScriptArtifactGraphCoverage; -} - -const addBundlerRuntimeEdges = ( - context: JavaScriptArtifactGraphContext, - record: BundlerGraphRecord, -): void => { - const { file, registration, chunk, coverage } = record; - for (const moduleKey of registration.entry_module_keys) { - const resolved = context.moduleNodes.get( - moduleLookupKey(file.path, moduleKey), - ); - const target = - resolved ?? unresolvedBundlerModuleNode(context, record, moduleKey); - context.accumulator.addEdge({ - source_node_id: chunk.node_id, - target_node_id: target.node_id, - relation: "loads", - properties: { - kind: "bundler-entry-module", - bundler: registration.bundler, - runtime: registration.runtime, - module_key: moduleKey, - resolution_status: resolved === undefined ? "not-found" : "resolved", - }, - evidence: staticInferenceEvidence({ - sha256: file.sha256, - path: file.path, - range: registration.location, - operation: "resolve-bundler-entry-module", - coverage, - confidence: resolved === undefined ? "low" : "high", - }), - }); - } - for (const chunkKey of registration.async_chunk_keys) { - const lookup = chunkLookupKey(file.path, registration.runtime, chunkKey); - const resolved = context.chunkNodes.get(lookup); - const target = - resolved ?? unresolvedBundlerChunkNode(context, record, chunkKey); - context.accumulator.addEdge({ - source_node_id: chunk.node_id, - target_node_id: target.node_id, - relation: "imports", - properties: { - kind: "bundler-async-chunk", - bundler: registration.bundler, - runtime: registration.runtime, - chunk_key: chunkKey, - resolution_status: resolved === undefined ? "not-found" : "resolved", - }, - evidence: staticInferenceEvidence({ - sha256: file.sha256, - path: file.path, - range: registration.location, - operation: "resolve-bundler-async-chunk", - coverage, - confidence: resolved === undefined ? "low" : "high", - }), - }); - } -}; - -const unresolvedBundlerModuleNode = ( - context: JavaScriptArtifactGraphContext, - record: BundlerGraphRecord, - moduleKey: string, -): ApplicationNode => - context.accumulator.addNode({ - kind: "javascript-module", - identity: artifactLocalIdentity( - record.file.sha256, - `${record.registration.runtime}:unresolved-entry-module`, - moduleKey, - ), - observations: [ - { - label: moduleKey, - properties: { - semantic_role: "bundler-module-reference", - bundler: record.registration.bundler, - runtime: record.registration.runtime, - module_key: moduleKey, - resolution_status: "not-found", - }, - evidence: staticInferenceEvidence({ - sha256: record.file.sha256, - path: record.file.path, - range: record.registration.location, - operation: "retain-unresolved-bundler-entry-module", - coverage: record.coverage, - confidence: "low", - }), - }, - ], - }); - -const unresolvedBundlerChunkNode = ( - context: JavaScriptArtifactGraphContext, - record: BundlerGraphRecord, - chunkKey: string, -): ApplicationNode => - context.accumulator.addNode({ - kind: "javascript-chunk", - identity: artifactLocalIdentity( - record.file.sha256, - `${record.registration.runtime}:unresolved-async-chunk`, - chunkKey, - ), - observations: [ - { - label: chunkKey, - properties: { - semantic_role: "bundler-chunk-reference", - bundler: record.registration.bundler, - runtime: record.registration.runtime, - chunk_key: chunkKey, - resolution_status: "not-found", - }, - evidence: staticInferenceEvidence({ - sha256: record.file.sha256, - path: record.file.path, - range: record.registration.location, - operation: "retain-unresolved-bundler-async-chunk", - coverage: record.coverage, - confidence: "low", - }), - }, - ], - }); - const createFileTarget = ( context: JavaScriptArtifactGraphContext, file: JavaScriptArtifactFile, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts new file mode 100644 index 0000000000..afdad12533 --- /dev/null +++ b/src/domain/analysisErrorPresentation.ts @@ -0,0 +1,280 @@ +import { + AnalysisInputError, + ArtifactOperationError, + BinaryTargetError, + BrowserObservationError, + ConfigurationError, + EvidenceFileError, + EvidenceIntegrityError, + EvidenceLimitError, + HopperRemoteError, + InvestigationWorkspaceError, + NoBinaryOpenError, + PermissionRequiredError, + ProviderSelectionError, + ReplayPlanStaleError, + UnknownRegistryError, + type AnalysisError, + type AnalysisErrorProjection, + type AnalysisErrorTag, +} from "./errors.js"; + +export const analysisErrorRemediationAction = ( + error: AnalysisError, +): string => { + if (error instanceof AnalysisInputError) + return "Correct the listed arguments and retry."; + if (error instanceof ReplayPlanStaleError) + return "Review the rebuilt replay plan and explicitly approve its new digest."; + if (error instanceof PermissionRequiredError) + return error.elicitationSupported + ? "Approve the exact missing scope, then retry the operation." + : "Add the exact missing scope beneath the administrator ceiling, then retry."; + return analysisErrorUserMessage(error); +}; + +export const analysisErrorCategory = ( + error: AnalysisError, +): AnalysisErrorProjection["category"] => { + if (error instanceof ReplayPlanStaleError) return "integrity_mismatch"; + if (error instanceof PermissionRequiredError) return "permission_required"; + if ( + error instanceof ProviderSelectionError && + error.reason === "provider_unavailable" + ) + return "unavailable"; + if (error._tag === "ProcessCaptureError") + return error.userCategory ?? "execution_failure"; + if (error instanceof BrowserObservationError) + return browserErrorCategory(error.reason); + if ( + error instanceof HopperRemoteError && + error.diagnosticType === "authorization" + ) + return "permission_required"; + if (error instanceof ArtifactOperationError) + return artifactErrorCategory(error.reason); + if (error instanceof EvidenceFileError && error.reason === "disabled") + return "unavailable"; + if (error instanceof InvestigationWorkspaceError) + return investigationWorkspaceCategory(error.reason); + return STATIC_ERROR_CATEGORIES[error._tag] ?? "execution_failure"; +}; + +const browserErrorCategory = ( + reason: BrowserObservationError["reason"], +): AnalysisErrorProjection["category"] => { + if (reason === "payload_limit") return "truncated"; + if ( + reason === "target_not_found" || + reason === "target_not_allowed" || + reason === "target_changed" || + reason === "endpoint_unreachable" || + reason === "disconnected" + ) + return "unavailable"; + return "execution_failure"; +}; + +const investigationWorkspaceCategory = ( + reason: InvestigationWorkspaceError["reason"], +): AnalysisErrorProjection["category"] => { + if ( + reason === "disabled" || + reason === "outside-root" || + reason === "not-file" + ) + return "unavailable"; + if (reason === "too-large") return "truncated"; + if (reason === "invalid-json" || reason === "integrity") + return "integrity_mismatch"; + return "execution_failure"; +}; + +const artifactErrorCategory = ( + reason: ArtifactOperationError["reason"], +): AnalysisErrorProjection["category"] => { + if (reason === "integrity") return "integrity_mismatch"; + if (reason === "limit") return "truncated"; + if (reason === "cancelled") return "cancelled"; + if (reason === "unavailable") return "unavailable"; + return "execution_failure"; +}; + +const STATIC_ERROR_CATEGORIES: Readonly< + Partial> +> = { + AnalysisInputError: "invalid_input", + AnalysisCapabilityUnavailableError: "unsupported_provider", + ProviderSelectionError: "unsupported_provider", + EvidenceIntegrityError: "integrity_mismatch", + EvidenceLimitError: "truncated", + AnalysisCancelledError: "cancelled", + HopperCancelledError: "cancelled", + AnalysisTimeoutError: "timeout", + HopperTimeoutError: "timeout", + NoBinaryOpenError: "unavailable", + BinaryTargetError: "unavailable", +}; + +export const analysisErrorUserMessage = (error: AnalysisError): string => { + if (error instanceof ReplayPlanStaleError) + return "The controlled replay plan changed before execution. Refresh the current state and try again."; + if (error instanceof PermissionRequiredError) + return "This operation needs additional local permission. Review the requested scope and remediation."; + if (error instanceof AnalysisInputError) + return "Analysis input is invalid. Check the arguments and try again."; + if (error.userMessage !== undefined) return error.userMessage; + const standardMessage = standardErrorMessage(error._tag); + if (standardMessage !== undefined) return standardMessage; + if (error instanceof ArtifactOperationError) + return artifactMessage(error.reason); + if (error instanceof EvidenceIntegrityError) + return "Evidence is invalid or has changed. Recreate or re-import it, then try again."; + if (error instanceof EvidenceLimitError) + return "Evidence is too large for this session. Reduce the evidence set and try again."; + if (error instanceof EvidenceFileError) + return evidenceFileMessage(error.reason); + if (error instanceof InvestigationWorkspaceError) + return investigationWorkspaceMessage(error.reason); + if (error instanceof UnknownRegistryError) + return "Evidence state changed before the update completed. Refresh the current state and try again."; + if (error instanceof ConfigurationError) + return "REA configuration is invalid. Run `rea doctor` and fix the reported setting."; + if (error instanceof NoBinaryOpenError) return error.message; + if (error instanceof BinaryTargetError) + return "REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file."; + if (error._tag === "ProcessCaptureError") + return ( + error.userMessage ?? + "Process capture could not complete. Run `rea doctor`, then review capture policy and try again." + ); + return "Analysis could not complete. Run `rea doctor`, then try again."; +}; + +const standardErrorMessage = (tag: AnalysisErrorTag): string | undefined => { + if (UNREADABLE_OUTPUT_TAGS.has(tag)) + return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`."; + if (UNSUPPORTED_PROVIDER_TAGS.has(tag)) + return "This analysis is unavailable for the current target. Choose another analysis or target."; + if (CANCELLED_TAGS.has(tag)) + return "Analysis was cancelled. Start it again when ready."; + if (TIMEOUT_TAGS.has(tag)) + return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues."; + if (ADAPTER_FAILURE_TAGS.has(tag)) + return "Analysis could not complete. Retry once; if it continues, run `rea doctor`."; + if (START_FAILURE_TAGS.has(tag)) + return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again."; + return undefined; +}; + +const UNREADABLE_OUTPUT_TAGS: ReadonlySet = new Set([ + "AnalysisProtocolError", + "AnalysisOutputError", + "HopperProtocolError", +]); +const UNSUPPORTED_PROVIDER_TAGS: ReadonlySet = new Set([ + "AnalysisCapabilityUnavailableError", + "ProviderSelectionError", +]); +const CANCELLED_TAGS: ReadonlySet = new Set([ + "AnalysisCancelledError", + "HopperCancelledError", +]); +const TIMEOUT_TAGS: ReadonlySet = new Set([ + "AnalysisTimeoutError", + "HopperTimeoutError", +]); +const ADAPTER_FAILURE_TAGS: ReadonlySet = new Set([ + "ProviderAdapterError", + "HopperRemoteError", +]); +const START_FAILURE_TAGS: ReadonlySet = new Set([ + "HopperProcessError", + "HopperStartError", +]); + +const artifactMessage = (reason: ArtifactOperationError["reason"]): string => { + if (reason === "cancelled") + return "Artifact operation was cancelled. Start it again when ready."; + if (reason === "limit") + return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact."; + if (reason === "path") + return "Artifact path is not allowed. Choose a path inside the artifact and try again."; + if (reason === "unavailable") + return "Artifact format is not available on this system. Choose another artifact or supported environment."; + if (reason === "format" || reason === "integrity") + return "Artifact is invalid or has changed. Get a fresh copy and try again."; + return "Artifact could not be read or written. Check file access and try again."; +}; + +const evidenceFileMessage = (reason: EvidenceFileError["reason"]): string => { + if (reason === "disabled") + return "Evidence file access is disabled. Enable an evidence directory or use inline evidence."; + if (reason === "outside-root") + return "Evidence path is outside the allowed directory. Choose a path inside the configured evidence directory."; + if (reason === "not-file") + return "Evidence path does not point to a file. Choose an evidence file and try again."; + if (reason === "too-large") + return "Evidence file is too large. Reduce its size and try again."; + if (reason === "exists") + return "Evidence file already exists. Choose another path or allow overwrite."; + if (reason === "invalid-json") + return "Evidence file is not valid JSON. Repair or recreate the file and try again."; + return "Evidence file could not be accessed. Check file permissions and try again."; +}; + +const investigationWorkspaceMessage = ( + reason: InvestigationWorkspaceError["reason"], +): string => { + if (reason === "disabled") + return "Investigation workspace access is disabled. Configure a workspace directory and try again."; + if (reason === "outside-root") + return "Investigation workspace is outside the allowed directory. Choose a configured workspace directory and try again."; + if (reason === "not-file") + return "Investigation workspace path is not a file. Choose a workspace file and try again."; + if (reason === "too-large") + return "Investigation workspace is too large. Reduce its size and try again."; + if (reason === "invalid-json" || reason === "integrity") + return "Investigation workspace is invalid or has changed. Recreate or repair it, then try again."; + if (reason === "locked") + return "Another investigation update is in progress. Try again when it finishes."; + if (reason === "revision-conflict" || reason === "name-conflict") + return "Investigation workspace state changed. Refresh the current state and try again."; + return "Investigation workspace could not be accessed. Check file permissions and try again."; +}; + +const KNOWN_ERROR_TAGS = { + AnalysisProtocolError: true, + AnalysisInputError: true, + AnalysisOutputError: true, + AnalysisCapabilityUnavailableError: true, + AnalysisCancelledError: true, + AnalysisTimeoutError: true, + ProviderSelectionError: true, + ProviderAdapterError: true, + BrowserObservationError: true, + ArtifactOperationError: true, + ProcessCaptureError: true, + EvidenceIntegrityError: true, + EvidenceLimitError: true, + EvidenceFileError: true, + InvestigationWorkspaceError: true, + UnknownRegistryError: true, + HopperTimeoutError: true, + HopperCancelledError: true, + HopperProtocolError: true, + HopperRemoteError: true, + HopperProcessError: true, + HopperStartError: true, + ConfigurationError: true, + NoBinaryOpenError: true, + BinaryTargetError: true, + PermissionRequiredError: true, + ReplayPlanStaleError: true, +} as const satisfies Readonly>; + +export const assertKnownAnalysisErrorTag = (tag: AnalysisErrorTag): void => { + if (KNOWN_ERROR_TAGS[tag] !== true) + throw new TypeError("Unknown analysis error tag"); +}; diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts new file mode 100644 index 0000000000..3b0e26d9e7 --- /dev/null +++ b/src/domain/analysisErrorProjection.ts @@ -0,0 +1,369 @@ +import type { JsonValue } from "./jsonValue.js"; +import type { + MissingPermissionScope, + PermissionScope, +} from "./permissionPolicy.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisInputError, + AnalysisTimeoutError, + ArtifactOperationError, + BinaryTargetError, + BrowserObservationError, + EvidenceFileError, + EvidenceLimitError, + EvidenceReferenceError, + HopperCancelledError, + HopperProcessError, + HopperRemoteError, + HopperTimeoutError, + InvestigationWorkspaceError, + PermissionRequiredError, + ProviderAdapterError, + ProviderSelectionError, + ReplayPlanStaleError, + UnknownRegistryError, + type AnalysisError, + type AnalysisErrorProjection, + type AnalysisErrorTag, +} from "./errors.js"; +import { + analysisErrorCategory, + analysisErrorRemediationAction, + analysisErrorUserMessage, + assertKnownAnalysisErrorTag, +} from "./analysisErrorPresentation.js"; + +/** Project expected failures into exhaustive, secret-safe caller fields. */ +export const projectAnalysisError = ( + error: AnalysisError, +): AnalysisErrorProjection => { + assertKnownAnalysisErrorTag(error._tag); + const code = errorCode(error); + const details = errorDetails(error); + return { + code, + category: analysisErrorCategory(error), + message: analysisErrorUserMessage(error), + retryable: RETRYABLE_CODES.has(code), + remediation: { + action: analysisErrorRemediationAction(error), + restart_required: + error instanceof PermissionRequiredError && error.restartRequired, + ...(error instanceof PermissionRequiredError + ? { elicitation_supported: error.elicitationSupported } + : {}), + }, + ...(details === undefined ? {} : { details }), + }; +}; + +const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => { + if (error instanceof ReplayPlanStaleError) return "plan_stale"; + if (error instanceof PermissionRequiredError) return "permission_required"; + if (error instanceof ProviderSelectionError) + return error.reason === "provider_unavailable" + ? "provider_unavailable" + : "capability_unavailable"; + if (error instanceof BrowserObservationError) + return browserErrorCode(error.reason); + if (error instanceof ArtifactOperationError) + return artifactOperationCode(error); + if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason); + if (error instanceof InvestigationWorkspaceError) + return investigationWorkspaceCode(error.reason); + if (error instanceof UnknownRegistryError) + return unknownRegistryCode(error.reason); + if (error._tag === "ProcessCaptureError") return processCaptureCode(error); + return staticErrorCode(error._tag); +}; + +const browserErrorCode = ( + reason: BrowserObservationError["reason"], +): AnalysisErrorProjection["code"] => { + if (reason === "payload_limit") return "truncated"; + if ( + reason === "target_not_found" || + reason === "target_not_allowed" || + reason === "target_changed" + ) + return "target_unavailable"; + if (reason === "endpoint_unreachable" || reason === "disconnected") + return "provider_unavailable"; + return "unreadable_output"; +}; + +const artifactOperationCode = ( + error: ArtifactOperationError, +): AnalysisErrorProjection["code"] => { + if (error.reason === "integrity" && error.artifactDetails !== undefined) + return "artifact_integrity_mismatch"; + if (error.reason === "limit") return "truncated"; + if (error.reason === "cancelled") return "cancelled"; + return "artifact_operation_failed"; +}; + +const evidenceFileCode = ( + reason: EvidenceFileError["reason"], +): AnalysisErrorProjection["code"] => { + if (reason === "outside-root") return "outside_approved_root"; + if (reason === "too-large") return "truncated"; + if (reason === "disabled") return "capability_unavailable"; + return "execution_failure"; +}; + +const investigationWorkspaceCode = ( + reason: InvestigationWorkspaceError["reason"], +): AnalysisErrorProjection["code"] => { + if (reason === "revision-conflict" || reason === "name-conflict") + return "revision_conflict"; + if (reason === "too-large") return "truncated"; + if (reason === "disabled") return "capability_unavailable"; + if (reason === "outside-root") return "outside_approved_root"; + if (reason === "integrity" || reason === "invalid-json") + return "evidence_integrity_mismatch"; + return "execution_failure"; +}; + +const unknownRegistryCode = ( + reason: UnknownRegistryError["reason"], +): AnalysisErrorProjection["code"] => { + if (reason === "revision-conflict" || reason === "already-exists") + return "revision_conflict"; + if (reason === "limit") return "truncated"; + if (reason === "integrity") return "evidence_integrity_mismatch"; + return "execution_failure"; +}; + +const processCaptureCode = ( + error: AnalysisError, +): AnalysisErrorProjection["code"] => { + if (error.cleanupIncomplete) return "cleanup_incomplete"; + if (error.userCategory === "cancelled") return "cancelled"; + if (error.userCategory === "permission_required") + return "permission_required"; + return "process_capture_failed"; +}; + +type SpecializedErrorTag = + | "ArtifactOperationError" + | "BrowserObservationError" + | "EvidenceFileError" + | "InvestigationWorkspaceError" + | "PermissionRequiredError" + | "ProcessCaptureError" + | "ProviderSelectionError" + | "ReplayPlanStaleError" + | "UnknownRegistryError"; + +const STATIC_ERROR_CODES = { + AnalysisProtocolError: "unreadable_output", + AnalysisOutputError: "unreadable_output", + HopperProtocolError: "unreadable_output", + AnalysisInputError: "invalid_request", + AnalysisCapabilityUnavailableError: "capability_unavailable", + AnalysisCancelledError: "cancelled", + HopperCancelledError: "cancelled", + AnalysisTimeoutError: "provider_timeout", + HopperTimeoutError: "provider_timeout", + HopperProcessError: "provider_unavailable", + HopperStartError: "provider_unavailable", + ConfigurationError: "configuration_invalid", + NoBinaryOpenError: "target_unavailable", + BinaryTargetError: "target_unavailable", + EvidenceIntegrityError: "evidence_integrity_mismatch", + EvidenceLimitError: "truncated", + ProviderAdapterError: "execution_failure", + HopperRemoteError: "execution_failure", +} as const satisfies Readonly< + Record< + Exclude, + AnalysisErrorProjection["code"] + > +>; + +const staticErrorCode = ( + tag: AnalysisErrorTag, +): AnalysisErrorProjection["code"] => { + switch (tag) { + case "ArtifactOperationError": + case "BrowserObservationError": + case "EvidenceFileError": + case "InvestigationWorkspaceError": + case "PermissionRequiredError": + case "ProcessCaptureError": + case "ProviderSelectionError": + case "ReplayPlanStaleError": + case "UnknownRegistryError": + throw new TypeError(`Unhandled specialized analysis error: ${tag}`); + default: + return STATIC_ERROR_CODES[tag]; + } +}; + +const errorDetails = ( + error: AnalysisError, +): Readonly> | undefined => + requestErrorDetails(error) ?? + artifactStateErrorDetails(error) ?? + providerErrorDetails(error) ?? + lifecycleErrorDetails(error); + +const requestErrorDetails = ( + error: AnalysisError, +): Readonly> | undefined => { + if (error instanceof AnalysisInputError && error.issues.length > 0) + return { + operation: error.operation, + issues: error.issues.map((issue) => ({ + path: [...issue.path], + reason: issue.reason, + ...(issue.expected === undefined ? {} : { expected: issue.expected }), + ...(issue.minimum === undefined ? {} : { minimum: issue.minimum }), + ...(issue.maximum === undefined ? {} : { maximum: issue.maximum }), + })), + }; + if (error instanceof EvidenceReferenceError) + return { + evidence_id: error.evidenceId, + reason: error.reason, + expected: error.expected, + actual: error.actual, + }; + if (error instanceof ReplayPlanStaleError) + return { + approved_plan_digest: error.approvedDigest, + actual_plan_digest: error.actualDigest, + application_code_admitted: false, + }; + if (error instanceof PermissionRequiredError) + return { + capability: error.requested.capability, + requested: scopeDetails(error.requested), + missing: missingScopeDetails(error.missing), + ceiling: error.ceiling === null ? null : scopeDetails(error.ceiling), + }; + return undefined; +}; + +const artifactStateErrorDetails = ( + error: AnalysisError, +): Readonly> | undefined => { + if (error instanceof ArtifactOperationError && error.artifactDetails) + return { + logical_path: error.artifactDetails.logicalPath, + declared_sha256: error.artifactDetails.declaredSha256, + calculated_sha256: error.artifactDetails.calculatedSha256, + unpacked: error.artifactDetails.unpacked, + }; + if (error instanceof ArtifactOperationError) + return { + operation: error.operation, + reason: error.reason, + ...(error.reason === "limit" ? { truncated: true } : {}), + }; + if (error instanceof EvidenceLimitError) + return { limit: error.limit, maximum: error.maximum, truncated: true }; + if (error instanceof InvestigationWorkspaceError) + return { operation: error.operation, reason: error.reason }; + if (error instanceof UnknownRegistryError) return { reason: error.reason }; + if (error instanceof EvidenceFileError) + return { operation: error.operation, reason: error.reason }; + return undefined; +}; + +const providerErrorDetails = ( + error: AnalysisError, +): Readonly> | undefined => { + if (error instanceof AnalysisCapabilityUnavailableError) + return { provider_id: error.providerId, operation: error.operation }; + if (error instanceof ProviderSelectionError) + return { + operation: error.operation, + selection_reason: error.reason, + requested_provider_id: error.requestedProviderId, + candidate_ids: [...error.candidateIds], + rejections: error.rejections.map((rejection) => ({ + provider_id: rejection.providerId, + code: rejection.code, + reason: rejection.reason, + diagnostics: rejection.diagnostics, + })), + }; + if (error instanceof ProviderAdapterError) + return { + provider_id: error.providerId, + operation: error.operation, + ...(error.diagnostics === undefined + ? {} + : { diagnostics: error.diagnostics }), + }; + if (error instanceof BrowserObservationError) + return { operation: error.operation, reason: error.reason }; + if (error instanceof HopperRemoteError) + return { provider_code: error.code, diagnostic_type: error.diagnosticType }; + if (error instanceof HopperProcessError && error.failureCode !== undefined) + return { failure_code: error.failureCode, exit_code: error.exitCode }; + return undefined; +}; + +const lifecycleErrorDetails = ( + error: AnalysisError, +): Readonly> | undefined => { + if (error instanceof AnalysisCancelledError) + return { operation: error.operation, cleanup: "complete" }; + if (error instanceof HopperCancelledError) + return { operation: "hopper", cleanup: "complete" }; + if (error instanceof AnalysisTimeoutError) + return { operation: error.operation, timeout_ms: error.timeoutMs }; + if (error instanceof HopperTimeoutError) + return { operation: "hopper", timeout_ms: error.timeoutMs }; + if (error._tag === "ProcessCaptureError" && error.cleanupIncomplete) + return { + cleanup: "incomplete", + resources: [...error.cleanupResources], + }; + if ( + error._tag === "ProcessCaptureError" && + error.userCategory === "cancelled" + ) + return { operation: "process_capture", cleanup: "complete" }; + if (error instanceof BinaryTargetError) return { path: error.path }; + return undefined; +}; + +const scopeDetails = ( + scope: PermissionScope, +): Readonly> => ({ + capability: scope.capability, + roots: [...scope.roots], + executables: [...scope.executables], + environment_names: [...scope.environment_names], + ...(scope.origins === undefined ? {} : { origins: [...scope.origins] }), + network: scope.network, + mount: scope.mount, +}); + +const missingScopeDetails = ( + scope: MissingPermissionScope, +): Readonly> => ({ + ...(scope.roots === undefined ? {} : { roots: [...scope.roots] }), + ...(scope.executables === undefined + ? {} + : { executables: [...scope.executables] }), + ...(scope.environment_names === undefined + ? {} + : { environment_names: [...scope.environment_names] }), + ...(scope.origins === undefined ? {} : { origins: [...scope.origins] }), + ...(scope.network === undefined ? {} : { network: scope.network }), + ...(scope.mount === undefined ? {} : { mount: scope.mount }), +}); + +const RETRYABLE_CODES: ReadonlySet = new Set([ + "invalid_request", + "provider_timeout", + "cancelled", + "revision_conflict", + "provider_unavailable", +]); diff --git a/src/domain/errors.ts b/src/domain/errors.ts index e064f7f00e..de0d931a1d 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -9,6 +9,7 @@ import { type HopperStartupFailureCode, } from "./hopperStartupFailure.js"; export { hopperStartupFailure, type HopperStartupFailureCode }; +export { projectAnalysisError } from "./analysisErrorProjection.js"; /** Stable tags exposed by safe analysis-error projections. */ const ANALYSIS_ERROR_TAGS = [ @@ -545,15 +546,29 @@ export interface AnalysisErrorProjection /** Exact denied authority used by both CLI and MCP remediation. */ export class PermissionRequiredError extends AnalysisError { readonly _tag = "PermissionRequiredError" as const; + readonly requested: PermissionRequest; + readonly missing: MissingPermissionScope; + readonly ceiling: PermissionScope | null; + readonly elicitationSupported: boolean; + readonly restartRequired: boolean; constructor( - readonly requested: PermissionRequest, - readonly missing: MissingPermissionScope, - readonly ceiling: PermissionScope | null, - readonly elicitationSupported: boolean, - readonly restartRequired: boolean, + ...args: readonly [ + requested: PermissionRequest, + missing: MissingPermissionScope, + ceiling: PermissionScope | null, + elicitationSupported: boolean, + restartRequired: boolean, + ] ) { + const [requested, missing, ceiling, elicitationSupported, restartRequired] = + args; super(`Permission required for ${requested.capability}`); + this.requested = requested; + this.missing = missing; + this.ceiling = ceiling; + this.elicitationSupported = elicitationSupported; + this.restartRequired = restartRequired; } } @@ -568,594 +583,3 @@ export class ReplayPlanStaleError extends AnalysisError { super("Controlled replay plan changed before execution"); } } - -/** Project expected failures into exhaustive, secret-safe caller fields. */ -export const projectAnalysisError = ( - error: AnalysisError, -): AnalysisErrorProjection => { - assertKnownTag(error._tag); - const code = errorCode(error); - const details = errorDetails(error); - return { - code, - category: errorCategory(error), - message: userMessage(error), - retryable: RETRYABLE_CODES.has(code), - remediation: { - action: remediationAction(error), - restart_required: - error instanceof PermissionRequiredError && error.restartRequired, - ...(error instanceof PermissionRequiredError - ? { elicitation_supported: error.elicitationSupported } - : {}), - }, - ...(details === undefined ? {} : { details }), - }; -}; - -const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => { - if (error instanceof ReplayPlanStaleError) return "plan_stale"; - if (error instanceof PermissionRequiredError) return "permission_required"; - if (error instanceof ProviderSelectionError) - return error.reason === "provider_unavailable" - ? "provider_unavailable" - : "capability_unavailable"; - if (error instanceof BrowserObservationError) - return browserErrorCode(error.reason); - if (error instanceof ArtifactOperationError) - return artifactOperationCode(error); - if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason); - if (error instanceof InvestigationWorkspaceError) - return investigationWorkspaceCode(error.reason); - if (error instanceof UnknownRegistryError) - return unknownRegistryCode(error.reason); - if (error._tag === "ProcessCaptureError") return processCaptureCode(error); - return staticErrorCode(error._tag); -}; - -const browserErrorCode = ( - reason: BrowserObservationError["reason"], -): AnalysisErrorProjection["code"] => { - if (reason === "payload_limit") return "truncated"; - if ( - reason === "target_not_found" || - reason === "target_not_allowed" || - reason === "target_changed" - ) - return "target_unavailable"; - if (reason === "endpoint_unreachable" || reason === "disconnected") - return "provider_unavailable"; - return "unreadable_output"; -}; - -const artifactOperationCode = ( - error: ArtifactOperationError, -): AnalysisErrorProjection["code"] => { - if (error.reason === "integrity" && error.artifactDetails !== undefined) - return "artifact_integrity_mismatch"; - if (error.reason === "limit") return "truncated"; - if (error.reason === "cancelled") return "cancelled"; - return "artifact_operation_failed"; -}; - -const evidenceFileCode = ( - reason: EvidenceFileError["reason"], -): AnalysisErrorProjection["code"] => { - if (reason === "outside-root") return "outside_approved_root"; - if (reason === "too-large") return "truncated"; - if (reason === "disabled") return "capability_unavailable"; - return "execution_failure"; -}; - -const investigationWorkspaceCode = ( - reason: InvestigationWorkspaceError["reason"], -): AnalysisErrorProjection["code"] => { - if (reason === "revision-conflict" || reason === "name-conflict") - return "revision_conflict"; - if (reason === "too-large") return "truncated"; - if (reason === "disabled") return "capability_unavailable"; - if (reason === "outside-root") return "outside_approved_root"; - if (reason === "integrity" || reason === "invalid-json") - return "evidence_integrity_mismatch"; - return "execution_failure"; -}; - -const unknownRegistryCode = ( - reason: UnknownRegistryError["reason"], -): AnalysisErrorProjection["code"] => { - if (reason === "revision-conflict" || reason === "already-exists") - return "revision_conflict"; - if (reason === "limit") return "truncated"; - if (reason === "integrity") return "evidence_integrity_mismatch"; - return "execution_failure"; -}; - -const processCaptureCode = ( - error: AnalysisError, -): AnalysisErrorProjection["code"] => { - if (error.cleanupIncomplete) return "cleanup_incomplete"; - if (error.userCategory === "cancelled") return "cancelled"; - if (error.userCategory === "permission_required") - return "permission_required"; - return "process_capture_failed"; -}; - -type SpecializedErrorTag = - | "ArtifactOperationError" - | "BrowserObservationError" - | "EvidenceFileError" - | "InvestigationWorkspaceError" - | "PermissionRequiredError" - | "ProcessCaptureError" - | "ProviderSelectionError" - | "ReplayPlanStaleError" - | "UnknownRegistryError"; - -const STATIC_ERROR_CODES = { - AnalysisProtocolError: "unreadable_output", - AnalysisOutputError: "unreadable_output", - HopperProtocolError: "unreadable_output", - AnalysisInputError: "invalid_request", - AnalysisCapabilityUnavailableError: "capability_unavailable", - AnalysisCancelledError: "cancelled", - HopperCancelledError: "cancelled", - AnalysisTimeoutError: "provider_timeout", - HopperTimeoutError: "provider_timeout", - HopperProcessError: "provider_unavailable", - HopperStartError: "provider_unavailable", - ConfigurationError: "configuration_invalid", - NoBinaryOpenError: "target_unavailable", - BinaryTargetError: "target_unavailable", - EvidenceIntegrityError: "evidence_integrity_mismatch", - EvidenceLimitError: "truncated", - ProviderAdapterError: "execution_failure", - HopperRemoteError: "execution_failure", -} as const satisfies Readonly< - Record< - Exclude, - AnalysisErrorProjection["code"] - > ->; - -const staticErrorCode = ( - tag: AnalysisErrorTag, -): AnalysisErrorProjection["code"] => { - switch (tag) { - case "ArtifactOperationError": - case "BrowserObservationError": - case "EvidenceFileError": - case "InvestigationWorkspaceError": - case "PermissionRequiredError": - case "ProcessCaptureError": - case "ProviderSelectionError": - case "ReplayPlanStaleError": - case "UnknownRegistryError": - throw new TypeError(`Unhandled specialized analysis error: ${tag}`); - default: - return STATIC_ERROR_CODES[tag]; - } -}; - -const errorDetails = ( - error: AnalysisError, -): Readonly> | undefined => - requestErrorDetails(error) ?? - artifactStateErrorDetails(error) ?? - providerErrorDetails(error) ?? - lifecycleErrorDetails(error); - -const requestErrorDetails = ( - error: AnalysisError, -): Readonly> | undefined => { - if (error instanceof AnalysisInputError && error.issues.length > 0) - return { - operation: error.operation, - issues: error.issues.map((issue) => ({ - path: [...issue.path], - reason: issue.reason, - ...(issue.expected === undefined ? {} : { expected: issue.expected }), - ...(issue.minimum === undefined ? {} : { minimum: issue.minimum }), - ...(issue.maximum === undefined ? {} : { maximum: issue.maximum }), - })), - }; - if (error instanceof EvidenceReferenceError) - return { - evidence_id: error.evidenceId, - reason: error.reason, - expected: error.expected, - actual: error.actual, - }; - if (error instanceof ReplayPlanStaleError) - return { - approved_plan_digest: error.approvedDigest, - actual_plan_digest: error.actualDigest, - application_code_admitted: false, - }; - if (error instanceof PermissionRequiredError) - return { - capability: error.requested.capability, - requested: scopeDetails(error.requested), - missing: missingScopeDetails(error.missing), - ceiling: error.ceiling === null ? null : scopeDetails(error.ceiling), - }; - return undefined; -}; - -const artifactStateErrorDetails = ( - error: AnalysisError, -): Readonly> | undefined => { - if (error instanceof ArtifactOperationError && error.artifactDetails) - return { - logical_path: error.artifactDetails.logicalPath, - declared_sha256: error.artifactDetails.declaredSha256, - calculated_sha256: error.artifactDetails.calculatedSha256, - unpacked: error.artifactDetails.unpacked, - }; - if (error instanceof ArtifactOperationError) - return { - operation: error.operation, - reason: error.reason, - ...(error.reason === "limit" ? { truncated: true } : {}), - }; - if (error instanceof EvidenceLimitError) - return { limit: error.limit, maximum: error.maximum, truncated: true }; - if (error instanceof InvestigationWorkspaceError) - return { operation: error.operation, reason: error.reason }; - if (error instanceof UnknownRegistryError) return { reason: error.reason }; - if (error instanceof EvidenceFileError) - return { operation: error.operation, reason: error.reason }; - return undefined; -}; - -const providerErrorDetails = ( - error: AnalysisError, -): Readonly> | undefined => { - if (error instanceof AnalysisCapabilityUnavailableError) - return { provider_id: error.providerId, operation: error.operation }; - if (error instanceof ProviderSelectionError) - return { - operation: error.operation, - selection_reason: error.reason, - requested_provider_id: error.requestedProviderId, - candidate_ids: [...error.candidateIds], - rejections: error.rejections.map((rejection) => ({ - provider_id: rejection.providerId, - code: rejection.code, - reason: rejection.reason, - diagnostics: rejection.diagnostics, - })), - }; - if (error instanceof ProviderAdapterError) - return { - provider_id: error.providerId, - operation: error.operation, - ...(error.diagnostics === undefined - ? {} - : { diagnostics: error.diagnostics }), - }; - if (error instanceof BrowserObservationError) - return { operation: error.operation, reason: error.reason }; - if (error instanceof HopperRemoteError) - return { provider_code: error.code, diagnostic_type: error.diagnosticType }; - if (error instanceof HopperProcessError && error.failureCode !== undefined) - return { failure_code: error.failureCode, exit_code: error.exitCode }; - return undefined; -}; - -const lifecycleErrorDetails = ( - error: AnalysisError, -): Readonly> | undefined => { - if (error instanceof AnalysisCancelledError) - return { operation: error.operation, cleanup: "complete" }; - if (error instanceof HopperCancelledError) - return { operation: "hopper", cleanup: "complete" }; - if (error instanceof AnalysisTimeoutError) - return { operation: error.operation, timeout_ms: error.timeoutMs }; - if (error instanceof HopperTimeoutError) - return { operation: "hopper", timeout_ms: error.timeoutMs }; - if (error._tag === "ProcessCaptureError" && error.cleanupIncomplete) - return { - cleanup: "incomplete", - resources: [...error.cleanupResources], - }; - if ( - error._tag === "ProcessCaptureError" && - error.userCategory === "cancelled" - ) - return { operation: "process_capture", cleanup: "complete" }; - if (error instanceof BinaryTargetError) return { path: error.path }; - return undefined; -}; - -const scopeDetails = ( - scope: PermissionScope, -): Readonly> => ({ - capability: scope.capability, - roots: [...scope.roots], - executables: [...scope.executables], - environment_names: [...scope.environment_names], - ...(scope.origins === undefined ? {} : { origins: [...scope.origins] }), - network: scope.network, - mount: scope.mount, -}); - -const missingScopeDetails = ( - scope: MissingPermissionScope, -): Readonly> => ({ - ...(scope.roots === undefined ? {} : { roots: [...scope.roots] }), - ...(scope.executables === undefined - ? {} - : { executables: [...scope.executables] }), - ...(scope.environment_names === undefined - ? {} - : { environment_names: [...scope.environment_names] }), - ...(scope.origins === undefined ? {} : { origins: [...scope.origins] }), - ...(scope.network === undefined ? {} : { network: scope.network }), - ...(scope.mount === undefined ? {} : { mount: scope.mount }), -}); - -const RETRYABLE_CODES: ReadonlySet = new Set([ - "invalid_request", - "provider_timeout", - "cancelled", - "revision_conflict", - "provider_unavailable", -]); - -const remediationAction = (error: AnalysisError): string => { - if (error instanceof AnalysisInputError) - return "Correct the listed arguments and retry."; - if (error instanceof ReplayPlanStaleError) - return "Review the rebuilt replay plan and explicitly approve its new digest."; - if (error instanceof PermissionRequiredError) - return error.elicitationSupported - ? "Approve the exact missing scope, then retry the operation." - : "Add the exact missing scope beneath the administrator ceiling, then retry."; - return userMessage(error); -}; - -const errorCategory = ( - error: AnalysisError, -): AnalysisErrorProjection["category"] => { - if (error instanceof ReplayPlanStaleError) return "integrity_mismatch"; - if (error instanceof PermissionRequiredError) return "permission_required"; - if ( - error instanceof ProviderSelectionError && - error.reason === "provider_unavailable" - ) - return "unavailable"; - if (error._tag === "ProcessCaptureError") - return error.userCategory ?? "execution_failure"; - if (error instanceof BrowserObservationError) - return browserErrorCategory(error.reason); - if ( - error instanceof HopperRemoteError && - error.diagnosticType === "authorization" - ) - return "permission_required"; - if (error instanceof ArtifactOperationError) - return artifactErrorCategory(error.reason); - if (error instanceof EvidenceFileError && error.reason === "disabled") - return "unavailable"; - if (error instanceof InvestigationWorkspaceError) - return investigationWorkspaceCategory(error.reason); - return STATIC_ERROR_CATEGORIES[error._tag] ?? "execution_failure"; -}; - -const browserErrorCategory = ( - reason: BrowserObservationError["reason"], -): AnalysisErrorProjection["category"] => { - if (reason === "payload_limit") return "truncated"; - if ( - reason === "target_not_found" || - reason === "target_not_allowed" || - reason === "target_changed" || - reason === "endpoint_unreachable" || - reason === "disconnected" - ) - return "unavailable"; - return "execution_failure"; -}; - -const investigationWorkspaceCategory = ( - reason: InvestigationWorkspaceError["reason"], -): AnalysisErrorProjection["category"] => { - if ( - reason === "disabled" || - reason === "outside-root" || - reason === "not-file" - ) - return "unavailable"; - if (reason === "too-large") return "truncated"; - if (reason === "invalid-json" || reason === "integrity") - return "integrity_mismatch"; - return "execution_failure"; -}; - -const artifactErrorCategory = ( - reason: ArtifactOperationError["reason"], -): AnalysisErrorProjection["category"] => { - if (reason === "integrity") return "integrity_mismatch"; - if (reason === "limit") return "truncated"; - if (reason === "cancelled") return "cancelled"; - if (reason === "unavailable") return "unavailable"; - return "execution_failure"; -}; - -const STATIC_ERROR_CATEGORIES: Readonly< - Partial> -> = { - AnalysisInputError: "invalid_input", - AnalysisCapabilityUnavailableError: "unsupported_provider", - ProviderSelectionError: "unsupported_provider", - EvidenceIntegrityError: "integrity_mismatch", - EvidenceLimitError: "truncated", - AnalysisCancelledError: "cancelled", - HopperCancelledError: "cancelled", - AnalysisTimeoutError: "timeout", - HopperTimeoutError: "timeout", - NoBinaryOpenError: "unavailable", - BinaryTargetError: "unavailable", -}; - -const userMessage = (error: AnalysisError): string => { - if (error instanceof ReplayPlanStaleError) - return "The controlled replay plan changed before execution. Refresh the current state and try again."; - if (error instanceof PermissionRequiredError) - return "This operation needs additional local permission. Review the requested scope and remediation."; - if (error instanceof AnalysisInputError) - return "Analysis input is invalid. Check the arguments and try again."; - if (error.userMessage !== undefined) return error.userMessage; - const standardMessage = standardErrorMessage(error._tag); - if (standardMessage !== undefined) return standardMessage; - if (error instanceof ArtifactOperationError) - return artifactMessage(error.reason); - if (error instanceof EvidenceIntegrityError) - return "Evidence is invalid or has changed. Recreate or re-import it, then try again."; - if (error instanceof EvidenceLimitError) - return "Evidence is too large for this session. Reduce the evidence set and try again."; - if (error instanceof EvidenceFileError) - return evidenceFileMessage(error.reason); - if (error instanceof InvestigationWorkspaceError) - return investigationWorkspaceMessage(error.reason); - if (error instanceof UnknownRegistryError) - return "Evidence state changed before the update completed. Refresh the current state and try again."; - if (error instanceof ConfigurationError) - return "REA configuration is invalid. Run `rea doctor` and fix the reported setting."; - if (error instanceof NoBinaryOpenError) return error.message; - if (error instanceof BinaryTargetError) - return "REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file."; - if (error._tag === "ProcessCaptureError") - return ( - error.userMessage ?? - "Process capture could not complete. Run `rea doctor`, then review capture policy and try again." - ); - return "Analysis could not complete. Run `rea doctor`, then try again."; -}; - -const standardErrorMessage = (tag: AnalysisErrorTag): string | undefined => { - if (UNREADABLE_OUTPUT_TAGS.has(tag)) - return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`."; - if (UNSUPPORTED_PROVIDER_TAGS.has(tag)) - return "This analysis is unavailable for the current target. Choose another analysis or target."; - if (CANCELLED_TAGS.has(tag)) - return "Analysis was cancelled. Start it again when ready."; - if (TIMEOUT_TAGS.has(tag)) - return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues."; - if (ADAPTER_FAILURE_TAGS.has(tag)) - return "Analysis could not complete. Retry once; if it continues, run `rea doctor`."; - if (START_FAILURE_TAGS.has(tag)) - return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again."; - return undefined; -}; - -const UNREADABLE_OUTPUT_TAGS: ReadonlySet = new Set([ - "AnalysisProtocolError", - "AnalysisOutputError", - "HopperProtocolError", -]); -const UNSUPPORTED_PROVIDER_TAGS: ReadonlySet = new Set([ - "AnalysisCapabilityUnavailableError", - "ProviderSelectionError", -]); -const CANCELLED_TAGS: ReadonlySet = new Set([ - "AnalysisCancelledError", - "HopperCancelledError", -]); -const TIMEOUT_TAGS: ReadonlySet = new Set([ - "AnalysisTimeoutError", - "HopperTimeoutError", -]); -const ADAPTER_FAILURE_TAGS: ReadonlySet = new Set([ - "ProviderAdapterError", - "HopperRemoteError", -]); -const START_FAILURE_TAGS: ReadonlySet = new Set([ - "HopperProcessError", - "HopperStartError", -]); - -const artifactMessage = (reason: ArtifactOperationError["reason"]): string => { - if (reason === "cancelled") - return "Artifact operation was cancelled. Start it again when ready."; - if (reason === "limit") - return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact."; - if (reason === "path") - return "Artifact path is not allowed. Choose a path inside the artifact and try again."; - if (reason === "unavailable") - return "Artifact format is not available on this system. Choose another artifact or supported environment."; - if (reason === "format" || reason === "integrity") - return "Artifact is invalid or has changed. Get a fresh copy and try again."; - return "Artifact could not be read or written. Check file access and try again."; -}; - -const evidenceFileMessage = (reason: EvidenceFileError["reason"]): string => { - if (reason === "disabled") - return "Evidence file access is disabled. Enable an evidence directory or use inline evidence."; - if (reason === "outside-root") - return "Evidence path is outside the allowed directory. Choose a path inside the configured evidence directory."; - if (reason === "not-file") - return "Evidence path does not point to a file. Choose an evidence file and try again."; - if (reason === "too-large") - return "Evidence file is too large. Reduce its size and try again."; - if (reason === "exists") - return "Evidence file already exists. Choose another path or allow overwrite."; - if (reason === "invalid-json") - return "Evidence file is not valid JSON. Repair or recreate the file and try again."; - return "Evidence file could not be accessed. Check file permissions and try again."; -}; - -const investigationWorkspaceMessage = ( - reason: InvestigationWorkspaceError["reason"], -): string => { - if (reason === "disabled") - return "Investigation workspace access is disabled. Configure a workspace directory and try again."; - if (reason === "outside-root") - return "Investigation workspace is outside the allowed directory. Choose a configured workspace directory and try again."; - if (reason === "not-file") - return "Investigation workspace path is not a file. Choose a workspace file and try again."; - if (reason === "too-large") - return "Investigation workspace is too large. Reduce its size and try again."; - if (reason === "invalid-json" || reason === "integrity") - return "Investigation workspace is invalid or has changed. Recreate or repair it, then try again."; - if (reason === "locked") - return "Another investigation update is in progress. Try again when it finishes."; - if (reason === "revision-conflict" || reason === "name-conflict") - return "Investigation workspace state changed. Refresh the current state and try again."; - return "Investigation workspace could not be accessed. Check file permissions and try again."; -}; - -const KNOWN_ERROR_TAGS = { - AnalysisProtocolError: true, - AnalysisInputError: true, - AnalysisOutputError: true, - AnalysisCapabilityUnavailableError: true, - AnalysisCancelledError: true, - AnalysisTimeoutError: true, - ProviderSelectionError: true, - ProviderAdapterError: true, - BrowserObservationError: true, - ArtifactOperationError: true, - ProcessCaptureError: true, - EvidenceIntegrityError: true, - EvidenceLimitError: true, - EvidenceFileError: true, - InvestigationWorkspaceError: true, - UnknownRegistryError: true, - HopperTimeoutError: true, - HopperCancelledError: true, - HopperProtocolError: true, - HopperRemoteError: true, - HopperProcessError: true, - HopperStartError: true, - ConfigurationError: true, - NoBinaryOpenError: true, - BinaryTargetError: true, - PermissionRequiredError: true, - ReplayPlanStaleError: true, -} as const satisfies Readonly>; - -const assertKnownTag = (tag: AnalysisErrorTag): void => { - if (KNOWN_ERROR_TAGS[tag] !== true) - throw new TypeError("Unknown analysis error tag"); -}; diff --git a/src/domain/reconstructionCoverage.ts b/src/domain/reconstructionCoverage.ts index 2a9702acc8..6ee0353481 100644 --- a/src/domain/reconstructionCoverage.ts +++ b/src/domain/reconstructionCoverage.ts @@ -4,6 +4,14 @@ import canonicalize from "canonicalize"; import { z } from "zod"; import { evidenceBundleSchema, parseEvidenceBundle } from "./evidenceBundle.js"; +import { + evaluateReconstructionClaims, + evaluateReconstructionPackageProofs, + evaluateReconstructionSurfaces, + evaluateReconstructionWorkspaceRisks, + recommendedReconstructionProbes, + type ReconstructionEvaluationContext, +} from "./reconstructionCoverageEvaluation.js"; const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); @@ -236,6 +244,9 @@ export type ReconstructionClosureResult = z.infer< typeof reconstructionClosureResultSchema >; +type WorkspaceSemantic = z.infer; +type ClosureReason = z.infer; + /** Build a verifier contract whose digest prevents retrospective coverage edits. */ export const createReconstructionVerifierContract = ( input: z.input, @@ -323,17 +334,17 @@ export const evaluateReconstructionClosure = ( ); if (boundary === undefined) throw new TypeError(`Unknown reconstruction boundary: ${boundaryId}`); - const context: EvaluationContext = { + const context: ReconstructionEvaluationContext = { workspace, boundary, nowEpochMs, reasons: [], evidenceIds: new Set(), }; - evaluateSurfaces(context); - evaluateClaims(context); - evaluateWorkspaceRisks(context); - evaluatePackageProofs(context); + evaluateReconstructionSurfaces(context); + evaluateReconstructionClaims(context); + evaluateReconstructionWorkspaceRisks(context); + evaluateReconstructionPackageProofs(context); const reasons = [...context.reasons].sort(reasonOrder); return reconstructionClosureResultSchema.parse({ boundary_id: boundary.boundary_id, @@ -345,377 +356,11 @@ export const evaluateReconstructionClosure = ( reasons: reasons.length, }, reasons, - recommended_probes: recommendedProbes(reasons), + recommended_probes: recommendedReconstructionProbes(reasons), evidence_ids: [...context.evidenceIds].sort(), }); }; -const recommendedProbes = ( - reasons: readonly ClosureReason[], -): readonly { operation: string; subject_id: string; rationale: string }[] => - reasons.slice(0, 100).map((reason) => ({ - operation: probeOperation(reason.code), - subject_id: reason.subject_id, - rationale: reason.detail, - })); - -const probeOperation = (code: ClosureReason["code"]): string => { - if (code.startsWith("owner-") || code.startsWith("authority-routing")) - return "reconcile_reconstruction_owner"; - if (code.startsWith("verifier-")) return "run_reconstruction_verifier"; - if (code.startsWith("package-proof")) return "verify_reconstruction_package"; - if (code === "active-contradiction") return "resolve_contradiction"; - if (code === "active-unknown") return "probe_residual_unknown"; - return "update_authoritative_inventory"; -}; - -type WorkspaceSemantic = z.infer; -type Boundary = z.infer; -type ClosureReason = z.infer; -interface EvaluationContext { - readonly workspace: ReconstructionCoverageWorkspace; - readonly boundary: Boundary; - readonly nowEpochMs: number; - readonly reasons: ClosureReason[]; - readonly evidenceIds: Set; -} - -const evaluateSurfaces = (context: EvaluationContext): void => { - const surfaces = indexUnique(context.workspace.surfaces, "surface_id"); - for (const surfaceId of context.boundary.required_surface_ids) { - const surface = surfaces.get(surfaceId); - if (surface === undefined) { - addReason( - context, - "surface-missing", - surfaceId, - "Required surface is absent from the authoritative inventory.", - ); - continue; - } - addEvidence(context, surface.evidence_ids); - for (const dependencyId of surface.dependency_surface_ids) - if (!surfaces.has(dependencyId)) - addReason( - context, - "dependency-surface-missing", - surfaceId, - `Declared dependency surface is absent: ${dependencyId}`, - ); - evaluateOwner(context, surfaceId); - } -}; - -const evaluateOwner = (context: EvaluationContext, surfaceId: string): void => { - const owners = context.workspace.owners.filter( - ({ surface_id: id }) => id === surfaceId, - ); - if (owners.length === 0) { - addReason( - context, - "owner-missing", - surfaceId, - "Required surface has no reconstructed owner or explicit disposition.", - ); - return; - } - if (owners.length > 1) { - addReason( - context, - "owner-duplicate", - surfaceId, - "Required surface has conflicting ownership declarations.", - ); - return; - } - const owner = owners[0]?.ownership; - if (owner === undefined) return; - if (owner.disposition !== "implemented") { - if (!context.boundary.allowed_dispositions.includes(owner.disposition)) - addReason( - context, - "disposition-not-allowed", - surfaceId, - `${owner.disposition} is not allowed by this completion boundary.`, - ); - return; - } - const ownerChecks: ReadonlyArray< - readonly [boolean, ClosureReason["code"], string] - > = [ - [ - owner.path_state === "missing", - "owner-path-missing", - "Declared owner path is missing.", - ], - [ - owner.path_state === "unknown", - "owner-path-unknown", - "Declared owner path has not been checked.", - ], - [ - owner.package_state === "missing", - "owner-undistributed", - "Declared owner is absent from the distributable.", - ], - [ - owner.package_state === "unknown", - "owner-distribution-unknown", - "Owner distribution state is unknown.", - ], - [ - owner.authority_route === "detected", - "authority-routing-detected", - "Reconstructed owner routes into the authority.", - ], - [ - owner.authority_route === "unknown", - "authority-routing-unknown", - "Authority independence has not been established.", - ], - ]; - for (const [matches, code, detail] of ownerChecks) - if (matches) addReason(context, code, surfaceId, detail); -}; - -const evaluateClaims = (context: EvaluationContext): void => { - const claims = indexUnique(context.workspace.claims, "claim_id"); - for (const claimId of context.boundary.required_claim_ids) { - const claim = claims.get(claimId); - if (claim === undefined) { - addReason( - context, - "claim-missing", - claimId, - "Required finite claim is absent.", - ); - continue; - } - const contracts = context.workspace.verifier_contracts.filter( - ({ claim_ids: ids }) => ids.includes(claimId), - ); - if (contracts.length === 0) { - addReason( - context, - "verifier-missing", - claimId, - "No verifier contract covers this claim.", - ); - continue; - } - if (contracts.length > 1) { - addReason( - context, - "verifier-duplicate", - claimId, - "Multiple verifier contracts claim primary coverage.", - ); - continue; - } - const contract = contracts[0]; - if (contract !== undefined) - evaluateVerifierResult(context, claim, contract); - } -}; - -const evaluateVerifierResult = ( - context: EvaluationContext, - claim: z.infer, - contract: ReconstructionVerifierContract, -): void => { - const results = context.workspace.verifier_results - .filter(({ verifier_id: id }) => id === contract.verifier_id) - .sort( - (left, right) => - Date.parse(right.observed_at) - Date.parse(left.observed_at), - ); - const result = results[0]; - if (result === undefined) { - addReason( - context, - "verifier-result-missing", - claim.claim_id, - "Verifier has no recorded result.", - ); - return; - } - addEvidence(context, result.evidence_ids); - const artifactDigests = new Set( - context.workspace.artifacts.map(({ artifact_sha256: value }) => value), - ); - const ownerDigests = new Set( - context.workspace.owners.flatMap(({ ownership }) => - ownership.disposition === "implemented" ? [ownership.owner_sha256] : [], - ), - ); - const surfaces = new Map( - context.workspace.surfaces.map((surface) => [surface.surface_id, surface]), - ); - const artifacts = new Map( - context.workspace.artifacts.map((artifact) => [ - artifact.artifact_id, - artifact, - ]), - ); - const expectedArtifactDigests = claim.surface_ids.flatMap((surfaceId) => { - const surface = surfaces.get(surfaceId); - const artifact = - surface === undefined ? undefined : artifacts.get(surface.artifact_id); - return artifact === undefined ? [] : [artifact.artifact_sha256]; - }); - const expectedOwnerDigests = context.workspace.owners.flatMap( - ({ surface_id: surfaceId, ownership }) => - claim.surface_ids.includes(surfaceId) && - ownership.disposition === "implemented" - ? [ownership.owner_sha256] - : [], - ); - const compatible = - result.contract_sha256 === contract.contract_sha256 && - result.normalization_sha256 === contract.normalization_sha256 && - result.covered_claim_ids.includes(claim.claim_id) && - result.covered_claim_ids.every((item) => - contract.claim_ids.includes(item), - ) && - claim.required_dimensions.every((item) => - contract.dimensions.includes(item), - ) && - claim.required_dimensions.every((item) => - result.covered_dimensions.includes(item), - ) && - result.covered_dimensions.every((item) => - contract.dimensions.includes(item), - ) && - result.artifact_sha256s.every((item) => artifactDigests.has(item)) && - expectedArtifactDigests.every((item) => - result.artifact_sha256s.includes(item), - ) && - result.owner_sha256s.every((item) => ownerDigests.has(item)) && - expectedOwnerDigests.every((item) => result.owner_sha256s.includes(item)) && - result.repeats >= contract.minimum_repeats && - claim.required_authority === contract.authority; - if (!compatible) { - addReason( - context, - "verifier-result-incompatible", - claim.claim_id, - "Latest verifier result does not match current artifacts, owners, dimensions, authority, normalization, repeats, or contract.", - ); - return; - } - const observedAt = Date.parse(result.observed_at); - if ( - observedAt > context.nowEpochMs || - context.nowEpochMs - observedAt > contract.max_age_ms - ) { - addReason( - context, - "verifier-result-stale", - claim.claim_id, - "Latest compatible verifier result is stale.", - ); - return; - } - if (result.status === "fail") - addReason( - context, - "verifier-failed", - claim.claim_id, - "Latest compatible verifier result failed.", - ); - else if (result.status !== "pass") - addReason( - context, - "verifier-unknown", - claim.claim_id, - `Latest compatible verifier result is ${result.status}.`, - ); -}; - -const evaluateWorkspaceRisks = (context: EvaluationContext): void => { - for (const unknownId of context.workspace.residual_unknown_ids) - if (!context.boundary.allowed_unknown_ids.includes(unknownId)) - addReason( - context, - "active-unknown", - unknownId, - "Active residual unknown is not permitted by the completion boundary.", - ); - for (const contradiction of context.workspace.contradictions) - if ( - contradiction.status === "active" && - (contradiction.surface_ids.some((id) => - context.boundary.required_surface_ids.includes(id), - ) || - contradiction.claim_ids.some((id) => - context.boundary.required_claim_ids.includes(id), - )) - ) { - addEvidence(context, contradiction.evidence_ids); - addReason( - context, - "active-contradiction", - contradiction.contradiction_id, - "Active contradiction affects the completion boundary.", - ); - } -}; - -const evaluatePackageProofs = (context: EvaluationContext): void => { - const surfaces = new Map( - context.workspace.surfaces.map((surface) => [surface.surface_id, surface]), - ); - const artifacts = new Map( - context.workspace.artifacts.map((artifact) => [ - artifact.artifact_id, - artifact, - ]), - ); - const requiredArtifactDigests = context.boundary.required_surface_ids.flatMap( - (surfaceId) => { - const surface = surfaces.get(surfaceId); - const artifact = - surface === undefined ? undefined : artifacts.get(surface.artifact_id); - return artifact === undefined ? [] : [artifact.artifact_sha256]; - }, - ); - for (const kind of context.boundary.required_package_proof_kinds) { - const proofs = context.workspace.package_proofs.filter( - (proof) => proof.kind === kind, - ); - const passing = proofs.find( - ({ artifact_sha256s: digests, status }) => - status === "pass" && - requiredArtifactDigests.every((digest) => digests.includes(digest)), - ); - if (passing !== undefined) { - addEvidence(context, passing.evidence_ids); - continue; - } - if (proofs.some(({ status }) => status === "fail")) - addReason( - context, - "package-proof-failed", - kind, - "Required package or integration proof failed.", - ); - else if (proofs.length > 0) - addReason( - context, - "package-proof-unknown", - kind, - "Required package or integration proof is not passing.", - ); - else - addReason( - context, - "package-proof-missing", - kind, - "Required package or integration proof is missing.", - ); - } -}; - const validateWorkspaceReferences = (workspace: WorkspaceSemantic): void => { const evidenceBundle = parseEvidenceBundle(workspace.evidence_bundle); const evidenceIds = new Set( @@ -816,32 +461,6 @@ const assertUnique = , Key extends keyof Item>( return new Set(values); }; -const indexUnique = , Key extends keyof Item>( - items: readonly Item[], - key: Key, -): ReadonlyMap => { - assertUnique(items, key); - return new Map(items.map((item) => [item[key], item])); -}; - -const addReason = ( - context: EvaluationContext, - code: ClosureReason["code"], - subjectId: string, - detail: string, -): void => { - context.reasons.push( - closureReasonSchema.parse({ code, subject_id: subjectId, detail }), - ); -}; - -const addEvidence = ( - context: EvaluationContext, - evidenceIds: readonly string[], -): void => { - for (const evidenceId of evidenceIds) context.evidenceIds.add(evidenceId); -}; - const closureStatus = ( reasons: readonly ClosureReason[], ): ReconstructionClosureResult["status"] => { diff --git a/src/domain/reconstructionCoverageEvaluation.ts b/src/domain/reconstructionCoverageEvaluation.ts new file mode 100644 index 0000000000..9036cadd83 --- /dev/null +++ b/src/domain/reconstructionCoverageEvaluation.ts @@ -0,0 +1,411 @@ +import type { + ReconstructionClosureResult, + ReconstructionCoverageWorkspace, + ReconstructionVerifierContract, +} from "./reconstructionCoverage.js"; + +export const recommendedReconstructionProbes = ( + reasons: readonly ClosureReason[], +): readonly { operation: string; subject_id: string; rationale: string }[] => + reasons.slice(0, 100).map((reason) => ({ + operation: probeOperation(reason.code), + subject_id: reason.subject_id, + rationale: reason.detail, + })); + +const probeOperation = (code: ClosureReason["code"]): string => { + if (code.startsWith("owner-") || code.startsWith("authority-routing")) + return "reconcile_reconstruction_owner"; + if (code.startsWith("verifier-")) return "run_reconstruction_verifier"; + if (code.startsWith("package-proof")) return "verify_reconstruction_package"; + if (code === "active-contradiction") return "resolve_contradiction"; + if (code === "active-unknown") return "probe_residual_unknown"; + return "update_authoritative_inventory"; +}; + +type Boundary = ReconstructionCoverageWorkspace["boundaries"][number]; +type ClosureReason = ReconstructionClosureResult["reasons"][number]; +export interface ReconstructionEvaluationContext { + readonly workspace: ReconstructionCoverageWorkspace; + readonly boundary: Boundary; + readonly nowEpochMs: number; + readonly reasons: ClosureReason[]; + readonly evidenceIds: Set; +} + +export const evaluateReconstructionSurfaces = ( + context: ReconstructionEvaluationContext, +): void => { + const surfaces = indexUnique(context.workspace.surfaces, "surface_id"); + for (const surfaceId of context.boundary.required_surface_ids) { + const surface = surfaces.get(surfaceId); + if (surface === undefined) { + addReason( + context, + "surface-missing", + surfaceId, + "Required surface is absent from the authoritative inventory.", + ); + continue; + } + addEvidence(context, surface.evidence_ids); + for (const dependencyId of surface.dependency_surface_ids) + if (!surfaces.has(dependencyId)) + addReason( + context, + "dependency-surface-missing", + surfaceId, + `Declared dependency surface is absent: ${dependencyId}`, + ); + evaluateOwner(context, surfaceId); + } +}; + +const evaluateOwner = ( + context: ReconstructionEvaluationContext, + surfaceId: string, +): void => { + const owners = context.workspace.owners.filter( + ({ surface_id: id }) => id === surfaceId, + ); + if (owners.length === 0) { + addReason( + context, + "owner-missing", + surfaceId, + "Required surface has no reconstructed owner or explicit disposition.", + ); + return; + } + if (owners.length > 1) { + addReason( + context, + "owner-duplicate", + surfaceId, + "Required surface has conflicting ownership declarations.", + ); + return; + } + const owner = owners[0]?.ownership; + if (owner === undefined) return; + if (owner.disposition !== "implemented") { + if (!context.boundary.allowed_dispositions.includes(owner.disposition)) + addReason( + context, + "disposition-not-allowed", + surfaceId, + `${owner.disposition} is not allowed by this completion boundary.`, + ); + return; + } + const ownerChecks: ReadonlyArray< + readonly [boolean, ClosureReason["code"], string] + > = [ + [ + owner.path_state === "missing", + "owner-path-missing", + "Declared owner path is missing.", + ], + [ + owner.path_state === "unknown", + "owner-path-unknown", + "Declared owner path has not been checked.", + ], + [ + owner.package_state === "missing", + "owner-undistributed", + "Declared owner is absent from the distributable.", + ], + [ + owner.package_state === "unknown", + "owner-distribution-unknown", + "Owner distribution state is unknown.", + ], + [ + owner.authority_route === "detected", + "authority-routing-detected", + "Reconstructed owner routes into the authority.", + ], + [ + owner.authority_route === "unknown", + "authority-routing-unknown", + "Authority independence has not been established.", + ], + ]; + for (const [matches, code, detail] of ownerChecks) + if (matches) addReason(context, code, surfaceId, detail); +}; + +export const evaluateReconstructionClaims = ( + context: ReconstructionEvaluationContext, +): void => { + const claims = indexUnique(context.workspace.claims, "claim_id"); + for (const claimId of context.boundary.required_claim_ids) { + const claim = claims.get(claimId); + if (claim === undefined) { + addReason( + context, + "claim-missing", + claimId, + "Required finite claim is absent.", + ); + continue; + } + const contracts = context.workspace.verifier_contracts.filter( + ({ claim_ids: ids }) => ids.includes(claimId), + ); + if (contracts.length === 0) { + addReason( + context, + "verifier-missing", + claimId, + "No verifier contract covers this claim.", + ); + continue; + } + if (contracts.length > 1) { + addReason( + context, + "verifier-duplicate", + claimId, + "Multiple verifier contracts claim primary coverage.", + ); + continue; + } + const contract = contracts[0]; + if (contract !== undefined) + evaluateVerifierResult(context, claim, contract); + } +}; + +const evaluateVerifierResult = ( + context: ReconstructionEvaluationContext, + claim: ReconstructionCoverageWorkspace["claims"][number], + contract: ReconstructionVerifierContract, +): void => { + const results = context.workspace.verifier_results + .filter(({ verifier_id: id }) => id === contract.verifier_id) + .sort( + (left, right) => + Date.parse(right.observed_at) - Date.parse(left.observed_at), + ); + const result = results[0]; + if (result === undefined) { + addReason( + context, + "verifier-result-missing", + claim.claim_id, + "Verifier has no recorded result.", + ); + return; + } + addEvidence(context, result.evidence_ids); + if (!verifierResultIsCompatible(context, claim, contract, result)) { + addReason( + context, + "verifier-result-incompatible", + claim.claim_id, + "Latest verifier result does not match current artifacts, owners, dimensions, authority, normalization, repeats, or contract.", + ); + return; + } + const observedAt = Date.parse(result.observed_at); + if ( + observedAt > context.nowEpochMs || + context.nowEpochMs - observedAt > contract.max_age_ms + ) { + addReason( + context, + "verifier-result-stale", + claim.claim_id, + "Latest compatible verifier result is stale.", + ); + return; + } + if (result.status === "fail") + addReason( + context, + "verifier-failed", + claim.claim_id, + "Latest compatible verifier result failed.", + ); + else if (result.status !== "pass") + addReason( + context, + "verifier-unknown", + claim.claim_id, + `Latest compatible verifier result is ${result.status}.`, + ); +}; + +const verifierResultIsCompatible = ( + context: ReconstructionEvaluationContext, + claim: ReconstructionCoverageWorkspace["claims"][number], + contract: ReconstructionVerifierContract, + result: ReconstructionCoverageWorkspace["verifier_results"][number], +): boolean => { + const artifactDigests = new Set( + context.workspace.artifacts.map(({ artifact_sha256: value }) => value), + ); + const ownerDigests = new Set( + context.workspace.owners.flatMap(({ ownership }) => + ownership.disposition === "implemented" ? [ownership.owner_sha256] : [], + ), + ); + const surfaces = new Map( + context.workspace.surfaces.map((surface) => [surface.surface_id, surface]), + ); + const artifacts = new Map( + context.workspace.artifacts.map((artifact) => [ + artifact.artifact_id, + artifact, + ]), + ); + const expectedArtifactDigests = claim.surface_ids.flatMap((surfaceId) => { + const surface = surfaces.get(surfaceId); + const artifact = + surface === undefined ? undefined : artifacts.get(surface.artifact_id); + return artifact === undefined ? [] : [artifact.artifact_sha256]; + }); + const expectedOwnerDigests = context.workspace.owners.flatMap( + ({ surface_id: surfaceId, ownership }) => + claim.surface_ids.includes(surfaceId) && + ownership.disposition === "implemented" + ? [ownership.owner_sha256] + : [], + ); + return ( + result.contract_sha256 === contract.contract_sha256 && + result.normalization_sha256 === contract.normalization_sha256 && + result.covered_claim_ids.includes(claim.claim_id) && + result.covered_claim_ids.every((item) => + contract.claim_ids.includes(item), + ) && + claim.required_dimensions.every((item) => + contract.dimensions.includes(item), + ) && + claim.required_dimensions.every((item) => + result.covered_dimensions.includes(item), + ) && + result.covered_dimensions.every((item) => + contract.dimensions.includes(item), + ) && + result.artifact_sha256s.every((item) => artifactDigests.has(item)) && + expectedArtifactDigests.every((item) => + result.artifact_sha256s.includes(item), + ) && + result.owner_sha256s.every((item) => ownerDigests.has(item)) && + expectedOwnerDigests.every((item) => result.owner_sha256s.includes(item)) && + result.repeats >= contract.minimum_repeats && + claim.required_authority === contract.authority + ); +}; + +export const evaluateReconstructionWorkspaceRisks = ( + context: ReconstructionEvaluationContext, +): void => { + for (const unknownId of context.workspace.residual_unknown_ids) + if (!context.boundary.allowed_unknown_ids.includes(unknownId)) + addReason( + context, + "active-unknown", + unknownId, + "Active residual unknown is not permitted by the completion boundary.", + ); + for (const contradiction of context.workspace.contradictions) + if ( + contradiction.status === "active" && + (contradiction.surface_ids.some((id) => + context.boundary.required_surface_ids.includes(id), + ) || + contradiction.claim_ids.some((id) => + context.boundary.required_claim_ids.includes(id), + )) + ) { + addEvidence(context, contradiction.evidence_ids); + addReason( + context, + "active-contradiction", + contradiction.contradiction_id, + "Active contradiction affects the completion boundary.", + ); + } +}; + +export const evaluateReconstructionPackageProofs = ( + context: ReconstructionEvaluationContext, +): void => { + const surfaces = new Map( + context.workspace.surfaces.map((surface) => [surface.surface_id, surface]), + ); + const artifacts = new Map( + context.workspace.artifacts.map((artifact) => [ + artifact.artifact_id, + artifact, + ]), + ); + const requiredArtifactDigests = context.boundary.required_surface_ids.flatMap( + (surfaceId) => { + const surface = surfaces.get(surfaceId); + const artifact = + surface === undefined ? undefined : artifacts.get(surface.artifact_id); + return artifact === undefined ? [] : [artifact.artifact_sha256]; + }, + ); + for (const kind of context.boundary.required_package_proof_kinds) { + const proofs = context.workspace.package_proofs.filter( + (proof) => proof.kind === kind, + ); + const passing = proofs.find( + ({ artifact_sha256s: digests, status }) => + status === "pass" && + requiredArtifactDigests.every((digest) => digests.includes(digest)), + ); + if (passing !== undefined) { + addEvidence(context, passing.evidence_ids); + continue; + } + if (proofs.some(({ status }) => status === "fail")) + addReason( + context, + "package-proof-failed", + kind, + "Required package or integration proof failed.", + ); + else if (proofs.length > 0) + addReason( + context, + "package-proof-unknown", + kind, + "Required package or integration proof is not passing.", + ); + else + addReason( + context, + "package-proof-missing", + kind, + "Required package or integration proof is missing.", + ); + } +}; + +const indexUnique = , Key extends keyof Item>( + items: readonly Item[], + key: Key, +): ReadonlyMap => new Map(items.map((item) => [item[key], item])); + +const addReason = ( + context: ReconstructionEvaluationContext, + code: ClosureReason["code"], + subjectId: string, + detail: string, +): void => { + context.reasons.push({ code, subject_id: subjectId, detail }); +}; + +const addEvidence = ( + context: ReconstructionEvaluationContext, + evidenceIds: readonly string[], +): void => { + for (const evidenceId of evidenceIds) context.evidenceIds.add(evidenceId); +}; diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 167d07f419..42586c0ed7 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -13,1323 +13,26 @@ import { managedTableRowCounts, readManagedMetadataInventory, } from "./ManagedMetadataInventory.js"; -import { - metadataRowOffset, - readManagedMetadataLayout, - type ManagedMetadataLayout, - type MetadataTableLayout, -} from "./ManagedMetadataLayout.js"; -import { - MetadataRowCursor, - metadataToken, - readMetadataBlob, - readMetadataString, - sha256Bytes, -} from "./ManagedMetadataHeaps.js"; +import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { ManagedReaderFailure, managedFailure, } from "./ManagedReaderFailure.js"; +import { + emptyPage, + page, + parseTypes, + typeRanges, + type ManagedMemberInspectionLimits, +} from "./ManagedMemberInspectorCore.js"; +import { + edges, + parseFields, + parseMemberRefs, + parseMethods, +} from "./ManagedMemberRows.js"; -type ManagedPage = { - readonly items: readonly Item[]; - readonly offset: number; - readonly limit: number; - readonly total: number; - readonly returned: number; - readonly dropped: number; - readonly complete: boolean; -}; -type ManagedType = ManagedMemberInspection["types"]["items"][number]; -type ManagedField = ManagedMemberInspection["fields"]["items"][number]; -type ManagedMethod = ManagedMemberInspection["methods"]["items"][number]; -type ManagedMemberRef = ManagedMemberInspection["member_refs"]["items"][number]; -type ManagedCallEdge = ManagedMemberInspection["call_edges"]["items"][number]; -type ManagedFieldAccess = - ManagedMemberInspection["field_accesses"]["items"][number]; -type ManagedSignature = ManagedMethod["signature"]; -type ManagedMethodBody = ManagedMethod["body"]; -type ManagedInstructionAnchor = ManagedMethodBody["anchors"][number]; -type ManagedExceptionRegion = ManagedMethodBody["exception_regions"][number]; - -export interface ManagedMemberInspectionLimits { - readonly typeOffset: number; - readonly typeLimit: number; - readonly methodOffset: number; - readonly methodLimit: number; - readonly fieldOffset: number; - readonly fieldLimit: number; - readonly memberRefOffset: number; - readonly memberRefLimit: number; - readonly edgeOffset: number; - readonly edgeLimit: number; - readonly instructionAnchorLimit: number; - readonly maxMetadataBytes: number; - readonly maxTableRows: number; - readonly maxHeapItemBytes: number; - readonly maxMethodBodyBytes: number; - readonly maxMethodInstructions: number; -} - -interface TypeRange { - readonly token: string; - readonly fullName: string; - readonly fieldStart: number; - readonly fieldEnd: number; - readonly methodStart: number; - readonly methodEnd: number; -} - -interface MethodCore { - readonly token: string; - readonly name: string; - readonly declaringType: string | null; -} - -interface FieldCore { - readonly token: string; - readonly name: string; -} - -interface MemberRefCore { - readonly token: string; - readonly name: string; -} - -interface ParsedInstruction { - readonly offset: number; - readonly opcode: string; - readonly operandKind: ManagedInstructionAnchor["operand_kind"]; - readonly operand: string | null; -} - -const emptyPage = (offset: number, limit: number): ManagedPage => ({ - items: [], - offset, - limit, - total: 0, - returned: 0, - dropped: 0, - complete: offset === 0, -}); - -const page = ( - all: readonly Item[], - offset: number, - limit: number, -): ManagedPage => { - const items = all.slice(offset, offset + limit); - return { - items, - offset, - limit, - total: all.length, - returned: items.length, - dropped: all.length - items.length, - complete: offset === 0 && offset + limit >= all.length, - }; -}; - -const rowCursor = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - table: number, - row: number, -): MetadataRowCursor => { - const descriptor = layout.table(table); - const start = metadataRowOffset(layout, table, row); - if (descriptor === undefined) - throw managedFailure( - "invalid-row", - `metadata.${String(table)}`, - "Metadata table is absent", - start, - ); - return new MetadataRowCursor( - bytes, - start, - start + descriptor.rowSize, - `metadata.${descriptor.name}`, - ); -}; - -const codedToken = ( - raw: number, - bits: number, - tables: readonly (number | undefined)[], -): string | null => { - if (raw === 0) return null; - const tag = raw & (2 ** bits - 1); - const row = Math.floor(raw / 2 ** bits); - const table = tables[tag]; - return table === undefined || row === 0 ? null : metadataToken(table, row); -}; - -const rowRange = ( - table: MetadataTableLayout | undefined, - start: number, - nextStart: number, -): { - readonly first: number | null; - readonly last: number | null; - readonly count: number; -} => { - const total = table?.rowCount ?? 0; - if (total === 0 || start === 0) return { first: null, last: null, count: 0 }; - const end = Math.min(nextStart === 0 ? total + 1 : nextStart, total + 1); - if (start >= end) return { first: null, last: null, count: 0 }; - return { first: start, last: end - 1, count: end - start }; -}; - -const fullName = (namespace: string, name: string): string => - namespace.length === 0 ? name : `${namespace}.${name}`; - -const readTypeRange = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - row: number, - maxBytes: number, -): TypeRange => { - const methods = layout.table(6); - const fields = layout.table(4); - const cursor = rowCursor(bytes, layout, 2, row); - cursor.readUInt32(); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const namespace = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); - const fieldStart = cursor.readIndex(layout.tableIndexSize(4)); - const methodStart = cursor.readIndex(layout.tableIndexSize(6)); - let nextField = (fields?.rowCount ?? 0) + 1; - let nextMethod = (methods?.rowCount ?? 0) + 1; - const typeTable = layout.table(2); - if (typeTable !== undefined && row < typeTable.rowCount) { - const next = rowCursor(bytes, layout, 2, row + 1); - next.readUInt32(); - next.readIndex(layout.stringIndexSize); - next.readIndex(layout.stringIndexSize); - next.readIndex(layout.codedIndexSize("TypeDefOrRef")); - nextField = next.readIndex(layout.tableIndexSize(4)); - nextMethod = next.readIndex(layout.tableIndexSize(6)); - } - return { - token: metadataToken(2, row), - fullName: fullName(namespace, name), - fieldStart, - fieldEnd: nextField, - methodStart, - methodEnd: nextMethod, - }; -}; - -const typeRanges = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - maxBytes: number, -): readonly TypeRange[] => { - const typeTable = layout.table(2); - const ranges: TypeRange[] = []; - for (let row = 1; row <= (typeTable?.rowCount ?? 0); row += 1) - ranges.push(readTypeRange(bytes, layout, row, maxBytes)); - return ranges; -}; - -const declaringType = ( - ranges: readonly TypeRange[], - table: "field" | "method", - row: number, -): { readonly token: string; readonly fullName: string } | null => { - for (const range of ranges) { - const start = table === "field" ? range.fieldStart : range.methodStart; - const end = table === "field" ? range.fieldEnd : range.methodEnd; - if (row >= start && row < end) - return { token: range.token, fullName: range.fullName }; - } - return null; -}; - -const parseTypes = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - ranges: readonly TypeRange[], - maxBytes: number, -): readonly ManagedType[] => { - const typeTable = layout.table(2); - const fields = layout.table(4); - const methods = layout.table(6); - const items: ManagedType[] = []; - for (let row = 1; row <= (typeTable?.rowCount ?? 0); row += 1) { - const cursor = rowCursor(bytes, layout, 2, row); - const flags = cursor.readUInt32(); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const namespace = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const extendsRaw = cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); - const fieldStart = cursor.readIndex(layout.tableIndexSize(4)); - const methodStart = cursor.readIndex(layout.tableIndexSize(6)); - const nextRange = ranges[row] ?? null; - const fieldRange = rowRange(fields, fieldStart, nextRange?.fieldStart ?? 0); - const methodRange = rowRange( - methods, - methodStart, - nextRange?.methodStart ?? 0, - ); - items.push({ - token: metadataToken(2, row), - row_offset: cursor.start, - namespace, - name, - full_name: fullName(namespace, name), - flags, - extends_token: codedToken(extendsRaw, 2, [2, 1, 27]), - field_list: { - first_row: fieldRange.first, - last_row: fieldRange.last, - count: fieldRange.count, - }, - method_list: { - first_row: methodRange.first, - last_row: methodRange.last, - count: methodRange.count, - }, - }); - } - return items; -}; - -const readCompressed = ( - bytes: Buffer, - offset: number, -): { readonly value: number; readonly next: number } => { - const first = bytes[offset]; - if (first === undefined) throw new RangeError("truncated compressed integer"); - if ((first & 0x80) === 0) return { value: first, next: offset + 1 }; - const second = bytes[offset + 1]; - if ((first & 0xc0) === 0x80 && second !== undefined) - return { value: ((first & 0x3f) << 8) | second, next: offset + 2 }; - const b1 = bytes[offset + 1]; - const b2 = bytes[offset + 2]; - const b3 = bytes[offset + 3]; - if ( - (first & 0xe0) === 0xc0 && - b1 !== undefined && - b2 !== undefined && - b3 !== undefined - ) - return { - value: - (first & 0x1f) * 0x01_00_00_00 + b1 * 0x01_00_00 + b2 * 0x0100 + b3, - next: offset + 4, - }; - throw new RangeError("reserved compressed integer"); -}; - -const ELEMENT_TYPES = new Map([ - [0x01, "void"], - [0x02, "bool"], - [0x03, "char"], - [0x04, "i1"], - [0x05, "u1"], - [0x06, "i2"], - [0x07, "u2"], - [0x08, "i4"], - [0x09, "u4"], - [0x0a, "i8"], - [0x0b, "u8"], - [0x0c, "r4"], - [0x0d, "r8"], - [0x0e, "string"], - [0x18, "native-int"], - [0x19, "native-uint"], - [0x1c, "object"], -]); - -const ELEMENT_TYPE_SUFFIXES = new Map([ - [0x0f, "*"], - [0x10, "&"], -]); - -const readTypeSignature = ( - blob: Buffer, - offset: number, -): { readonly value: string; readonly next: number } => { - const kind = blob[offset]; - if (kind === undefined) throw new RangeError("truncated type signature"); - const named = ELEMENT_TYPES.get(kind); - if (named !== undefined) return { value: named, next: offset + 1 }; - const suffix = ELEMENT_TYPE_SUFFIXES.get(kind); - if (suffix !== undefined) { - const inner = readTypeSignature(blob, offset + 1); - return { value: `${inner.value}${suffix}`, next: inner.next }; - } - if (kind === 0x11 || kind === 0x12) { - const token = readCompressed(blob, offset + 1); - return { - value: `${kind === 0x11 ? "valuetype" : "class"}:${String(token.value)}`, - next: token.next, - }; - } - if (kind === 0x1d) { - const inner = readTypeSignature(blob, offset + 1); - return { value: `${inner.value}[]`, next: inner.next }; - } - if (kind === 0x1e) { - const variable = readCompressed(blob, offset + 1); - return { value: `mvar:${String(variable.value)}`, next: variable.next }; - } - if (kind === 0x13) { - const variable = readCompressed(blob, offset + 1); - return { value: `var:${String(variable.value)}`, next: variable.next }; - } - throw new RangeError(`unsupported element type 0x${kind.toString(16)}`); -}; - -const callingConvention = (value: number): string => { - const base = value & 0x0f; - const flags = [ - (value & 0x20) === 0 ? null : "has-this", - (value & 0x40) === 0 ? null : "explicit-this", - (value & 0x10) === 0 ? null : "generic", - ].filter((flag) => flag !== null); - const name = - base === 0 - ? "default" - : base === 5 - ? "vararg" - : base === 6 - ? "field" - : `unknown:${String(base)}`; - return flags.length === 0 ? name : `${name} ${flags.join(" ")}`; -}; - -const signature = (blob: Buffer): ManagedSignature => { - const raw = { - raw_length: blob.length, - raw_sha256: sha256Bytes(blob), - }; - try { - if (blob.length === 0) throw new RangeError("empty signature"); - const first = blob[0] ?? 0; - if ((first & 0x0f) === 6) { - const fieldType = readTypeSignature(blob, 1); - return { - ...raw, - kind: "field", - parse_status: fieldType.next === blob.length ? "decoded" : "partial", - calling_convention: callingConvention(first), - generic_parameter_count: null, - parameter_count: null, - return_type: null, - parameter_types: [], - field_type: fieldType.value, - issue: - fieldType.next === blob.length ? null : "Trailing signature data", - }; - } - let offset = 1; - let genericParameterCount: number | null = null; - if ((first & 0x10) !== 0) { - const generic = readCompressed(blob, offset); - genericParameterCount = generic.value; - offset = generic.next; - } - const parameterCount = readCompressed(blob, offset); - offset = parameterCount.next; - const returnType = readTypeSignature(blob, offset); - offset = returnType.next; - const parameters: string[] = []; - for (let index = 0; index < parameterCount.value; index += 1) { - const parameter = readTypeSignature(blob, offset); - parameters.push(parameter.value); - offset = parameter.next; - } - return { - ...raw, - kind: "method", - parse_status: offset === blob.length ? "decoded" : "partial", - calling_convention: callingConvention(first), - generic_parameter_count: genericParameterCount, - parameter_count: parameterCount.value, - return_type: returnType.value, - parameter_types: parameters, - field_type: null, - issue: offset === blob.length ? null : "Trailing signature data", - }; - } catch (cause: unknown) { - return { - ...raw, - kind: "unknown", - parse_status: cause instanceof RangeError ? "unsupported" : "malformed", - calling_convention: null, - generic_parameter_count: null, - parameter_count: null, - return_type: null, - parameter_types: [], - field_type: null, - issue: cause instanceof Error ? cause.message : "Signature parse failed", - }; - } -}; - -const opcode = ( - code: number, -): { readonly name: string; readonly operand: string } | undefined => - OPCODES.get(code); - -const OPCODES = new Map< - number, - { readonly name: string; readonly operand: string } ->([ - [0x00, { name: "nop", operand: "none" }], - [0x01, { name: "break", operand: "none" }], - [0x02, { name: "ldarg.0", operand: "none" }], - [0x03, { name: "ldarg.1", operand: "none" }], - [0x04, { name: "ldarg.2", operand: "none" }], - [0x05, { name: "ldarg.3", operand: "none" }], - [0x06, { name: "ldloc.0", operand: "none" }], - [0x07, { name: "ldloc.1", operand: "none" }], - [0x08, { name: "ldloc.2", operand: "none" }], - [0x09, { name: "ldloc.3", operand: "none" }], - [0x0a, { name: "stloc.0", operand: "none" }], - [0x0b, { name: "stloc.1", operand: "none" }], - [0x0c, { name: "stloc.2", operand: "none" }], - [0x0d, { name: "stloc.3", operand: "none" }], - [0x0e, { name: "ldarg.s", operand: "short-var" }], - [0x0f, { name: "ldarga.s", operand: "short-var" }], - [0x10, { name: "starg.s", operand: "short-var" }], - [0x11, { name: "ldloc.s", operand: "short-var" }], - [0x12, { name: "ldloca.s", operand: "short-var" }], - [0x13, { name: "stloc.s", operand: "short-var" }], - [0x14, { name: "ldnull", operand: "none" }], - [0x15, { name: "ldc.i4.m1", operand: "none" }], - [0x16, { name: "ldc.i4.0", operand: "none" }], - [0x17, { name: "ldc.i4.1", operand: "none" }], - [0x18, { name: "ldc.i4.2", operand: "none" }], - [0x19, { name: "ldc.i4.3", operand: "none" }], - [0x1a, { name: "ldc.i4.4", operand: "none" }], - [0x1b, { name: "ldc.i4.5", operand: "none" }], - [0x1c, { name: "ldc.i4.6", operand: "none" }], - [0x1d, { name: "ldc.i4.7", operand: "none" }], - [0x1e, { name: "ldc.i4.8", operand: "none" }], - [0x1f, { name: "ldc.i4.s", operand: "short-i" }], - [0x20, { name: "ldc.i4", operand: "i" }], - [0x21, { name: "ldc.i8", operand: "i8" }], - [0x22, { name: "ldc.r4", operand: "r4" }], - [0x23, { name: "ldc.r8", operand: "r8" }], - [0x25, { name: "dup", operand: "none" }], - [0x26, { name: "pop", operand: "none" }], - [0x27, { name: "jmp", operand: "method" }], - [0x28, { name: "call", operand: "method" }], - [0x29, { name: "calli", operand: "signature" }], - [0x2a, { name: "ret", operand: "none" }], - [0x2b, { name: "br.s", operand: "short-branch" }], - [0x2c, { name: "brfalse.s", operand: "short-branch" }], - [0x2d, { name: "brtrue.s", operand: "short-branch" }], - [0x2e, { name: "beq.s", operand: "short-branch" }], - [0x2f, { name: "bge.s", operand: "short-branch" }], - [0x30, { name: "bgt.s", operand: "short-branch" }], - [0x31, { name: "ble.s", operand: "short-branch" }], - [0x32, { name: "blt.s", operand: "short-branch" }], - [0x33, { name: "bne.un.s", operand: "short-branch" }], - [0x34, { name: "bge.un.s", operand: "short-branch" }], - [0x35, { name: "bgt.un.s", operand: "short-branch" }], - [0x36, { name: "ble.un.s", operand: "short-branch" }], - [0x37, { name: "blt.un.s", operand: "short-branch" }], - [0x38, { name: "br", operand: "branch" }], - [0x39, { name: "brfalse", operand: "branch" }], - [0x3a, { name: "brtrue", operand: "branch" }], - [0x3b, { name: "beq", operand: "branch" }], - [0x3c, { name: "bge", operand: "branch" }], - [0x3d, { name: "bgt", operand: "branch" }], - [0x3e, { name: "ble", operand: "branch" }], - [0x3f, { name: "blt", operand: "branch" }], - [0x40, { name: "bne.un", operand: "branch" }], - [0x41, { name: "bge.un", operand: "branch" }], - [0x42, { name: "bgt.un", operand: "branch" }], - [0x43, { name: "ble.un", operand: "branch" }], - [0x44, { name: "blt.un", operand: "branch" }], - [0x45, { name: "switch", operand: "switch" }], - [0x46, { name: "ldind.i1", operand: "none" }], - [0x47, { name: "ldind.u1", operand: "none" }], - [0x48, { name: "ldind.i2", operand: "none" }], - [0x49, { name: "ldind.u2", operand: "none" }], - [0x4a, { name: "ldind.i4", operand: "none" }], - [0x4b, { name: "ldind.u4", operand: "none" }], - [0x4c, { name: "ldind.i8", operand: "none" }], - [0x4d, { name: "ldind.i", operand: "none" }], - [0x4e, { name: "ldind.r4", operand: "none" }], - [0x4f, { name: "ldind.r8", operand: "none" }], - [0x50, { name: "ldind.ref", operand: "none" }], - [0x51, { name: "stind.ref", operand: "none" }], - [0x52, { name: "stind.i1", operand: "none" }], - [0x53, { name: "stind.i2", operand: "none" }], - [0x54, { name: "stind.i4", operand: "none" }], - [0x55, { name: "stind.i8", operand: "none" }], - [0x56, { name: "stind.r4", operand: "none" }], - [0x57, { name: "stind.r8", operand: "none" }], - [0x58, { name: "add", operand: "none" }], - [0x59, { name: "sub", operand: "none" }], - [0x5a, { name: "mul", operand: "none" }], - [0x5b, { name: "div", operand: "none" }], - [0x5c, { name: "div.un", operand: "none" }], - [0x5d, { name: "rem", operand: "none" }], - [0x5e, { name: "rem.un", operand: "none" }], - [0x5f, { name: "and", operand: "none" }], - [0x60, { name: "or", operand: "none" }], - [0x61, { name: "xor", operand: "none" }], - [0x62, { name: "shl", operand: "none" }], - [0x63, { name: "shr", operand: "none" }], - [0x64, { name: "shr.un", operand: "none" }], - [0x65, { name: "neg", operand: "none" }], - [0x66, { name: "not", operand: "none" }], - [0x67, { name: "conv.i1", operand: "none" }], - [0x68, { name: "conv.i2", operand: "none" }], - [0x69, { name: "conv.i4", operand: "none" }], - [0x6a, { name: "conv.i8", operand: "none" }], - [0x6b, { name: "conv.r4", operand: "none" }], - [0x6c, { name: "conv.r8", operand: "none" }], - [0x6d, { name: "conv.u4", operand: "none" }], - [0x6e, { name: "conv.u8", operand: "none" }], - [0x6f, { name: "callvirt", operand: "method" }], - [0x70, { name: "cpobj", operand: "type" }], - [0x71, { name: "ldobj", operand: "type" }], - [0x72, { name: "ldstr", operand: "string" }], - [0x73, { name: "newobj", operand: "method" }], - [0x74, { name: "castclass", operand: "type" }], - [0x75, { name: "isinst", operand: "type" }], - [0x76, { name: "conv.r.un", operand: "none" }], - [0x79, { name: "unbox", operand: "type" }], - [0x7a, { name: "throw", operand: "none" }], - [0x7b, { name: "ldfld", operand: "field" }], - [0x7c, { name: "ldflda", operand: "field" }], - [0x7d, { name: "stfld", operand: "field" }], - [0x7e, { name: "ldsfld", operand: "field" }], - [0x7f, { name: "ldsflda", operand: "field" }], - [0x80, { name: "stsfld", operand: "field" }], - [0x81, { name: "stobj", operand: "type" }], - [0x82, { name: "conv.ovf.i1.un", operand: "none" }], - [0x83, { name: "conv.ovf.i2.un", operand: "none" }], - [0x84, { name: "conv.ovf.i4.un", operand: "none" }], - [0x85, { name: "conv.ovf.i8.un", operand: "none" }], - [0x86, { name: "conv.ovf.u1.un", operand: "none" }], - [0x87, { name: "conv.ovf.u2.un", operand: "none" }], - [0x88, { name: "conv.ovf.u4.un", operand: "none" }], - [0x89, { name: "conv.ovf.u8.un", operand: "none" }], - [0x8a, { name: "conv.ovf.i.un", operand: "none" }], - [0x8b, { name: "conv.ovf.u.un", operand: "none" }], - [0x8c, { name: "box", operand: "type" }], - [0x8d, { name: "newarr", operand: "type" }], - [0x8e, { name: "ldlen", operand: "none" }], - [0x8f, { name: "ldelema", operand: "type" }], - [0x90, { name: "ldelem.i1", operand: "none" }], - [0x91, { name: "ldelem.u1", operand: "none" }], - [0x92, { name: "ldelem.i2", operand: "none" }], - [0x93, { name: "ldelem.u2", operand: "none" }], - [0x94, { name: "ldelem.i4", operand: "none" }], - [0x95, { name: "ldelem.u4", operand: "none" }], - [0x96, { name: "ldelem.i8", operand: "none" }], - [0x97, { name: "ldelem.i", operand: "none" }], - [0x98, { name: "ldelem.r4", operand: "none" }], - [0x99, { name: "ldelem.r8", operand: "none" }], - [0x9a, { name: "ldelem.ref", operand: "none" }], - [0x9b, { name: "stelem.i", operand: "none" }], - [0x9c, { name: "stelem.i1", operand: "none" }], - [0x9d, { name: "stelem.i2", operand: "none" }], - [0x9e, { name: "stelem.i4", operand: "none" }], - [0x9f, { name: "stelem.i8", operand: "none" }], - [0xa0, { name: "stelem.r4", operand: "none" }], - [0xa1, { name: "stelem.r8", operand: "none" }], - [0xa2, { name: "stelem.ref", operand: "none" }], - [0xa3, { name: "ldelem", operand: "type" }], - [0xa4, { name: "stelem", operand: "type" }], - [0xa5, { name: "unbox.any", operand: "type" }], - [0xb3, { name: "conv.ovf.i1", operand: "none" }], - [0xb4, { name: "conv.ovf.u1", operand: "none" }], - [0xb5, { name: "conv.ovf.i2", operand: "none" }], - [0xb6, { name: "conv.ovf.u2", operand: "none" }], - [0xb7, { name: "conv.ovf.i4", operand: "none" }], - [0xb8, { name: "conv.ovf.u4", operand: "none" }], - [0xb9, { name: "conv.ovf.i8", operand: "none" }], - [0xba, { name: "conv.ovf.u8", operand: "none" }], - [0xc2, { name: "refanyval", operand: "type" }], - [0xc3, { name: "ckfinite", operand: "none" }], - [0xc6, { name: "mkrefany", operand: "type" }], - [0xd0, { name: "ldtoken", operand: "token" }], - [0xd1, { name: "conv.u2", operand: "none" }], - [0xd2, { name: "conv.u1", operand: "none" }], - [0xd3, { name: "conv.i", operand: "none" }], - [0xd4, { name: "conv.ovf.i", operand: "none" }], - [0xd5, { name: "conv.ovf.u", operand: "none" }], - [0xd6, { name: "add.ovf", operand: "none" }], - [0xd7, { name: "add.ovf.un", operand: "none" }], - [0xd8, { name: "mul.ovf", operand: "none" }], - [0xd9, { name: "mul.ovf.un", operand: "none" }], - [0xda, { name: "sub.ovf", operand: "none" }], - [0xdb, { name: "sub.ovf.un", operand: "none" }], - [0xdc, { name: "endfinally", operand: "none" }], - [0xdd, { name: "leave", operand: "branch" }], - [0xde, { name: "leave.s", operand: "short-branch" }], - [0xdf, { name: "stind.i", operand: "none" }], - [0xe0, { name: "conv.u", operand: "none" }], - [0xfe00, { name: "arglist", operand: "none" }], - [0xfe01, { name: "ceq", operand: "none" }], - [0xfe02, { name: "cgt", operand: "none" }], - [0xfe03, { name: "cgt.un", operand: "none" }], - [0xfe04, { name: "clt", operand: "none" }], - [0xfe05, { name: "clt.un", operand: "none" }], - [0xfe06, { name: "ldftn", operand: "method" }], - [0xfe07, { name: "ldvirtftn", operand: "method" }], - [0xfe09, { name: "ldarg", operand: "var" }], - [0xfe0a, { name: "ldarga", operand: "var" }], - [0xfe0b, { name: "starg", operand: "var" }], - [0xfe0c, { name: "ldloc", operand: "var" }], - [0xfe0d, { name: "ldloca", operand: "var" }], - [0xfe0e, { name: "stloc", operand: "var" }], - [0xfe0f, { name: "localloc", operand: "none" }], - [0xfe11, { name: "endfilter", operand: "none" }], - [0xfe12, { name: "unaligned.", operand: "short-i" }], - [0xfe13, { name: "volatile.", operand: "none" }], - [0xfe14, { name: "tail.", operand: "none" }], - [0xfe15, { name: "initobj", operand: "type" }], - [0xfe16, { name: "constrained.", operand: "type" }], - [0xfe17, { name: "cpblk", operand: "none" }], - [0xfe18, { name: "initblk", operand: "none" }], - [0xfe1a, { name: "rethrow", operand: "none" }], - [0xfe1c, { name: "sizeof", operand: "type" }], - [0xfe1d, { name: "refanytype", operand: "none" }], - [0xfe1e, { name: "readonly.", operand: "none" }], -]); - -const tokenKind = (token: string): ManagedInstructionAnchor["operand_kind"] => { - if ( - token.startsWith("0x06") || - token.startsWith("0x0a") || - token.startsWith("0x2b") - ) - return "method"; - if (token.startsWith("0x04")) return "field"; - if (token.startsWith("0x70")) return "string"; - if (token.startsWith("0x11")) return "signature"; - if ( - token.startsWith("0x01") || - token.startsWith("0x02") || - token.startsWith("0x1b") - ) - return "type"; - return "token"; -}; - -type OperandResult = { - readonly next: number; - readonly anchorKind: ManagedInstructionAnchor["operand_kind"]; - readonly value: string | null; -}; - -type OperandReader = (il: Buffer, offset: number) => OperandResult; - -const FIXED_OPERAND_READERS = new Map([ - [ - "none", - (_il, offset) => ({ next: offset, anchorKind: "none", value: null }), - ], - [ - "short-var", - (il, offset) => ({ - next: offset + 1, - anchorKind: "variable", - value: String(il.readUInt8(offset)), - }), - ], - [ - "var", - (il, offset) => ({ - next: offset + 2, - anchorKind: "variable", - value: String(il.readUInt16LE(offset)), - }), - ], - [ - "short-i", - (il, offset) => ({ - next: offset + 1, - anchorKind: "constant", - value: String(il.readInt8(offset)), - }), - ], - [ - "i", - (il, offset) => ({ - next: offset + 4, - anchorKind: "constant", - value: String(il.readInt32LE(offset)), - }), - ], - [ - "i8", - (il, offset) => ({ - next: offset + 8, - anchorKind: "constant", - value: il.readBigInt64LE(offset).toString(), - }), - ], - [ - "r4", - (il, offset) => ({ - next: offset + 4, - anchorKind: "constant", - value: String(il.readFloatLE(offset)), - }), - ], - [ - "r8", - (il, offset) => ({ - next: offset + 8, - anchorKind: "constant", - value: String(il.readDoubleLE(offset)), - }), - ], - [ - "short-branch", - (il, offset) => ({ - next: offset + 1, - anchorKind: "branch", - value: String(offset + 1 + il.readInt8(offset)), - }), - ], - [ - "branch", - (il, offset) => ({ - next: offset + 4, - anchorKind: "branch", - value: String(offset + 4 + il.readInt32LE(offset)), - }), - ], -]); - -const TOKEN_OPERAND_KINDS = new Map< - string, - ManagedInstructionAnchor["operand_kind"] ->([ - ["method", "method"], - ["field", "field"], - ["type", "type"], - ["string", "string"], - ["signature", "signature"], -]); - -const readOperand = ( - il: Buffer, - offset: number, - kind: string, -): OperandResult => { - const fixed = FIXED_OPERAND_READERS.get(kind); - if (fixed !== undefined) return fixed(il, offset); - if (kind === "switch") { - const count = il.readUInt32LE(offset); - return { - next: offset + 4 + count * 4, - anchorKind: "switch", - value: String(count), - }; - } - const raw = il.readUInt32LE(offset); - const token = `0x${raw.toString(16).padStart(8, "0")}`; - const tokenOperandKind = TOKEN_OPERAND_KINDS.get(kind); - if (tokenOperandKind !== undefined) - return { next: offset + 4, anchorKind: tokenOperandKind, value: token }; - if (kind === "token") - return { next: offset + 4, anchorKind: tokenKind(token), value: token }; - return { next: offset, anchorKind: "unknown", value: null }; -}; - -const decodeInstructions = ( - il: Buffer, - limit: number, -): { - readonly parsed: readonly ParsedInstruction[]; - readonly count: number; - readonly truncated: number; - readonly issue: string | null; -} => { - const parsed: ParsedInstruction[] = []; - let offset = 0; - let issue: string | null = null; - try { - while (offset < il.length && parsed.length < limit) { - const start = offset; - const first = il.readUInt8(offset); - offset += 1; - const code = first === 0xfe ? 0xfe00 + il.readUInt8(offset++) : first; - const descriptor = opcode(code); - if (descriptor === undefined) { - issue = `Unsupported CIL opcode 0x${code.toString(16)} at IL offset ${String(start)}`; - break; - } - const operand = readOperand(il, offset, descriptor.operand); - if (operand.next < offset || operand.next > il.length) { - issue = `Instruction ${descriptor.name} at IL offset ${String(start)} leaves method body`; - break; - } - offset = operand.next; - parsed.push({ - offset: start, - opcode: descriptor.name, - operandKind: operand.anchorKind, - operand: operand.value, - }); - } - } catch (cause: unknown) { - issue = - cause instanceof Error ? cause.message : "Instruction decode failed"; - } - const truncated = offset < il.length && issue === null ? 1 : 0; - return { parsed, count: parsed.length, truncated, issue }; -}; - -const parseExceptionRegions = ( - bytes: Buffer, - offset: number, - methodEnd: number, -): ManagedExceptionRegion[] => { - if (offset >= methodEnd) return []; - const kind = bytes.readUInt8(offset); - if ((kind & 0x3f) !== 1) return []; - const fat = (kind & 0x40) !== 0; - const size = fat - ? bytes.readUInt32LE(offset) >>> 8 - : bytes.readUInt8(offset + 1); - const start = fat ? offset + 4 : offset + 4; - const clauseSize = fat ? 24 : 12; - if (size < 4 || offset > methodEnd - size) return []; - const count = Math.floor((size - 4) / clauseSize); - const regions: ManagedExceptionRegion[] = []; - for (let index = 0; index < count; index += 1) { - const clause = start + index * clauseSize; - if (fat) { - const flags = bytes.readUInt32LE(clause); - const extra = bytes.readUInt32LE(clause + 20); - regions.push({ - flags, - try_offset: bytes.readUInt32LE(clause + 4), - try_length: bytes.readUInt32LE(clause + 8), - handler_offset: bytes.readUInt32LE(clause + 12), - handler_length: bytes.readUInt32LE(clause + 16), - class_token: - flags === 0 ? `0x${extra.toString(16).padStart(8, "0")}` : null, - filter_offset: flags === 1 ? extra : null, - }); - } else { - const flags = bytes.readUInt16LE(clause); - const extra = bytes.readUInt32LE(clause + 8); - regions.push({ - flags, - try_offset: bytes.readUInt16LE(clause + 2), - try_length: bytes.readUInt8(clause + 4), - handler_offset: bytes.readUInt16LE(clause + 5), - handler_length: bytes.readUInt8(clause + 7), - class_token: - flags === 0 ? `0x${extra.toString(16).padStart(8, "0")}` : null, - filter_offset: flags === 1 ? extra : null, - }); - } - } - return regions; -}; - -interface MethodBodyHeader { - readonly format: "tiny" | "fat"; - readonly size: number; - readonly flags: number; - readonly maxStack: number; - readonly ilSize: number; - readonly localSig: number; -} - -const readMethodBodyHeader = ( - bytes: Buffer, - offset: number, -): MethodBodyHeader => { - const first = bytes.readUInt8(offset); - if ((first & 3) === 2) - return { - format: "tiny", - size: 1, - flags: 0, - maxStack: 8, - ilSize: first >> 2, - localSig: 0, - }; - if ((first & 3) !== 3) throw new RangeError("Unsupported method body header"); - if (offset > bytes.length - 12) - throw new RangeError("Fat method body header leaves artifact"); - const flagsAndSize = bytes.readUInt16LE(offset); - const headerDwords = flagsAndSize >>> 12; - if (headerDwords < 3) - throw new RangeError("Fat method body header is smaller than 12 bytes"); - const size = headerDwords * 4; - if (offset > bytes.length - size) - throw new RangeError("Method body header leaves artifact"); - return { - format: "fat", - size, - flags: flagsAndSize & 0x0fff, - maxStack: bytes.readUInt16LE(offset + 2), - ilSize: bytes.readUInt32LE(offset + 4), - localSig: bytes.readUInt32LE(offset + 8), - }; -}; - -const methodBody = ( - bytes: Buffer, - pe: ManagedPeLayout, - rva: number, - limits: ManagedMemberInspectionLimits, -): ManagedMethodBody => { - if (rva === 0) - return { - status: "absent", - header_format: "none", - rva, - file_offset: null, - max_stack: null, - init_locals: null, - local_var_sig_token: null, - il_size: 0, - il_sha256: null, - normalized_il_sha256: null, - instruction_count: 0, - decoded_instruction_count: 0, - truncated_instructions: 0, - opcode_counts: {}, - anchors: [], - exception_regions: [], - issue: null, - }; - try { - const offset = pe.rvaToOffset(rva, 1, "method.body"); - const header = readMethodBodyHeader(bytes, offset); - if (header.ilSize > limits.maxMethodBodyBytes) - return { - status: "too-large", - header_format: header.format, - rva, - file_offset: offset, - max_stack: header.maxStack, - init_locals: (header.flags & 0x10) !== 0, - local_var_sig_token: - header.localSig === 0 - ? null - : `0x${header.localSig.toString(16).padStart(8, "0")}`, - il_size: header.ilSize, - il_sha256: null, - normalized_il_sha256: null, - instruction_count: 0, - decoded_instruction_count: 0, - truncated_instructions: 0, - opcode_counts: {}, - anchors: [], - exception_regions: [], - issue: `Method body exceeds max_method_body_bytes ${String(limits.maxMethodBodyBytes)}`, - }; - const ilOffset = offset + header.size; - if (ilOffset > bytes.length - header.ilSize) - throw new RangeError("Method IL bytes leave artifact"); - const il = bytes.subarray(ilOffset, ilOffset + header.ilSize); - const decoded = decodeInstructions(il, limits.maxMethodInstructions); - const opcodeCounts: Record = {}; - for (const instruction of decoded.parsed) - opcodeCounts[instruction.opcode] = - (opcodeCounts[instruction.opcode] ?? 0) + 1; - const anchors = decoded.parsed - .map((instruction, index) => ({ instruction, index })) - .filter(({ instruction }) => instruction.operandKind !== "none") - .slice(0, limits.instructionAnchorLimit) - .map(({ instruction }) => ({ - il_offset: instruction.offset, - opcode: instruction.opcode, - operand_kind: instruction.operandKind, - operand: instruction.operand, - })); - const normalized = Buffer.from( - JSON.stringify( - decoded.parsed.map(({ opcode, operandKind, operand }) => [ - opcode, - operandKind, - operand, - ]), - ), - "utf8", - ); - const methodEnd = ilOffset + header.ilSize; - const sectionOffset = (methodEnd + 3) & ~3; - const status = - decoded.issue !== null - ? "malformed" - : decoded.truncated > 0 - ? "partial" - : "present"; - return { - status, - header_format: header.format, - rva, - file_offset: offset, - max_stack: header.maxStack, - init_locals: (header.flags & 0x10) !== 0, - local_var_sig_token: - header.localSig === 0 - ? null - : `0x${header.localSig.toString(16).padStart(8, "0")}`, - il_size: header.ilSize, - il_sha256: sha256Bytes(il), - normalized_il_sha256: - status === "present" ? sha256Bytes(normalized) : null, - instruction_count: decoded.count, - decoded_instruction_count: decoded.parsed.length, - truncated_instructions: decoded.truncated, - opcode_counts: opcodeCounts, - anchors, - exception_regions: - header.format === "fat" && (header.flags & 8) !== 0 - ? parseExceptionRegions(bytes, sectionOffset, bytes.length) - : [], - issue: - decoded.issue ?? - (decoded.truncated > 0 - ? `Instruction decode reached max_method_instructions ${String(limits.maxMethodInstructions)} before the end of the method body` - : null), - }; - } catch (cause: unknown) { - return { - status: "malformed", - header_format: "unknown", - rva, - file_offset: null, - max_stack: null, - init_locals: null, - local_var_sig_token: null, - il_size: 0, - il_sha256: null, - normalized_il_sha256: null, - instruction_count: 0, - decoded_instruction_count: 0, - truncated_instructions: 0, - opcode_counts: {}, - anchors: [], - exception_regions: [], - issue: - cause instanceof Error ? cause.message : "Method body parse failed", - }; - } -}; - -const parseFields = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - ranges: readonly TypeRange[], - maxBytes: number, -): { - readonly fields: readonly ManagedField[]; - readonly core: ReadonlyMap; -} => { - const fields: ManagedField[] = []; - const core = new Map(); - const table = layout.table(4); - for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { - const cursor = rowCursor(bytes, layout, 4, row); - const flags = cursor.readUInt16(); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const sig = readMetadataBlob( - bytes, - layout, - cursor.readIndex(layout.blobIndexSize), - maxBytes, - ); - const declared = declaringType(ranges, "field", row); - const token = metadataToken(4, row); - fields.push({ - token, - row_offset: cursor.start, - declaring_type_token: declared?.token ?? null, - declaring_type: declared?.fullName ?? null, - name, - flags, - signature: signature(sig), - }); - core.set(token, { token, name }); - } - return { fields, core }; -}; - -const parseMemberRefs = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - maxBytes: number, -): { - readonly refs: readonly ManagedMemberRef[]; - readonly core: ReadonlyMap; -} => { - const refs: ManagedMemberRef[] = []; - const core = new Map(); - const table = layout.table(10); - for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { - const cursor = rowCursor(bytes, layout, 10, row); - const parentRaw = cursor.readIndex( - layout.codedIndexSize("MemberRefParent"), - ); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const sig = readMetadataBlob( - bytes, - layout, - cursor.readIndex(layout.blobIndexSize), - maxBytes, - ); - const token = metadataToken(10, row); - refs.push({ - token, - row_offset: cursor.start, - parent_token: codedToken(parentRaw, 3, [2, 1, 26, 6, 27]), - name, - signature: signature(sig), - }); - core.set(token, { token, name }); - } - return { refs, core }; -}; - -const parseMethods = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - pe: ManagedPeLayout, - ranges: readonly TypeRange[], - maxBytes: number, - limits: ManagedMemberInspectionLimits, -): { - readonly methods: readonly ManagedMethod[]; - readonly core: ReadonlyMap; -} => { - const methods: ManagedMethod[] = []; - const core = new Map(); - const table = layout.table(6); - for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { - const cursor = rowCursor(bytes, layout, 6, row); - const rva = cursor.readUInt32(); - const implFlags = cursor.readUInt16(); - const flags = cursor.readUInt16(); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const sig = readMetadataBlob( - bytes, - layout, - cursor.readIndex(layout.blobIndexSize), - maxBytes, - ); - cursor.readIndex(layout.tableIndexSize(8)); - const declared = declaringType(ranges, "method", row); - const token = metadataToken(6, row); - methods.push({ - token, - row_offset: cursor.start, - declaring_type_token: declared?.token ?? null, - declaring_type: declared?.fullName ?? null, - name, - rva, - impl_flags: implFlags, - flags, - signature: signature(sig), - body: methodBody(bytes, pe, rva, limits), - }); - core.set(token, { token, name, declaringType: declared?.fullName ?? null }); - } - return { methods, core }; -}; - -const targetKind = (token: string): ManagedCallEdge["target_kind"] => - token.startsWith("0x06") - ? "method-def" - : token.startsWith("0x0a") - ? "member-ref" - : token.startsWith("0x2b") - ? "method-spec" - : "unknown"; - -const edges = ( - methods: readonly ManagedMethod[], - methodCore: ReadonlyMap, - fieldCore: ReadonlyMap, - refCore: ReadonlyMap, -): { - readonly callEdges: readonly ManagedCallEdge[]; - readonly fieldAccesses: readonly ManagedFieldAccess[]; -} => { - const callEdges: ManagedCallEdge[] = []; - const fieldAccesses: ManagedFieldAccess[] = []; - for (const method of methods) { - for (const anchor of method.body.anchors) { - if (anchor.operand_kind === "method" && anchor.operand !== null) { - const named = - methodCore.get(anchor.operand)?.name ?? - refCore.get(anchor.operand)?.name ?? - null; - callEdges.push({ - caller_token: method.token, - caller: - method.declaring_type === null - ? method.name - : `${method.declaring_type}.${method.name}`, - opcode: anchor.opcode, - target_token: anchor.operand, - target_kind: targetKind(anchor.operand), - target_name: named, - }); - } - if (anchor.operand_kind === "field" && anchor.operand !== null) { - fieldAccesses.push({ - method_token: method.token, - method: - method.declaring_type === null - ? method.name - : `${method.declaring_type}.${method.name}`, - opcode: anchor.opcode, - field_token: anchor.operand, - field_name: fieldCore.get(anchor.operand)?.name ?? null, - }); - } - } - } - return { callEdges, fieldAccesses }; -}; +export type { ManagedMemberInspectionLimits } from "./ManagedMemberInspectorCore.js"; const unavailable = ( target: BinaryTarget, @@ -1392,6 +95,68 @@ const resourceDirectory = ( }; }; +const collectMethodBodyIssues = ( + methods: ReturnType, + limits: ManagedMemberInspectionLimits, +): ManagedParseIssue[] => + methods.methods.flatMap((method) => + method.body.status === "partial" + ? [ + { + code: "limit-exceeded" as const, + scope: `method.${method.token}.body.instructions`, + offset: method.body.file_offset, + detail: + method.body.issue ?? + `Instruction decode reached max_method_instructions ${String(limits.maxMethodInstructions)}`, + }, + ] + : [], + ); + +const readMemberInventory = ( + bytes: Buffer, + pe: ManagedPeLayout, + limits: ManagedMemberInspectionLimits, +) => { + const cli = pe.cli; + if (cli === null) + throw new TypeError("Managed inventory requires CLI metadata"); + const rootOffset = pe.rvaToOffset( + cli.metadata.rva, + cli.metadata.size, + "cli.metadata", + ); + if (cli.metadata.size > limits.maxMetadataBytes) + throw managedFailure( + "limit-exceeded", + "metadata.root", + `CLI metadata size exceeds max_metadata_bytes ${String(limits.maxMetadataBytes)}`, + rootOffset, + ); + const layout = readManagedMetadataLayout( + bytes, + rootOffset, + cli.metadata.size, + limits.maxTableRows, + ); + const inventory = readManagedMetadataInventory( + bytes, + layout, + { + referenceOffset: 0, + referenceLimit: 1, + resourceOffset: 0, + resourceLimit: 1, + attributeOffset: 0, + attributeLimit: 1, + maxHeapItemBytes: limits.maxHeapItemBytes, + }, + resourceDirectory(pe), + ); + return { layout, inventory }; +}; + /** Inspect metadata members and method bodies without loading target code. */ export const inspectManagedMembersBytes = ( bytes: Buffer, @@ -1402,66 +167,21 @@ export const inspectManagedMembersBytes = ( if (pe.cli === null) return unavailable(target, bytes, limits, pe.cliIssue); const issues: ManagedParseIssue[] = []; try { - const rootOffset = pe.rvaToOffset( - pe.cli.metadata.rva, - pe.cli.metadata.size, - "cli.metadata", - ); - if (pe.cli.metadata.size > limits.maxMetadataBytes) - throw managedFailure( - "limit-exceeded", - "metadata.root", - `CLI metadata size exceeds max_metadata_bytes ${String(limits.maxMetadataBytes)}`, - rootOffset, - ); - const layout = readManagedMetadataLayout( - bytes, - rootOffset, - pe.cli.metadata.size, - limits.maxTableRows, - ); - const inventory = readManagedMetadataInventory( - bytes, - layout, - { - referenceOffset: 0, - referenceLimit: 1, - resourceOffset: 0, - resourceLimit: 1, - attributeOffset: 0, - attributeLimit: 1, - maxHeapItemBytes: limits.maxHeapItemBytes, - }, - resourceDirectory(pe), - ); + const { layout, inventory } = readMemberInventory(bytes, pe, limits); issues.push(...inventory.issues); const ranges = typeRanges(bytes, layout, limits.maxHeapItemBytes); const types = parseTypes(bytes, layout, ranges, limits.maxHeapItemBytes); const fields = parseFields(bytes, layout, ranges, limits.maxHeapItemBytes); const memberRefs = parseMemberRefs(bytes, layout, limits.maxHeapItemBytes); - const methods = parseMethods( + const methods = parseMethods({ bytes, layout, pe, ranges, - limits.maxHeapItemBytes, + maxBytes: limits.maxHeapItemBytes, limits, - ); - const methodBodyIssues: ManagedParseIssue[] = methods.methods.flatMap( - (method) => - method.body.status === "partial" - ? [ - { - code: "limit-exceeded" as const, - scope: `method.${method.token}.body.instructions`, - offset: method.body.file_offset, - detail: - method.body.issue ?? - `Instruction decode reached max_method_instructions ${String(limits.maxMethodInstructions)}`, - }, - ] - : [], - ); + }); + const methodBodyIssues = collectMethodBodyIssues(methods, limits); const coverageIssues = [...issues, ...methodBodyIssues]; const related = edges( methods.methods, diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts new file mode 100644 index 0000000000..78b7e2fd4b --- /dev/null +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -0,0 +1,477 @@ +import type { ManagedMemberInspection } from "../domain/managedArtifact.js"; +import { + metadataRowOffset, + type ManagedMetadataLayout, + type MetadataTableLayout, +} from "./ManagedMetadataLayout.js"; +import { + MetadataRowCursor, + metadataToken, + readMetadataString, + sha256Bytes, +} from "./ManagedMetadataHeaps.js"; +import { managedFailure } from "./ManagedReaderFailure.js"; + +export type ManagedPage = { + readonly items: readonly Item[]; + readonly offset: number; + readonly limit: number; + readonly total: number; + readonly returned: number; + readonly dropped: number; + readonly complete: boolean; +}; +export type ManagedType = ManagedMemberInspection["types"]["items"][number]; +export type ManagedField = ManagedMemberInspection["fields"]["items"][number]; +export type ManagedMethod = ManagedMemberInspection["methods"]["items"][number]; +export type ManagedMemberRef = + ManagedMemberInspection["member_refs"]["items"][number]; +export type ManagedCallEdge = + ManagedMemberInspection["call_edges"]["items"][number]; +export type ManagedFieldAccess = + ManagedMemberInspection["field_accesses"]["items"][number]; +export type ManagedSignature = ManagedMethod["signature"]; +export type ManagedMethodBody = ManagedMethod["body"]; +export type ManagedInstructionAnchor = ManagedMethodBody["anchors"][number]; +export type ManagedExceptionRegion = + ManagedMethodBody["exception_regions"][number]; + +export interface ManagedMemberInspectionLimits { + readonly typeOffset: number; + readonly typeLimit: number; + readonly methodOffset: number; + readonly methodLimit: number; + readonly fieldOffset: number; + readonly fieldLimit: number; + readonly memberRefOffset: number; + readonly memberRefLimit: number; + readonly edgeOffset: number; + readonly edgeLimit: number; + readonly instructionAnchorLimit: number; + readonly maxMetadataBytes: number; + readonly maxTableRows: number; + readonly maxHeapItemBytes: number; + readonly maxMethodBodyBytes: number; + readonly maxMethodInstructions: number; +} + +export interface TypeRange { + readonly token: string; + readonly fullName: string; + readonly fieldStart: number; + readonly fieldEnd: number; + readonly methodStart: number; + readonly methodEnd: number; +} + +export interface MethodCore { + readonly token: string; + readonly name: string; + readonly declaringType: string | null; +} + +export interface FieldCore { + readonly token: string; + readonly name: string; +} + +export interface MemberRefCore { + readonly token: string; + readonly name: string; +} + +export interface ParsedInstruction { + readonly offset: number; + readonly opcode: string; + readonly operandKind: ManagedInstructionAnchor["operand_kind"]; + readonly operand: string | null; +} + +export const emptyPage = ( + offset: number, + limit: number, +): ManagedPage => ({ + items: [], + offset, + limit, + total: 0, + returned: 0, + dropped: 0, + complete: offset === 0, +}); + +export const page = ( + all: readonly Item[], + offset: number, + limit: number, +): ManagedPage => { + const items = all.slice(offset, offset + limit); + return { + items, + offset, + limit, + total: all.length, + returned: items.length, + dropped: all.length - items.length, + complete: offset === 0 && offset + limit >= all.length, + }; +}; + +export const rowCursor = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + table: number, + row: number, +): MetadataRowCursor => { + const descriptor = layout.table(table); + const start = metadataRowOffset(layout, table, row); + if (descriptor === undefined) + throw managedFailure( + "invalid-row", + `metadata.${String(table)}`, + "Metadata table is absent", + start, + ); + return new MetadataRowCursor( + bytes, + start, + start + descriptor.rowSize, + `metadata.${descriptor.name}`, + ); +}; + +export const codedToken = ( + raw: number, + bits: number, + tables: readonly (number | undefined)[], +): string | null => { + if (raw === 0) return null; + const tag = raw & (2 ** bits - 1); + const row = Math.floor(raw / 2 ** bits); + const table = tables[tag]; + return table === undefined || row === 0 ? null : metadataToken(table, row); +}; + +const rowRange = ( + table: MetadataTableLayout | undefined, + start: number, + nextStart: number, +): { + readonly first: number | null; + readonly last: number | null; + readonly count: number; +} => { + const total = table?.rowCount ?? 0; + if (total === 0 || start === 0) return { first: null, last: null, count: 0 }; + const end = Math.min(nextStart === 0 ? total + 1 : nextStart, total + 1); + if (start >= end) return { first: null, last: null, count: 0 }; + return { first: start, last: end - 1, count: end - start }; +}; + +const fullName = (namespace: string, name: string): string => + namespace.length === 0 ? name : `${namespace}.${name}`; + +const readTypeRange = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + row: number, + maxBytes: number, +): TypeRange => { + const methods = layout.table(6); + const fields = layout.table(4); + const cursor = rowCursor(bytes, layout, 2, row); + cursor.readUInt32(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const namespace = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); + const fieldStart = cursor.readIndex(layout.tableIndexSize(4)); + const methodStart = cursor.readIndex(layout.tableIndexSize(6)); + let nextField = (fields?.rowCount ?? 0) + 1; + let nextMethod = (methods?.rowCount ?? 0) + 1; + const typeTable = layout.table(2); + if (typeTable !== undefined && row < typeTable.rowCount) { + const next = rowCursor(bytes, layout, 2, row + 1); + next.readUInt32(); + next.readIndex(layout.stringIndexSize); + next.readIndex(layout.stringIndexSize); + next.readIndex(layout.codedIndexSize("TypeDefOrRef")); + nextField = next.readIndex(layout.tableIndexSize(4)); + nextMethod = next.readIndex(layout.tableIndexSize(6)); + } + return { + token: metadataToken(2, row), + fullName: fullName(namespace, name), + fieldStart, + fieldEnd: nextField, + methodStart, + methodEnd: nextMethod, + }; +}; + +export const typeRanges = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + maxBytes: number, +): readonly TypeRange[] => { + const typeTable = layout.table(2); + const ranges: TypeRange[] = []; + for (let row = 1; row <= (typeTable?.rowCount ?? 0); row += 1) + ranges.push(readTypeRange(bytes, layout, row, maxBytes)); + return ranges; +}; + +export const declaringType = ( + ranges: readonly TypeRange[], + table: "field" | "method", + row: number, +): { readonly token: string; readonly fullName: string } | null => { + for (const range of ranges) { + const start = table === "field" ? range.fieldStart : range.methodStart; + const end = table === "field" ? range.fieldEnd : range.methodEnd; + if (row >= start && row < end) + return { token: range.token, fullName: range.fullName }; + } + return null; +}; + +export const parseTypes = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + ranges: readonly TypeRange[], + maxBytes: number, +): readonly ManagedType[] => { + const typeTable = layout.table(2); + const fields = layout.table(4); + const methods = layout.table(6); + const items: ManagedType[] = []; + for (let row = 1; row <= (typeTable?.rowCount ?? 0); row += 1) { + const cursor = rowCursor(bytes, layout, 2, row); + const flags = cursor.readUInt32(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const namespace = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const extendsRaw = cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); + const fieldStart = cursor.readIndex(layout.tableIndexSize(4)); + const methodStart = cursor.readIndex(layout.tableIndexSize(6)); + const nextRange = ranges[row] ?? null; + const fieldRange = rowRange(fields, fieldStart, nextRange?.fieldStart ?? 0); + const methodRange = rowRange( + methods, + methodStart, + nextRange?.methodStart ?? 0, + ); + items.push({ + token: metadataToken(2, row), + row_offset: cursor.start, + namespace, + name, + full_name: fullName(namespace, name), + flags, + extends_token: codedToken(extendsRaw, 2, [2, 1, 27]), + field_list: { + first_row: fieldRange.first, + last_row: fieldRange.last, + count: fieldRange.count, + }, + method_list: { + first_row: methodRange.first, + last_row: methodRange.last, + count: methodRange.count, + }, + }); + } + return items; +}; + +export const readCompressed = ( + bytes: Buffer, + offset: number, +): { readonly value: number; readonly next: number } => { + const first = bytes[offset]; + if (first === undefined) throw new RangeError("truncated compressed integer"); + if ((first & 0x80) === 0) return { value: first, next: offset + 1 }; + const second = bytes[offset + 1]; + if ((first & 0xc0) === 0x80 && second !== undefined) + return { value: ((first & 0x3f) << 8) | second, next: offset + 2 }; + const b1 = bytes[offset + 1]; + const b2 = bytes[offset + 2]; + const b3 = bytes[offset + 3]; + if ( + (first & 0xe0) === 0xc0 && + b1 !== undefined && + b2 !== undefined && + b3 !== undefined + ) + return { + value: + (first & 0x1f) * 0x01_00_00_00 + b1 * 0x01_00_00 + b2 * 0x0100 + b3, + next: offset + 4, + }; + throw new RangeError("reserved compressed integer"); +}; + +const ELEMENT_TYPES = new Map([ + [0x01, "void"], + [0x02, "bool"], + [0x03, "char"], + [0x04, "i1"], + [0x05, "u1"], + [0x06, "i2"], + [0x07, "u2"], + [0x08, "i4"], + [0x09, "u4"], + [0x0a, "i8"], + [0x0b, "u8"], + [0x0c, "r4"], + [0x0d, "r8"], + [0x0e, "string"], + [0x18, "native-int"], + [0x19, "native-uint"], + [0x1c, "object"], +]); + +const ELEMENT_TYPE_SUFFIXES = new Map([ + [0x0f, "*"], + [0x10, "&"], +]); + +const readTypeSignature = ( + blob: Buffer, + offset: number, +): { readonly value: string; readonly next: number } => { + const kind = blob[offset]; + if (kind === undefined) throw new RangeError("truncated type signature"); + const named = ELEMENT_TYPES.get(kind); + if (named !== undefined) return { value: named, next: offset + 1 }; + const suffix = ELEMENT_TYPE_SUFFIXES.get(kind); + if (suffix !== undefined) { + const inner = readTypeSignature(blob, offset + 1); + return { value: `${inner.value}${suffix}`, next: inner.next }; + } + if (kind === 0x11 || kind === 0x12) { + const token = readCompressed(blob, offset + 1); + return { + value: `${kind === 0x11 ? "valuetype" : "class"}:${String(token.value)}`, + next: token.next, + }; + } + if (kind === 0x1d) { + const inner = readTypeSignature(blob, offset + 1); + return { value: `${inner.value}[]`, next: inner.next }; + } + if (kind === 0x1e) { + const variable = readCompressed(blob, offset + 1); + return { value: `mvar:${String(variable.value)}`, next: variable.next }; + } + if (kind === 0x13) { + const variable = readCompressed(blob, offset + 1); + return { value: `var:${String(variable.value)}`, next: variable.next }; + } + throw new RangeError(`unsupported element type 0x${kind.toString(16)}`); +}; + +const callingConvention = (value: number): string => { + const base = value & 0x0f; + const flags = [ + (value & 0x20) === 0 ? null : "has-this", + (value & 0x40) === 0 ? null : "explicit-this", + (value & 0x10) === 0 ? null : "generic", + ].filter((flag) => flag !== null); + const name = + base === 0 + ? "default" + : base === 5 + ? "vararg" + : base === 6 + ? "field" + : `unknown:${String(base)}`; + return flags.length === 0 ? name : `${name} ${flags.join(" ")}`; +}; + +export const signature = (blob: Buffer): ManagedSignature => { + const raw = { + raw_length: blob.length, + raw_sha256: sha256Bytes(blob), + }; + try { + if (blob.length === 0) throw new RangeError("empty signature"); + const first = blob[0] ?? 0; + if ((first & 0x0f) === 6) { + const fieldType = readTypeSignature(blob, 1); + return { + ...raw, + kind: "field", + parse_status: fieldType.next === blob.length ? "decoded" : "partial", + calling_convention: callingConvention(first), + generic_parameter_count: null, + parameter_count: null, + return_type: null, + parameter_types: [], + field_type: fieldType.value, + issue: + fieldType.next === blob.length ? null : "Trailing signature data", + }; + } + let offset = 1; + let genericParameterCount: number | null = null; + if ((first & 0x10) !== 0) { + const generic = readCompressed(blob, offset); + genericParameterCount = generic.value; + offset = generic.next; + } + const parameterCount = readCompressed(blob, offset); + offset = parameterCount.next; + const returnType = readTypeSignature(blob, offset); + offset = returnType.next; + const parameters: string[] = []; + for (let index = 0; index < parameterCount.value; index += 1) { + const parameter = readTypeSignature(blob, offset); + parameters.push(parameter.value); + offset = parameter.next; + } + return { + ...raw, + kind: "method", + parse_status: offset === blob.length ? "decoded" : "partial", + calling_convention: callingConvention(first), + generic_parameter_count: genericParameterCount, + parameter_count: parameterCount.value, + return_type: returnType.value, + parameter_types: parameters, + field_type: null, + issue: offset === blob.length ? null : "Trailing signature data", + }; + } catch (cause: unknown) { + return { + ...raw, + kind: "unknown", + parse_status: cause instanceof RangeError ? "unsupported" : "malformed", + calling_convention: null, + generic_parameter_count: null, + parameter_count: null, + return_type: null, + parameter_types: [], + field_type: null, + issue: cause instanceof Error ? cause.message : "Signature parse failed", + }; + } +}; diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts new file mode 100644 index 0000000000..1cc976bad0 --- /dev/null +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -0,0 +1,470 @@ +import type { + ManagedExceptionRegion, + ManagedInstructionAnchor, + ParsedInstruction, +} from "./ManagedMemberInspectorCore.js"; + +const opcode = ( + code: number, +): { readonly name: string; readonly operand: string } | undefined => + OPCODES.get(code); + +const OPCODES = new Map< + number, + { readonly name: string; readonly operand: string } +>([ + [0x00, { name: "nop", operand: "none" }], + [0x01, { name: "break", operand: "none" }], + [0x02, { name: "ldarg.0", operand: "none" }], + [0x03, { name: "ldarg.1", operand: "none" }], + [0x04, { name: "ldarg.2", operand: "none" }], + [0x05, { name: "ldarg.3", operand: "none" }], + [0x06, { name: "ldloc.0", operand: "none" }], + [0x07, { name: "ldloc.1", operand: "none" }], + [0x08, { name: "ldloc.2", operand: "none" }], + [0x09, { name: "ldloc.3", operand: "none" }], + [0x0a, { name: "stloc.0", operand: "none" }], + [0x0b, { name: "stloc.1", operand: "none" }], + [0x0c, { name: "stloc.2", operand: "none" }], + [0x0d, { name: "stloc.3", operand: "none" }], + [0x0e, { name: "ldarg.s", operand: "short-var" }], + [0x0f, { name: "ldarga.s", operand: "short-var" }], + [0x10, { name: "starg.s", operand: "short-var" }], + [0x11, { name: "ldloc.s", operand: "short-var" }], + [0x12, { name: "ldloca.s", operand: "short-var" }], + [0x13, { name: "stloc.s", operand: "short-var" }], + [0x14, { name: "ldnull", operand: "none" }], + [0x15, { name: "ldc.i4.m1", operand: "none" }], + [0x16, { name: "ldc.i4.0", operand: "none" }], + [0x17, { name: "ldc.i4.1", operand: "none" }], + [0x18, { name: "ldc.i4.2", operand: "none" }], + [0x19, { name: "ldc.i4.3", operand: "none" }], + [0x1a, { name: "ldc.i4.4", operand: "none" }], + [0x1b, { name: "ldc.i4.5", operand: "none" }], + [0x1c, { name: "ldc.i4.6", operand: "none" }], + [0x1d, { name: "ldc.i4.7", operand: "none" }], + [0x1e, { name: "ldc.i4.8", operand: "none" }], + [0x1f, { name: "ldc.i4.s", operand: "short-i" }], + [0x20, { name: "ldc.i4", operand: "i" }], + [0x21, { name: "ldc.i8", operand: "i8" }], + [0x22, { name: "ldc.r4", operand: "r4" }], + [0x23, { name: "ldc.r8", operand: "r8" }], + [0x25, { name: "dup", operand: "none" }], + [0x26, { name: "pop", operand: "none" }], + [0x27, { name: "jmp", operand: "method" }], + [0x28, { name: "call", operand: "method" }], + [0x29, { name: "calli", operand: "signature" }], + [0x2a, { name: "ret", operand: "none" }], + [0x2b, { name: "br.s", operand: "short-branch" }], + [0x2c, { name: "brfalse.s", operand: "short-branch" }], + [0x2d, { name: "brtrue.s", operand: "short-branch" }], + [0x2e, { name: "beq.s", operand: "short-branch" }], + [0x2f, { name: "bge.s", operand: "short-branch" }], + [0x30, { name: "bgt.s", operand: "short-branch" }], + [0x31, { name: "ble.s", operand: "short-branch" }], + [0x32, { name: "blt.s", operand: "short-branch" }], + [0x33, { name: "bne.un.s", operand: "short-branch" }], + [0x34, { name: "bge.un.s", operand: "short-branch" }], + [0x35, { name: "bgt.un.s", operand: "short-branch" }], + [0x36, { name: "ble.un.s", operand: "short-branch" }], + [0x37, { name: "blt.un.s", operand: "short-branch" }], + [0x38, { name: "br", operand: "branch" }], + [0x39, { name: "brfalse", operand: "branch" }], + [0x3a, { name: "brtrue", operand: "branch" }], + [0x3b, { name: "beq", operand: "branch" }], + [0x3c, { name: "bge", operand: "branch" }], + [0x3d, { name: "bgt", operand: "branch" }], + [0x3e, { name: "ble", operand: "branch" }], + [0x3f, { name: "blt", operand: "branch" }], + [0x40, { name: "bne.un", operand: "branch" }], + [0x41, { name: "bge.un", operand: "branch" }], + [0x42, { name: "bgt.un", operand: "branch" }], + [0x43, { name: "ble.un", operand: "branch" }], + [0x44, { name: "blt.un", operand: "branch" }], + [0x45, { name: "switch", operand: "switch" }], + [0x46, { name: "ldind.i1", operand: "none" }], + [0x47, { name: "ldind.u1", operand: "none" }], + [0x48, { name: "ldind.i2", operand: "none" }], + [0x49, { name: "ldind.u2", operand: "none" }], + [0x4a, { name: "ldind.i4", operand: "none" }], + [0x4b, { name: "ldind.u4", operand: "none" }], + [0x4c, { name: "ldind.i8", operand: "none" }], + [0x4d, { name: "ldind.i", operand: "none" }], + [0x4e, { name: "ldind.r4", operand: "none" }], + [0x4f, { name: "ldind.r8", operand: "none" }], + [0x50, { name: "ldind.ref", operand: "none" }], + [0x51, { name: "stind.ref", operand: "none" }], + [0x52, { name: "stind.i1", operand: "none" }], + [0x53, { name: "stind.i2", operand: "none" }], + [0x54, { name: "stind.i4", operand: "none" }], + [0x55, { name: "stind.i8", operand: "none" }], + [0x56, { name: "stind.r4", operand: "none" }], + [0x57, { name: "stind.r8", operand: "none" }], + [0x58, { name: "add", operand: "none" }], + [0x59, { name: "sub", operand: "none" }], + [0x5a, { name: "mul", operand: "none" }], + [0x5b, { name: "div", operand: "none" }], + [0x5c, { name: "div.un", operand: "none" }], + [0x5d, { name: "rem", operand: "none" }], + [0x5e, { name: "rem.un", operand: "none" }], + [0x5f, { name: "and", operand: "none" }], + [0x60, { name: "or", operand: "none" }], + [0x61, { name: "xor", operand: "none" }], + [0x62, { name: "shl", operand: "none" }], + [0x63, { name: "shr", operand: "none" }], + [0x64, { name: "shr.un", operand: "none" }], + [0x65, { name: "neg", operand: "none" }], + [0x66, { name: "not", operand: "none" }], + [0x67, { name: "conv.i1", operand: "none" }], + [0x68, { name: "conv.i2", operand: "none" }], + [0x69, { name: "conv.i4", operand: "none" }], + [0x6a, { name: "conv.i8", operand: "none" }], + [0x6b, { name: "conv.r4", operand: "none" }], + [0x6c, { name: "conv.r8", operand: "none" }], + [0x6d, { name: "conv.u4", operand: "none" }], + [0x6e, { name: "conv.u8", operand: "none" }], + [0x6f, { name: "callvirt", operand: "method" }], + [0x70, { name: "cpobj", operand: "type" }], + [0x71, { name: "ldobj", operand: "type" }], + [0x72, { name: "ldstr", operand: "string" }], + [0x73, { name: "newobj", operand: "method" }], + [0x74, { name: "castclass", operand: "type" }], + [0x75, { name: "isinst", operand: "type" }], + [0x76, { name: "conv.r.un", operand: "none" }], + [0x79, { name: "unbox", operand: "type" }], + [0x7a, { name: "throw", operand: "none" }], + [0x7b, { name: "ldfld", operand: "field" }], + [0x7c, { name: "ldflda", operand: "field" }], + [0x7d, { name: "stfld", operand: "field" }], + [0x7e, { name: "ldsfld", operand: "field" }], + [0x7f, { name: "ldsflda", operand: "field" }], + [0x80, { name: "stsfld", operand: "field" }], + [0x81, { name: "stobj", operand: "type" }], + [0x82, { name: "conv.ovf.i1.un", operand: "none" }], + [0x83, { name: "conv.ovf.i2.un", operand: "none" }], + [0x84, { name: "conv.ovf.i4.un", operand: "none" }], + [0x85, { name: "conv.ovf.i8.un", operand: "none" }], + [0x86, { name: "conv.ovf.u1.un", operand: "none" }], + [0x87, { name: "conv.ovf.u2.un", operand: "none" }], + [0x88, { name: "conv.ovf.u4.un", operand: "none" }], + [0x89, { name: "conv.ovf.u8.un", operand: "none" }], + [0x8a, { name: "conv.ovf.i.un", operand: "none" }], + [0x8b, { name: "conv.ovf.u.un", operand: "none" }], + [0x8c, { name: "box", operand: "type" }], + [0x8d, { name: "newarr", operand: "type" }], + [0x8e, { name: "ldlen", operand: "none" }], + [0x8f, { name: "ldelema", operand: "type" }], + [0x90, { name: "ldelem.i1", operand: "none" }], + [0x91, { name: "ldelem.u1", operand: "none" }], + [0x92, { name: "ldelem.i2", operand: "none" }], + [0x93, { name: "ldelem.u2", operand: "none" }], + [0x94, { name: "ldelem.i4", operand: "none" }], + [0x95, { name: "ldelem.u4", operand: "none" }], + [0x96, { name: "ldelem.i8", operand: "none" }], + [0x97, { name: "ldelem.i", operand: "none" }], + [0x98, { name: "ldelem.r4", operand: "none" }], + [0x99, { name: "ldelem.r8", operand: "none" }], + [0x9a, { name: "ldelem.ref", operand: "none" }], + [0x9b, { name: "stelem.i", operand: "none" }], + [0x9c, { name: "stelem.i1", operand: "none" }], + [0x9d, { name: "stelem.i2", operand: "none" }], + [0x9e, { name: "stelem.i4", operand: "none" }], + [0x9f, { name: "stelem.i8", operand: "none" }], + [0xa0, { name: "stelem.r4", operand: "none" }], + [0xa1, { name: "stelem.r8", operand: "none" }], + [0xa2, { name: "stelem.ref", operand: "none" }], + [0xa3, { name: "ldelem", operand: "type" }], + [0xa4, { name: "stelem", operand: "type" }], + [0xa5, { name: "unbox.any", operand: "type" }], + [0xb3, { name: "conv.ovf.i1", operand: "none" }], + [0xb4, { name: "conv.ovf.u1", operand: "none" }], + [0xb5, { name: "conv.ovf.i2", operand: "none" }], + [0xb6, { name: "conv.ovf.u2", operand: "none" }], + [0xb7, { name: "conv.ovf.i4", operand: "none" }], + [0xb8, { name: "conv.ovf.u4", operand: "none" }], + [0xb9, { name: "conv.ovf.i8", operand: "none" }], + [0xba, { name: "conv.ovf.u8", operand: "none" }], + [0xc2, { name: "refanyval", operand: "type" }], + [0xc3, { name: "ckfinite", operand: "none" }], + [0xc6, { name: "mkrefany", operand: "type" }], + [0xd0, { name: "ldtoken", operand: "token" }], + [0xd1, { name: "conv.u2", operand: "none" }], + [0xd2, { name: "conv.u1", operand: "none" }], + [0xd3, { name: "conv.i", operand: "none" }], + [0xd4, { name: "conv.ovf.i", operand: "none" }], + [0xd5, { name: "conv.ovf.u", operand: "none" }], + [0xd6, { name: "add.ovf", operand: "none" }], + [0xd7, { name: "add.ovf.un", operand: "none" }], + [0xd8, { name: "mul.ovf", operand: "none" }], + [0xd9, { name: "mul.ovf.un", operand: "none" }], + [0xda, { name: "sub.ovf", operand: "none" }], + [0xdb, { name: "sub.ovf.un", operand: "none" }], + [0xdc, { name: "endfinally", operand: "none" }], + [0xdd, { name: "leave", operand: "branch" }], + [0xde, { name: "leave.s", operand: "short-branch" }], + [0xdf, { name: "stind.i", operand: "none" }], + [0xe0, { name: "conv.u", operand: "none" }], + [0xfe00, { name: "arglist", operand: "none" }], + [0xfe01, { name: "ceq", operand: "none" }], + [0xfe02, { name: "cgt", operand: "none" }], + [0xfe03, { name: "cgt.un", operand: "none" }], + [0xfe04, { name: "clt", operand: "none" }], + [0xfe05, { name: "clt.un", operand: "none" }], + [0xfe06, { name: "ldftn", operand: "method" }], + [0xfe07, { name: "ldvirtftn", operand: "method" }], + [0xfe09, { name: "ldarg", operand: "var" }], + [0xfe0a, { name: "ldarga", operand: "var" }], + [0xfe0b, { name: "starg", operand: "var" }], + [0xfe0c, { name: "ldloc", operand: "var" }], + [0xfe0d, { name: "ldloca", operand: "var" }], + [0xfe0e, { name: "stloc", operand: "var" }], + [0xfe0f, { name: "localloc", operand: "none" }], + [0xfe11, { name: "endfilter", operand: "none" }], + [0xfe12, { name: "unaligned.", operand: "short-i" }], + [0xfe13, { name: "volatile.", operand: "none" }], + [0xfe14, { name: "tail.", operand: "none" }], + [0xfe15, { name: "initobj", operand: "type" }], + [0xfe16, { name: "constrained.", operand: "type" }], + [0xfe17, { name: "cpblk", operand: "none" }], + [0xfe18, { name: "initblk", operand: "none" }], + [0xfe1a, { name: "rethrow", operand: "none" }], + [0xfe1c, { name: "sizeof", operand: "type" }], + [0xfe1d, { name: "refanytype", operand: "none" }], + [0xfe1e, { name: "readonly.", operand: "none" }], +]); + +const tokenKind = (token: string): ManagedInstructionAnchor["operand_kind"] => { + if ( + token.startsWith("0x06") || + token.startsWith("0x0a") || + token.startsWith("0x2b") + ) + return "method"; + if (token.startsWith("0x04")) return "field"; + if (token.startsWith("0x70")) return "string"; + if (token.startsWith("0x11")) return "signature"; + if ( + token.startsWith("0x01") || + token.startsWith("0x02") || + token.startsWith("0x1b") + ) + return "type"; + return "token"; +}; + +type OperandResult = { + readonly next: number; + readonly anchorKind: ManagedInstructionAnchor["operand_kind"]; + readonly value: string | null; +}; + +type OperandReader = (il: Buffer, offset: number) => OperandResult; + +const FIXED_OPERAND_READERS = new Map([ + [ + "none", + (_il, offset) => ({ next: offset, anchorKind: "none", value: null }), + ], + [ + "short-var", + (il, offset) => ({ + next: offset + 1, + anchorKind: "variable", + value: String(il.readUInt8(offset)), + }), + ], + [ + "var", + (il, offset) => ({ + next: offset + 2, + anchorKind: "variable", + value: String(il.readUInt16LE(offset)), + }), + ], + [ + "short-i", + (il, offset) => ({ + next: offset + 1, + anchorKind: "constant", + value: String(il.readInt8(offset)), + }), + ], + [ + "i", + (il, offset) => ({ + next: offset + 4, + anchorKind: "constant", + value: String(il.readInt32LE(offset)), + }), + ], + [ + "i8", + (il, offset) => ({ + next: offset + 8, + anchorKind: "constant", + value: il.readBigInt64LE(offset).toString(), + }), + ], + [ + "r4", + (il, offset) => ({ + next: offset + 4, + anchorKind: "constant", + value: String(il.readFloatLE(offset)), + }), + ], + [ + "r8", + (il, offset) => ({ + next: offset + 8, + anchorKind: "constant", + value: String(il.readDoubleLE(offset)), + }), + ], + [ + "short-branch", + (il, offset) => ({ + next: offset + 1, + anchorKind: "branch", + value: String(offset + 1 + il.readInt8(offset)), + }), + ], + [ + "branch", + (il, offset) => ({ + next: offset + 4, + anchorKind: "branch", + value: String(offset + 4 + il.readInt32LE(offset)), + }), + ], +]); + +const TOKEN_OPERAND_KINDS = new Map< + string, + ManagedInstructionAnchor["operand_kind"] +>([ + ["method", "method"], + ["field", "field"], + ["type", "type"], + ["string", "string"], + ["signature", "signature"], +]); + +const readOperand = ( + il: Buffer, + offset: number, + kind: string, +): OperandResult => { + const fixed = FIXED_OPERAND_READERS.get(kind); + if (fixed !== undefined) return fixed(il, offset); + if (kind === "switch") { + const count = il.readUInt32LE(offset); + return { + next: offset + 4 + count * 4, + anchorKind: "switch", + value: String(count), + }; + } + const raw = il.readUInt32LE(offset); + const token = `0x${raw.toString(16).padStart(8, "0")}`; + const tokenOperandKind = TOKEN_OPERAND_KINDS.get(kind); + if (tokenOperandKind !== undefined) + return { next: offset + 4, anchorKind: tokenOperandKind, value: token }; + if (kind === "token") + return { next: offset + 4, anchorKind: tokenKind(token), value: token }; + return { next: offset, anchorKind: "unknown", value: null }; +}; + +export const decodeInstructions = ( + il: Buffer, + limit: number, +): { + readonly parsed: readonly ParsedInstruction[]; + readonly count: number; + readonly truncated: number; + readonly issue: string | null; +} => { + const parsed: ParsedInstruction[] = []; + let offset = 0; + let issue: string | null = null; + try { + while (offset < il.length && parsed.length < limit) { + const start = offset; + const first = il.readUInt8(offset); + offset += 1; + const code = first === 0xfe ? 0xfe00 + il.readUInt8(offset++) : first; + const descriptor = opcode(code); + if (descriptor === undefined) { + issue = `Unsupported CIL opcode 0x${code.toString(16)} at IL offset ${String(start)}`; + break; + } + const operand = readOperand(il, offset, descriptor.operand); + if (operand.next < offset || operand.next > il.length) { + issue = `Instruction ${descriptor.name} at IL offset ${String(start)} leaves method body`; + break; + } + offset = operand.next; + parsed.push({ + offset: start, + opcode: descriptor.name, + operandKind: operand.anchorKind, + operand: operand.value, + }); + } + } catch (cause: unknown) { + issue = + cause instanceof Error ? cause.message : "Instruction decode failed"; + } + const truncated = offset < il.length && issue === null ? 1 : 0; + return { parsed, count: parsed.length, truncated, issue }; +}; + +export const parseExceptionRegions = ( + bytes: Buffer, + offset: number, + methodEnd: number, +): ManagedExceptionRegion[] => { + if (offset >= methodEnd) return []; + const kind = bytes.readUInt8(offset); + if ((kind & 0x3f) !== 1) return []; + const fat = (kind & 0x40) !== 0; + const size = fat + ? bytes.readUInt32LE(offset) >>> 8 + : bytes.readUInt8(offset + 1); + const start = fat ? offset + 4 : offset + 4; + const clauseSize = fat ? 24 : 12; + if (size < 4 || offset > methodEnd - size) return []; + const count = Math.floor((size - 4) / clauseSize); + const regions: ManagedExceptionRegion[] = []; + for (let index = 0; index < count; index += 1) { + const clause = start + index * clauseSize; + if (fat) { + const flags = bytes.readUInt32LE(clause); + const extra = bytes.readUInt32LE(clause + 20); + regions.push({ + flags, + try_offset: bytes.readUInt32LE(clause + 4), + try_length: bytes.readUInt32LE(clause + 8), + handler_offset: bytes.readUInt32LE(clause + 12), + handler_length: bytes.readUInt32LE(clause + 16), + class_token: + flags === 0 ? `0x${extra.toString(16).padStart(8, "0")}` : null, + filter_offset: flags === 1 ? extra : null, + }); + } else { + const flags = bytes.readUInt16LE(clause); + const extra = bytes.readUInt32LE(clause + 8); + regions.push({ + flags, + try_offset: bytes.readUInt16LE(clause + 2), + try_length: bytes.readUInt8(clause + 4), + handler_offset: bytes.readUInt16LE(clause + 5), + handler_length: bytes.readUInt8(clause + 7), + class_token: + flags === 0 ? `0x${extra.toString(16).padStart(8, "0")}` : null, + filter_offset: flags === 1 ? extra : null, + }); + } + } + return regions; +}; diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts new file mode 100644 index 0000000000..3d81c53ff1 --- /dev/null +++ b/src/dotnet/ManagedMemberRows.ts @@ -0,0 +1,224 @@ +import type { ManagedPeLayout } from "./ManagedPeReader.js"; +import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { + codedToken, + declaringType, + rowCursor, + signature, + type FieldCore, + type ManagedCallEdge, + type ManagedField, + type ManagedFieldAccess, + type ManagedMemberInspectionLimits, + type ManagedMemberRef, + type ManagedMethod, + type MemberRefCore, + type MethodCore, + type TypeRange, +} from "./ManagedMemberInspectorCore.js"; +import { + metadataToken, + readMetadataBlob, + readMetadataString, +} from "./ManagedMetadataHeaps.js"; +import { methodBody } from "./ManagedMethodBodyReader.js"; + +export const parseFields = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + ranges: readonly TypeRange[], + maxBytes: number, +): { + readonly fields: readonly ManagedField[]; + readonly core: ReadonlyMap; +} => { + const fields: ManagedField[] = []; + const core = new Map(); + const table = layout.table(4); + for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + const cursor = rowCursor(bytes, layout, 4, row); + const flags = cursor.readUInt16(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const sig = readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + maxBytes, + ); + const declared = declaringType(ranges, "field", row); + const token = metadataToken(4, row); + fields.push({ + token, + row_offset: cursor.start, + declaring_type_token: declared?.token ?? null, + declaring_type: declared?.fullName ?? null, + name, + flags, + signature: signature(sig), + }); + core.set(token, { token, name }); + } + return { fields, core }; +}; + +export const parseMemberRefs = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + maxBytes: number, +): { + readonly refs: readonly ManagedMemberRef[]; + readonly core: ReadonlyMap; +} => { + const refs: ManagedMemberRef[] = []; + const core = new Map(); + const table = layout.table(10); + for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + const cursor = rowCursor(bytes, layout, 10, row); + const parentRaw = cursor.readIndex( + layout.codedIndexSize("MemberRefParent"), + ); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const sig = readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + maxBytes, + ); + const token = metadataToken(10, row); + refs.push({ + token, + row_offset: cursor.start, + parent_token: codedToken(parentRaw, 3, [2, 1, 26, 6, 27]), + name, + signature: signature(sig), + }); + core.set(token, { token, name }); + } + return { refs, core }; +}; + +interface ParseMethodsInput { + readonly bytes: Buffer; + readonly layout: ManagedMetadataLayout; + readonly pe: ManagedPeLayout; + readonly ranges: readonly TypeRange[]; + readonly maxBytes: number; + readonly limits: ManagedMemberInspectionLimits; +} + +export const parseMethods = ({ + bytes, + layout, + pe, + ranges, + maxBytes, + limits, +}: ParseMethodsInput): { + readonly methods: readonly ManagedMethod[]; + readonly core: ReadonlyMap; +} => { + const methods: ManagedMethod[] = []; + const core = new Map(); + const table = layout.table(6); + for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + const cursor = rowCursor(bytes, layout, 6, row); + const rva = cursor.readUInt32(); + const implFlags = cursor.readUInt16(); + const flags = cursor.readUInt16(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const sig = readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + maxBytes, + ); + cursor.readIndex(layout.tableIndexSize(8)); + const declared = declaringType(ranges, "method", row); + const token = metadataToken(6, row); + methods.push({ + token, + row_offset: cursor.start, + declaring_type_token: declared?.token ?? null, + declaring_type: declared?.fullName ?? null, + name, + rva, + impl_flags: implFlags, + flags, + signature: signature(sig), + body: methodBody(bytes, pe, rva, limits), + }); + core.set(token, { token, name, declaringType: declared?.fullName ?? null }); + } + return { methods, core }; +}; + +const targetKind = (token: string): ManagedCallEdge["target_kind"] => + token.startsWith("0x06") + ? "method-def" + : token.startsWith("0x0a") + ? "member-ref" + : token.startsWith("0x2b") + ? "method-spec" + : "unknown"; + +export const edges = ( + methods: readonly ManagedMethod[], + methodCore: ReadonlyMap, + fieldCore: ReadonlyMap, + refCore: ReadonlyMap, +): { + readonly callEdges: readonly ManagedCallEdge[]; + readonly fieldAccesses: readonly ManagedFieldAccess[]; +} => { + const callEdges: ManagedCallEdge[] = []; + const fieldAccesses: ManagedFieldAccess[] = []; + for (const method of methods) { + for (const anchor of method.body.anchors) { + if (anchor.operand_kind === "method" && anchor.operand !== null) { + const named = + methodCore.get(anchor.operand)?.name ?? + refCore.get(anchor.operand)?.name ?? + null; + callEdges.push({ + caller_token: method.token, + caller: + method.declaring_type === null + ? method.name + : `${method.declaring_type}.${method.name}`, + opcode: anchor.opcode, + target_token: anchor.operand, + target_kind: targetKind(anchor.operand), + target_name: named, + }); + } + if (anchor.operand_kind === "field" && anchor.operand !== null) { + fieldAccesses.push({ + method_token: method.token, + method: + method.declaring_type === null + ? method.name + : `${method.declaring_type}.${method.name}`, + opcode: anchor.opcode, + field_token: anchor.operand, + field_name: fieldCore.get(anchor.operand)?.name ?? null, + }); + } + } + } + return { callEdges, fieldAccesses }; +}; diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts new file mode 100644 index 0000000000..a117e51b39 --- /dev/null +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -0,0 +1,193 @@ +import type { ManagedPeLayout } from "./ManagedPeReader.js"; +import type { + ManagedMemberInspectionLimits, + ManagedMethodBody, +} from "./ManagedMemberInspectorCore.js"; +import { + decodeInstructions, + parseExceptionRegions, +} from "./ManagedMemberInstructionDecoder.js"; +import { sha256Bytes } from "./ManagedMetadataHeaps.js"; + +interface MethodBodyHeader { + readonly format: "tiny" | "fat"; + readonly size: number; + readonly flags: number; + readonly maxStack: number; + readonly ilSize: number; + readonly localSig: number; +} + +const readMethodBodyHeader = ( + bytes: Buffer, + offset: number, +): MethodBodyHeader => { + const first = bytes.readUInt8(offset); + if ((first & 3) === 2) + return { + format: "tiny", + size: 1, + flags: 0, + maxStack: 8, + ilSize: first >> 2, + localSig: 0, + }; + if ((first & 3) !== 3) throw new RangeError("Unsupported method body header"); + if (offset > bytes.length - 12) + throw new RangeError("Fat method body header leaves artifact"); + const flagsAndSize = bytes.readUInt16LE(offset); + const headerDwords = flagsAndSize >>> 12; + if (headerDwords < 3) + throw new RangeError("Fat method body header is smaller than 12 bytes"); + const size = headerDwords * 4; + if (offset > bytes.length - size) + throw new RangeError("Method body header leaves artifact"); + return { + format: "fat", + size, + flags: flagsAndSize & 0x0fff, + maxStack: bytes.readUInt16LE(offset + 2), + ilSize: bytes.readUInt32LE(offset + 4), + localSig: bytes.readUInt32LE(offset + 8), + }; +}; + +export const methodBody = ( + bytes: Buffer, + pe: ManagedPeLayout, + rva: number, + limits: ManagedMemberInspectionLimits, +): ManagedMethodBody => { + if (rva === 0) return emptyMethodBody(rva, "absent", null); + try { + const offset = pe.rvaToOffset(rva, 1, "method.body"); + const header = readMethodBodyHeader(bytes, offset); + if (header.ilSize > limits.maxMethodBodyBytes) + return oversizedMethodBody(header, rva, offset, limits); + const ilOffset = offset + header.size; + if (ilOffset > bytes.length - header.ilSize) + throw new RangeError("Method IL bytes leave artifact"); + const il = bytes.subarray(ilOffset, ilOffset + header.ilSize); + const decoded = decodeInstructions(il, limits.maxMethodInstructions); + const opcodeCounts: Record = {}; + for (const instruction of decoded.parsed) + opcodeCounts[instruction.opcode] = + (opcodeCounts[instruction.opcode] ?? 0) + 1; + const anchors = decoded.parsed + .map((instruction, index) => ({ instruction, index })) + .filter(({ instruction }) => instruction.operandKind !== "none") + .slice(0, limits.instructionAnchorLimit) + .map(({ instruction }) => ({ + il_offset: instruction.offset, + opcode: instruction.opcode, + operand_kind: instruction.operandKind, + operand: instruction.operand, + })); + const normalized = Buffer.from( + JSON.stringify( + decoded.parsed.map(({ opcode, operandKind, operand }) => [ + opcode, + operandKind, + operand, + ]), + ), + "utf8", + ); + const methodEnd = ilOffset + header.ilSize; + const sectionOffset = (methodEnd + 3) & ~3; + const status = + decoded.issue !== null + ? "malformed" + : decoded.truncated > 0 + ? "partial" + : "present"; + return { + status, + header_format: header.format, + rva, + file_offset: offset, + max_stack: header.maxStack, + init_locals: (header.flags & 0x10) !== 0, + local_var_sig_token: + header.localSig === 0 + ? null + : `0x${header.localSig.toString(16).padStart(8, "0")}`, + il_size: header.ilSize, + il_sha256: sha256Bytes(il), + normalized_il_sha256: + status === "present" ? sha256Bytes(normalized) : null, + instruction_count: decoded.count, + decoded_instruction_count: decoded.parsed.length, + truncated_instructions: decoded.truncated, + opcode_counts: opcodeCounts, + anchors, + exception_regions: + header.format === "fat" && (header.flags & 8) !== 0 + ? parseExceptionRegions(bytes, sectionOffset, bytes.length) + : [], + issue: + decoded.issue ?? + (decoded.truncated > 0 + ? `Instruction decode reached max_method_instructions ${String(limits.maxMethodInstructions)} before the end of the method body` + : null), + }; + } catch (cause: unknown) { + return emptyMethodBody( + rva, + "malformed", + cause instanceof Error ? cause.message : "Method body parse failed", + ); + } +}; + +const emptyMethodBody = ( + rva: number, + status: "absent" | "malformed", + issue: string | null, +): ManagedMethodBody => ({ + status, + header_format: status === "absent" ? "none" : "unknown", + rva, + file_offset: null, + max_stack: null, + init_locals: null, + local_var_sig_token: null, + il_size: 0, + il_sha256: null, + normalized_il_sha256: null, + instruction_count: 0, + decoded_instruction_count: 0, + truncated_instructions: 0, + opcode_counts: {}, + anchors: [], + exception_regions: [], + issue, +}); + +const oversizedMethodBody = ( + header: MethodBodyHeader, + rva: number, + offset: number, + limits: ManagedMemberInspectionLimits, +): ManagedMethodBody => ({ + status: "too-large", + header_format: header.format, + rva, + file_offset: offset, + max_stack: header.maxStack, + init_locals: (header.flags & 0x10) !== 0, + local_var_sig_token: + header.localSig === 0 + ? null + : `0x${header.localSig.toString(16).padStart(8, "0")}`, + il_size: header.ilSize, + il_sha256: null, + normalized_il_sha256: null, + instruction_count: 0, + decoded_instruction_count: 0, + truncated_instructions: 0, + opcode_counts: {}, + anchors: [], + exception_regions: [], + issue: `Method body exceeds max_method_body_bytes ${String(limits.maxMethodBodyBytes)}`, +}); diff --git a/tests/fixtures/managedPe.ts b/tests/fixtures/managedPe.ts index f5e9361962..04d6540627 100644 --- a/tests/fixtures/managedPe.ts +++ b/tests/fixtures/managedPe.ts @@ -111,13 +111,21 @@ const typeDefRow = ( const fieldRow = (name: number, signature: number): Buffer => Buffer.concat([u16(0x0001), u16(name), u16(signature)]); -const methodDefRow = ( - name: number, - signature: number, +interface MethodDefRowInput { + readonly name: number; + readonly signature: number; + readonly rva?: number; + readonly flags?: number; + readonly implFlags?: number; +} + +const methodDefRow = ({ + name, + signature, rva = 0, flags = 0x0016, implFlags = 0, -): Buffer => +}: MethodDefRowInput): Buffer => Buffer.concat([ u32(rva), u16(implFlags), @@ -265,9 +273,8 @@ const tablesStream = ( return Buffer.concat([header, ...counts, ...data]); }; -/** Build a source-owned PE32/CLI fixture without committing compiled binaries. */ -export const buildManagedPeFixture = ( - options: ManagedPeFixtureOptions = {}, +const buildManagedFixtureMetadata = ( + options: ManagedPeFixtureOptions, ): Buffer => { const strings = new StringHeap(); const blobs = new BlobHeap(); @@ -314,12 +321,12 @@ export const buildManagedPeFixture = ( [ 6, [ - methodDefRow( - methodName, - methodSignature, - 0x2800, - options.pinvoke === undefined ? 0x0016 : 0x2016, - ), + methodDefRow({ + name: methodName, + signature: methodSignature, + rva: 0x2800, + flags: options.pinvoke === undefined ? 0x0016 : 0x2016, + }), ], ], [10, [memberRefRow(constructorName, constructorSignature)]], @@ -352,6 +359,13 @@ export const buildManagedPeFixture = ( blobs.toBuffer(), ); if (options.corruptMetadataSignature === true) metadata.writeUInt32LE(0, 0); + return metadata; +}; + +const buildManagedFixtureImage = ( + options: ManagedPeFixtureOptions, + metadata: Buffer, +): Buffer => { const resourceData = options.resourceData ?? Buffer.from("resource-data"); const resourceDirectory = Buffer.concat([ u32(resourceData.length), @@ -411,6 +425,12 @@ export const buildManagedPeFixture = ( return image; }; +/** Build a source-owned PE32/CLI fixture without committing compiled binaries. */ +export const buildManagedPeFixture = ( + options: ManagedPeFixtureOptions = {}, +): Buffer => + buildManagedFixtureImage(options, buildManagedFixtureMetadata(options)); + /** Build a syntactically valid PE32 fixture with no CLI directory. */ export const buildNativePeFixture = (): Buffer => { const image = buildManagedPeFixture();