diff --git a/src/domain/managedApplicationGraph.ts b/src/domain/managedApplicationGraph.ts index 32b29da62..c3daed94e 100644 --- a/src/domain/managedApplicationGraph.ts +++ b/src/domain/managedApplicationGraph.ts @@ -1,16 +1,13 @@ import { createHash } from "node:crypto"; -import { basename } from "node:path"; import canonicalize from "canonicalize"; import { z } from "zod"; import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; import { - createJavaScriptApplicationEdge, createJavaScriptApplicationGraph, - createJavaScriptApplicationNode, javascriptApplicationGraphSchema, - type ApplicationGraphEvidence, + type ApplicationEdge, type ApplicationNode, } from "./javascriptApplicationGraph.js"; import { @@ -24,13 +21,16 @@ import { import type { JsonValue } from "./jsonValue.js"; import { assessManagedGraphOmissions, - completeManagedGraphCoverage, managedGraphEvidenceCoverage, managedGraphResultCoverage, - managedSourceCoverage, - managedSourcePageCoverage, totalManagedGraphOmitted, } from "./managedApplicationGraphCoverage.js"; +import { + addArtifactNode, + addArtifactIdentityNodes, + addBoundaryNodes, + addMemberNodes, +} from "./managedApplicationGraphNodes.js"; const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); @@ -117,15 +117,7 @@ export type ManagedApplicationGraphResult = z.infer< typeof managedApplicationGraphResultSchema >; -type ManagedMethod = ManagedMemberInspection["methods"]["items"][number]; -type ManagedField = ManagedMemberInspection["fields"]["items"][number]; -type ManagedType = ManagedMemberInspection["types"]["items"][number]; -type PinvokeImport = - ManagedNativeBoundaryInspection["pinvoke_imports"]["items"][number]; -type NativeImplementation = - ManagedNativeBoundaryInspection["native_implementations"]["items"][number]; - -interface ParsedManagedGraphInput { +export interface ParsedManagedGraphInput { readonly artifact: { readonly evidence: Evidence; readonly result: ManagedArtifactInspection; @@ -140,9 +132,9 @@ interface ParsedManagedGraphInput { } | null; } -interface GraphBuildState { +export interface GraphBuildState { readonly nodes: ApplicationNode[]; - readonly edges: ReturnType[]; + readonly edges: ApplicationEdge[]; readonly artifactSha256: string; readonly artifactPath: string; readonly evidenceLinks: string[]; @@ -151,11 +143,6 @@ interface GraphBuildState { readonly fieldNodes: Map; } -interface ContainsEdgeOptions { - readonly kind: string; - readonly coverage: ApplicationGraphEvidence["coverage"]; -} - const sha256 = (value: JsonValue): string => { const serialized = canonicalize(value); if (serialized === undefined) @@ -306,495 +293,6 @@ const assertSameArtifact = ( ); }; -const addArtifactNode = (state: GraphBuildState): ApplicationNode => { - const node = createJavaScriptApplicationNode({ - kind: "artifact", - identity: { - strategy: "content-digest", - stability: "global-exact", - sha256: state.artifactSha256, - }, - observations: [ - { - label: basename(state.artifactPath), - properties: { - format: "pe", - source: "managed-application-graph", - }, - evidence: managedEvidence(state, "project-managed-artifact"), - }, - ], - }); - state.nodes.push(node); - return node; -}; - -const addArtifactIdentityNodes = ( - state: GraphBuildState, - artifactNode: ApplicationNode, - parsed: ParsedManagedGraphInput, -): void => { - const artifact = parsed.artifact?.result; - const artifactCoverage = managedSourceCoverage( - parsed.artifact?.result.coverage.state ?? "complete", - ); - if (artifact?.assembly !== undefined && artifact.assembly !== null) { - const assembly = createJavaScriptApplicationNode({ - kind: "managed-assembly", - identity: artifactLocalIdentity( - state.artifactSha256, - "managed-assembly", - artifact.assembly.token, - ), - observations: [ - { - label: artifact.assembly.name, - properties: { - name: artifact.assembly.name, - version: artifact.assembly.version, - culture: artifact.assembly.culture, - public_key_kind: artifact.assembly.public_key.kind, - }, - evidence: managedEvidence( - state, - "inspect_managed_artifact", - artifactCoverage, - ), - }, - ], - }); - state.nodes.push(assembly); - addContainsEdge(state, artifactNode, assembly, { - kind: "declares-managed-assembly", - coverage: artifactCoverage, - }); - } - const module = - artifact?.module ?? - parsed.members?.result.module ?? - parsed.boundaries?.result.module; - if (module !== undefined && module !== null) { - const moduleCoverage = managedSourceCoverage(moduleCoverageState(parsed)); - const moduleNode = createJavaScriptApplicationNode({ - kind: "managed-module", - identity: artifactLocalIdentity( - state.artifactSha256, - "managed-module", - module.mvid ?? module.token, - ), - observations: [ - { - label: module.name, - properties: { - name: module.name, - mvid: module.mvid, - generation: module.generation, - token: module.token, - }, - evidence: managedEvidence( - state, - "inspect_managed_artifact", - moduleCoverage, - ), - }, - ], - }); - state.nodes.push(moduleNode); - addContainsEdge(state, artifactNode, moduleNode, { - kind: "declares-managed-module", - coverage: moduleCoverage, - }); - } -}; - -const addMemberNodes = ( - state: GraphBuildState, - artifactNode: ApplicationNode, - parsed: ParsedManagedGraphInput, - limits: ProjectManagedApplicationGraphInput["limits"], -) => { - const members = parsed.members?.result; - if (members === undefined) return { types: 0, methods: 0, fields: 0 }; - const types = members.types.items.slice(0, limits.max_types); - const methods = members.methods.items.slice(0, limits.max_methods); - const fields = members.fields.items.slice(0, limits.max_fields); - const typeCoverage = managedSourcePageCoverage( - members.coverage.state, - members.types, - "types", - ); - const methodCoverage = managedSourcePageCoverage( - members.coverage.state, - members.methods, - "methods", - ); - const fieldCoverage = managedSourcePageCoverage( - members.coverage.state, - members.fields, - "fields", - ); - for (const type of types) { - const node = typeNode(state, type, typeCoverage); - state.nodes.push(node); - state.typeNodes.set(type.token, node); - addContainsEdge(state, artifactNode, node, { - kind: "declares-managed-type", - coverage: typeCoverage, - }); - } - for (const method of methods) { - const node = methodNode(state, method, methodCoverage); - state.nodes.push(node); - state.methodNodes.set(method.token, node); - const owner = - method.declaring_type_token === null - ? artifactNode - : (state.typeNodes.get(method.declaring_type_token) ?? artifactNode); - addContainsEdge(state, owner, node, { - kind: "declares-managed-method", - coverage: methodCoverage, - }); - } - for (const field of fields) { - const node = fieldNode(state, field, fieldCoverage); - state.nodes.push(node); - state.fieldNodes.set(field.token, node); - const owner = - field.declaring_type_token === null - ? artifactNode - : (state.typeNodes.get(field.declaring_type_token) ?? artifactNode); - addContainsEdge(state, owner, node, { - kind: "declares-managed-field", - coverage: fieldCoverage, - }); - } - return { - types: types.length, - methods: methods.length, - fields: fields.length, - }; -}; - -const typeNode = ( - state: GraphBuildState, - type: ManagedType, - coverage: ApplicationGraphEvidence["coverage"], -): ApplicationNode => - createJavaScriptApplicationNode({ - kind: "managed-type", - identity: artifactLocalIdentity( - state.artifactSha256, - "managed-type-token", - type.token, - ), - observations: [ - { - label: type.full_name, - properties: { - token: type.token, - namespace: type.namespace, - name: type.name, - full_name: type.full_name, - flags: type.flags, - extends_token: type.extends_token, - }, - evidence: managedEvidence(state, "inspect_managed_members", coverage), - }, - ], - }); - -const methodNode = ( - state: GraphBuildState, - method: ManagedMethod, - coverage: ApplicationGraphEvidence["coverage"], -): ApplicationNode => - createJavaScriptApplicationNode({ - kind: "managed-method", - identity: artifactLocalIdentity( - state.artifactSha256, - "managed-method-token", - method.token, - ), - observations: [ - { - label: - method.declaring_type === null - ? method.name - : `${method.declaring_type}.${method.name}`, - properties: { - token: method.token, - declaring_type_token: method.declaring_type_token, - declaring_type: method.declaring_type, - name: method.name, - rva: method.rva, - signature_sha256: method.signature.raw_sha256, - signature_status: method.signature.parse_status, - body_status: method.body.status, - normalized_il_sha256: method.body.normalized_il_sha256, - il_size: method.body.il_size, - }, - evidence: managedEvidence(state, "inspect_managed_members", coverage), - }, - ], - }); - -const fieldNode = ( - state: GraphBuildState, - field: ManagedField, - coverage: ApplicationGraphEvidence["coverage"], -): ApplicationNode => - createJavaScriptApplicationNode({ - kind: "managed-field", - identity: artifactLocalIdentity( - state.artifactSha256, - "managed-field-token", - field.token, - ), - observations: [ - { - label: - field.declaring_type === null - ? field.name - : `${field.declaring_type}.${field.name}`, - properties: { - token: field.token, - declaring_type_token: field.declaring_type_token, - declaring_type: field.declaring_type, - name: field.name, - flags: field.flags, - signature_sha256: field.signature.raw_sha256, - signature_status: field.signature.parse_status, - }, - evidence: managedEvidence(state, "inspect_managed_members", coverage), - }, - ], - }); - -const addBoundaryNodes = ( - state: GraphBuildState, - artifactNode: ApplicationNode, - parsed: ParsedManagedGraphInput, - limits: ProjectManagedApplicationGraphInput["limits"], -) => { - const boundaries = parsed.boundaries?.result; - if (boundaries === undefined) - return { pinvoke_imports: 0, native_implementations: 0 }; - const pinvokes = boundaries.pinvoke_imports.items.slice( - 0, - limits.max_pinvoke_imports, - ); - const implementations = boundaries.native_implementations.items.slice( - 0, - limits.max_native_implementations, - ); - const pinvokeCoverage = managedSourcePageCoverage( - boundaries.coverage.state, - boundaries.pinvoke_imports, - "pinvoke_imports", - ); - const implementationCoverage = managedSourcePageCoverage( - boundaries.coverage.state, - boundaries.native_implementations, - "native_implementations", - ); - for (const pinvoke of pinvokes) { - const node = pinvokeNode(state, pinvoke, pinvokeCoverage); - state.nodes.push(node); - const owner = - pinvoke.member_token === null - ? artifactNode - : (state.methodNodes.get(pinvoke.member_token) ?? artifactNode); - state.edges.push( - createJavaScriptApplicationEdge({ - source_node_id: owner.node_id, - target_node_id: node.node_id, - relation: "imports", - properties: { - kind: "managed-pinvoke", - import_name: pinvoke.import_name, - import_scope_name: pinvoke.import_scope_name, - }, - evidence: managedEvidence( - state, - "inspect_managed_native_boundaries", - pinvokeCoverage, - ), - }), - ); - } - for (const implementation of implementations) { - const node = nativeImplementationNode( - state, - implementation, - implementationCoverage, - ); - state.nodes.push(node); - const owner = state.methodNodes.get(implementation.token) ?? artifactNode; - addContainsEdge(state, owner, node, { - kind: "declares-managed-native-implementation", - coverage: implementationCoverage, - }); - } - return { - pinvoke_imports: pinvokes.length, - native_implementations: implementations.length, - }; -}; - -const pinvokeNode = ( - state: GraphBuildState, - pinvoke: PinvokeImport, - coverage: ApplicationGraphEvidence["coverage"], -): ApplicationNode => - createJavaScriptApplicationNode({ - kind: "managed-pinvoke-import", - identity: artifactLocalIdentity( - state.artifactSha256, - "managed-pinvoke-token", - pinvoke.token, - ), - observations: [ - { - label: pinvoke.import_name, - properties: { - token: pinvoke.token, - member_token: pinvoke.member_token, - member_name: pinvoke.member_name, - import_name: pinvoke.import_name, - import_scope_name: pinvoke.import_scope_name, - char_set: pinvoke.char_set, - call_convention: pinvoke.call_convention, - verification: pinvoke.verification, - }, - evidence: managedEvidence( - state, - "inspect_managed_native_boundaries", - coverage, - ), - }, - ], - }); - -const nativeImplementationNode = ( - state: GraphBuildState, - implementation: NativeImplementation, - coverage: ApplicationGraphEvidence["coverage"], -): ApplicationNode => - createJavaScriptApplicationNode({ - kind: "managed-native-implementation", - identity: artifactLocalIdentity( - state.artifactSha256, - "managed-native-implementation-token", - implementation.token, - ), - observations: [ - { - label: implementation.name, - properties: { - token: implementation.token, - name: implementation.name, - rva: implementation.rva, - code_type: implementation.code_type, - managed_kind: implementation.managed_kind, - pinvoke_declared: implementation.pinvoke_declared, - boundary_kind: implementation.boundary_kind, - body_interpretation: implementation.body_interpretation, - }, - evidence: managedEvidence( - state, - "inspect_managed_native_boundaries", - coverage, - ), - }, - ], - }); - -const addContainsEdge = ( - state: GraphBuildState, - source: ApplicationNode, - target: ApplicationNode, - options: ContainsEdgeOptions, -): void => { - state.edges.push( - createJavaScriptApplicationEdge({ - source_node_id: source.node_id, - target_node_id: target.node_id, - relation: "contains", - properties: { kind: options.kind }, - evidence: managedEvidence( - state, - "project-managed-contains", - options.coverage, - ), - }), - ); -}; - -const artifactLocalIdentity = ( - artifactSha256: string, - namespace: string, - key: string, -) => ({ - strategy: "artifact-local-key" as const, - stability: "artifact-version" as const, - artifact_sha256: artifactSha256, - namespace, - key, -}); - -const managedEvidence = ( - state: GraphBuildState, - operation: string, - coverage: ApplicationGraphEvidence["coverage"] = completeManagedGraphCoverage(), -): ApplicationGraphEvidence => ({ - authority: "managed-static-analysis", - state: "observed", - confidence: "exact", - artifact: { - available: true, - artifact_id: `art_${state.artifactSha256}`, - sha256: state.artifactSha256, - }, - location: { - available: true, - value: { - kind: "artifact-path", - path: graphArtifactPath(state.artifactPath), - }, - }, - extractor: { - name: "rea-dotnet-static", - version: "1", - operation, - executable_sha256: null, - }, - coverage, - limitations: - coverage.status === "complete" - ? [] - : [ - "The source managed Evidence slice is incomplete; unreturned or unavailable items remain unobserved.", - ], - evidence_ids: state.evidenceLinks, -}); - -const graphArtifactPath = (path: string): string => { - const name = basename(path).replaceAll(/[^A-Za-z0-9._-]/gu, "_"); - return `managed/${name.length === 0 ? "artifact.pe" : name}`; -}; - -const moduleCoverageState = ( - parsed: ParsedManagedGraphInput, -): "complete" | "partial" | "unavailable" => { - const artifact = parsed.artifact; - if (artifact !== null && artifact.result.module !== null) - return artifact.result.coverage.state; - const members = parsed.members; - if (members !== null && members.result.module !== null) - return members.result.coverage.state; - return parsed.boundaries?.result.coverage.state ?? "complete"; -}; - const omittedCounts = ( parsed: ParsedManagedGraphInput, limits: ProjectManagedApplicationGraphInput["limits"], diff --git a/src/domain/managedApplicationGraphNodes.ts b/src/domain/managedApplicationGraphNodes.ts new file mode 100644 index 000000000..b4cef9872 --- /dev/null +++ b/src/domain/managedApplicationGraphNodes.ts @@ -0,0 +1,532 @@ +import { basename } from "node:path"; + +import { + createJavaScriptApplicationEdge, + createJavaScriptApplicationNode, + type ApplicationGraphEvidence, + type ApplicationNode, +} from "./javascriptApplicationGraph.js"; +import { + managedSourceCoverage, + managedSourcePageCoverage, + type ManagedGraphProjectionLimits, +} from "./managedApplicationGraphCoverage.js"; +import { + type ManagedMemberInspection, + type ManagedNativeBoundaryInspection, +} from "./managedArtifact.js"; +import type { GraphBuildState, ParsedManagedGraphInput } from "./managedApplicationGraph.js"; + +type ManagedMethod = ManagedMemberInspection["methods"]["items"][number]; +type ManagedField = ManagedMemberInspection["fields"]["items"][number]; +type ManagedType = ManagedMemberInspection["types"]["items"][number]; +type PinvokeImport = + ManagedNativeBoundaryInspection["pinvoke_imports"]["items"][number]; +type NativeImplementation = + ManagedNativeBoundaryInspection["native_implementations"]["items"][number]; + +/** Options for a managed contains relationship edge. */ +export interface ContainsEdgeOptions { + readonly kind: string; + readonly coverage: ApplicationGraphEvidence["coverage"]; +} + +/** Add the root managed artifact node to the graph state. */ +export const addArtifactNode = (state: GraphBuildState): ApplicationNode => { + const node = createJavaScriptApplicationNode({ + kind: "artifact", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: state.artifactSha256, + }, + observations: [ + { + label: basename(state.artifactPath), + properties: { + format: "pe", + source: "managed-application-graph", + }, + evidence: managedEvidence(state, "project-managed-artifact"), + }, + ], + }); + state.nodes.push(node); + return node; +}; + +/** Add managed assembly and module identity nodes for the artifact. */ +export const addArtifactIdentityNodes = ( + state: GraphBuildState, + artifactNode: ApplicationNode, + parsed: ParsedManagedGraphInput, +): void => { + const artifact = parsed.artifact?.result; + const artifactCoverage = managedSourceCoverage( + parsed.artifact?.result.coverage.state ?? "complete", + ); + if (artifact?.assembly !== undefined && artifact.assembly !== null) { + const assembly = createJavaScriptApplicationNode({ + kind: "managed-assembly", + identity: artifactLocalIdentity( + state.artifactSha256, + "managed-assembly", + artifact.assembly.token, + ), + observations: [ + { + label: artifact.assembly.name, + properties: { + name: artifact.assembly.name, + version: artifact.assembly.version, + culture: artifact.assembly.culture, + public_key_kind: artifact.assembly.public_key.kind, + }, + evidence: managedEvidence( + state, + "inspect_managed_artifact", + artifactCoverage, + ), + }, + ], + }); + state.nodes.push(assembly); + addContainsEdge(state, artifactNode, assembly, { + kind: "declares-managed-assembly", + coverage: artifactCoverage, + }); + } + const module = + artifact?.module ?? + parsed.members?.result.module ?? + parsed.boundaries?.result.module; + if (module !== undefined && module !== null) { + const moduleCoverage = managedSourceCoverage(moduleCoverageState(parsed)); + const moduleNode = createJavaScriptApplicationNode({ + kind: "managed-module", + identity: artifactLocalIdentity( + state.artifactSha256, + "managed-module", + module.mvid ?? module.token, + ), + observations: [ + { + label: module.name, + properties: { + name: module.name, + mvid: module.mvid, + generation: module.generation, + token: module.token, + }, + evidence: managedEvidence( + state, + "inspect_managed_artifact", + moduleCoverage, + ), + }, + ], + }); + state.nodes.push(moduleNode); + addContainsEdge(state, artifactNode, moduleNode, { + kind: "declares-managed-module", + coverage: moduleCoverage, + }); + } +}; + +/** Add managed type, method, and field nodes from member evidence. */ +export const addMemberNodes = ( + state: GraphBuildState, + artifactNode: ApplicationNode, + parsed: ParsedManagedGraphInput, + limits: ManagedGraphProjectionLimits, +) => { + const members = parsed.members?.result; + if (members === undefined) return { types: 0, methods: 0, fields: 0 }; + const types = members.types.items.slice(0, limits.max_types); + const methods = members.methods.items.slice(0, limits.max_methods); + const fields = members.fields.items.slice(0, limits.max_fields); + const typeCoverage = managedSourcePageCoverage( + members.coverage.state, + members.types, + "types", + ); + const methodCoverage = managedSourcePageCoverage( + members.coverage.state, + members.methods, + "methods", + ); + const fieldCoverage = managedSourcePageCoverage( + members.coverage.state, + members.fields, + "fields", + ); + for (const type of types) { + const node = typeNode(state, type, typeCoverage); + state.nodes.push(node); + state.typeNodes.set(type.token, node); + addContainsEdge(state, artifactNode, node, { + kind: "declares-managed-type", + coverage: typeCoverage, + }); + } + for (const method of methods) { + const node = methodNode(state, method, methodCoverage); + state.nodes.push(node); + state.methodNodes.set(method.token, node); + const owner = + method.declaring_type_token === null + ? artifactNode + : (state.typeNodes.get(method.declaring_type_token) ?? artifactNode); + addContainsEdge(state, owner, node, { + kind: "declares-managed-method", + coverage: methodCoverage, + }); + } + for (const field of fields) { + const node = fieldNode(state, field, fieldCoverage); + state.nodes.push(node); + state.fieldNodes.set(field.token, node); + const owner = + field.declaring_type_token === null + ? artifactNode + : (state.typeNodes.get(field.declaring_type_token) ?? artifactNode); + addContainsEdge(state, owner, node, { + kind: "declares-managed-field", + coverage: fieldCoverage, + }); + } + return { + types: types.length, + methods: methods.length, + fields: fields.length, + }; +}; + +/** Build a managed type application node. */ +export const typeNode = ( + state: GraphBuildState, + type: ManagedType, + coverage: ApplicationGraphEvidence["coverage"], +): ApplicationNode => + createJavaScriptApplicationNode({ + kind: "managed-type", + identity: artifactLocalIdentity( + state.artifactSha256, + "managed-type-token", + type.token, + ), + observations: [ + { + label: type.full_name, + properties: { + token: type.token, + namespace: type.namespace, + name: type.name, + full_name: type.full_name, + flags: type.flags, + extends_token: type.extends_token, + }, + evidence: managedEvidence(state, "inspect_managed_members", coverage), + }, + ], + }); + +/** Build a managed method application node. */ +export const methodNode = ( + state: GraphBuildState, + method: ManagedMethod, + coverage: ApplicationGraphEvidence["coverage"], +): ApplicationNode => + createJavaScriptApplicationNode({ + kind: "managed-method", + identity: artifactLocalIdentity( + state.artifactSha256, + "managed-method-token", + method.token, + ), + observations: [ + { + label: + method.declaring_type === null + ? method.name + : `${method.declaring_type}.${method.name}`, + properties: { + token: method.token, + declaring_type_token: method.declaring_type_token, + declaring_type: method.declaring_type, + name: method.name, + rva: method.rva, + signature_sha256: method.signature.raw_sha256, + signature_status: method.signature.parse_status, + body_status: method.body.status, + normalized_il_sha256: method.body.normalized_il_sha256, + il_size: method.body.il_size, + }, + evidence: managedEvidence(state, "inspect_managed_members", coverage), + }, + ], + }); + +/** Build a managed field application node. */ +export const fieldNode = ( + state: GraphBuildState, + field: ManagedField, + coverage: ApplicationGraphEvidence["coverage"], +): ApplicationNode => + createJavaScriptApplicationNode({ + kind: "managed-field", + identity: artifactLocalIdentity( + state.artifactSha256, + "managed-field-token", + field.token, + ), + observations: [ + { + label: + field.declaring_type === null + ? field.name + : `${field.declaring_type}.${field.name}`, + properties: { + token: field.token, + declaring_type_token: field.declaring_type_token, + declaring_type: field.declaring_type, + name: field.name, + flags: field.flags, + signature_sha256: field.signature.raw_sha256, + signature_status: field.signature.parse_status, + }, + evidence: managedEvidence(state, "inspect_managed_members", coverage), + }, + ], + }); + +/** Add P/Invoke import and native implementation boundary nodes. */ +export const addBoundaryNodes = ( + state: GraphBuildState, + artifactNode: ApplicationNode, + parsed: ParsedManagedGraphInput, + limits: ManagedGraphProjectionLimits, +) => { + const boundaries = parsed.boundaries?.result; + if (boundaries === undefined) + return { pinvoke_imports: 0, native_implementations: 0 }; + const pinvokes = boundaries.pinvoke_imports.items.slice( + 0, + limits.max_pinvoke_imports, + ); + const implementations = boundaries.native_implementations.items.slice( + 0, + limits.max_native_implementations, + ); + const pinvokeCoverage = managedSourcePageCoverage( + boundaries.coverage.state, + boundaries.pinvoke_imports, + "pinvoke_imports", + ); + const implementationCoverage = managedSourcePageCoverage( + boundaries.coverage.state, + boundaries.native_implementations, + "native_implementations", + ); + for (const pinvoke of pinvokes) { + const node = pinvokeNode(state, pinvoke, pinvokeCoverage); + state.nodes.push(node); + const owner = + pinvoke.member_token === null + ? artifactNode + : (state.methodNodes.get(pinvoke.member_token) ?? artifactNode); + state.edges.push( + createJavaScriptApplicationEdge({ + source_node_id: owner.node_id, + target_node_id: node.node_id, + relation: "imports", + properties: { + kind: "managed-pinvoke", + import_name: pinvoke.import_name, + import_scope_name: pinvoke.import_scope_name, + }, + evidence: managedEvidence( + state, + "inspect_managed_native_boundaries", + pinvokeCoverage, + ), + }), + ); + } + for (const implementation of implementations) { + const node = nativeImplementationNode( + state, + implementation, + implementationCoverage, + ); + state.nodes.push(node); + const owner = state.methodNodes.get(implementation.token) ?? artifactNode; + addContainsEdge(state, owner, node, { + kind: "declares-managed-native-implementation", + coverage: implementationCoverage, + }); + } + return { + pinvoke_imports: pinvokes.length, + native_implementations: implementations.length, + }; +}; + +/** Build a managed P/Invoke import application node. */ +export const pinvokeNode = ( + state: GraphBuildState, + pinvoke: PinvokeImport, + coverage: ApplicationGraphEvidence["coverage"], +): ApplicationNode => + createJavaScriptApplicationNode({ + kind: "managed-pinvoke-import", + identity: artifactLocalIdentity( + state.artifactSha256, + "managed-pinvoke-token", + pinvoke.token, + ), + observations: [ + { + label: pinvoke.import_name, + properties: { + token: pinvoke.token, + member_token: pinvoke.member_token, + member_name: pinvoke.member_name, + import_name: pinvoke.import_name, + import_scope_name: pinvoke.import_scope_name, + char_set: pinvoke.char_set, + call_convention: pinvoke.call_convention, + verification: pinvoke.verification, + }, + evidence: managedEvidence( + state, + "inspect_managed_native_boundaries", + coverage, + ), + }, + ], + }); + +/** Build a managed native implementation application node. */ +export const nativeImplementationNode = ( + state: GraphBuildState, + implementation: NativeImplementation, + coverage: ApplicationGraphEvidence["coverage"], +): ApplicationNode => + createJavaScriptApplicationNode({ + kind: "managed-native-implementation", + identity: artifactLocalIdentity( + state.artifactSha256, + "managed-native-implementation-token", + implementation.token, + ), + observations: [ + { + label: implementation.name, + properties: { + token: implementation.token, + name: implementation.name, + rva: implementation.rva, + code_type: implementation.code_type, + managed_kind: implementation.managed_kind, + pinvoke_declared: implementation.pinvoke_declared, + boundary_kind: implementation.boundary_kind, + body_interpretation: implementation.body_interpretation, + }, + evidence: managedEvidence( + state, + "inspect_managed_native_boundaries", + coverage, + ), + }, + ], + }); + +/** Add a contains relationship edge between two nodes. */ +export const addContainsEdge = ( + state: GraphBuildState, + source: ApplicationNode, + target: ApplicationNode, + options: ContainsEdgeOptions, +): void => { + state.edges.push( + createJavaScriptApplicationEdge({ + source_node_id: source.node_id, + target_node_id: target.node_id, + relation: "contains", + properties: { kind: options.kind }, + evidence: managedEvidence( + state, + "project-managed-contains", + options.coverage, + ), + }), + ); +}; + +/** Build an artifact-local key identity for a managed graph entity. */ +export const artifactLocalIdentity = ( + artifactSha256: string, + namespace: string, + key: string, +) => ({ + strategy: "artifact-local-key" as const, + stability: "artifact-version" as const, + artifact_sha256: artifactSha256, + namespace, + key, +}); + +/** Build managed static-analysis evidence for a graph observation. */ +export const managedEvidence = ( + state: GraphBuildState, + operation: string, + coverage: ApplicationGraphEvidence["coverage"] = managedSourceCoverage("complete"), +): ApplicationGraphEvidence => ({ + authority: "managed-static-analysis", + state: "observed", + confidence: "exact", + artifact: { + available: true, + artifact_id: `art_${state.artifactSha256}`, + sha256: state.artifactSha256, + }, + location: { + available: true, + value: { + kind: "artifact-path", + path: graphArtifactPath(state.artifactPath), + }, + }, + extractor: { + name: "rea-dotnet-static", + version: "1", + operation, + executable_sha256: null, + }, + coverage, + limitations: + coverage.status === "complete" + ? [] + : [ + "The source managed Evidence slice is incomplete; unreturned or unavailable items remain unobserved.", + ], + evidence_ids: state.evidenceLinks, +}); + +/** Sanitize an artifact path into a managed graph location. */ +export const graphArtifactPath = (path: string): string => { + const name = basename(path).replaceAll(/[^A-Za-z0-9._-]/gu, "_"); + return `managed/${name.length === 0 ? "artifact.pe" : name}`; +}; + +/** Derive the module coverage state from the best available evidence source. */ +export const moduleCoverageState = ( + parsed: ParsedManagedGraphInput, +): "complete" | "partial" | "unavailable" => + parsed.artifact !== null && parsed.artifact.result.module !== null + ? parsed.artifact.result.coverage.state + : parsed.members !== null && parsed.members.result.module !== null + ? parsed.members.result.coverage.state + : (parsed.boundaries?.result.coverage.state ?? "complete"); diff --git a/src/domain/managedMemberComparison.ts b/src/domain/managedMemberComparison.ts index b09b9d67f..8bda016dd 100644 --- a/src/domain/managedMemberComparison.ts +++ b/src/domain/managedMemberComparison.ts @@ -1,16 +1,24 @@ -import { createHash } from "node:crypto"; - -import canonicalize from "canonicalize"; import { z } from "zod"; -import { evidenceSchema, parseEvidence } from "./evidence.js"; +import { evidenceSchema } from "./evidence.js"; import { cliMetadataGuidSchema, - managedMemberInspectionSchema, type ManagedMemberInspection, } from "./managedArtifact.js"; -import type { JsonValue } from "./jsonValue.js"; import { assessKnownPageCoverage } from "./knownPageCoverage.js"; +import { + buildComparisonCoverage, + buildComparisonMatching, + buildComparisonSummary, + comparisonLimitations, + sideManifest, +} from "./managedMemberComparisonCoverage.js"; +import { + buildFieldItems, + buildMethodItems, + keyMembers, + sha256, +} from "./managedMemberComparisonMatch.js"; const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); @@ -176,44 +184,12 @@ export type ManagedMemberComparisonResult = z.infer< typeof managedMemberComparisonResultSchema >; -type Method = ManagedMemberInspection["methods"]["items"][number]; -type Field = ManagedMemberInspection["fields"]["items"][number]; -type MethodItem = ManagedMemberComparisonResult["methods"][number]; -type FieldItem = ManagedMemberComparisonResult["fields"][number]; -type MatchBasis = z.infer; - /** One authenticated side of a managed member comparison. */ export interface ManagedMemberComparisonSide { readonly evidenceId: string; readonly result: ManagedMemberInspection; } -interface Keyed { - readonly item: Item; - readonly exactKey: string | null; - readonly structuralKey: string | null; -} - -interface MatchedPair { - readonly left: Keyed; - readonly right: Keyed; - readonly basis: MatchBasis; - readonly confidence: "exact" | "high"; -} - -interface Ambiguous { - readonly left: readonly Keyed[]; - readonly right: readonly Keyed[]; - readonly basis: MatchBasis; -} - -const sha256 = (value: JsonValue): string => { - const serialized = canonicalize(value); - if (serialized === undefined) - throw new TypeError("Managed comparison canonicalization failed"); - return createHash("sha256").update(serialized).digest("hex"); -}; - /** Compare two parsed managed member observations without name-based matching. */ export const compareManagedMembers = ( left: ManagedMemberComparisonSide, @@ -224,56 +200,24 @@ export const compareManagedMembers = ( const rightMethodPage = assessKnownPageCoverage(right.result.methods); const leftFieldPage = assessKnownPageCoverage(left.result.fields); const rightFieldPage = assessKnownPageCoverage(right.result.fields); - const methodMatches = matchMethods( - left.result.methods.items.map(keyMethod), - right.result.methods.items.map(keyMethod), + const { methodMatches, fieldMatches } = keyMembers( + left, + right, limits.max_candidates, ); - const fieldMatches = matchFields( - left.result.fields.items.map(keyField), - right.result.fields.items.map(keyField), - limits.max_candidates, - ); - const methodItems = buildMethodItems(methodMatches, { + const itemContext = { leftEvidenceId: left.evidenceId, rightEvidenceId: right.evidenceId, - leftComplete: leftMethodPage.sourceComplete, - rightComplete: rightMethodPage.sourceComplete, + leftComplete: leftMethodPage.sourceComplete && leftFieldPage.sourceComplete, + rightComplete: + rightMethodPage.sourceComplete && rightFieldPage.sourceComplete, limits, - }); - const fieldItems = buildFieldItems(fieldMatches, { - leftEvidenceId: left.evidenceId, - rightEvidenceId: right.evidenceId, - leftComplete: leftFieldPage.sourceComplete, - rightComplete: rightFieldPage.sourceComplete, - limits, - }); + }; + const methodItems = buildMethodItems(methodMatches, itemContext); + const fieldItems = buildFieldItems(fieldMatches, itemContext); const allItems = [...methodItems.items, ...fieldItems.items]; - const summary = { - unchanged: allItems.filter(({ status }) => status === "unchanged").length, - changed: allItems.filter(({ status }) => status === "changed").length, - added: allItems.filter(({ status }) => status === "added").length, - removed: allItems.filter(({ status }) => status === "removed").length, - unknown: allItems.filter(({ status }) => status === "unknown").length, - }; - const matching = { - exact_il_signature: methodItems.items.filter( - ({ match }) => match.basis === "exact-il-signature", - ).length, - structural_method_shape: methodItems.items.filter( - ({ match }) => match.basis === "structural-method-shape", - ).length, - field_signature: fieldItems.items.filter( - ({ match }) => match.basis === "field-signature", - ).length, - ambiguous: - methodItems.items.filter(({ match }) => match.status === "ambiguous") - .length + - fieldItems.items.filter(({ match }) => match.status === "ambiguous") - .length, - unmatched: allItems.filter(({ match }) => match.status === "unmatched") - .length, - }; + const summary = buildComparisonSummary(allItems); + const matching = buildComparisonMatching(methodItems.items, fieldItems.items); const coverage = buildComparisonCoverage({ left: left.result, right: right.result, @@ -293,7 +237,7 @@ export const compareManagedMembers = ( coverage.omitted_candidates, ); const result = { - schema_version: 1, + schema_version: 1 as const, comparison_id: `mmc_${sha256({ left: left.evidenceId, right: right.evidenceId, @@ -302,10 +246,10 @@ export const compareManagedMembers = ( limits, })}`, algorithm: { - name: "rea-managed-member-comparison", - version: 1, - token_identity: "build-local", - name_matching: "not-used", + name: "rea-managed-member-comparison" as const, + version: 1 as const, + token_identity: "build-local" as const, + name_matching: "not-used" as const, }, left: sideManifest(left), right: sideManifest(right), @@ -320,539 +264,4 @@ export const compareManagedMembers = ( return managedMemberComparisonResultSchema.parse(result); }; -type PageAssessment = ReturnType; - -interface ComparisonCoverageInput { - readonly left: ManagedMemberInspection; - readonly right: ManagedMemberInspection; - readonly leftMethodPage: PageAssessment; - readonly rightMethodPage: PageAssessment; - readonly leftFieldPage: PageAssessment; - readonly rightFieldPage: PageAssessment; - readonly omittedMethodItems: number; - readonly omittedFieldItems: number; - readonly omittedCandidates: number; -} - -const buildComparisonCoverage = ({ - left, - right, - leftMethodPage, - rightMethodPage, - leftFieldPage, - rightFieldPage, - omittedMethodItems, - omittedFieldItems, - omittedCandidates, -}: ComparisonCoverageInput): ManagedMemberComparisonResult["coverage"] => { - const omittedMethods = - omittedMethodItems + - leftMethodPage.sourceOmittedCount + - rightMethodPage.sourceOmittedCount; - const omittedFields = - omittedFieldItems + - leftFieldPage.sourceOmittedCount + - rightFieldPage.sourceOmittedCount; - const leftStatus = sideCoverageStatus( - left, - leftMethodPage.sourceComplete && leftFieldPage.sourceComplete, - ); - const rightStatus = sideCoverageStatus( - right, - rightMethodPage.sourceComplete && rightFieldPage.sourceComplete, - ); - const unknownInput = - left.coverage.state !== "complete" || right.coverage.state !== "complete"; - const truncated = omittedMethods + omittedFields + omittedCandidates > 0; - return { - status: unknownInput - ? "partial" - : truncated - ? "truncated" - : leftStatus === "complete" && rightStatus === "complete" - ? "complete-within-inputs" - : "partial", - left_status: leftStatus, - right_status: rightStatus, - omitted_methods: omittedMethods, - omitted_fields: omittedFields, - omitted_candidates: omittedCandidates, - }; -}; - -const sideCoverageStatus = ( - result: ManagedMemberInspection, - pagesComplete: boolean, -): ManagedMemberComparisonResult["coverage"]["left_status"] => - result.coverage.state === "unavailable" - ? "unavailable" - : result.coverage.state === "complete" && pagesComplete - ? "complete" - : "partial"; - -const sideManifest = ( - side: ManagedMemberComparisonSide, -): ManagedMemberComparisonResult["left"] => ({ - evidence_id: side.evidenceId, - artifact_sha256: side.result.artifact.sha256, - mvid: side.result.module?.mvid ?? null, - module_name: side.result.module?.name ?? null, - metadata_status: side.result.metadata.status, - methods_total: side.result.methods.total, - fields_total: side.result.fields.total, -}); - -const keyMethod = (item: Method): Keyed => ({ - item, - exactKey: - item.signature.parse_status === "decoded" && - item.body.status === "present" && - item.body.normalized_il_sha256 !== null - ? stableKey([ - "method-exact", - item.signature.raw_sha256, - item.body.normalized_il_sha256, - ]) - : null, - structuralKey: - item.body.status === "present" - ? stableKey([ - "method-structural", - item.signature.kind, - item.signature.calling_convention, - item.signature.generic_parameter_count, - item.signature.parameter_count, - item.signature.return_type, - item.signature.parameter_types, - item.body.status, - item.body.header_format, - item.body.il_size, - item.body.max_stack, - item.body.init_locals, - item.body.opcode_counts, - item.body.anchors.map(({ opcode, operand_kind }) => [ - opcode, - operand_kind, - ]), - item.body.exception_regions.map((region) => [ - region.flags, - region.try_length, - region.handler_length, - region.class_token === null ? null : "type-token", - region.filter_offset === null ? null : "filter", - ]), - ]) - : null, -}); - -const keyField = (item: Field): Keyed => ({ - item, - exactKey: - item.signature.parse_status === "decoded" - ? stableKey(["field-exact", item.signature.raw_sha256]) - : null, - structuralKey: stableKey([ - "field-structural", - item.signature.kind, - item.signature.field_type, - item.flags, - ]), -}); - -const stableKey = (value: JsonValue): string => sha256(value); - -const matchMethods = ( - left: readonly Keyed[], - right: readonly Keyed[], - maxCandidates: number, -) => - matchByKeys(left, right, maxCandidates, "exact-il-signature", [ - "structural-method-shape", - ]); - -const matchFields = ( - left: readonly Keyed[], - right: readonly Keyed[], - maxCandidates: number, -) => matchByKeys(left, right, maxCandidates, "field-signature", []); - -const matchByKeys = ( - left: readonly Keyed[], - right: readonly Keyed[], - maxCandidates: number, - exactBasis: MatchBasis, - fallbackBases: readonly MatchBasis[], -): { - readonly pairs: readonly MatchedPair[]; - readonly ambiguous: readonly Ambiguous[]; - readonly leftOnly: readonly Keyed[]; - readonly rightOnly: readonly Keyed[]; - readonly omittedCandidates: number; -} => { - const usedLeft = new Set>(); - const usedRight = new Set>(); - const pairs: MatchedPair[] = []; - const ambiguous: Ambiguous[] = []; - let omittedCandidates = 0; - const rounds: readonly { - readonly basis: MatchBasis; - readonly key: (item: Keyed) => string | null; - }[] = [ - { basis: exactBasis, key: ({ exactKey }) => exactKey }, - ...fallbackBases.map((basis) => ({ - basis, - key: ({ structuralKey }: Keyed) => structuralKey, - })), - ]; - for (const round of rounds) { - const leftGroups = groupBy( - left.filter((item) => !usedLeft.has(item)), - round.key, - ); - const rightGroups = groupBy( - right.filter((item) => !usedRight.has(item)), - round.key, - ); - for (const [key, leftItems] of leftGroups) { - const rightItems = rightGroups.get(key); - if (rightItems === undefined) continue; - if (leftItems.length === 1 && rightItems.length === 1) { - const [leftItem] = leftItems; - const [rightItem] = rightItems; - if (leftItem === undefined || rightItem === undefined) continue; - usedLeft.add(leftItem); - usedRight.add(rightItem); - pairs.push({ - left: leftItem, - right: rightItem, - basis: round.basis, - confidence: - round.basis === "exact-il-signature" || - round.basis === "field-signature" - ? "exact" - : "high", - }); - } else { - for (const item of leftItems) usedLeft.add(item); - for (const item of rightItems) usedRight.add(item); - omittedCandidates += Math.max( - 0, - leftItems.length + rightItems.length - maxCandidates * 2, - ); - ambiguous.push({ - left: leftItems.slice(0, maxCandidates), - right: rightItems.slice(0, maxCandidates), - basis: round.basis, - }); - } - } - } - return { - pairs, - ambiguous, - leftOnly: left.filter((item) => !usedLeft.has(item)), - rightOnly: right.filter((item) => !usedRight.has(item)), - omittedCandidates, - }; -}; - -const groupBy = ( - items: readonly Item[], - keyOf: (item: Item) => string | null, -): Map => { - const grouped = new Map(); - for (const item of items) { - const key = keyOf(item); - if (key === null) continue; - const bucket = grouped.get(key) ?? []; - bucket.push(item); - grouped.set(key, bucket); - } - return grouped; -}; - -const buildMethodItems = ( - matches: ReturnType, - context: ComparisonItemContext, -): { readonly items: MethodItem[]; readonly omitted: number } => { - const items: MethodItem[] = []; - for (const pair of matches.pairs) { - const dimensions = changedMethodDimensions(pair.left.item, pair.right.item); - items.push({ - item_id: `mmc_method_${sha256({ - left: pair.left.item.token, - right: pair.right.item.token, - basis: pair.basis, - })}`, - status: dimensions.length === 0 ? "unchanged" : "changed", - left: methodIdentity(pair.left.item), - right: methodIdentity(pair.right.item), - match: { - status: "matched", - basis: pair.basis, - confidence: pair.confidence, - candidate_left_tokens: [], - candidate_right_tokens: [], - }, - dimensions, - evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: [], - }); - } - for (const ambiguous of matches.ambiguous) { - items.push({ - item_id: `mmc_method_${sha256({ - left: ambiguous.left.map(({ item }) => item.token), - right: ambiguous.right.map(({ item }) => item.token), - basis: ambiguous.basis, - })}`, - status: "unknown", - left: null, - right: null, - match: { - status: "ambiguous", - basis: ambiguous.basis, - confidence: "unknown", - candidate_left_tokens: ambiguous.left.map(({ item }) => item.token), - candidate_right_tokens: ambiguous.right.map(({ item }) => item.token), - }, - dimensions: ["availability"], - evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: [ - "Multiple managed methods share the same non-name identity key; REA did not guess a token remap.", - ], - }); - } - for (const item of matches.leftOnly) - items.push(methodOnlyItem(item.item, context, true)); - for (const item of matches.rightOnly) - items.push(methodOnlyItem(item.item, context, false)); - return limitItems(items, context.limits.max_method_matches); -}; - -const buildFieldItems = ( - matches: ReturnType, - context: ComparisonItemContext, -): { readonly items: FieldItem[]; readonly omitted: number } => { - const items: FieldItem[] = []; - for (const pair of matches.pairs) { - const changed = - pair.left.item.signature.raw_sha256 !== - pair.right.item.signature.raw_sha256 || - pair.left.item.flags !== pair.right.item.flags; - items.push({ - item_id: `mmc_field_${sha256({ - left: pair.left.item.token, - right: pair.right.item.token, - })}`, - status: changed ? "changed" : "unchanged", - left: fieldIdentity(pair.left.item), - right: fieldIdentity(pair.right.item), - match: { - status: "matched", - basis: pair.basis, - confidence: pair.confidence, - candidate_left_tokens: [], - candidate_right_tokens: [], - }, - evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: [], - }); - } - for (const ambiguous of matches.ambiguous) - items.push({ - item_id: `mmc_field_${sha256({ - left: ambiguous.left.map(({ item }) => item.token), - right: ambiguous.right.map(({ item }) => item.token), - })}`, - status: "unknown", - left: null, - right: null, - match: { - status: "ambiguous", - basis: ambiguous.basis, - confidence: "unknown", - candidate_left_tokens: ambiguous.left.map(({ item }) => item.token), - candidate_right_tokens: ambiguous.right.map(({ item }) => item.token), - }, - evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: [ - "Multiple managed fields share the same signature; REA did not guess a token remap from names.", - ], - }); - for (const item of matches.leftOnly) - items.push(fieldOnlyItem(item.item, context, true)); - for (const item of matches.rightOnly) - items.push(fieldOnlyItem(item.item, context, false)); - return limitItems(items, context.limits.max_field_matches); -}; - -interface ComparisonItemContext { - readonly leftEvidenceId: string; - readonly rightEvidenceId: string; - readonly leftComplete: boolean; - readonly rightComplete: boolean; - readonly limits: CompareManagedMembersInput["limits"]; -} - -const limitItems = ( - items: readonly Item[], - limit: number, -): { readonly items: Item[]; readonly omitted: number } => ({ - items: items.slice(0, limit), - omitted: Math.max(0, items.length - limit), -}); - -const methodOnlyItem = ( - item: Method, - context: ComparisonItemContext, - left: boolean, -): MethodItem => { - const absenceObserved = left ? context.rightComplete : context.leftComplete; - return { - item_id: `mmc_method_${sha256({ token: item.token, side: left ? "left" : "right" })}`, - status: absenceObserved ? (left ? "removed" : "added") : "unknown", - left: left ? methodIdentity(item) : null, - right: left ? null : methodIdentity(item), - match: { - status: "unmatched", - basis: "none", - confidence: "unknown", - candidate_left_tokens: [], - candidate_right_tokens: [], - }, - dimensions: ["availability"], - evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: absenceObserved - ? [] - : [ - `unknown-within-unobserved-page: The ${left ? "right" : "left"} method page is incomplete, so absence was not observed.`, - ], - }; -}; - -const fieldOnlyItem = ( - item: Field, - context: ComparisonItemContext, - left: boolean, -): FieldItem => { - const absenceObserved = left ? context.rightComplete : context.leftComplete; - return { - item_id: `mmc_field_${sha256({ token: item.token, side: left ? "left" : "right" })}`, - status: absenceObserved ? (left ? "removed" : "added") : "unknown", - left: left ? fieldIdentity(item) : null, - right: left ? null : fieldIdentity(item), - match: { - status: "unmatched", - basis: "none", - confidence: "unknown", - candidate_left_tokens: [], - candidate_right_tokens: [], - }, - evidence_links: [context.leftEvidenceId, context.rightEvidenceId], - limitations: absenceObserved - ? [] - : [ - `unknown-within-unobserved-page: The ${left ? "right" : "left"} field page is incomplete, so absence was not observed.`, - ], - }; -}; - -const methodIdentity = (item: Method): NonNullable => ({ - token: item.token, - declaring_type: item.declaring_type, - name: item.name, - signature_sha256: item.signature.raw_sha256, - normalized_il_sha256: item.body.normalized_il_sha256, -}); - -const fieldIdentity = (item: Field): NonNullable => ({ - token: item.token, - declaring_type: item.declaring_type, - name: item.name, - signature_sha256: item.signature.raw_sha256, -}); - -const changedMethodDimensions = ( - left: Method, - right: Method, -): MethodItem["dimensions"] => { - const dimensions: MethodItem["dimensions"] = []; - if (left.signature.raw_sha256 !== right.signature.raw_sha256) - dimensions.push("signature"); - if (left.body.normalized_il_sha256 !== right.body.normalized_il_sha256) - dimensions.push("cil"); - if ( - canonicalize(left.body.opcode_counts) !== - canonicalize(right.body.opcode_counts) - ) - dimensions.push("opcode-shape"); - if (callShape(left.body.anchors) !== callShape(right.body.anchors)) - dimensions.push("call-shape"); - if (fieldShape(left.body.anchors) !== fieldShape(right.body.anchors)) - dimensions.push("field-shape"); - if ( - canonicalize(left.body.exception_regions) !== - canonicalize(right.body.exception_regions) - ) - dimensions.push("exception-shape"); - if (left.body.status !== right.body.status) dimensions.push("availability"); - return dimensions; -}; - -const callShape = (anchors: Method["body"]["anchors"]): string => - JSON.stringify( - anchors - .filter(({ operand_kind }) => operand_kind === "method") - .map(({ opcode, operand_kind }) => [opcode, operand_kind]), - ); - -const fieldShape = (anchors: Method["body"]["anchors"]): string => - JSON.stringify( - anchors - .filter(({ operand_kind }) => operand_kind === "field") - .map(({ opcode, operand_kind }) => [opcode, operand_kind]), - ); - -const comparisonLimitations = ( - left: ManagedMemberInspection, - right: ManagedMemberInspection, - omittedItems: number, - omittedCandidates: number, -): string[] => { - const limitations: string[] = [ - "Metadata tokens are build-local coordinates; matched pairs are remaps, not persistent identities.", - "Names are reported as observations but are not used as a matching basis.", - ]; - if (!left.methods.complete || !right.methods.complete) - limitations.push("At least one method page is incomplete."); - if (!left.fields.complete || !right.fields.complete) - limitations.push("At least one field page is incomplete."); - if (left.coverage.state !== "complete" || right.coverage.state !== "complete") - limitations.push("At least one managed member observation is partial."); - if (omittedItems > 0) - limitations.push( - `${String(omittedItems)} comparison items were omitted by output limits.`, - ); - if (omittedCandidates > 0) - limitations.push( - `${String(omittedCandidates)} ambiguous candidates were omitted by candidate limits.`, - ); - return limitations; -}; - -/** Extract and authenticate a managed member inspection Evidence record. */ -export const parseManagedMemberEvidence = ( - evidence: unknown, -): { - readonly evidenceId: string; - readonly result: ManagedMemberInspection; -} => { - const parsed = parseEvidence(evidence); - if (parsed.operation !== "inspect_managed_members") - throw new TypeError("Evidence operation is not inspect_managed_members"); - return { - evidenceId: parsed.evidence_id, - result: managedMemberInspectionSchema.parse(parsed.normalized_result), - }; -}; +export { parseManagedMemberEvidence } from "./managedMemberComparisonMatch.js"; diff --git a/src/domain/managedMemberComparisonCoverage.ts b/src/domain/managedMemberComparisonCoverage.ts new file mode 100644 index 000000000..8a52914bc --- /dev/null +++ b/src/domain/managedMemberComparisonCoverage.ts @@ -0,0 +1,151 @@ +import type { + ManagedMemberComparisonResult, + ManagedMemberComparisonSide, +} from "./managedMemberComparison.js"; +import type { ManagedMemberInspection } from "./managedArtifact.js"; + +export interface PageAssessment { + readonly sourceComplete: boolean; + readonly sourceOmittedCount: number; +} + +interface ComparisonCoverageInput { + readonly left: ManagedMemberInspection; + readonly right: ManagedMemberInspection; + readonly leftMethodPage: PageAssessment; + readonly rightMethodPage: PageAssessment; + readonly leftFieldPage: PageAssessment; + readonly rightFieldPage: PageAssessment; + readonly omittedMethodItems: number; + readonly omittedFieldItems: number; + readonly omittedCandidates: number; +} + +export const buildComparisonCoverage = ({ + left, + right, + leftMethodPage, + rightMethodPage, + leftFieldPage, + rightFieldPage, + omittedMethodItems, + omittedFieldItems, + omittedCandidates, +}: ComparisonCoverageInput): ManagedMemberComparisonResult["coverage"] => { + const omittedMethods = + omittedMethodItems + + leftMethodPage.sourceOmittedCount + + rightMethodPage.sourceOmittedCount; + const omittedFields = + omittedFieldItems + + leftFieldPage.sourceOmittedCount + + rightFieldPage.sourceOmittedCount; + const leftStatus = sideCoverageStatus( + left, + leftMethodPage.sourceComplete && leftFieldPage.sourceComplete, + ); + const rightStatus = sideCoverageStatus( + right, + rightMethodPage.sourceComplete && rightFieldPage.sourceComplete, + ); + const unknownInput = + left.coverage.state !== "complete" || right.coverage.state !== "complete"; + const truncated = omittedMethods + omittedFields + omittedCandidates > 0; + return { + status: unknownInput + ? "partial" + : truncated + ? "truncated" + : leftStatus === "complete" && rightStatus === "complete" + ? "complete-within-inputs" + : "partial", + left_status: leftStatus, + right_status: rightStatus, + omitted_methods: omittedMethods, + omitted_fields: omittedFields, + omitted_candidates: omittedCandidates, + }; +}; + +const sideCoverageStatus = ( + result: ManagedMemberInspection, + pagesComplete: boolean, +): ManagedMemberComparisonResult["coverage"]["left_status"] => + result.coverage.state === "unavailable" + ? "unavailable" + : result.coverage.state === "complete" && pagesComplete + ? "complete" + : "partial"; + +export const sideManifest = ( + side: ManagedMemberComparisonSide, +): ManagedMemberComparisonResult["left"] => ({ + evidence_id: side.evidenceId, + artifact_sha256: side.result.artifact.sha256, + mvid: side.result.module?.mvid ?? null, + module_name: side.result.module?.name ?? null, + metadata_status: side.result.metadata.status, + methods_total: side.result.methods.total, + fields_total: side.result.fields.total, +}); + +export const buildComparisonSummary = ( + items: readonly ( + | ManagedMemberComparisonResult["methods"][number] + | ManagedMemberComparisonResult["fields"][number] + )[], +): ManagedMemberComparisonResult["summary"] => ({ + unchanged: items.filter(({ status }) => status === "unchanged").length, + changed: items.filter(({ status }) => status === "changed").length, + added: items.filter(({ status }) => status === "added").length, + removed: items.filter(({ status }) => status === "removed").length, + unknown: items.filter(({ status }) => status === "unknown").length, +}); + +export const buildComparisonMatching = ( + methodItems: readonly ManagedMemberComparisonResult["methods"][number][], + fieldItems: readonly ManagedMemberComparisonResult["fields"][number][], +): ManagedMemberComparisonResult["matching"] => ({ + exact_il_signature: methodItems.filter( + ({ match }) => match.basis === "exact-il-signature", + ).length, + structural_method_shape: methodItems.filter( + ({ match }) => match.basis === "structural-method-shape", + ).length, + field_signature: fieldItems.filter( + ({ match }) => match.basis === "field-signature", + ).length, + ambiguous: + methodItems.filter(({ match }) => match.status === "ambiguous").length + + fieldItems.filter(({ match }) => match.status === "ambiguous").length, + unmatched: [...methodItems, ...fieldItems].filter( + ({ match }) => match.status === "unmatched", + ).length, +}); + +export const comparisonLimitations = ( + left: ManagedMemberInspection, + right: ManagedMemberInspection, + omittedItems: number, + omittedCandidates: number, +): string[] => { + const limitations: string[] = [ + "Metadata tokens are build-local coordinates; matched pairs are remaps, not persistent identities.", + "Names are reported as observations but are not used as a matching basis.", + ]; + if (!left.methods.complete || !right.methods.complete) + limitations.push("At least one method page is incomplete."); + if (!left.fields.complete || !right.fields.complete) + limitations.push("At least one field page is incomplete."); + if (left.coverage.state !== "complete" || right.coverage.state !== "complete") + limitations.push("At least one managed member observation is partial."); + if (omittedItems > 0) + limitations.push( + `${String(omittedItems)} comparison items were omitted by output limits.`, + ); + if (omittedCandidates > 0) + limitations.push( + `${String(omittedCandidates)} ambiguous candidates were omitted by candidate limits.`, + ); + return limitations; +}; diff --git a/src/domain/managedMemberComparisonMatch.ts b/src/domain/managedMemberComparisonMatch.ts new file mode 100644 index 000000000..241237446 --- /dev/null +++ b/src/domain/managedMemberComparisonMatch.ts @@ -0,0 +1,513 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import { parseEvidence } from "./evidence.js"; +import { + managedMemberInspectionSchema, + type ManagedMemberInspection, +} from "./managedArtifact.js"; +import type { + CompareManagedMembersInput, + ManagedMemberComparisonResult, + ManagedMemberComparisonSide, +} from "./managedMemberComparison.js"; +import type { JsonValue } from "./jsonValue.js"; + +export const sha256 = (value: JsonValue): string => { + const serialized = canonicalize(value); + if (serialized === undefined) + throw new TypeError("Managed comparison canonicalization failed"); + return createHash("sha256").update(serialized).digest("hex"); +}; + +type Method = ManagedMemberInspection["methods"]["items"][number]; +type Field = ManagedMemberInspection["fields"]["items"][number]; +type MethodItem = ManagedMemberComparisonResult["methods"][number]; +type FieldItem = ManagedMemberComparisonResult["fields"][number]; +type MatchBasis = MethodItem["match"]["basis"]; + +interface Keyed { + readonly item: Item; + readonly exactKey: string | null; + readonly structuralKey: string | null; +} + +interface MatchedPair { + readonly left: Keyed; + readonly right: Keyed; + readonly basis: MatchBasis; + readonly confidence: "exact" | "high"; +} + +interface Ambiguous { + readonly left: readonly Keyed[]; + readonly right: readonly Keyed[]; + readonly basis: MatchBasis; +} + +export interface ComparisonItemContext { + readonly leftEvidenceId: string; + readonly rightEvidenceId: string; + readonly leftComplete: boolean; + readonly rightComplete: boolean; + readonly limits: CompareManagedMembersInput["limits"]; +} + +const keyMethod = (item: Method): Keyed => ({ + item, + exactKey: + item.signature.parse_status === "decoded" && + item.body.status === "present" && + item.body.normalized_il_sha256 !== null + ? stableKey([ + "method-exact", + item.signature.raw_sha256, + item.body.normalized_il_sha256, + ]) + : null, + structuralKey: + item.body.status === "present" + ? stableKey([ + "method-structural", + item.signature.kind, + item.signature.calling_convention, + item.signature.generic_parameter_count, + item.signature.parameter_count, + item.signature.return_type, + item.signature.parameter_types, + item.body.status, + item.body.header_format, + item.body.il_size, + item.body.max_stack, + item.body.init_locals, + item.body.opcode_counts, + item.body.anchors.map(({ opcode, operand_kind }) => [ + opcode, + operand_kind, + ]), + item.body.exception_regions.map((region) => [ + region.flags, + region.try_length, + region.handler_length, + region.class_token === null ? null : "type-token", + region.filter_offset === null ? null : "filter", + ]), + ]) + : null, +}); + +const keyField = (item: Field): Keyed => ({ + item, + exactKey: + item.signature.parse_status === "decoded" + ? stableKey(["field-exact", item.signature.raw_sha256]) + : null, + structuralKey: stableKey([ + "field-structural", + item.signature.kind, + item.signature.field_type, + item.flags, + ]), +}); + +const stableKey = (value: JsonValue): string => sha256(value); + +const matchMethods = ( + left: readonly Keyed[], + right: readonly Keyed[], + maxCandidates: number, +) => + matchByKeys({ + left, + right, + maxCandidates, + exactBasis: "exact-il-signature", + fallbackBases: ["structural-method-shape"], + }); + +const matchFields = ( + left: readonly Keyed[], + right: readonly Keyed[], + maxCandidates: number, +) => + matchByKeys({ + left, + right, + maxCandidates, + exactBasis: "field-signature", + fallbackBases: [], + }); + +interface MatchByKeysInput { + readonly left: readonly Keyed[]; + readonly right: readonly Keyed[]; + readonly maxCandidates: number; + readonly exactBasis: MatchBasis; + readonly fallbackBases: readonly MatchBasis[]; +} + +const matchByKeys = ({ + left, + right, + maxCandidates, + exactBasis, + fallbackBases, +}: MatchByKeysInput): { + readonly pairs: readonly MatchedPair[]; + readonly ambiguous: readonly Ambiguous[]; + readonly leftOnly: readonly Keyed[]; + readonly rightOnly: readonly Keyed[]; + readonly omittedCandidates: number; +} => { + const usedLeft = new Set>(); + const usedRight = new Set>(); + const pairs: MatchedPair[] = []; + const ambiguous: Ambiguous[] = []; + let omittedCandidates = 0; + const rounds: readonly { + readonly basis: MatchBasis; + readonly key: (item: Keyed) => string | null; + }[] = [ + { basis: exactBasis, key: ({ exactKey }) => exactKey }, + ...fallbackBases.map((basis) => ({ + basis, + key: ({ structuralKey }: Keyed) => structuralKey, + })), + ]; + for (const round of rounds) { + const leftGroups = groupBy( + left.filter((item) => !usedLeft.has(item)), + round.key, + ); + const rightGroups = groupBy( + right.filter((item) => !usedRight.has(item)), + round.key, + ); + for (const [key, leftItems] of leftGroups) { + const rightItems = rightGroups.get(key); + if (rightItems === undefined) continue; + if (leftItems.length === 1 && rightItems.length === 1) { + const [leftItem] = leftItems; + const [rightItem] = rightItems; + if (leftItem === undefined || rightItem === undefined) continue; + usedLeft.add(leftItem); + usedRight.add(rightItem); + pairs.push({ + left: leftItem, + right: rightItem, + basis: round.basis, + confidence: + round.basis === "exact-il-signature" || + round.basis === "field-signature" + ? "exact" + : "high", + }); + } else { + for (const item of leftItems) usedLeft.add(item); + for (const item of rightItems) usedRight.add(item); + omittedCandidates += Math.max( + 0, + leftItems.length + rightItems.length - maxCandidates * 2, + ); + ambiguous.push({ + left: leftItems.slice(0, maxCandidates), + right: rightItems.slice(0, maxCandidates), + basis: round.basis, + }); + } + } + } + return { + pairs, + ambiguous, + leftOnly: left.filter((item) => !usedLeft.has(item)), + rightOnly: right.filter((item) => !usedRight.has(item)), + omittedCandidates, + }; +}; + +const groupBy = ( + items: readonly Item[], + keyOf: (item: Item) => string | null, +): Map => { + const grouped = new Map(); + for (const item of items) { + const key = keyOf(item); + if (key === null) continue; + const bucket = grouped.get(key) ?? []; + bucket.push(item); + grouped.set(key, bucket); + } + return grouped; +}; + +const changedMethodDimensions = ( + left: Method, + right: Method, +): MethodItem["dimensions"] => { + const dimensions: MethodItem["dimensions"] = []; + if (left.signature.raw_sha256 !== right.signature.raw_sha256) + dimensions.push("signature"); + if (left.body.normalized_il_sha256 !== right.body.normalized_il_sha256) + dimensions.push("cil"); + if ( + canonicalize(left.body.opcode_counts) !== + canonicalize(right.body.opcode_counts) + ) + dimensions.push("opcode-shape"); + if (callShape(left.body.anchors) !== callShape(right.body.anchors)) + dimensions.push("call-shape"); + if (fieldShape(left.body.anchors) !== fieldShape(right.body.anchors)) + dimensions.push("field-shape"); + if ( + canonicalize(left.body.exception_regions) !== + canonicalize(right.body.exception_regions) + ) + dimensions.push("exception-shape"); + if (left.body.status !== right.body.status) dimensions.push("availability"); + return dimensions; +}; + +const callShape = (anchors: Method["body"]["anchors"]): string => + JSON.stringify( + anchors + .filter(({ operand_kind }) => operand_kind === "method") + .map(({ opcode, operand_kind }) => [opcode, operand_kind]), + ); + +const fieldShape = (anchors: Method["body"]["anchors"]): string => + JSON.stringify( + anchors + .filter(({ operand_kind }) => operand_kind === "field") + .map(({ opcode, operand_kind }) => [opcode, operand_kind]), + ); + +const methodIdentity = (item: Method): NonNullable => ({ + token: item.token, + declaring_type: item.declaring_type, + name: item.name, + signature_sha256: item.signature.raw_sha256, + normalized_il_sha256: item.body.normalized_il_sha256, +}); + +const fieldIdentity = (item: Field): NonNullable => ({ + token: item.token, + declaring_type: item.declaring_type, + name: item.name, + signature_sha256: item.signature.raw_sha256, +}); + +const methodOnlyItem = ( + item: Method, + context: ComparisonItemContext, + left: boolean, +): MethodItem => { + const absenceObserved = left ? context.rightComplete : context.leftComplete; + return { + item_id: `mmc_method_${sha256({ token: item.token, side: left ? "left" : "right" })}`, + status: absenceObserved ? (left ? "removed" : "added") : "unknown", + left: left ? methodIdentity(item) : null, + right: left ? null : methodIdentity(item), + match: { + status: "unmatched", + basis: "none", + confidence: "unknown", + candidate_left_tokens: [], + candidate_right_tokens: [], + }, + dimensions: ["availability"], + evidence_links: [context.leftEvidenceId, context.rightEvidenceId], + limitations: absenceObserved + ? [] + : [ + `unknown-within-unobserved-page: The ${left ? "right" : "left"} method page is incomplete, so absence was not observed.`, + ], + }; +}; + +const fieldOnlyItem = ( + item: Field, + context: ComparisonItemContext, + left: boolean, +): FieldItem => { + const absenceObserved = left ? context.rightComplete : context.leftComplete; + return { + item_id: `mmc_field_${sha256({ token: item.token, side: left ? "left" : "right" })}`, + status: absenceObserved ? (left ? "removed" : "added") : "unknown", + left: left ? fieldIdentity(item) : null, + right: left ? null : fieldIdentity(item), + match: { + status: "unmatched", + basis: "none", + confidence: "unknown", + candidate_left_tokens: [], + candidate_right_tokens: [], + }, + evidence_links: [context.leftEvidenceId, context.rightEvidenceId], + limitations: absenceObserved + ? [] + : [ + `unknown-within-unobserved-page: The ${left ? "right" : "left"} field page is incomplete, so absence was not observed.`, + ], + }; +}; + +export const buildMethodItems = ( + matches: ReturnType, + context: ComparisonItemContext, +): { readonly items: MethodItem[]; readonly omitted: number } => { + const items: MethodItem[] = []; + for (const pair of matches.pairs) { + const dimensions = changedMethodDimensions(pair.left.item, pair.right.item); + items.push({ + item_id: `mmc_method_${sha256({ + left: pair.left.item.token, + right: pair.right.item.token, + basis: pair.basis, + })}`, + status: dimensions.length === 0 ? "unchanged" : "changed", + left: methodIdentity(pair.left.item), + right: methodIdentity(pair.right.item), + match: { + status: "matched", + basis: pair.basis, + confidence: pair.confidence, + candidate_left_tokens: [], + candidate_right_tokens: [], + }, + dimensions, + evidence_links: [context.leftEvidenceId, context.rightEvidenceId], + limitations: [], + }); + } + for (const ambiguous of matches.ambiguous) { + items.push({ + item_id: `mmc_method_${sha256({ + left: ambiguous.left.map(({ item }) => item.token), + right: ambiguous.right.map(({ item }) => item.token), + basis: ambiguous.basis, + })}`, + status: "unknown", + left: null, + right: null, + match: { + status: "ambiguous", + basis: ambiguous.basis, + confidence: "unknown", + candidate_left_tokens: ambiguous.left.map(({ item }) => item.token), + candidate_right_tokens: ambiguous.right.map(({ item }) => item.token), + }, + dimensions: ["availability"], + evidence_links: [context.leftEvidenceId, context.rightEvidenceId], + limitations: [ + "Multiple managed methods share the same non-name identity key; REA did not guess a token remap.", + ], + }); + } + for (const item of matches.leftOnly) + items.push(methodOnlyItem(item.item, context, true)); + for (const item of matches.rightOnly) + items.push(methodOnlyItem(item.item, context, false)); + return limitItems(items, context.limits.max_method_matches); +}; + +export const buildFieldItems = ( + matches: ReturnType, + context: ComparisonItemContext, +): { readonly items: FieldItem[]; readonly omitted: number } => { + const items: FieldItem[] = []; + for (const pair of matches.pairs) { + const changed = + pair.left.item.signature.raw_sha256 !== + pair.right.item.signature.raw_sha256 || + pair.left.item.flags !== pair.right.item.flags; + items.push({ + item_id: `mmc_field_${sha256({ + left: pair.left.item.token, + right: pair.right.item.token, + })}`, + status: changed ? "changed" : "unchanged", + left: fieldIdentity(pair.left.item), + right: fieldIdentity(pair.right.item), + match: { + status: "matched", + basis: pair.basis, + confidence: pair.confidence, + candidate_left_tokens: [], + candidate_right_tokens: [], + }, + evidence_links: [context.leftEvidenceId, context.rightEvidenceId], + limitations: [], + }); + } + for (const ambiguous of matches.ambiguous) + items.push({ + item_id: `mmc_field_${sha256({ + left: ambiguous.left.map(({ item }) => item.token), + right: ambiguous.right.map(({ item }) => item.token), + })}`, + status: "unknown", + left: null, + right: null, + match: { + status: "ambiguous", + basis: ambiguous.basis, + confidence: "unknown", + candidate_left_tokens: ambiguous.left.map(({ item }) => item.token), + candidate_right_tokens: ambiguous.right.map(({ item }) => item.token), + }, + evidence_links: [context.leftEvidenceId, context.rightEvidenceId], + limitations: [ + "Multiple managed fields share the same signature; REA did not guess a token remap from names.", + ], + }); + for (const item of matches.leftOnly) + items.push(fieldOnlyItem(item.item, context, true)); + for (const item of matches.rightOnly) + items.push(fieldOnlyItem(item.item, context, false)); + return limitItems(items, context.limits.max_field_matches); +}; + +const limitItems = ( + items: readonly Item[], + limit: number, +): { readonly items: Item[]; readonly omitted: number } => ({ + items: items.slice(0, limit), + omitted: Math.max(0, items.length - limit), +}); + +export const keyMembers = ( + left: ManagedMemberComparisonSide, + right: ManagedMemberComparisonSide, + maxCandidates: number, +): { + readonly methodMatches: ReturnType; + readonly fieldMatches: ReturnType; +} => ({ + methodMatches: matchMethods( + left.result.methods.items.map(keyMethod), + right.result.methods.items.map(keyMethod), + maxCandidates, + ), + fieldMatches: matchFields( + left.result.fields.items.map(keyField), + right.result.fields.items.map(keyField), + maxCandidates, + ), +}); + +export const parseManagedMemberEvidence = ( + evidence: unknown, +): { + readonly evidenceId: string; + readonly result: ManagedMemberInspection; +} => { + const parsed = parseEvidence(evidence); + if (parsed.operation !== "inspect_managed_members") + throw new TypeError("Evidence operation is not inspect_managed_members"); + return { + evidenceId: parsed.evidence_id, + result: managedMemberInspectionSchema.parse(parsed.normalized_result), + }; +}; diff --git a/src/domain/managedNativeVerification.ts b/src/domain/managedNativeVerification.ts index b844e4bca..e355fba57 100644 --- a/src/domain/managedNativeVerification.ts +++ b/src/domain/managedNativeVerification.ts @@ -1,181 +1,30 @@ import { createHash } from "node:crypto"; -import { basename } from "node:path"; import canonicalize from "canonicalize"; -import { z } from "zod"; -import { evidenceSchema, parseEvidence } from "./evidence.js"; -import { functionDossierSchema } from "./hopperValues.js"; +import { parseEvidence } from "./evidence.js"; import { - cliMetadataGuidSchema, managedNativeBoundaryInspectionSchema, type ManagedNativeBoundaryInspection, } from "./managedArtifact.js"; -import { inspectMachoSchema } from "./nativeInspection.js"; import type { JsonValue } from "./jsonValue.js"; +import { + managedNativeVerificationResultSchema, + type ManagedNativeVerificationInput, + type ManagedNativeVerificationResult, +} from "./managedNativeVerificationSchemas.js"; +import { + buildVerificationResult, + collectNativeSymbols, + verifyPinvoke, +} from "./managedNativeVerificationMatch.js"; -const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); -const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); -const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u); -const boundedTextSchema = z.string().min(1).max(4_096); - -const nativeVerificationLimitsSchema = z.strictObject({ - max_native_observations: z.number().int().min(1).max(50).default(20), - max_candidates_per_import: z.number().int().min(1).max(100).default(25), -}); - -/** Authenticated managed boundary Evidence plus native observations. */ -export const managedNativeVerificationInputSchema = z - .strictObject({ - managed_boundaries: evidenceSchema, - native_observations: z.array(evidenceSchema).min(1).max(50), - limits: nativeVerificationLimitsSchema.default({ - max_native_observations: 20, - max_candidates_per_import: 25, - }), - unknown_registry_approved: z.literal(true).optional(), - }) - .superRefine((input, context) => { - const ids = new Set(); - for (const [index, evidence] of input.native_observations.entries()) { - if (evidence.evidence_id === input.managed_boundaries.evidence_id) { - context.addIssue({ - code: "custom", - path: ["native_observations", index], - message: - "Native observation Evidence must be distinct from managed boundary Evidence", - }); - } - if (ids.has(evidence.evidence_id)) { - context.addIssue({ - code: "custom", - path: ["native_observations", index], - message: "Native observation Evidence IDs must be unique", - }); - } - ids.add(evidence.evidence_id); - } - }); - -const nativeSymbolSchema = z.strictObject({ - evidence_id: evidenceIdSchema, - operation: z.string().min(1), - name: z.string().min(1), - address: z.string().nullable(), - module_name: z.string().nullable(), - module_path: z.string().nullable(), - source: z.enum(["macho-export", "function-dossier"]), -}); - -const matchStatusSchema = z.enum([ - "verified", - "inferred", - "unresolved", - "contradicted", -]); - -const matchBasisSchema = z.enum([ - "exact-export-name", - "exact-function-name", - "decorated-name-candidate", - "module-mismatch", - "no-native-candidate", - "unsupported-native-evidence", -]); - -const pinvokeVerificationSchema = z.strictObject({ - item_id: z.string().regex(/^mnv_pinvoke_[a-f0-9]{64}$/u), - managed: z.strictObject({ - token: tokenSchema, - member_token: tokenSchema.nullable(), - member_name: z.string().nullable(), - import_name: z.string().min(1), - import_scope_name: z.string().nullable(), - no_mangle: z.boolean(), - char_set: z.enum(["not-specified", "ansi", "unicode", "auto", "unknown"]), - call_convention: z.enum([ - "not-specified", - "winapi", - "cdecl", - "stdcall", - "thiscall", - "fastcall", - "unknown", - ]), - declaration_verification: z.literal("managed-declaration-only"), - }), - status: matchStatusSchema, - basis: matchBasisSchema, - confidence: z.enum(["observed", "inferred", "unknown"]), - matched_native: nativeSymbolSchema.nullable(), - candidates: z.array(nativeSymbolSchema).max(100), - evidence_links: z.array(evidenceIdSchema).min(1).max(51), - limitations: z.array(boundedTextSchema).max(100), -}); - -/** Provider-neutral managed/native verification result. */ -export const managedNativeVerificationResultSchema = z.strictObject({ - schema_version: z.literal(1), - verification_id: z.string().regex(/^mnv_[a-f0-9]{64}$/u), - algorithm: z.strictObject({ - name: z.literal("rea-managed-native-verification"), - version: z.literal(1), - token_identity: z.literal("build-local"), - token_to_address_mapping: z.literal("not-inferred"), - }), - managed_boundary: z.strictObject({ - evidence_id: evidenceIdSchema, - artifact_sha256: digestSchema, - artifact_path: z.string().min(1), - mvid: cliMetadataGuidSchema.nullable(), - metadata_status: z.enum(["absent", "complete", "partial", "malformed"]), - pinvoke_imports_total: z.number().int().min(0), - native_implementations_total: z.number().int().min(0), - coverage_state: z.enum(["complete", "partial", "unavailable"]), - }), - native_observations: z.strictObject({ - total: z.number().int().min(1), - accepted: z.number().int().min(0), - unsupported: z.number().int().min(0), - symbols: z.number().int().min(0), - truncated: z.boolean(), - }), - summary: z.strictObject({ - verified: z.number().int().min(0), - inferred: z.number().int().min(0), - unresolved: z.number().int().min(0), - contradicted: z.number().int().min(0), - native_body_unresolved: z.number().int().min(0), - }), - pinvoke_imports: z.array(pinvokeVerificationSchema).max(50_000), - native_implementations: z.strictObject({ - unresolved: z.number().int().min(0), - reason: boundedTextSchema, - }), - coverage: z.strictObject({ - status: z.enum(["complete-within-inputs", "partial", "truncated"]), - omitted_native_observations: z.number().int().min(0), - omitted_candidates: z.number().int().min(0), - }), - evidence_links: z.array(evidenceIdSchema).min(2).max(51), - limitations: z.array(boundedTextSchema).max(1_000), -}); - -export type ManagedNativeVerificationInput = z.infer< - typeof managedNativeVerificationInputSchema ->; -export type ManagedNativeVerificationResult = z.infer< - typeof managedNativeVerificationResultSchema ->; - -type NativeSymbol = z.infer; -type PinvokeImport = - ManagedNativeBoundaryInspection["pinvoke_imports"]["items"][number]; -type PinvokeVerification = z.infer; -type VerifiedPinvoke = { - readonly verification: PinvokeVerification; - readonly omittedCandidates: number; -}; +export { + managedNativeVerificationInputSchema, + type ManagedNativeVerificationInput, + type ManagedNativeVerificationResult, +} from "./managedNativeVerificationSchemas.js"; +export { managedNativeVerificationResultSchema }; const sha256 = (value: JsonValue): string => { const serialized = canonicalize(value); @@ -184,346 +33,50 @@ const sha256 = (value: JsonValue): string => { return createHash("sha256").update(serialized).digest("hex"); }; -/** Verify managed P/Invoke declarations against authenticated native Evidence. */ -export const verifyManagedNativeBoundaries = ( +const managedBoundaryFromInput = ( input: ManagedNativeVerificationInput, -): ManagedNativeVerificationResult => { +): ManagedNativeBoundaryInspection => { const managedEvidence = parseEvidence(input.managed_boundaries); if (managedEvidence.operation !== "inspect_managed_native_boundaries") throw new TypeError( "Evidence operation is not inspect_managed_native_boundaries", ); - const managed = managedNativeBoundaryInspectionSchema.parse( + return managedNativeBoundaryInspectionSchema.parse( managedEvidence.normalized_result, ); +}; + +/** Verify managed P/Invoke declarations against authenticated native Evidence. */ +export const verifyManagedNativeBoundaries = ( + input: ManagedNativeVerificationInput, +): ManagedNativeVerificationResult => { + const managed = managedBoundaryFromInput(input); + const managedEvidence = parseEvidence(input.managed_boundaries); const native = collectNativeSymbols( input.native_observations.slice(0, input.limits.max_native_observations), ); const verifiedPinvokes = managed.pinvoke_imports.items.map((item) => - verifyPinvoke( + verifyPinvoke({ item, - managedEvidence.evidence_id, - native.symbols, - native.accepted > 0, + managedEvidenceId: managedEvidence.evidence_id, + symbols: native.symbols, + hasSupportedNativeEvidence: native.accepted > 0, input, - ), + }), ); const pinvokeImports = verifiedPinvokes.map( ({ verification }) => verification, ); - const counts = countStatuses(pinvokeImports); - const omittedNativeObservations = Math.max( - 0, - input.native_observations.length - input.limits.max_native_observations, - ); - const omittedCandidates = verifiedPinvokes.reduce( - (sum, item) => sum + item.omittedCandidates, - 0, - ); - const coverageStatus = - omittedNativeObservations > 0 || omittedCandidates > 0 - ? "truncated" - : managed.coverage.state === "complete" && native.unsupported === 0 - ? "complete-within-inputs" - : "partial"; - const withoutId = { - schema_version: 1 as const, - algorithm: { - name: "rea-managed-native-verification" as const, - version: 1 as const, - token_identity: "build-local" as const, - token_to_address_mapping: "not-inferred" as const, - }, - managed_boundary: { - evidence_id: managedEvidence.evidence_id, - artifact_sha256: managed.artifact.sha256, - artifact_path: managed.artifact.path, - mvid: managed.module?.mvid ?? null, - metadata_status: managed.metadata.status, - pinvoke_imports_total: managed.pinvoke_imports.total, - native_implementations_total: managed.native_implementations.total, - coverage_state: managed.coverage.state, - }, - native_observations: { - total: input.native_observations.length, - accepted: native.accepted, - unsupported: native.unsupported, - symbols: native.symbols.length, - truncated: omittedNativeObservations > 0, - }, - summary: { - ...counts, - native_body_unresolved: managed.native_implementations.items.filter( - ({ boundary_kind: kind }) => kind !== "pinvoke", - ).length, - }, - pinvoke_imports: pinvokeImports, - native_implementations: { - unresolved: managed.native_implementations.items.filter( - ({ boundary_kind: kind }) => kind !== "pinvoke", - ).length, - reason: - "Managed metadata tokens and RVAs are not translated to native provider addresses by this workflow; C++/CLI, ReadyToRun, and native-body mappings require explicit provider-supported bridge evidence.", - }, - coverage: { - status: coverageStatus, - omitted_native_observations: omittedNativeObservations, - omitted_candidates: omittedCandidates, - }, - evidence_links: [ - managedEvidence.evidence_id, - ...input.native_observations - .slice(0, input.limits.max_native_observations) - .map(({ evidence_id: id }) => id), - ], - limitations: [ - "P/Invoke verification checks declared import names against supplied native export or function-name Evidence only.", - "A matching native symbol verifies that a candidate export/function was observed; it does not prove CLR binding, marshaling behavior, call reachability, or runtime loading.", - "Missing supplied native symbols are reported as unresolved, not proof that a dependency cannot exist.", - "Managed metadata tokens and method RVAs are not interpreted as native addresses.", - ...(managed.coverage.state === "complete" - ? [] - : ["Managed boundary input is partial or unavailable."]), - ...(native.unsupported === 0 - ? [] - : [ - "Some native Evidence operations were unsupported by this workflow.", - ]), - ], - }; + const withoutId = buildVerificationResult({ + managedEvidence, + managed, + native, + pinvokeImports, + verifiedPinvokes, + input, + }); return managedNativeVerificationResultSchema.parse({ ...withoutId, verification_id: `mnv_${sha256(withoutId)}`, }); }; - -const collectNativeSymbols = ( - observations: readonly ManagedNativeVerificationInput["native_observations"][number][], -): { - readonly symbols: readonly NativeSymbol[]; - readonly accepted: number; - readonly unsupported: number; -} => { - const symbols: NativeSymbol[] = []; - let accepted = 0; - let unsupported = 0; - for (const raw of observations) { - const evidence = parseEvidence(raw); - const extracted = symbolsForEvidence(evidence); - if (!extracted.supported) { - unsupported += 1; - } else { - accepted += 1; - symbols.push(...extracted.symbols); - } - } - return { symbols, accepted, unsupported }; -}; - -const symbolsForEvidence = ( - evidence: ReturnType, -): { - readonly supported: boolean; - readonly symbols: readonly NativeSymbol[]; -} => { - if (evidence.operation === "inspect_macho") { - const result = inspectMachoSchema.parse(evidence.normalized_result); - return { - supported: true, - symbols: result.exports.items.map((symbol) => ({ - evidence_id: evidence.evidence_id, - operation: evidence.operation, - name: symbol.name, - address: symbol.address, - module_name: moduleName(evidence), - module_path: modulePath(evidence), - source: "macho-export" as const, - })), - }; - } - if (evidence.operation === "analyze_function") { - const dossier = functionDossierSchema.parse(evidence.normalized_result); - return { - supported: true, - symbols: [ - { - evidence_id: evidence.evidence_id, - operation: evidence.operation, - name: dossier.procedure.name, - address: dossier.procedure.address, - module_name: moduleName(evidence), - module_path: modulePath(evidence), - source: "function-dossier" as const, - }, - ], - }; - } - return { supported: false, symbols: [] }; -}; - -const verifyPinvoke = ( - item: PinvokeImport, - managedEvidenceId: string, - symbols: readonly NativeSymbol[], - hasSupportedNativeEvidence: boolean, - input: ManagedNativeVerificationInput, -): VerifiedPinvoke => { - const managed = { - token: item.token, - member_token: item.member_token, - member_name: item.member_name, - import_name: item.import_name, - import_scope_name: item.import_scope_name, - no_mangle: item.no_mangle, - char_set: item.char_set, - call_convention: item.call_convention, - declaration_verification: item.verification, - }; - const names = candidateNames(managed); - const allCandidates = symbols.filter((symbol) => - names.some((name) => sameSymbolName(name, symbol.name)), - ); - const candidates = allCandidates.slice( - 0, - input.limits.max_candidates_per_import, - ); - const exact = candidates.find( - (symbol) => - sameSymbolName(item.import_name, symbol.name) && - moduleCompatible(item.import_scope_name, symbol), - ); - const decorated = candidates.find((symbol) => - moduleCompatible(item.import_scope_name, symbol), - ); - const selected = exact ?? decorated ?? candidates[0] ?? null; - const basis = - selected === null - ? hasSupportedNativeEvidence - ? "no-native-candidate" - : "unsupported-native-evidence" - : !moduleCompatible(item.import_scope_name, selected) - ? "module-mismatch" - : exact !== undefined - ? selected.source === "macho-export" - ? "exact-export-name" - : "exact-function-name" - : "decorated-name-candidate"; - const status = - selected === null - ? "unresolved" - : basis === "module-mismatch" - ? "contradicted" - : basis === "decorated-name-candidate" - ? "inferred" - : "verified"; - const limitations = [ - ...(item.import_scope_name === null - ? ["Managed declaration does not name an import scope/module."] - : []), - ...(status === "inferred" - ? [ - "Native symbol matched a decorated candidate rather than the exact declared import name.", - ] - : []), - ...(status === "contradicted" - ? [ - "A symbol name candidate exists, but the supplied native Evidence module identity does not match the managed import scope.", - ] - : []), - ...(status === "unresolved" - ? [ - "No supplied native export/function Evidence matched this declared import.", - ] - : []), - ]; - return { - verification: pinvokeVerificationSchema.parse({ - item_id: `mnv_pinvoke_${sha256({ - managedEvidenceId, - token: item.token, - importName: item.import_name, - importScope: item.import_scope_name, - candidates, - })}`, - managed, - status, - basis, - confidence: - status === "verified" - ? "observed" - : status === "inferred" - ? "inferred" - : "unknown", - matched_native: selected, - candidates, - evidence_links: [ - managedEvidenceId, - ...new Set(candidates.map(({ evidence_id: id }) => id)), - ], - limitations, - }), - omittedCandidates: Math.max(0, allCandidates.length - candidates.length), - }; -}; - -const candidateNames = ( - managed: Pick< - PinvokeImport, - "import_name" | "char_set" | "call_convention" | "no_mangle" - >, -): readonly string[] => { - const names = new Set([managed.import_name]); - if (!managed.no_mangle) { - if (managed.char_set === "unicode") names.add(`${managed.import_name}W`); - if (managed.char_set === "ansi") names.add(`${managed.import_name}A`); - if (managed.call_convention === "stdcall") { - names.add(`_${managed.import_name}`); - names.add(`_${managed.import_name}@0`); - } - } - return [...names]; -}; - -const sameSymbolName = (left: string, right: string): boolean => - normalizeSymbol(left) === normalizeSymbol(right); - -const normalizeSymbol = (value: string): string => - value.replace(/^_+/u, "").replace(/@\d+$/u, "").toLowerCase(); - -const moduleCompatible = ( - scope: string | null, - symbol: NativeSymbol, -): boolean => { - if (scope === null) return true; - const expected = normalizeModule(scope); - return [symbol.module_name, symbol.module_path] - .filter((value): value is string => value !== null) - .map(normalizeModule) - .some((value) => value === expected); -}; - -const normalizeModule = (value: string): string => - basename(value) - .toLowerCase() - .replace(/^lib/u, "") - .replace(/\.(dll|dylib|so|node|exe)$/u, ""); - -const moduleName = ( - evidence: ReturnType, -): string | null => evidence.subject?.name ?? null; - -const modulePath = ( - evidence: ReturnType, -): string | null => evidence.subject?.local_path ?? null; - -const countStatuses = ( - items: readonly z.infer[], -): Pick< - ManagedNativeVerificationResult["summary"], - "verified" | "inferred" | "unresolved" | "contradicted" -> => ({ - verified: items.filter(({ status }) => status === "verified").length, - inferred: items.filter(({ status }) => status === "inferred").length, - unresolved: items.filter(({ status }) => status === "unresolved").length, - contradicted: items.filter(({ status }) => status === "contradicted").length, -}); diff --git a/src/domain/managedNativeVerificationMatch.ts b/src/domain/managedNativeVerificationMatch.ts new file mode 100644 index 000000000..3dea36998 --- /dev/null +++ b/src/domain/managedNativeVerificationMatch.ts @@ -0,0 +1,423 @@ +import { createHash } from "node:crypto"; +import { basename } from "node:path"; + +import canonicalize from "canonicalize"; + +import { parseEvidence } from "./evidence.js"; +import { functionDossierSchema } from "./hopperValues.js"; +import { inspectMachoSchema } from "./nativeInspection.js"; +import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js"; +import type { JsonValue } from "./jsonValue.js"; +import { + pinvokeVerificationSchema, + type ManagedNativeVerificationInput, + type ManagedNativeVerificationResult, + type NativeSymbol, + type PinvokeVerification, +} from "./managedNativeVerificationSchemas.js"; + +const digest = (value: JsonValue): string => { + const serialized = canonicalize(value); + if (serialized === undefined) + throw new TypeError("Managed/native verification canonicalization failed"); + return createHash("sha256").update(serialized).digest("hex"); +}; + +type PinvokeImport = + ManagedNativeBoundaryInspection["pinvoke_imports"]["items"][number]; +type VerifiedPinvoke = { + readonly verification: PinvokeVerification; + readonly omittedCandidates: number; +}; + +const moduleName = ( + evidence: ReturnType, +): string | null => evidence.subject?.name ?? null; + +const modulePath = ( + evidence: ReturnType, +): string | null => evidence.subject?.local_path ?? null; + +export const symbolsForEvidence = ( + evidence: ReturnType, +): { + readonly supported: boolean; + readonly symbols: readonly NativeSymbol[]; +} => { + if (evidence.operation === "inspect_macho") { + const result = inspectMachoSchema.parse(evidence.normalized_result); + return { + supported: true, + symbols: result.exports.items.map((symbol) => ({ + evidence_id: evidence.evidence_id, + operation: evidence.operation, + name: symbol.name, + address: symbol.address, + module_name: moduleName(evidence), + module_path: modulePath(evidence), + source: "macho-export" as const, + })), + }; + } + if (evidence.operation === "analyze_function") { + const dossier = functionDossierSchema.parse(evidence.normalized_result); + return { + supported: true, + symbols: [ + { + evidence_id: evidence.evidence_id, + operation: evidence.operation, + name: dossier.procedure.name, + address: dossier.procedure.address, + module_name: moduleName(evidence), + module_path: modulePath(evidence), + source: "function-dossier" as const, + }, + ], + }; + } + return { supported: false, symbols: [] }; +}; + +export const collectNativeSymbols = ( + observations: readonly ManagedNativeVerificationInput["native_observations"][number][], +): { + readonly symbols: readonly NativeSymbol[]; + readonly accepted: number; + readonly unsupported: number; +} => { + const symbols: NativeSymbol[] = []; + let accepted = 0; + let unsupported = 0; + for (const raw of observations) { + const evidence = parseEvidence(raw); + const extracted = symbolsForEvidence(evidence); + if (!extracted.supported) { + unsupported += 1; + } else { + accepted += 1; + symbols.push(...extracted.symbols); + } + } + return { symbols, accepted, unsupported }; +}; + +const normalizeSymbol = (value: string): string => + value.replace(/^_+/u, "").replace(/@\d+$/u, "").toLowerCase(); + +const sameSymbolName = (left: string, right: string): boolean => + normalizeSymbol(left) === normalizeSymbol(right); + +const normalizeModule = (value: string): string => + basename(value) + .toLowerCase() + .replace(/^lib/u, "") + .replace(/\.(dll|dylib|so|node|exe)$/u, ""); + +const moduleCompatible = ( + scope: string | null, + symbol: NativeSymbol, +): boolean => { + if (scope === null) return true; + const expected = normalizeModule(scope); + return [symbol.module_name, symbol.module_path] + .filter((value): value is string => value !== null) + .map(normalizeModule) + .some((value) => value === expected); +}; + +const candidateNames = ( + managed: Pick< + PinvokeImport, + "import_name" | "char_set" | "call_convention" | "no_mangle" + >, +): readonly string[] => { + const names = new Set([managed.import_name]); + if (!managed.no_mangle) { + if (managed.char_set === "unicode") names.add(`${managed.import_name}W`); + if (managed.char_set === "ansi") names.add(`${managed.import_name}A`); + if (managed.call_convention === "stdcall") { + names.add(`_${managed.import_name}`); + names.add(`_${managed.import_name}@0`); + } + } + return [...names]; +}; + +interface CandidateSelection { + readonly allCandidates: readonly NativeSymbol[]; + readonly candidates: readonly NativeSymbol[]; + readonly selected: NativeSymbol | null; + readonly exact: NativeSymbol | undefined; + readonly decorated: NativeSymbol | undefined; +} + +const selectCandidates = ( + managed: PinvokeImport, + symbols: readonly NativeSymbol[], + maxCandidates: number, +): CandidateSelection => { + const names = candidateNames(managed); + const allCandidates = symbols.filter((symbol) => + names.some((name) => sameSymbolName(name, symbol.name)), + ); + const candidates = allCandidates.slice(0, maxCandidates); + const exact = candidates.find( + (symbol) => + sameSymbolName(managed.import_name, symbol.name) && + moduleCompatible(managed.import_scope_name, symbol), + ); + const decorated = candidates.find((symbol) => + moduleCompatible(managed.import_scope_name, symbol), + ); + const selected = exact ?? decorated ?? candidates[0] ?? null; + return { allCandidates, candidates, selected, exact, decorated }; +}; + +interface BasisAndStatus { + readonly basis: PinvokeVerification["basis"]; + readonly status: PinvokeVerification["status"]; + readonly confidence: PinvokeVerification["confidence"]; +} + +const basisAndStatus = ( + managed: PinvokeImport, + selection: CandidateSelection, + hasSupportedNativeEvidence: boolean, +): BasisAndStatus => { + const { selected, exact } = selection; + if (selected === null) + return { + basis: hasSupportedNativeEvidence + ? "no-native-candidate" + : "unsupported-native-evidence", + status: "unresolved", + confidence: "unknown", + }; + if (!moduleCompatible(managed.import_scope_name, selected)) + return { + basis: "module-mismatch", + status: "contradicted", + confidence: "unknown", + }; + if (exact !== undefined) + return { + basis: + selected.source === "macho-export" + ? "exact-export-name" + : "exact-function-name", + status: "verified", + confidence: "observed", + }; + return { + basis: "decorated-name-candidate", + status: "inferred", + confidence: "inferred", + }; +}; + +const pinvokeLimitations = ( + status: PinvokeVerification["status"], + managed: PinvokeImport, +): readonly string[] => [ + ...(managed.import_scope_name === null + ? ["Managed declaration does not name an import scope/module."] + : []), + ...(status === "inferred" + ? [ + "Native symbol matched a decorated candidate rather than the exact declared import name.", + ] + : []), + ...(status === "contradicted" + ? [ + "A symbol name candidate exists, but the supplied native Evidence module identity does not match the managed import scope.", + ] + : []), + ...(status === "unresolved" + ? [ + "No supplied native export/function Evidence matched this declared import.", + ] + : []), +]; + +interface VerifyPinvokeContext { + readonly item: PinvokeImport; + readonly managedEvidenceId: string; + readonly symbols: readonly NativeSymbol[]; + readonly hasSupportedNativeEvidence: boolean; + readonly input: ManagedNativeVerificationInput; +} + +export const verifyPinvoke = ({ + item, + managedEvidenceId, + symbols, + hasSupportedNativeEvidence, + input, +}: VerifyPinvokeContext): VerifiedPinvoke => { + const managed = { + token: item.token, + member_token: item.member_token, + member_name: item.member_name, + import_name: item.import_name, + import_scope_name: item.import_scope_name, + no_mangle: item.no_mangle, + char_set: item.char_set, + call_convention: item.call_convention, + declaration_verification: item.verification, + }; + const selection = selectCandidates( + item, + symbols, + input.limits.max_candidates_per_import, + ); + const { status, basis, confidence } = basisAndStatus( + item, + selection, + hasSupportedNativeEvidence, + ); + const limitations = pinvokeLimitations(status, item); + return { + verification: pinvokeVerificationSchema.parse({ + item_id: `mnv_pinvoke_${digest({ + managedEvidenceId, + token: item.token, + importName: item.import_name, + importScope: item.import_scope_name, + candidates: [...selection.candidates], + })}`, + managed, + status, + basis, + confidence, + matched_native: selection.selected, + candidates: selection.candidates, + evidence_links: [ + managedEvidenceId, + ...new Set(selection.candidates.map(({ evidence_id: id }) => id)), + ], + limitations, + }), + omittedCandidates: Math.max( + 0, + selection.allCandidates.length - selection.candidates.length, + ), + }; +}; + +const countStatuses = ( + items: readonly PinvokeVerification[], +): Pick< + ManagedNativeVerificationResult["summary"], + "verified" | "inferred" | "unresolved" | "contradicted" +> => ({ + verified: items.filter(({ status }) => status === "verified").length, + inferred: items.filter(({ status }) => status === "inferred").length, + unresolved: items.filter(({ status }) => status === "unresolved").length, + contradicted: items.filter(({ status }) => status === "contradicted").length, +}); + +interface VerificationResultInput { + readonly managedEvidence: ReturnType; + readonly managed: ManagedNativeBoundaryInspection; + readonly native: { + readonly symbols: readonly NativeSymbol[]; + readonly accepted: number; + readonly unsupported: number; + }; + readonly pinvokeImports: readonly PinvokeVerification[]; + readonly verifiedPinvokes: readonly VerifiedPinvoke[]; + readonly input: ManagedNativeVerificationInput; +} + +export const buildVerificationResult = ({ + managedEvidence, + managed, + native, + pinvokeImports, + verifiedPinvokes, + input, +}: VerificationResultInput): Omit< + ManagedNativeVerificationResult, + "verification_id" +> => { + const counts = countStatuses(pinvokeImports); + const omittedNativeObservations = Math.max( + 0, + input.native_observations.length - input.limits.max_native_observations, + ); + const omittedCandidates = verifiedPinvokes.reduce( + (sum, item) => sum + item.omittedCandidates, + 0, + ); + const nativeBodyUnresolved = managed.native_implementations.items.filter( + ({ boundary_kind: kind }) => kind !== "pinvoke", + ).length; + const coverageStatus = + omittedNativeObservations > 0 || omittedCandidates > 0 + ? "truncated" + : managed.coverage.state === "complete" && native.unsupported === 0 + ? "complete-within-inputs" + : "partial"; + return { + schema_version: 1 as const, + algorithm: { + name: "rea-managed-native-verification" as const, + version: 1 as const, + token_identity: "build-local" as const, + token_to_address_mapping: "not-inferred" as const, + }, + managed_boundary: { + evidence_id: managedEvidence.evidence_id, + artifact_sha256: managed.artifact.sha256, + artifact_path: managed.artifact.path, + mvid: managed.module?.mvid ?? null, + metadata_status: managed.metadata.status, + pinvoke_imports_total: managed.pinvoke_imports.total, + native_implementations_total: managed.native_implementations.total, + coverage_state: managed.coverage.state, + }, + native_observations: { + total: input.native_observations.length, + accepted: native.accepted, + unsupported: native.unsupported, + symbols: native.symbols.length, + truncated: omittedNativeObservations > 0, + }, + summary: { + ...counts, + native_body_unresolved: nativeBodyUnresolved, + }, + pinvoke_imports: [...pinvokeImports], + native_implementations: { + unresolved: nativeBodyUnresolved, + reason: + "Managed metadata tokens and RVAs are not translated to native provider addresses by this workflow; C++/CLI, ReadyToRun, and native-body mappings require explicit provider-supported bridge evidence.", + }, + coverage: { + status: coverageStatus, + omitted_native_observations: omittedNativeObservations, + omitted_candidates: omittedCandidates, + }, + evidence_links: [ + managedEvidence.evidence_id, + ...input.native_observations + .slice(0, input.limits.max_native_observations) + .map(({ evidence_id: id }) => id), + ], + limitations: [ + "P/Invoke verification checks declared import names against supplied native export or function-name Evidence only.", + "A matching native symbol verifies that a candidate export/function was observed; it does not prove CLR binding, marshaling behavior, call reachability, or runtime loading.", + "Missing supplied native symbols are reported as unresolved, not proof that a dependency cannot exist.", + "Managed metadata tokens and method RVAs are not interpreted as native addresses.", + ...(managed.coverage.state === "complete" + ? [] + : ["Managed boundary input is partial or unavailable."]), + ...(native.unsupported === 0 + ? [] + : [ + "Some native Evidence operations were unsupported by this workflow.", + ]), + ], + }; +}; diff --git a/src/domain/managedNativeVerificationSchemas.ts b/src/domain/managedNativeVerificationSchemas.ts new file mode 100644 index 000000000..346b7848a --- /dev/null +++ b/src/domain/managedNativeVerificationSchemas.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; + +import { evidenceSchema } from "./evidence.js"; +import { cliMetadataGuidSchema } from "./managedArtifact.js"; + +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u); +const boundedTextSchema = z.string().min(1).max(4_096); + +const matchStatusSchema = z.enum([ + "verified", + "inferred", + "unresolved", + "contradicted", +]); + +const matchBasisSchema = z.enum([ + "exact-export-name", + "exact-function-name", + "decorated-name-candidate", + "module-mismatch", + "no-native-candidate", + "unsupported-native-evidence", +]); + +export const nativeVerificationLimitsSchema = z.strictObject({ + max_native_observations: z.number().int().min(1).max(50).default(20), + max_candidates_per_import: z.number().int().min(1).max(100).default(25), +}); + +export const nativeSymbolSchema = z.strictObject({ + evidence_id: evidenceIdSchema, + operation: z.string().min(1), + name: z.string().min(1), + address: z.string().nullable(), + module_name: z.string().nullable(), + module_path: z.string().nullable(), + source: z.enum(["macho-export", "function-dossier"]), +}); + +export type NativeSymbol = z.infer; + +export const pinvokeVerificationSchema = z.strictObject({ + item_id: z.string().regex(/^mnv_pinvoke_[a-f0-9]{64}$/u), + managed: z.strictObject({ + token: tokenSchema, + member_token: tokenSchema.nullable(), + member_name: z.string().nullable(), + import_name: z.string().min(1), + import_scope_name: z.string().nullable(), + no_mangle: z.boolean(), + char_set: z.enum(["not-specified", "ansi", "unicode", "auto", "unknown"]), + call_convention: z.enum([ + "not-specified", + "winapi", + "cdecl", + "stdcall", + "thiscall", + "fastcall", + "unknown", + ]), + declaration_verification: z.literal("managed-declaration-only"), + }), + status: matchStatusSchema, + basis: matchBasisSchema, + confidence: z.enum(["observed", "inferred", "unknown"]), + matched_native: nativeSymbolSchema.nullable(), + candidates: z.array(nativeSymbolSchema).max(100), + evidence_links: z.array(evidenceIdSchema).min(1).max(51), + limitations: z.array(boundedTextSchema).max(100), +}); + +export type PinvokeVerification = z.infer; + +/** Authenticated managed boundary Evidence plus native observations. */ +export const managedNativeVerificationInputSchema = z + .strictObject({ + managed_boundaries: evidenceSchema, + native_observations: z.array(evidenceSchema).min(1).max(50), + limits: nativeVerificationLimitsSchema.default({ + max_native_observations: 20, + max_candidates_per_import: 25, + }), + unknown_registry_approved: z.literal(true).optional(), + }) + .superRefine((input, context) => { + const ids = new Set(); + for (const [index, evidence] of input.native_observations.entries()) { + if (evidence.evidence_id === input.managed_boundaries.evidence_id) { + context.addIssue({ + code: "custom", + path: ["native_observations", index], + message: + "Native observation Evidence must be distinct from managed boundary Evidence", + }); + } + if (ids.has(evidence.evidence_id)) { + context.addIssue({ + code: "custom", + path: ["native_observations", index], + message: "Native observation Evidence IDs must be unique", + }); + } + ids.add(evidence.evidence_id); + } + }); + +export type ManagedNativeVerificationInput = z.infer< + typeof managedNativeVerificationInputSchema +>; + +/** Provider-neutral managed/native verification result. */ +export const managedNativeVerificationResultSchema = z.strictObject({ + schema_version: z.literal(1), + verification_id: z.string().regex(/^mnv_[a-f0-9]{64}$/u), + algorithm: z.strictObject({ + name: z.literal("rea-managed-native-verification"), + version: z.literal(1), + token_identity: z.literal("build-local"), + token_to_address_mapping: z.literal("not-inferred"), + }), + managed_boundary: z.strictObject({ + evidence_id: evidenceIdSchema, + artifact_sha256: digestSchema, + artifact_path: z.string().min(1), + mvid: cliMetadataGuidSchema.nullable(), + metadata_status: z.enum(["absent", "complete", "partial", "malformed"]), + pinvoke_imports_total: z.number().int().min(0), + native_implementations_total: z.number().int().min(0), + coverage_state: z.enum(["complete", "partial", "unavailable"]), + }), + native_observations: z.strictObject({ + total: z.number().int().min(1), + accepted: z.number().int().min(0), + unsupported: z.number().int().min(0), + symbols: z.number().int().min(0), + truncated: z.boolean(), + }), + summary: z.strictObject({ + verified: z.number().int().min(0), + inferred: z.number().int().min(0), + unresolved: z.number().int().min(0), + contradicted: z.number().int().min(0), + native_body_unresolved: z.number().int().min(0), + }), + pinvoke_imports: z.array(pinvokeVerificationSchema).max(50_000), + native_implementations: z.strictObject({ + unresolved: z.number().int().min(0), + reason: boundedTextSchema, + }), + coverage: z.strictObject({ + status: z.enum(["complete-within-inputs", "partial", "truncated"]), + omitted_native_observations: z.number().int().min(0), + omitted_candidates: z.number().int().min(0), + }), + evidence_links: z.array(evidenceIdSchema).min(2).max(51), + limitations: z.array(boundedTextSchema).max(1_000), +}); + +export type ManagedNativeVerificationResult = z.infer< + typeof managedNativeVerificationResultSchema +>; diff --git a/src/domain/managedReconstruction.ts b/src/domain/managedReconstruction.ts index d501e67e6..248d56a9f 100644 --- a/src/domain/managedReconstruction.ts +++ b/src/domain/managedReconstruction.ts @@ -7,6 +7,7 @@ import { evidenceSchema, parseEvidence } from "./evidence.js"; import { cliMetadataGuidSchema, managedMemberInspectionSchema, + type ManagedMemberInspection, } from "./managedArtifact.js"; import type { JsonValue } from "./jsonValue.js"; @@ -39,6 +40,10 @@ const managedReconstructionMethodInputSchema = z.strictObject({ }), }); +type ManagedReconstructionMethodInput = z.infer< + typeof managedReconstructionMethodInputSchema +>; + export const managedReconstructionImportInputSchema = z.strictObject({ static_members: evidenceSchema, decompiler: decompilerSchema, @@ -111,6 +116,11 @@ export type ManagedReconstructionImportResult = z.infer< typeof managedReconstructionImportResultSchema >; +type Member = ManagedMemberInspection["methods"]["items"][number]; +type ImportedMethod = ManagedReconstructionImportResult["methods"][number]; +type StaticObservation = + ManagedReconstructionImportResult["static_observation"]; + const sha256Text = (value: string): string => createHash("sha256").update(value).digest("hex"); @@ -121,6 +131,131 @@ const sha256Json = (value: JsonValue): string => { return createHash("sha256").update(serialized).digest("hex"); }; +const countLines = (text: string): number => + text.length === 0 ? 0 : text.split(/\r\n|\r|\n/u).length; + +const findObservedMethod = ( + members: ManagedMemberInspection, + candidate: ManagedReconstructionMethodInput, +): Member | undefined => + members.methods.items.find( + (item) => + item.token === candidate.token && + item.signature.raw_sha256 === candidate.signature_sha256 && + item.body.status === "present" && + item.body.normalized_il_sha256 !== null && + candidate.normalized_il_sha256 !== null && + item.body.normalized_il_sha256 === candidate.normalized_il_sha256, + ); + +const verifyTextSha256 = ( + candidate: ManagedReconstructionMethodInput, + textSha256: string, +): void => { + if ( + candidate.reconstruction.text_sha256 !== undefined && + candidate.reconstruction.text_sha256 !== textSha256 + ) + throw new TypeError( + `Reconstruction text hash mismatch for ${candidate.token}`, + ); +}; + +const buildImportedMethod = ( + observed: Member, + candidate: ManagedReconstructionMethodInput, + textSha256: string, +): ImportedMethod => ({ + token: observed.token, + declaring_type: observed.declaring_type, + name: observed.name, + signature_sha256: observed.signature.raw_sha256, + normalized_il_sha256: observed.body.normalized_il_sha256, + body_sha256: observed.body.il_sha256, + il_size: observed.body.il_size, + reconstruction: { + kind: candidate.reconstruction.kind, + language: candidate.reconstruction.language, + text: candidate.reconstruction.text, + text_sha256: textSha256, + text_length: candidate.reconstruction.text.length, + line_count: countLines(candidate.reconstruction.text), + source_path: candidate.reconstruction.source_path ?? null, + start_line: candidate.reconstruction.start_line ?? null, + end_line: candidate.reconstruction.end_line ?? null, + }, + validation: { + matched_static_member: true as const, + exact_build_required: true as const, + canonical_observation: false as const, + confidence_floor: "inference" as const, + }, +}); + +const buildReconstructionSummary = ( + methods: readonly ImportedMethod[], +): ManagedReconstructionImportResult["summary"] => ({ + imported_methods: methods.length, + decompiled_csharp_methods: methods.filter( + ({ reconstruction }) => reconstruction.kind === "decompiled-csharp", + ).length, + decompiled_il_methods: methods.filter( + ({ reconstruction }) => reconstruction.kind === "decompiled-il", + ).length, + pseudocode_methods: methods.filter( + ({ reconstruction }) => reconstruction.kind === "semantic-pseudocode", + ).length, + total_text_bytes: methods.reduce( + (total, { reconstruction }) => total + reconstruction.text_length, + 0, + ), +}); + +const buildStaticObservation = ( + staticEvidence: ReturnType, + members: ManagedMemberInspection, +): StaticObservation => ({ + evidence_id: staticEvidence.evidence_id, + artifact_sha256: members.artifact.sha256, + artifact_path: members.artifact.path, + byte_length: members.artifact.byte_length, + module_name: members.module?.name ?? null, + mvid: members.module?.mvid ?? null, + metadata_status: members.metadata.status, +}); + +const buildDecompilerRecord = ( + decompiler: ManagedReconstructionImportInput["decompiler"], +): ManagedReconstructionImportResult["decompiler"] => ({ + ...decompiler, + options_sha256: sha256Json(decompiler.options), +}); + +const RECONSTRUCTION_LIMITATIONS = [ + "Decompiler output is imported as reconstruction evidence and analyst inference; metadata and IL observations remain canonical.", + "This operation did not execute, load, reflect over, debug, instrument, patch, or decompile the target artifact.", + "The imported reconstruction is valid only for the exact artifact SHA-256, MVID, method signature, and normalized IL shape recorded here.", + "Obfuscated names, decompiler variables, and control-flow restructuring are not treated as proof without separate validation.", +]; + +const buildReconstructionWithoutId = ( + input: ManagedReconstructionImportInput, + staticEvidence: ReturnType, + members: ManagedMemberInspection, + methods: readonly ImportedMethod[], +): Omit => ({ + schema_version: 1 as const, + phase: "reconstruction-import" as const, + executed: false as const, + static_observation: buildStaticObservation(staticEvidence, members), + decompiler: buildDecompilerRecord(input.decompiler), + summary: buildReconstructionSummary(methods), + methods: [...methods], + notes: input.notes, + evidence_links: [staticEvidence.evidence_id], + limitations: RECONSTRUCTION_LIMITATIONS, +}); + /** Authenticate decompiler reconstruction against static IL Evidence. */ export const importManagedReconstruction = ( input: ManagedReconstructionImportInput, @@ -132,102 +267,23 @@ export const importManagedReconstruction = ( staticEvidence.normalized_result, ); const methods = input.methods.map((candidate) => { - const observed = members.methods.items.find( - (item) => - item.token === candidate.token && - item.signature.raw_sha256 === candidate.signature_sha256 && - item.body.status === "present" && - item.body.normalized_il_sha256 !== null && - candidate.normalized_il_sha256 !== null && - item.body.normalized_il_sha256 === candidate.normalized_il_sha256, - ); + const observed = findObservedMethod(members, candidate); if (observed === undefined) throw new TypeError( `Reconstruction method lock ${candidate.token} does not match a static managed member observation`, ); const textSha256 = sha256Text(candidate.reconstruction.text); - if ( - candidate.reconstruction.text_sha256 !== undefined && - candidate.reconstruction.text_sha256 !== textSha256 - ) - throw new TypeError( - `Reconstruction text hash mismatch for ${candidate.token}`, - ); - return { - token: observed.token, - declaring_type: observed.declaring_type, - name: observed.name, - signature_sha256: observed.signature.raw_sha256, - normalized_il_sha256: observed.body.normalized_il_sha256, - body_sha256: observed.body.il_sha256, - il_size: observed.body.il_size, - reconstruction: { - kind: candidate.reconstruction.kind, - language: candidate.reconstruction.language, - text: candidate.reconstruction.text, - text_sha256: textSha256, - text_length: candidate.reconstruction.text.length, - line_count: countLines(candidate.reconstruction.text), - source_path: candidate.reconstruction.source_path ?? null, - start_line: candidate.reconstruction.start_line ?? null, - end_line: candidate.reconstruction.end_line ?? null, - }, - validation: { - matched_static_member: true as const, - exact_build_required: true as const, - canonical_observation: false as const, - confidence_floor: "inference" as const, - }, - }; + verifyTextSha256(candidate, textSha256); + return buildImportedMethod(observed, candidate, textSha256); }); - const withoutId = { - schema_version: 1 as const, - phase: "reconstruction-import" as const, - executed: false as const, - static_observation: { - evidence_id: staticEvidence.evidence_id, - artifact_sha256: members.artifact.sha256, - artifact_path: members.artifact.path, - byte_length: members.artifact.byte_length, - module_name: members.module?.name ?? null, - mvid: members.module?.mvid ?? null, - metadata_status: members.metadata.status, - }, - decompiler: { - ...input.decompiler, - options_sha256: sha256Json(input.decompiler.options), - }, - summary: { - imported_methods: methods.length, - decompiled_csharp_methods: methods.filter( - ({ reconstruction }) => reconstruction.kind === "decompiled-csharp", - ).length, - decompiled_il_methods: methods.filter( - ({ reconstruction }) => reconstruction.kind === "decompiled-il", - ).length, - pseudocode_methods: methods.filter( - ({ reconstruction }) => reconstruction.kind === "semantic-pseudocode", - ).length, - total_text_bytes: methods.reduce( - (total, { reconstruction }) => total + reconstruction.text_length, - 0, - ), - }, + const withoutId = buildReconstructionWithoutId( + input, + staticEvidence, + members, methods, - notes: input.notes, - evidence_links: [staticEvidence.evidence_id], - limitations: [ - "Decompiler output is imported as reconstruction evidence and analyst inference; metadata and IL observations remain canonical.", - "This operation did not execute, load, reflect over, debug, instrument, patch, or decompile the target artifact.", - "The imported reconstruction is valid only for the exact artifact SHA-256, MVID, method signature, and normalized IL shape recorded here.", - "Obfuscated names, decompiler variables, and control-flow restructuring are not treated as proof without separate validation.", - ], - }; + ); return managedReconstructionImportResultSchema.parse({ ...withoutId, reconstruction_id: `mre_${sha256Json(withoutId)}`, }); }; - -const countLines = (text: string): number => - text.length === 0 ? 0 : text.split(/\r\n|\r|\n/u).length; diff --git a/src/dotnet/ManagedArtifactInspector.ts b/src/dotnet/ManagedArtifactInspector.ts index e4cdc78e9..ec70d67f4 100644 --- a/src/dotnet/ManagedArtifactInspector.ts +++ b/src/dotnet/ManagedArtifactInspector.ts @@ -161,16 +161,21 @@ const unavailableResult = ( }); }; -const runtimeClassification = ( - layout: ManagedPeLayout, - metadata: ManagedMetadataLayout, +interface RuntimeClassificationInput { + readonly layout: ManagedPeLayout; + readonly metadata: ManagedMetadataLayout; + readonly inventory: ManagedMetadataInventory; +} + +const runtimeFamilyFor = ( + cli: ManagedCliHeader, inventory: ManagedMetadataInventory, + metadata: ManagedMetadataLayout, + layout: ManagedPeLayout, ): Pick< ManagedArtifactInspection["classification"], - "runtime_family" | "implementation" | "managed_architecture" | "evidence" + "runtime_family" | "evidence" > => { - const cli = layout.cli; - if (cli === null) throw new TypeError("Managed classification requires CLI"); const evidence: ManagedArtifactInspection["classification"]["evidence"] = [ { code: "cli-header-observed", @@ -232,22 +237,47 @@ const runtimeClassification = ( : (metadata.table(12)?.offset ?? null), }); } - const implementation = - (cli.flags & 1) === 0 - ? ("cpp-cli-mixed" as const) - : cli.readyToRunSignature - ? ("cil-and-ready-to-run" as const) - : (metadata.rowCounts[6] ?? 0) === 0 - ? ("metadata-only" as const) - : ("cil" as const); - const managedArchitecture = - layout.architecture === "x86" && (cli.flags & 1) !== 0 - ? (cli.flags & 0x0000_0002) !== 0 - ? "x86" - : (cli.flags & 0x0002_0000) !== 0 - ? "anycpu-prefer-32" - : "anycpu" - : layout.architecture; + return { runtime_family: runtimeFamily, evidence }; +}; + +const implementationFor = ( + cli: ManagedCliHeader, + metadata: ManagedMetadataLayout, +): ManagedArtifactInspection["classification"]["implementation"] => { + if ((cli.flags & 1) === 0) return "cpp-cli-mixed"; + if (cli.readyToRunSignature) return "cil-and-ready-to-run"; + return (metadata.rowCounts[6] ?? 0) === 0 ? "metadata-only" : "cil"; +}; + +const managedArchitectureFor = ( + layout: ManagedPeLayout, + cli: ManagedCliHeader, +): ManagedArtifactInspection["classification"]["managed_architecture"] => { + if (layout.architecture !== "x86" || (cli.flags & 1) === 0) + return layout.architecture; + if ((cli.flags & 0x0000_0002) !== 0) return "x86"; + if ((cli.flags & 0x0002_0000) !== 0) return "anycpu-prefer-32"; + return "anycpu"; +}; + +const runtimeClassification = ({ + layout, + metadata, + inventory, +}: RuntimeClassificationInput): Pick< + ManagedArtifactInspection["classification"], + "runtime_family" | "implementation" | "managed_architecture" | "evidence" +> => { + const cli = layout.cli; + if (cli === null) throw new TypeError("Managed classification requires CLI"); + const { runtime_family: runtimeFamily, evidence } = runtimeFamilyFor( + cli, + inventory, + metadata, + layout, + ); + const implementation = implementationFor(cli, metadata); + const managedArchitecture = managedArchitectureFor(layout, cli); evidence.push({ code: "managed-architecture-derived", detail: `Managed architecture ${managedArchitecture} is derived from PE machine and CLI flags.`, @@ -261,13 +291,21 @@ const runtimeClassification = ( }; }; -const partialMetadataResult = ( - target: BinaryTarget, - bytes: Buffer, - layout: ManagedPeLayout, - limits: ManagedInspectionLimits, - issue: ManagedParseIssue, -): ManagedArtifactInspection => ({ +interface PartialMetadataContext { + readonly target: BinaryTarget; + readonly bytes: Buffer; + readonly layout: ManagedPeLayout; + readonly limits: ManagedInspectionLimits; + readonly issue: ManagedParseIssue; +} + +const partialMetadataResult = ({ + target, + bytes, + layout, + limits, + issue, +}: PartialMetadataContext): ManagedArtifactInspection => ({ ...base(target, bytes, layout), classification: { status: "malformed", @@ -338,11 +376,17 @@ export const inspectManagedArtifactBytes = ( const cli = layout.cli; if (cli.metadata.size > limits.maxMetadataBytes) return managedArtifactInspectionSchema.parse( - partialMetadataResult(target, bytes, layout, limits, { - code: "limit-exceeded", - scope: "cli.metadata", - offset: cli.headerOffset + 8, - detail: `CLI metadata size exceeds max_metadata_bytes ${String(limits.maxMetadataBytes)}`, + partialMetadataResult({ + target, + bytes, + layout, + limits, + issue: { + code: "limit-exceeded", + scope: "cli.metadata", + offset: cli.headerOffset + 8, + detail: `CLI metadata size exceeds max_metadata_bytes ${String(limits.maxMetadataBytes)}`, + }, }), ); let metadata: ManagedMetadataLayout; @@ -361,7 +405,7 @@ export const inspectManagedArtifactBytes = ( } catch (cause: unknown) { if (!(cause instanceof ManagedReaderFailure)) throw cause; return managedArtifactInspectionSchema.parse( - partialMetadataResult(target, bytes, layout, limits, cause.issue), + partialMetadataResult({ target, bytes, layout, limits, issue: cause.issue }), ); } const resourceIssues: ManagedParseIssue[] = []; @@ -395,7 +439,7 @@ export const inspectManagedArtifactBytes = ( classification: { status: "managed", container: "pe", - ...runtimeClassification(layout, metadata, inventory), + ...runtimeClassification({ layout, metadata, inventory }), }, metadata: { status: issues.length === 0 ? "complete" : "partial", diff --git a/src/dotnet/ManagedMetadataInventory.ts b/src/dotnet/ManagedMetadataInventory.ts index d7f84aa06..46819b0e8 100644 --- a/src/dotnet/ManagedMetadataInventory.ts +++ b/src/dotnet/ManagedMetadataInventory.ts @@ -4,23 +4,18 @@ import type { } from "../domain/managedArtifact.js"; import { METADATA_TABLE_NAMES, - metadataRowOffset, type ManagedMetadataLayout, type MetadataTableLayout, } from "./ManagedMetadataLayout.js"; import { - MetadataRowCursor, - metadataToken, - readMetadataBlob, - readMetadataGuid, - readMetadataString, - sha256Bytes, - strongNameToken, -} from "./ManagedMetadataHeaps.js"; -import { - ManagedReaderFailure, - managedFailure, -} from "./ManagedReaderFailure.js"; + readAssembly, + readAssemblyReference, + readCustomAttribute, + readModule, + readResource, +} from "./ManagedMetadataInventoryRows.js"; +import { metadataToken } from "./ManagedMetadataHeaps.js"; +import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; type ModuleIdentity = NonNullable; type AssemblyIdentity = NonNullable; @@ -64,469 +59,6 @@ export interface ManagedMetadataInventory { readonly issues: readonly ManagedParseIssue[]; } -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 publicKeyIdentity = ( - bytes: Buffer, - kind: "public-key" | "public-key-token", -): AssemblyIdentity["public_key"] => ({ - kind: bytes.length === 0 ? "none" : kind, - byte_length: bytes.length, - sha256: bytes.length === 0 ? null : sha256Bytes(bytes), - token: - bytes.length === 0 - ? null - : kind === "public-key" - ? strongNameToken(bytes) - : bytes.length === 8 - ? bytes.toString("hex") - : null, -}); - -const readModule = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - maxBytes: number, -): ModuleIdentity | null => { - const table = layout.table(0); - if (table === undefined || table.rowCount === 0) return null; - const cursor = rowCursor(bytes, layout, 0, 1); - const generation = cursor.readUInt16(); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const mvid = readMetadataGuid( - bytes, - layout, - cursor.readIndex(layout.guidIndexSize), - ); - const encId = readMetadataGuid( - bytes, - layout, - cursor.readIndex(layout.guidIndexSize), - ); - const encBaseId = readMetadataGuid( - bytes, - layout, - cursor.readIndex(layout.guidIndexSize), - ); - return { - name, - generation, - mvid, - enc_id: encId, - enc_base_id: encBaseId, - token: metadataToken(0, 1), - row_offset: table.offset, - }; -}; - -const readAssembly = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - maxBytes: number, -): AssemblyIdentity | null => { - const table = layout.table(32); - if (table === undefined || table.rowCount === 0) return null; - const cursor = rowCursor(bytes, layout, 32, 1); - const hashAlgorithm = cursor.readUInt32(); - const version = [ - cursor.readUInt16(), - cursor.readUInt16(), - cursor.readUInt16(), - cursor.readUInt16(), - ].join("."); - const flags = cursor.readUInt32(); - const key = readMetadataBlob( - bytes, - layout, - cursor.readIndex(layout.blobIndexSize), - maxBytes, - ); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const culture = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - return { - name, - version, - culture: culture.length === 0 ? null : culture, - flags, - hash_algorithm: hashAlgorithm, - public_key: publicKeyIdentity( - key, - (flags & 1) === 0 ? "public-key-token" : "public-key", - ), - token: metadataToken(32, 1), - row_offset: table.offset, - }; -}; - -const readAssemblyReference = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - row: number, - maxBytes: number, -): AssemblyReference => { - const cursor = rowCursor(bytes, layout, 35, row); - const version = [ - cursor.readUInt16(), - cursor.readUInt16(), - cursor.readUInt16(), - cursor.readUInt16(), - ].join("."); - const flags = cursor.readUInt32(); - const keyOrToken = readMetadataBlob( - bytes, - layout, - cursor.readIndex(layout.blobIndexSize), - maxBytes, - ); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const culture = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const hashValue = readMetadataBlob( - bytes, - layout, - cursor.readIndex(layout.blobIndexSize), - maxBytes, - ); - return { - name, - version, - culture: culture.length === 0 ? null : culture, - flags, - public_key_or_token: publicKeyIdentity( - keyOrToken, - (flags & 1) === 0 ? "public-key-token" : "public-key", - ), - hash_value_sha256: hashValue.length === 0 ? null : sha256Bytes(hashValue), - hash_value_length: hashValue.length, - token: metadataToken(35, row), - row_offset: metadataRowOffset(layout, 35, row), - }; -}; - -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 readTypeName = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - table: 1 | 2, - row: number, - maxBytes: number, -): string | null => { - const descriptor = layout.table(table); - if (descriptor === undefined || row < 1 || row > descriptor.rowCount) - return null; - const cursor = rowCursor(bytes, layout, table, row); - if (table === 1) cursor.readIndex(layout.codedIndexSize("ResolutionScope")); - else cursor.readUInt32(); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const namespace = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - return namespace.length === 0 ? name : `${namespace}.${name}`; -}; - -const declaringTypeForMethod = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - methodRow: number, - maxBytes: number, -): string | null => { - const types = layout.table(2); - const methods = layout.table(6); - if (types === undefined || methods === undefined) return null; - for (let row = 1; row <= types.rowCount; row += 1) { - const cursor = rowCursor(bytes, layout, 2, row); - cursor.readUInt32(); - const nameIndex = cursor.readIndex(layout.stringIndexSize); - const namespaceIndex = cursor.readIndex(layout.stringIndexSize); - cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); - cursor.readIndex(layout.tableIndexSize(4)); - const methodStart = cursor.readIndex(layout.tableIndexSize(6)); - const methodEnd = - row === types.rowCount - ? methods.rowCount + 1 - : methodListForType(bytes, layout, row + 1); - if (methodRow < methodStart || methodRow >= methodEnd) continue; - const name = readMetadataString(bytes, layout, nameIndex, maxBytes); - const namespace = readMetadataString( - bytes, - layout, - namespaceIndex, - maxBytes, - ); - return namespace.length === 0 ? name : `${namespace}.${name}`; - } - return null; -}; - -const methodListForType = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - row: number, -): number => { - const cursor = rowCursor(bytes, layout, 2, row); - cursor.readUInt32(); - cursor.readIndex(layout.stringIndexSize); - cursor.readIndex(layout.stringIndexSize); - cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); - cursor.readIndex(layout.tableIndexSize(4)); - return cursor.readIndex(layout.tableIndexSize(6)); -}; - -const attributeTypeName = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - rawType: number, - maxBytes: number, -): string | null => { - const tag = rawType & 7; - const row = Math.floor(rawType / 8); - if (row === 0) return null; - if (tag === 2) return declaringTypeForMethod(bytes, layout, row, maxBytes); - if (tag !== 3) return null; - const memberRefs = layout.table(10); - if (memberRefs === undefined || row > memberRefs.rowCount) return null; - const cursor = rowCursor(bytes, layout, 10, row); - const parent = cursor.readIndex(layout.codedIndexSize("MemberRefParent")); - const parentTag = parent & 7; - const parentRow = Math.floor(parent / 8); - if (parentTag === 0) - return readTypeName(bytes, layout, 2, parentRow, maxBytes); - if (parentTag === 1) - return readTypeName(bytes, layout, 1, parentRow, maxBytes); - if (parentTag === 3) - return declaringTypeForMethod(bytes, layout, parentRow, maxBytes); - return null; -}; - -const decodeCompressedLength = ( - blob: Buffer, - offset: number, -): { readonly length: number; readonly prefix: number } | undefined => { - const first = blob[offset]; - if (first === undefined) return undefined; - if ((first & 0x80) === 0) return { length: first, prefix: 1 }; - if ((first & 0xc0) === 0x80 && blob[offset + 1] !== undefined) - return { - length: ((first & 0x3f) << 8) | (blob[offset + 1] ?? 0), - prefix: 2, - }; - if ( - (first & 0xe0) === 0xc0 && - blob[offset + 1] !== undefined && - blob[offset + 2] !== undefined && - blob[offset + 3] !== undefined - ) - return { - length: - (first & 0x1f) * 0x01_00_00_00 + - (blob[offset + 1] ?? 0) * 0x01_00_00 + - (blob[offset + 2] ?? 0) * 0x0100 + - (blob[offset + 3] ?? 0), - prefix: 4, - }; - return undefined; -}; - -const decodeFixedString = (blob: Buffer): string | null => { - if (blob.length < 3 || blob.readUInt16LE(0) !== 1 || blob[2] === 0xff) - return null; - const length = decodeCompressedLength(blob, 2); - if (length === undefined) return null; - const start = 2 + length.prefix; - if (start > blob.length - length.length) return null; - try { - return new TextDecoder("utf-8", { fatal: true }).decode( - blob.subarray(start, start + length.length), - ); - } catch { - return null; - } -}; - -const readCustomAttribute = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - row: number, - maxBytes: number, -): CustomAttribute => { - const cursor = rowCursor(bytes, layout, 12, row); - const parentRaw = cursor.readIndex( - layout.codedIndexSize("HasCustomAttribute"), - ); - const typeRaw = cursor.readIndex( - layout.codedIndexSize("CustomAttributeType"), - ); - const value = readMetadataBlob( - bytes, - layout, - cursor.readIndex(layout.blobIndexSize), - maxBytes, - ); - const parent = codedToken( - parentRaw, - 5, - [ - 6, 4, 1, 2, 8, 9, 10, 0, 14, 23, 20, 17, 26, 27, 32, 35, 38, 39, 40, 42, - 44, 43, - ], - ); - if (parent === null) - throw managedFailure( - "invalid-row", - "metadata.CustomAttribute", - "CustomAttribute parent coded index is invalid", - cursor.start, - ); - const typeName = attributeTypeName(bytes, layout, typeRaw, maxBytes); - return { - parent_token: parent, - constructor_token: codedToken(typeRaw, 3, [undefined, undefined, 6, 10]), - type_name: typeName, - value_length: value.length, - value_sha256: sha256Bytes(value), - decoded_fixed_string: - typeName === "System.Runtime.Versioning.TargetFrameworkAttribute" - ? decodeFixedString(value) - : null, - token: metadataToken(12, row), - row_offset: cursor.start, - }; -}; - -const readResource = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - row: number, - maxBytes: number, - directory: ManagedResourceDirectory | null, - issues: ManagedParseIssue[], -): ManagedResource => { - const cursor = rowCursor(bytes, layout, 40, row); - const declaredOffset = cursor.readUInt32(); - const flags = cursor.readUInt32(); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const implementationRaw = cursor.readIndex( - layout.codedIndexSize("Implementation"), - ); - const implementationToken = codedToken(implementationRaw, 2, [38, 35, 39]); - let dataLength: number | null = null; - let dataSha256: string | null = null; - if (implementationRaw === 0) { - if (directory === null || declaredOffset > directory.size - 4) { - issues.push({ - code: "invalid-resource", - scope: `metadata.ManifestResource:${metadataToken(40, row)}`, - offset: directory?.offset ?? null, - detail: "Embedded resource offset leaves the CLI resources directory", - }); - } else { - const start = directory.offset + declaredOffset; - dataLength = bytes.readUInt32LE(start); - if (dataLength > directory.size - declaredOffset - 4) { - issues.push({ - code: "invalid-resource", - scope: `metadata.ManifestResource:${metadataToken(40, row)}`, - offset: start, - detail: "Embedded resource length leaves the CLI resources directory", - }); - } else if (dataLength > maxBytes) { - issues.push({ - code: "limit-exceeded", - scope: `metadata.ManifestResource:${metadataToken(40, row)}`, - offset: start + 4, - detail: `Embedded resource exceeds max_heap_item_bytes ${String(maxBytes)}; digest omitted`, - }); - } else { - dataSha256 = sha256Bytes( - bytes.subarray(start + 4, start + 4 + dataLength), - ); - } - } - } - return { - name, - flags, - visibility: - (flags & 7) === 1 ? "public" : (flags & 7) === 2 ? "private" : "unknown", - implementation_token: implementationToken, - embedded: implementationRaw === 0, - declared_offset: declaredOffset, - data_length: dataLength, - data_sha256: dataSha256, - token: metadataToken(40, row), - row_offset: cursor.start, - }; -}; - const safeRead = ( operation: () => Value, issues: ManagedParseIssue[], @@ -570,6 +102,111 @@ const uniqueIssues = ( ...new Map(issues.map((issue) => [JSON.stringify(issue), issue])).values(), ]; +const readPagedReferences = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + input: ManagedInventoryInput, + issues: ManagedParseIssue[], +): ManagedPage => + pageRows( + layout.table(35), + input.referenceOffset, + input.referenceLimit, + (row) => + safeRead( + () => readAssemblyReference(bytes, layout, row, input.maxHeapItemBytes), + issues, + ), + ); + +const readPagedResources = ({ + bytes, + layout, + input, + resourceDirectory, + issues, +}: { + readonly bytes: Buffer; + readonly layout: ManagedMetadataLayout; + readonly input: ManagedInventoryInput; + readonly resourceDirectory: ManagedResourceDirectory | null; + readonly issues: ManagedParseIssue[]; +}): ManagedPage => + pageRows(layout.table(40), input.resourceOffset, input.resourceLimit, (row) => + safeRead( + () => + readResource({ + bytes, + layout, + row, + maxBytes: input.maxHeapItemBytes, + directory: resourceDirectory, + issues, + }), + issues, + ), + ); + +const readPagedAttributes = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + input: ManagedInventoryInput, + issues: ManagedParseIssue[], +): ManagedPage => + pageRows( + layout.table(12), + input.attributeOffset, + input.attributeLimit, + (row) => + safeRead( + () => readCustomAttribute(bytes, layout, row, input.maxHeapItemBytes), + issues, + ), + ); + +const collectReferenceNames = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + maxBytes: number, + issues: ManagedParseIssue[], +): readonly string[] => { + const names: string[] = []; + const referenceTable = layout.table(35); + for (let row = 1; row <= (referenceTable?.rowCount ?? 0); row += 1) { + const reference = safeRead( + () => readAssemblyReference(bytes, layout, row, maxBytes), + issues, + ); + if (reference !== undefined) names.push(reference.name); + } + return names; +}; + +const collectTargetFrameworks = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + maxBytes: number, + issues: ManagedParseIssue[], +): readonly string[] => { + const targetFrameworks: string[] = []; + const attributeTable = layout.table(12); + for (let row = 1; row <= (attributeTable?.rowCount ?? 0); row += 1) { + const attribute = safeRead( + () => readCustomAttribute(bytes, layout, row, maxBytes), + issues, + ); + if ( + attribute?.parent_token === metadataToken(32, 1) && + attribute.type_name === + "System.Runtime.Versioning.TargetFrameworkAttribute" && + attribute.decoded_fixed_string !== null && + !targetFrameworks.includes(attribute.decoded_fixed_string) + ) + targetFrameworks.push(attribute.decoded_fixed_string); + } + return targetFrameworks; +}; + /** Inventory bounded identity tables without CLR reflection or execution. */ export const readManagedMetadataInventory = ( bytes: Buffer, @@ -586,73 +223,31 @@ export const readManagedMetadataInventory = ( () => readAssembly(bytes, layout, input.maxHeapItemBytes), issues, ) ?? null; - const references = pageRows( - layout.table(35), - input.referenceOffset, - input.referenceLimit, - (row) => - safeRead( - () => readAssemblyReference(bytes, layout, row, input.maxHeapItemBytes), - issues, - ), + const references = readPagedReferences(bytes, layout, input, issues); + const resources = readPagedResources({ + bytes, + layout, + input, + resourceDirectory, + issues, + }); + const attributes = readPagedAttributes(bytes, layout, input, issues); + const referenceNames = collectReferenceNames( + bytes, + layout, + input.maxHeapItemBytes, + issues, ); - const resources = pageRows( - layout.table(40), - input.resourceOffset, - input.resourceLimit, - (row) => - safeRead( - () => - readResource( - bytes, - layout, - row, - input.maxHeapItemBytes, - resourceDirectory, - issues, - ), - issues, - ), + const targetFrameworks = collectTargetFrameworks( + bytes, + layout, + input.maxHeapItemBytes, + issues, ); - const attributes = pageRows( - layout.table(12), - input.attributeOffset, - input.attributeLimit, - (row) => - safeRead( - () => readCustomAttribute(bytes, layout, row, input.maxHeapItemBytes), - issues, - ), - ); - const referenceNames: string[] = []; - const referenceTable = layout.table(35); - for (let row = 1; row <= (referenceTable?.rowCount ?? 0); row += 1) { - const reference = safeRead( - () => readAssemblyReference(bytes, layout, row, input.maxHeapItemBytes), - issues, - ); - if (reference !== undefined) referenceNames.push(reference.name); - } - const targetFrameworks: string[] = []; - const attributeTable = layout.table(12); - for (let row = 1; row <= (attributeTable?.rowCount ?? 0); row += 1) { - const attribute = safeRead( - () => readCustomAttribute(bytes, layout, row, input.maxHeapItemBytes), - issues, - ); - if ( - attribute?.parent_token === metadataToken(32, 1) && - attribute.type_name === - "System.Runtime.Versioning.TargetFrameworkAttribute" && - attribute.decoded_fixed_string !== null && - !targetFrameworks.includes(attribute.decoded_fixed_string) - ) - targetFrameworks.push(attribute.decoded_fixed_string); - } return { module, assembly, - targetFrameworks: targetFrameworks.sort(), + targetFrameworks: [...targetFrameworks].sort(), referenceNames: [...new Set(referenceNames)].sort(), references, resources, diff --git a/src/dotnet/ManagedMetadataInventoryRows.ts b/src/dotnet/ManagedMetadataInventoryRows.ts new file mode 100644 index 000000000..e5c0ce250 --- /dev/null +++ b/src/dotnet/ManagedMetadataInventoryRows.ts @@ -0,0 +1,513 @@ +import type { + ManagedArtifactInspection, + ManagedParseIssue, +} from "../domain/managedArtifact.js"; +import { + metadataRowOffset, + type ManagedMetadataLayout, +} from "./ManagedMetadataLayout.js"; +import { + MetadataRowCursor, + metadataToken, + readMetadataBlob, + readMetadataGuid, + readMetadataString, + sha256Bytes, + strongNameToken, +} from "./ManagedMetadataHeaps.js"; +import { managedFailure } from "./ManagedReaderFailure.js"; +import type { ManagedResourceDirectory } from "./ManagedMetadataInventory.js"; + +type ModuleIdentity = NonNullable; +type AssemblyIdentity = NonNullable; +type AssemblyReference = + ManagedArtifactInspection["references"]["items"][number]; +type ManagedResource = ManagedArtifactInspection["resources"]["items"][number]; +type CustomAttribute = ManagedArtifactInspection["attributes"]["items"][number]; + +interface RowCursorContext { + readonly bytes: Buffer; + readonly layout: ManagedMetadataLayout; + readonly table: number; + readonly row: number; +} + +const rowCursor = ({ + bytes, + layout, + table, + row, +}: RowCursorContext): 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 publicKeyIdentity = ( + bytes: Buffer, + kind: "public-key" | "public-key-token", +): AssemblyIdentity["public_key"] => ({ + kind: bytes.length === 0 ? "none" : kind, + byte_length: bytes.length, + sha256: bytes.length === 0 ? null : sha256Bytes(bytes), + token: + bytes.length === 0 + ? null + : kind === "public-key" + ? strongNameToken(bytes) + : bytes.length === 8 + ? bytes.toString("hex") + : null, +}); + +export const readModule = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + maxBytes: number, +): ModuleIdentity | null => { + const table = layout.table(0); + if (table === undefined || table.rowCount === 0) return null; + const cursor = rowCursor({ bytes, layout, table: 0, row: 1 }); + const generation = cursor.readUInt16(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const mvid = readMetadataGuid( + bytes, + layout, + cursor.readIndex(layout.guidIndexSize), + ); + const encId = readMetadataGuid( + bytes, + layout, + cursor.readIndex(layout.guidIndexSize), + ); + const encBaseId = readMetadataGuid( + bytes, + layout, + cursor.readIndex(layout.guidIndexSize), + ); + return { + name, + generation, + mvid, + enc_id: encId, + enc_base_id: encBaseId, + token: metadataToken(0, 1), + row_offset: table.offset, + }; +}; + +export const readAssembly = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + maxBytes: number, +): AssemblyIdentity | null => { + const table = layout.table(32); + if (table === undefined || table.rowCount === 0) return null; + const cursor = rowCursor({ bytes, layout, table: 32, row: 1 }); + const hashAlgorithm = cursor.readUInt32(); + const version = [ + cursor.readUInt16(), + cursor.readUInt16(), + cursor.readUInt16(), + cursor.readUInt16(), + ].join("."); + const flags = cursor.readUInt32(); + const key = readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + maxBytes, + ); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const culture = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + return { + name, + version, + culture: culture.length === 0 ? null : culture, + flags, + hash_algorithm: hashAlgorithm, + public_key: publicKeyIdentity( + key, + (flags & 1) === 0 ? "public-key-token" : "public-key", + ), + token: metadataToken(32, 1), + row_offset: table.offset, + }; +}; + +export const readAssemblyReference = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + row: number, + maxBytes: number, +): AssemblyReference => { + const cursor = rowCursor({ bytes, layout, table: 35, row }); + const version = [ + cursor.readUInt16(), + cursor.readUInt16(), + cursor.readUInt16(), + cursor.readUInt16(), + ].join("."); + const flags = cursor.readUInt32(); + const keyOrToken = readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + maxBytes, + ); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const culture = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const hashValue = readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + maxBytes, + ); + return { + name, + version, + culture: culture.length === 0 ? null : culture, + flags, + public_key_or_token: publicKeyIdentity( + keyOrToken, + (flags & 1) === 0 ? "public-key-token" : "public-key", + ), + hash_value_sha256: hashValue.length === 0 ? null : sha256Bytes(hashValue), + hash_value_length: hashValue.length, + token: metadataToken(35, row), + row_offset: metadataRowOffset(layout, 35, row), + }; +}; + +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); +}; + +interface ReadTypeNameContext { + readonly bytes: Buffer; + readonly layout: ManagedMetadataLayout; + readonly table: 1 | 2; + readonly row: number; + readonly maxBytes: number; +} + +const readTypeName = ({ + bytes, + layout, + table, + row, + maxBytes, +}: ReadTypeNameContext): string | null => { + const descriptor = layout.table(table); + if (descriptor === undefined || row < 1 || row > descriptor.rowCount) + return null; + const cursor = rowCursor({ bytes, layout, table, row }); + if (table === 1) cursor.readIndex(layout.codedIndexSize("ResolutionScope")); + else cursor.readUInt32(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const namespace = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + return namespace.length === 0 ? name : `${namespace}.${name}`; +}; + +const declaringTypeForMethod = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + methodRow: number, + maxBytes: number, +): string | null => { + const types = layout.table(2); + const methods = layout.table(6); + if (types === undefined || methods === undefined) return null; + for (let row = 1; row <= types.rowCount; row += 1) { + const cursor = rowCursor({ bytes, layout, table: 2, row }); + cursor.readUInt32(); + const nameIndex = cursor.readIndex(layout.stringIndexSize); + const namespaceIndex = cursor.readIndex(layout.stringIndexSize); + cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); + cursor.readIndex(layout.tableIndexSize(4)); + const methodStart = cursor.readIndex(layout.tableIndexSize(6)); + const methodEnd = + row === types.rowCount + ? methods.rowCount + 1 + : methodListForType(bytes, layout, row + 1); + if (methodRow < methodStart || methodRow >= methodEnd) continue; + const name = readMetadataString(bytes, layout, nameIndex, maxBytes); + const namespace = readMetadataString( + bytes, + layout, + namespaceIndex, + maxBytes, + ); + return namespace.length === 0 ? name : `${namespace}.${name}`; + } + return null; +}; + +const methodListForType = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + row: number, +): number => { + const cursor = rowCursor({ bytes, layout, table: 2, row }); + cursor.readUInt32(); + cursor.readIndex(layout.stringIndexSize); + cursor.readIndex(layout.stringIndexSize); + cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); + cursor.readIndex(layout.tableIndexSize(4)); + return cursor.readIndex(layout.tableIndexSize(6)); +}; + +const attributeTypeName = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + rawType: number, + maxBytes: number, +): string | null => { + const tag = rawType & 7; + const row = Math.floor(rawType / 8); + if (row === 0) return null; + if (tag === 2) return declaringTypeForMethod(bytes, layout, row, maxBytes); + if (tag !== 3) return null; + const memberRefs = layout.table(10); + if (memberRefs === undefined || row > memberRefs.rowCount) return null; + const cursor = rowCursor({ bytes, layout, table: 10, row }); + const parent = cursor.readIndex(layout.codedIndexSize("MemberRefParent")); + const parentTag = parent & 7; + const parentRow = Math.floor(parent / 8); + if (parentTag === 0) + return readTypeName({ bytes, layout, table: 2, row: parentRow, maxBytes }); + if (parentTag === 1) + return readTypeName({ bytes, layout, table: 1, row: parentRow, maxBytes }); + if (parentTag === 3) + return declaringTypeForMethod(bytes, layout, parentRow, maxBytes); + return null; +}; + +const decodeCompressedLength = ( + blob: Buffer, + offset: number, +): { readonly length: number; readonly prefix: number } | undefined => { + const first = blob[offset]; + if (first === undefined) return undefined; + if ((first & 0x80) === 0) return { length: first, prefix: 1 }; + if ((first & 0xc0) === 0x80 && blob[offset + 1] !== undefined) + return { + length: ((first & 0x3f) << 8) | (blob[offset + 1] ?? 0), + prefix: 2, + }; + if ( + (first & 0xe0) === 0xc0 && + blob[offset + 1] !== undefined && + blob[offset + 2] !== undefined && + blob[offset + 3] !== undefined + ) + return { + length: + (first & 0x1f) * 0x01_00_00_00 + + (blob[offset + 1] ?? 0) * 0x01_00_00 + + (blob[offset + 2] ?? 0) * 0x0100 + + (blob[offset + 3] ?? 0), + prefix: 4, + }; + return undefined; +}; + +const decodeFixedString = (blob: Buffer): string | null => { + if (blob.length < 3 || blob.readUInt16LE(0) !== 1 || blob[2] === 0xff) + return null; + const length = decodeCompressedLength(blob, 2); + if (length === undefined) return null; + const start = 2 + length.prefix; + if (start > blob.length - length.length) return null; + try { + return new TextDecoder("utf-8", { fatal: true }).decode( + blob.subarray(start, start + length.length), + ); + } catch { + return null; + } +}; + +export const readCustomAttribute = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + row: number, + maxBytes: number, +): CustomAttribute => { + const cursor = rowCursor({ bytes, layout, table: 12, row }); + const parentRaw = cursor.readIndex( + layout.codedIndexSize("HasCustomAttribute"), + ); + const typeRaw = cursor.readIndex( + layout.codedIndexSize("CustomAttributeType"), + ); + const value = readMetadataBlob( + bytes, + layout, + cursor.readIndex(layout.blobIndexSize), + maxBytes, + ); + const parent = codedToken( + parentRaw, + 5, + [ + 6, 4, 1, 2, 8, 9, 10, 0, 14, 23, 20, 17, 26, 27, 32, 35, 38, 39, 40, 42, + 44, 43, + ], + ); + if (parent === null) + throw managedFailure( + "invalid-row", + "metadata.CustomAttribute", + "CustomAttribute parent coded index is invalid", + cursor.start, + ); + const typeName = attributeTypeName(bytes, layout, typeRaw, maxBytes); + return { + parent_token: parent, + constructor_token: codedToken(typeRaw, 3, [undefined, undefined, 6, 10]), + type_name: typeName, + value_length: value.length, + value_sha256: sha256Bytes(value), + decoded_fixed_string: + typeName === "System.Runtime.Versioning.TargetFrameworkAttribute" + ? decodeFixedString(value) + : null, + token: metadataToken(12, row), + row_offset: cursor.start, + }; +}; + +interface ReadResourceContext { + readonly bytes: Buffer; + readonly layout: ManagedMetadataLayout; + readonly row: number; + readonly maxBytes: number; + readonly directory: ManagedResourceDirectory | null; + readonly issues: ManagedParseIssue[]; +} + +export const readResource = ({ + bytes, + layout, + row, + maxBytes, + directory, + issues, +}: ReadResourceContext): ManagedResource => { + const cursor = rowCursor({ bytes, layout, table: 40, row }); + const declaredOffset = cursor.readUInt32(); + const flags = cursor.readUInt32(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const implementationRaw = cursor.readIndex( + layout.codedIndexSize("Implementation"), + ); + const implementationToken = codedToken(implementationRaw, 2, [38, 35, 39]); + let dataLength: number | null = null; + let dataSha256: string | null = null; + if (implementationRaw === 0) { + if (directory === null || declaredOffset > directory.size - 4) { + issues.push({ + code: "invalid-resource", + scope: `metadata.ManifestResource:${metadataToken(40, row)}`, + offset: directory?.offset ?? null, + detail: "Embedded resource offset leaves the CLI resources directory", + }); + } else { + const start = directory.offset + declaredOffset; + dataLength = bytes.readUInt32LE(start); + if (dataLength > directory.size - declaredOffset - 4) { + issues.push({ + code: "invalid-resource", + scope: `metadata.ManifestResource:${metadataToken(40, row)}`, + offset: start, + detail: "Embedded resource length leaves the CLI resources directory", + }); + } else if (dataLength > maxBytes) { + issues.push({ + code: "limit-exceeded", + scope: `metadata.ManifestResource:${metadataToken(40, row)}`, + offset: start + 4, + detail: `Embedded resource exceeds max_heap_item_bytes ${String(maxBytes)}; digest omitted`, + }); + } else { + dataSha256 = sha256Bytes( + bytes.subarray(start + 4, start + 4 + dataLength), + ); + } + } + } + return { + name, + flags, + visibility: + (flags & 7) === 1 ? "public" : (flags & 7) === 2 ? "private" : "unknown", + implementation_token: implementationToken, + embedded: implementationRaw === 0, + declared_offset: declaredOffset, + data_length: dataLength, + data_sha256: dataSha256, + token: metadataToken(40, row), + row_offset: cursor.start, + }; +}; diff --git a/src/dotnet/ManagedMetadataLayout.ts b/src/dotnet/ManagedMetadataLayout.ts index 0ce818223..29c6551fd 100644 --- a/src/dotnet/ManagedMetadataLayout.ts +++ b/src/dotnet/ManagedMetadataLayout.ts @@ -1,4 +1,8 @@ import { managedFailure } from "./ManagedReaderFailure.js"; +import { + computeRowSize, + type RowSizeContext, +} from "./ManagedMetadataRowSizes.js"; export const METADATA_TABLE_NAMES = [ "Module", @@ -80,7 +84,7 @@ export interface ManagedMetadataLayout { codedIndexSize(name: CodedIndexName): 2 | 4; } -type CodedIndexName = keyof typeof CODED_INDEXES; +export type CodedIndexName = keyof typeof CODED_INDEXES; const CODED_INDEXES = { TypeDefOrRef: { bits: 2, tables: [2, 1, 27] }, @@ -196,129 +200,15 @@ const codedSize = ( const tableSize = (index: number, rows: readonly number[]): 2 | 4 => (rows[index] ?? 0) < 0x1_0000 ? 2 : 4; -const rowSize = ( - table: number, - rowCounts: readonly number[], - heaps: { readonly string: 2 | 4; readonly guid: 2 | 4; readonly blob: 2 | 4 }, -): number => { - const t = (index: number): number => tableSize(index, rowCounts); - const c = (name: CodedIndexName): number => codedSize(name, rowCounts); - const { string: s, guid: g, blob: b } = heaps; - switch (table) { - case 0: - return 2 + s + g * 3; - case 1: - return c("ResolutionScope") + s * 2; - case 2: - return 4 + s * 2 + c("TypeDefOrRef") + t(4) + t(6); - case 3: - return t(4); - case 4: - return 2 + s + b; - case 5: - return t(6); - case 6: - return 8 + s + b + t(8); - case 7: - return t(8); - case 8: - return 4 + s; - case 9: - return t(2) + c("TypeDefOrRef"); - case 10: - return c("MemberRefParent") + s + b; - case 11: - return 2 + c("HasConstant") + b; - case 12: - return c("HasCustomAttribute") + c("CustomAttributeType") + b; - case 13: - return c("HasFieldMarshal") + b; - case 14: - return 2 + c("HasDeclSecurity") + b; - case 15: - return 6 + t(2); - case 16: - return 4 + t(4); - case 17: - return b; - case 18: - return t(2) + t(20); - case 19: - return t(20); - case 20: - return 2 + s + c("TypeDefOrRef"); - case 21: - return t(2) + t(23); - case 22: - return t(23); - case 23: - return 2 + s + b; - case 24: - return 2 + t(6) + c("HasSemantics"); - case 25: - return t(2) + c("MethodDefOrRef") * 2; - case 26: - return s; - case 27: - return b; - case 28: - return 2 + c("MemberForwarded") + s + t(26); - case 29: - return 4 + t(4); - case 30: - return 8; - case 31: - return 4; - case 32: - return 16 + b + s * 2; - case 33: - return 4; - case 34: - return 12; - case 35: - return 12 + b * 2 + s * 2; - case 36: - return 4 + t(35); - case 37: - return 12 + t(35); - case 38: - return 4 + s + b; - case 39: - return 8 + s * 2 + c("Implementation"); - case 40: - return 8 + s + c("Implementation"); - case 41: - return t(2) * 2; - case 42: - return 4 + c("TypeOrMethodDef") + s; - case 43: - return c("MethodDefOrRef") + b; - case 44: - return t(42) + c("TypeDefOrRef"); - default: - throw managedFailure( - "invalid-tables", - "metadata.tables", - `Metadata table ${String(table)} is unsupported`, - ); - } -}; +interface ParsedStreams { + readonly streams: ReadonlyMap; + readonly version: string; +} -/** Parse ECMA-335 stream and table layout with checked byte extents. */ -export const readManagedMetadataLayout = ( - artifact: Buffer, +const readMetadataStreams = ( + bytes: Buffer, rootOffset: number, - size: number, - maxTableRows: number, -): ManagedMetadataLayout => { - if (size < 20 || rootOffset < 0 || rootOffset > artifact.length - size) - throw managedFailure( - "invalid-metadata-root", - "metadata.root", - "CLI metadata directory leaves the artifact byte range", - rootOffset, - ); - const bytes = artifact.subarray(rootOffset, rootOffset + size); +): ParsedStreams => { if (bytes.readUInt32LE(0) !== 0x424a_5342) throw managedFailure( "invalid-metadata-root", @@ -365,6 +255,26 @@ export const readManagedMetadataLayout = ( }); cursor = named.next; } + return { streams, version }; +}; + +interface HeapSizes { + readonly string: 2 | 4; + readonly guid: 2 | 4; + readonly blob: 2 | 4; +} + +interface ParsedTablesHeader { + readonly tableBytes: Buffer; + readonly tablesOffset: number; + readonly heaps: HeapSizes; + readonly valid: bigint; +} + +const readTablesHeader = ( + bytes: Buffer, + streams: ReadonlyMap, +): ParsedTablesHeader => { const tablesStream = streamByName(streams, ["#~", "#-"]); if (tablesStream === undefined) throw managedFailure( @@ -372,7 +282,7 @@ export const readManagedMetadataLayout = ( "metadata.tables", "Required metadata table stream #~ or #- is absent", ); - const tableBytes = artifact.subarray( + const tableBytes = bytes.subarray( tablesStream.offset, tablesStream.offset + tablesStream.size, ); @@ -391,6 +301,15 @@ export const readManagedMetadataLayout = ( "Metadata valid mask names unsupported tables outside the admitted ECMA-335 range", tablesStream.offset + 8, ); + return { tableBytes, tablesOffset: tablesStream.offset, heaps, valid }; +}; + +const readRowCounts = ( + tableBytes: Buffer, + valid: bigint, + maxTableRows: number, + tablesOffset: number, +): readonly number[] => { const rowCounts = Array(METADATA_TABLE_NAMES.length).fill(0); let tableCursor = 24; for (let index = 0; index < METADATA_TABLE_NAMES.length; index += 1) { @@ -402,23 +321,56 @@ export const readManagedMetadataLayout = ( "limit-exceeded", `metadata.${METADATA_TABLE_NAMES[index] ?? String(index)}`, `Metadata table row count ${String(count)} exceeds max_table_rows ${String(maxTableRows)}`, - tablesStream.offset + tableCursor, + tablesOffset + tableCursor, ); rowCounts[index] = count; tableCursor += 4; } + return rowCounts; +}; + +const buildRowSizeContext = ( + rowCounts: readonly number[], + heaps: HeapSizes, +): RowSizeContext => ({ + tableSize: (index) => tableSize(index, rowCounts), + codedSize: (name) => codedSize(name, rowCounts), + stringSize: heaps.string, + guidSize: heaps.guid, + blobSize: heaps.blob, +}); + +const buildMetadataTables = ({ + tableBytes, + rowCounts, + heaps, + valid, + tablesOffset, +}: { + readonly tableBytes: Buffer; + readonly rowCounts: readonly number[]; + readonly heaps: HeapSizes; + readonly valid: bigint; + readonly tablesOffset: number; +}): ReadonlyMap => { const tables = new Map(); + const context = buildRowSizeContext(rowCounts, heaps); + let tableCursor = 24; + for (let index = 0; index < METADATA_TABLE_NAMES.length; index += 1) { + if ((valid & (1n << BigInt(index))) === 0n) continue; + tableCursor += 4; + } for (let index = 0; index < METADATA_TABLE_NAMES.length; index += 1) { const count = rowCounts[index] ?? 0; if ((valid & (1n << BigInt(index))) === 0n) continue; - const sizeOfRow = rowSize(index, rowCounts, heaps); + const sizeOfRow = computeRowSize(index, context); const byteLength = count * sizeOfRow; if (!Number.isSafeInteger(byteLength)) throw managedFailure( "invalid-tables", "metadata.tables", "Metadata table byte size overflowed", - tablesStream.offset + tableCursor, + tablesOffset + tableCursor, ); requireRange(tableBytes, tableCursor, byteLength, "metadata.table-data"); tables.set(index, { @@ -426,11 +378,47 @@ export const readManagedMetadataLayout = ( name: METADATA_TABLE_NAMES[index] ?? `Table${String(index)}`, rowCount: count, rowSize: sizeOfRow, - offset: tablesStream.offset + tableCursor, + offset: tablesOffset + tableCursor, }); tableCursor += byteLength; } - const layout: ManagedMetadataLayout = { + return tables; +}; + +/** Parse ECMA-335 stream and table layout with checked byte extents. */ +export const readManagedMetadataLayout = ( + artifact: Buffer, + rootOffset: number, + size: number, + maxTableRows: number, +): ManagedMetadataLayout => { + if (size < 20 || rootOffset < 0 || rootOffset > artifact.length - size) + throw managedFailure( + "invalid-metadata-root", + "metadata.root", + "CLI metadata directory leaves the artifact byte range", + rootOffset, + ); + const bytes = artifact.subarray(rootOffset, rootOffset + size); + const { streams, version } = readMetadataStreams(bytes, rootOffset); + const { tableBytes, tablesOffset, heaps, valid } = readTablesHeader( + artifact, + streams, + ); + const rowCounts = readRowCounts( + tableBytes, + valid, + maxTableRows, + tablesOffset, + ); + const tables = buildMetadataTables({ + tableBytes, + rowCounts, + heaps, + valid, + tablesOffset, + }); + return { rootOffset, size, version, @@ -447,7 +435,6 @@ export const readManagedMetadataLayout = ( tableIndexSize: (index) => tableSize(index, rowCounts), codedIndexSize: (name) => codedSize(name, rowCounts), }; - return layout; }; /** Resolve a one-based metadata row to its exact file offset. */ diff --git a/src/dotnet/ManagedMetadataRowSizes.ts b/src/dotnet/ManagedMetadataRowSizes.ts new file mode 100644 index 000000000..738defb44 --- /dev/null +++ b/src/dotnet/ManagedMetadataRowSizes.ts @@ -0,0 +1,78 @@ +import { managedFailure } from "./ManagedReaderFailure.js"; +import type { CodedIndexName } from "./ManagedMetadataLayout.js"; + +export interface RowSizeContext { + readonly tableSize: (index: number) => number; + readonly codedSize: (name: CodedIndexName) => number; + readonly stringSize: 2 | 4; + readonly guidSize: 2 | 4; + readonly blobSize: 2 | 4; +} + +type RowSizeFactory = (context: RowSizeContext) => number; + +const ROW_SIZE_FACTORIES: readonly RowSizeFactory[] = [ + ({ stringSize: s, guidSize: g }) => 2 + s + g * 3, + ({ codedSize: c, stringSize: s }) => c("ResolutionScope") + s * 2, + ({ tableSize: t, codedSize: c, stringSize: s }) => + 4 + s * 2 + c("TypeDefOrRef") + t(4) + t(6), + ({ tableSize: t }) => t(4), + ({ stringSize: s, blobSize: b }) => 2 + s + b, + ({ tableSize: t }) => t(6), + ({ tableSize: t, stringSize: s, blobSize: b }) => 8 + s + b + t(8), + ({ tableSize: t }) => t(8), + ({ stringSize: s }) => 4 + s, + ({ tableSize: t, codedSize: c }) => t(2) + c("TypeDefOrRef"), + ({ codedSize: c, stringSize: s, blobSize: b }) => + c("MemberRefParent") + s + b, + ({ codedSize: c, blobSize: b }) => 2 + c("HasConstant") + b, + ({ codedSize: c, blobSize: b }) => + c("HasCustomAttribute") + c("CustomAttributeType") + b, + ({ codedSize: c, blobSize: b }) => c("HasFieldMarshal") + b, + ({ codedSize: c, blobSize: b }) => 2 + c("HasDeclSecurity") + b, + ({ tableSize: t }) => 6 + t(2), + ({ tableSize: t }) => 4 + t(4), + ({ blobSize: b }) => b, + ({ tableSize: t }) => t(2) + t(20), + ({ tableSize: t }) => t(20), + ({ stringSize: s, codedSize: c }) => 2 + s + c("TypeDefOrRef"), + ({ tableSize: t }) => t(2) + t(23), + ({ tableSize: t }) => t(23), + ({ stringSize: s, blobSize: b }) => 2 + s + b, + ({ tableSize: t, codedSize: c }) => 2 + t(6) + c("HasSemantics"), + ({ tableSize: t, codedSize: c }) => t(2) + c("MethodDefOrRef") * 2, + ({ stringSize: s }) => s, + ({ blobSize: b }) => b, + ({ tableSize: t, codedSize: c, stringSize: s }) => + 2 + c("MemberForwarded") + s + t(26), + ({ tableSize: t }) => 4 + t(4), + () => 8, + () => 4, + ({ blobSize: b, stringSize: s }) => 16 + b + s * 2, + () => 4, + () => 12, + ({ blobSize: b, stringSize: s }) => 12 + b * 2 + s * 2, + ({ tableSize: t }) => 4 + t(35), + ({ tableSize: t }) => 12 + t(35), + ({ stringSize: s, blobSize: b }) => 4 + s + b, + ({ stringSize: s, codedSize: c }) => 8 + s * 2 + c("Implementation"), + ({ stringSize: s, codedSize: c }) => 8 + s + c("Implementation"), + ({ tableSize: t }) => t(2) * 2, + ({ stringSize: s, codedSize: c }) => 4 + c("TypeOrMethodDef") + s, + ({ codedSize: c, blobSize: b }) => c("MethodDefOrRef") + b, + ({ tableSize: t, codedSize: c }) => t(42) + c("TypeDefOrRef"), +]; + +export const computeRowSize = ( + table: number, + context: RowSizeContext, +): number => { + const factory = ROW_SIZE_FACTORIES[table]; + if (factory === undefined) + throw managedFailure( + "invalid-tables", + "metadata.tables", + `Metadata table ${String(table)} is unsupported`, + ); + return factory(context); +}; diff --git a/src/dotnet/ManagedNativeBoundaryHelpers.ts b/src/dotnet/ManagedNativeBoundaryHelpers.ts new file mode 100644 index 000000000..b7311e844 --- /dev/null +++ b/src/dotnet/ManagedNativeBoundaryHelpers.ts @@ -0,0 +1,499 @@ +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import { + managedNativeBoundaryInspectionSchema, + type ManagedNativeBoundaryInspection, + type ManagedParseIssue, +} from "../domain/managedArtifact.js"; +import { + metadataRowOffset, + type ManagedMetadataLayout, +} from "./ManagedMetadataLayout.js"; +import { + MetadataRowCursor, + metadataToken, + readMetadataString, +} from "./ManagedMetadataHeaps.js"; +import { managedTableRowCounts } from "./ManagedMetadataInventory.js"; +import type { ManagedMetadataInventory } from "./ManagedMetadataInventory.js"; +import { managedFailure } from "./ManagedReaderFailure.js"; +import type { ManagedNativeBoundaryInspectionLimits } from "./ManagedNativeBoundaryInspector.js"; +import type { ManagedPeLayout } from "./ManagedPeReader.js"; + +type Page = { + readonly items: readonly Item[]; + readonly offset: number; + readonly limit: number; + readonly total: number; + readonly returned: number; + readonly dropped: number; + readonly complete: boolean; +}; + +type ModuleRef = + ManagedNativeBoundaryInspection["module_refs"]["items"][number]; +type NativeImport = + ManagedNativeBoundaryInspection["pinvoke_imports"]["items"][number]; +type NativeImplementation = + ManagedNativeBoundaryInspection["native_implementations"]["items"][number]; + +interface MemberCore { + readonly token: string; + readonly rowOffset: number; + readonly kind: NativeImport["member_kind"]; + readonly name: string; + readonly flags: number; + readonly implFlags: number | null; + readonly rva: number | null; +} + +interface RowCursorContext { + readonly bytes: Buffer; + readonly layout: ManagedMetadataLayout; + readonly table: number; + readonly row: number; +} + +const rowCursor = ({ + bytes, + layout, + table, + row, +}: RowCursorContext): 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, + tagBits: number, + tables: readonly number[], +): string | null => { + const tagMask = (1 << tagBits) - 1; + const table = tables[raw & tagMask]; + const row = raw >> tagBits; + if (table === undefined || row === 0) return null; + return metadataToken(table, row); +}; + +const flagsHex = (value: number): string => + `0x${value.toString(16).padStart(4, "0")}`; + +const page = ( + items: readonly Item[], + offset: number, + limit: number, +): Page => { + const safeOffset = Math.min(offset, items.length); + const selected = items.slice(safeOffset, safeOffset + limit); + return { + items: selected, + offset, + limit, + total: items.length, + returned: selected.length, + dropped: Math.max(0, items.length - safeOffset - selected.length), + complete: safeOffset === 0 && selected.length === items.length, + }; +}; + +export const parseModuleRefs = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + maxBytes: number, +): readonly ModuleRef[] => { + const refs: ModuleRef[] = []; + const table = layout.table(26); + for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + const cursor = rowCursor({ bytes, layout, table: 26, row }); + refs.push({ + token: metadataToken(26, row), + row_offset: cursor.start, + name: readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ), + }); + } + return refs; +}; + +export const parseFields = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + maxBytes: number, +): ReadonlyMap => { + const fields = new Map(); + const table = layout.table(4); + for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + const cursor = rowCursor({ bytes, layout, table: 4, row }); + const flags = cursor.readUInt16(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + cursor.readIndex(layout.blobIndexSize); + const token = metadataToken(4, row); + fields.set(token, { + token, + rowOffset: cursor.start, + kind: "field", + name, + flags, + implFlags: null, + rva: null, + }); + } + return fields; +}; + +export const parseMethods = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + maxBytes: number, +): ReadonlyMap => { + const methods = new Map(); + const table = layout.table(6); + for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + const cursor = rowCursor({ bytes, layout, table: 6, row }); + const rva = cursor.readUInt32(); + const implFlags = cursor.readUInt16(); + const flags = cursor.readUInt16(); + const name = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + cursor.readIndex(layout.blobIndexSize); + cursor.readIndex(layout.tableIndexSize(8)); + const token = metadataToken(6, row); + methods.set(token, { + token, + rowOffset: cursor.start, + kind: "method", + name, + flags, + implFlags, + rva, + }); + } + return methods; +}; + +interface ParseImplMapsContext { + readonly bytes: Buffer; + readonly layout: ManagedMetadataLayout; + readonly maxBytes: number; + readonly modules: readonly ModuleRef[]; + readonly members: ReadonlyMap; +} + +export const parseImplMaps = ({ + bytes, + layout, + maxBytes, + modules, + members, +}: ParseImplMapsContext): readonly NativeImport[] => { + const moduleNames = new Map(modules.map((module) => [module.token, module])); + const imports: NativeImport[] = []; + const table = layout.table(28); + for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + const cursor = rowCursor({ bytes, layout, table: 28, row }); + const mappingFlags = cursor.readUInt16(); + const memberToken = codedToken( + cursor.readIndex(layout.codedIndexSize("MemberForwarded")), + 1, + [4, 6], + ); + const importName = readMetadataString( + bytes, + layout, + cursor.readIndex(layout.stringIndexSize), + maxBytes, + ); + const importScopeRow = cursor.readIndex(layout.tableIndexSize(26)); + const importScopeToken = + importScopeRow === 0 ? null : metadataToken(26, importScopeRow); + const member = memberToken === null ? undefined : members.get(memberToken); + imports.push({ + token: metadataToken(28, row), + row_offset: cursor.start, + mapping_flags: mappingFlags, + mapping_flags_hex: flagsHex(mappingFlags), + member_token: memberToken, + member_kind: member?.kind ?? "unknown", + member_name: member?.name ?? null, + import_name: importName, + import_scope_token: importScopeToken, + import_scope_name: + importScopeToken === null + ? null + : (moduleNames.get(importScopeToken)?.name ?? null), + no_mangle: (mappingFlags & 0x0001) !== 0, + char_set: charSet(mappingFlags), + call_convention: callConvention(mappingFlags), + supports_last_error: (mappingFlags & 0x0040) !== 0, + best_fit: bestFit(mappingFlags), + throw_on_unmappable_char: throwOnUnmappable(mappingFlags), + verification: "managed-declaration-only", + }); + } + return imports; +}; + +const charSet = (flags: number): NativeImport["char_set"] => { + switch (flags & 0x0006) { + case 0x0000: + return "not-specified"; + case 0x0002: + return "ansi"; + case 0x0004: + return "unicode"; + case 0x0006: + return "auto"; + default: + return "unknown"; + } +}; + +const callConvention = (flags: number): NativeImport["call_convention"] => { + switch (flags & 0x0700) { + case 0x0000: + return "not-specified"; + case 0x0100: + return "winapi"; + case 0x0200: + return "cdecl"; + case 0x0300: + return "stdcall"; + case 0x0400: + return "thiscall"; + case 0x0500: + return "fastcall"; + default: + return "unknown"; + } +}; + +const bestFit = (flags: number): NativeImport["best_fit"] => { + switch (flags & 0x0030) { + case 0x0000: + return "assembly-default"; + case 0x0010: + return "enabled"; + case 0x0020: + return "disabled"; + default: + return "unknown"; + } +}; + +const throwOnUnmappable = ( + flags: number, +): NativeImport["throw_on_unmappable_char"] => { + switch (flags & 0x3000) { + case 0x0000: + return "assembly-default"; + case 0x1000: + return "enabled"; + case 0x2000: + return "disabled"; + default: + return "unknown"; + } +}; + +export const nativeImplementations = ( + methods: Iterable, + pinvokeTokens: ReadonlySet, +): readonly NativeImplementation[] => { + const implementations: NativeImplementation[] = []; + for (const method of methods) { + const implFlags = method.implFlags ?? 0; + const codeType = codeTypeFor(implFlags); + const managedKind = managedKindFor(implFlags); + const pinvokeDeclared = + pinvokeTokens.has(method.token) || (method.flags & 0x2000) !== 0; + if (!pinvokeDeclared && codeType === "il" && managedKind === "managed") + continue; + implementations.push({ + token: method.token, + row_offset: method.rowOffset, + name: method.name, + rva: method.rva ?? 0, + flags: method.flags, + impl_flags: implFlags, + code_type: codeType, + managed_kind: managedKind, + pinvoke_declared: pinvokeDeclared, + boundary_kind: boundaryKind(pinvokeDeclared, codeType, managedKind), + body_interpretation: + codeType === "il" && managedKind === "managed" && !pinvokeDeclared + ? "managed-cil" + : method.rva === 0 + ? "not-file-backed" + : "native-or-runtime", + }); + } + return implementations; +}; + +const codeTypeFor = (implFlags: number): NativeImplementation["code_type"] => { + switch (implFlags & 0x0003) { + case 0x0000: + return "il"; + case 0x0001: + return "native"; + case 0x0002: + return "optil"; + case 0x0003: + return "runtime"; + default: + return "unknown"; + } +}; + +const managedKindFor = ( + implFlags: number, +): NativeImplementation["managed_kind"] => { + switch (implFlags & 0x0004) { + case 0x0000: + return "managed"; + case 0x0004: + return "unmanaged"; + default: + return "unknown"; + } +}; + +const boundaryKind = ( + pinvokeDeclared: boolean, + codeType: NativeImplementation["code_type"], + managedKind: NativeImplementation["managed_kind"], +): NativeImplementation["boundary_kind"] => { + if (pinvokeDeclared) return "pinvoke"; + if (codeType === "native") return "native-body"; + if (codeType === "runtime") return "runtime-provided"; + if (managedKind === "unmanaged") return "unmanaged-method"; + return "mixed-or-unknown"; +}; + +export const cliNative = ( + pe: ManagedPeLayout, +): ManagedNativeBoundaryInspection["cli_native"] => { + if (pe.cli === null) + return { + il_only: false, + requires_32bit: false, + strong_name_signed: false, + native_entry_point: false, + ready_to_run_signature: false, + managed_native_header_rva: 0, + managed_native_header_size: 0, + }; + return { + il_only: (pe.cli.flags & 0x0000_0001) !== 0, + requires_32bit: (pe.cli.flags & 0x0000_0002) !== 0, + strong_name_signed: (pe.cli.flags & 0x0000_0008) !== 0, + native_entry_point: (pe.cli.flags & 0x0000_0010) !== 0, + ready_to_run_signature: pe.cli.readyToRunSignature, + managed_native_header_rva: pe.cli.managedNativeHeader.rva, + managed_native_header_size: pe.cli.managedNativeHeader.size, + }; +}; + +interface BoundaryInspectionContext { + readonly target: BinaryTarget; + readonly bytes: Buffer; + readonly limits: ManagedNativeBoundaryInspectionLimits; + readonly pe: ManagedPeLayout; + readonly layout: ManagedMetadataLayout; + readonly inventory: ManagedMetadataInventory; + readonly moduleRefs: readonly ModuleRef[]; + readonly imports: readonly NativeImport[]; + readonly implementations: readonly NativeImplementation[]; + readonly native: ManagedNativeBoundaryInspection["cli_native"]; + readonly issues: readonly ManagedParseIssue[]; +} + +export const buildNativeBoundaryInspection = ({ + target, + bytes, + limits, + layout, + inventory, + moduleRefs, + imports, + implementations, + native, + issues, +}: BoundaryInspectionContext): ManagedNativeBoundaryInspection => + managedNativeBoundaryInspectionSchema.parse({ + schema_version: 1, + artifact: { + path: target.path, + sha256: target.sha256, + byte_length: bytes.length, + format: "pe", + }, + module: inventory.module, + metadata: { + status: issues.length === 0 ? "complete" : "partial", + version: layout.version, + table_row_counts: managedTableRowCounts(layout), + }, + identity_scope: { + token_identity: "build-local", + requires_artifact_sha256: target.sha256, + requires_mvid: inventory.module?.mvid ?? null, + }, + cli_native: native, + module_refs: page( + moduleRefs, + limits.moduleRefOffset, + limits.moduleRefLimit, + ), + pinvoke_imports: page(imports, limits.importOffset, limits.importLimit), + native_implementations: page( + implementations, + limits.implementationOffset, + limits.implementationLimit, + ), + summary: { + module_ref_count: moduleRefs.length, + pinvoke_import_count: imports.length, + native_implementation_count: implementations.length, + ready_to_run: native.ready_to_run_signature, + mixed_mode_or_native_header: + native.managed_native_header_rva !== 0 || + native.managed_native_header_size !== 0 || + native.native_entry_point, + }, + coverage: { + state: issues.length === 0 ? "complete" : "partial", + issues, + }, + limitations: [ + "P/Invoke rows prove managed import declarations only; this inspection does not verify that a native library, export, thunk, or provider-qualified function exists.", + "Managed metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.", + "ReadyToRun, NativeAOT, C++/CLI, and IL2CPP native semantics require separately selected native-provider evidence; this tool does not translate managed tokens into native addresses.", + ], + }); diff --git a/src/dotnet/ManagedNativeBoundaryInspector.ts b/src/dotnet/ManagedNativeBoundaryInspector.ts index 2ea871c7f..915d9eca3 100644 --- a/src/dotnet/ManagedNativeBoundaryInspector.ts +++ b/src/dotnet/ManagedNativeBoundaryInspector.ts @@ -4,30 +4,29 @@ import { type ManagedNativeBoundaryInspection, type ManagedParseIssue, } from "../domain/managedArtifact.js"; -import { - metadataRowOffset, - type ManagedMetadataLayout, - readManagedMetadataLayout, -} from "./ManagedMetadataLayout.js"; -import { - MetadataRowCursor, - metadataToken, - readMetadataString, -} from "./ManagedMetadataHeaps.js"; import { readManagedMetadataInventory, - managedTableRowCounts, + type ManagedResourceDirectory, } from "./ManagedMetadataInventory.js"; +import { + readManagedMetadataLayout, + type ManagedMetadataLayout, +} from "./ManagedMetadataLayout.js"; import { readManagedPeLayout, type ManagedPeLayout, } from "./ManagedPeReader.js"; +import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; import { - ManagedReaderFailure, - managedFailure, -} from "./ManagedReaderFailure.js"; + buildNativeBoundaryInspection, + cliNative, + nativeImplementations, + parseFields, + parseImplMaps, + parseMethods, + parseModuleRefs, +} from "./ManagedNativeBoundaryHelpers.js"; -/** Bounds for execution-free managed/native boundary inspection. */ export interface ManagedNativeBoundaryInspectionLimits { readonly moduleRefOffset: number; readonly moduleRefLimit: number; @@ -40,396 +39,25 @@ export interface ManagedNativeBoundaryInspectionLimits { readonly maxHeapItemBytes: number; } -type Page = { - readonly items: readonly Item[]; - readonly offset: number; - readonly limit: number; - readonly total: number; - readonly returned: number; - readonly dropped: number; - readonly complete: boolean; -}; -type ModuleRef = - ManagedNativeBoundaryInspection["module_refs"]["items"][number]; -type NativeImport = - ManagedNativeBoundaryInspection["pinvoke_imports"]["items"][number]; -type NativeImplementation = - ManagedNativeBoundaryInspection["native_implementations"]["items"][number]; +type Inventory = ReturnType; -interface MemberCore { - readonly token: string; - readonly rowOffset: number; - readonly kind: NativeImport["member_kind"]; - readonly name: string; - readonly flags: number; - readonly implFlags: number | null; - readonly rva: number | null; -} +const emptyPage = (): ManagedNativeBoundaryInspection["module_refs"] & { + items: readonly Item[]; +} => ({ + items: [], + offset: 0, + limit: 0, + total: 0, + returned: 0, + dropped: 0, + complete: true, +}); -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, - tagBits: number, - tables: readonly number[], -): string | null => { - const tagMask = (1 << tagBits) - 1; - const table = tables[raw & tagMask]; - const row = raw >> tagBits; - if (table === undefined || row === 0) return null; - return metadataToken(table, row); -}; - -const flagsHex = (value: number): string => - `0x${value.toString(16).padStart(4, "0")}`; - -const page = ( - items: readonly Item[], - offset: number, - limit: number, -): Page => { - const safeOffset = Math.min(offset, items.length); - const selected = items.slice(safeOffset, safeOffset + limit); - return { - items: selected, - offset, - limit, - total: items.length, - returned: selected.length, - dropped: Math.max(0, items.length - safeOffset - selected.length), - complete: safeOffset === 0 && selected.length === items.length, - }; -}; - -const parseModuleRefs = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - maxBytes: number, -): readonly ModuleRef[] => { - const refs: ModuleRef[] = []; - const table = layout.table(26); - for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { - const cursor = rowCursor(bytes, layout, 26, row); - refs.push({ - token: metadataToken(26, row), - row_offset: cursor.start, - name: readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ), - }); - } - return refs; -}; - -const parseFields = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - maxBytes: number, -): ReadonlyMap => { - const fields = 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, - ); - cursor.readIndex(layout.blobIndexSize); - const token = metadataToken(4, row); - fields.set(token, { - token, - rowOffset: cursor.start, - kind: "field", - name, - flags, - implFlags: null, - rva: null, - }); - } - return fields; -}; - -const parseMethods = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - maxBytes: number, -): ReadonlyMap => { - const methods = 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, - ); - cursor.readIndex(layout.blobIndexSize); - cursor.readIndex(layout.tableIndexSize(8)); - const token = metadataToken(6, row); - methods.set(token, { - token, - rowOffset: cursor.start, - kind: "method", - name, - flags, - implFlags, - rva, - }); - } - return methods; -}; - -const parseImplMaps = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - maxBytes: number, - modules: readonly ModuleRef[], - members: ReadonlyMap, -): readonly NativeImport[] => { - const moduleNames = new Map(modules.map((module) => [module.token, module])); - const imports: NativeImport[] = []; - const table = layout.table(28); - for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { - const cursor = rowCursor(bytes, layout, 28, row); - const mappingFlags = cursor.readUInt16(); - const memberToken = codedToken( - cursor.readIndex(layout.codedIndexSize("MemberForwarded")), - 1, - [4, 6], - ); - const importName = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - maxBytes, - ); - const importScopeRow = cursor.readIndex(layout.tableIndexSize(26)); - const importScopeToken = - importScopeRow === 0 ? null : metadataToken(26, importScopeRow); - const member = memberToken === null ? undefined : members.get(memberToken); - imports.push({ - token: metadataToken(28, row), - row_offset: cursor.start, - mapping_flags: mappingFlags, - mapping_flags_hex: flagsHex(mappingFlags), - member_token: memberToken, - member_kind: member?.kind ?? "unknown", - member_name: member?.name ?? null, - import_name: importName, - import_scope_token: importScopeToken, - import_scope_name: - importScopeToken === null - ? null - : (moduleNames.get(importScopeToken)?.name ?? null), - no_mangle: (mappingFlags & 0x0001) !== 0, - char_set: charSet(mappingFlags), - call_convention: callConvention(mappingFlags), - supports_last_error: (mappingFlags & 0x0040) !== 0, - best_fit: bestFit(mappingFlags), - throw_on_unmappable_char: throwOnUnmappable(mappingFlags), - verification: "managed-declaration-only", - }); - } - return imports; -}; - -const charSet = (flags: number): NativeImport["char_set"] => { - switch (flags & 0x0006) { - case 0x0000: - return "not-specified"; - case 0x0002: - return "ansi"; - case 0x0004: - return "unicode"; - case 0x0006: - return "auto"; - default: - return "unknown"; - } -}; - -const callConvention = (flags: number): NativeImport["call_convention"] => { - switch (flags & 0x0700) { - case 0x0000: - return "not-specified"; - case 0x0100: - return "winapi"; - case 0x0200: - return "cdecl"; - case 0x0300: - return "stdcall"; - case 0x0400: - return "thiscall"; - case 0x0500: - return "fastcall"; - default: - return "unknown"; - } -}; - -const bestFit = (flags: number): NativeImport["best_fit"] => { - switch (flags & 0x0030) { - case 0x0000: - return "assembly-default"; - case 0x0010: - return "enabled"; - case 0x0020: - return "disabled"; - default: - return "unknown"; - } -}; - -const throwOnUnmappable = ( - flags: number, -): NativeImport["throw_on_unmappable_char"] => { - switch (flags & 0x3000) { - case 0x0000: - return "assembly-default"; - case 0x1000: - return "enabled"; - case 0x2000: - return "disabled"; - default: - return "unknown"; - } -}; - -const nativeImplementations = ( - methods: Iterable, - pinvokeTokens: ReadonlySet, -): readonly NativeImplementation[] => { - const implementations: NativeImplementation[] = []; - for (const method of methods) { - const implFlags = method.implFlags ?? 0; - const codeType = codeTypeFor(implFlags); - const managedKind = managedKindFor(implFlags); - const pinvokeDeclared = - pinvokeTokens.has(method.token) || (method.flags & 0x2000) !== 0; - if (!pinvokeDeclared && codeType === "il" && managedKind === "managed") - continue; - implementations.push({ - token: method.token, - row_offset: method.rowOffset, - name: method.name, - rva: method.rva ?? 0, - flags: method.flags, - impl_flags: implFlags, - code_type: codeType, - managed_kind: managedKind, - pinvoke_declared: pinvokeDeclared, - boundary_kind: boundaryKind(pinvokeDeclared, codeType, managedKind), - body_interpretation: - codeType === "il" && managedKind === "managed" && !pinvokeDeclared - ? "managed-cil" - : method.rva === 0 - ? "not-file-backed" - : "native-or-runtime", - }); - } - return implementations; -}; - -const codeTypeFor = (implFlags: number): NativeImplementation["code_type"] => { - switch (implFlags & 0x0003) { - case 0x0000: - return "il"; - case 0x0001: - return "native"; - case 0x0002: - return "optil"; - case 0x0003: - return "runtime"; - default: - return "unknown"; - } -}; - -const managedKindFor = ( - implFlags: number, -): NativeImplementation["managed_kind"] => { - switch (implFlags & 0x0004) { - case 0x0000: - return "managed"; - case 0x0004: - return "unmanaged"; - default: - return "unknown"; - } -}; - -const boundaryKind = ( - pinvokeDeclared: boolean, - codeType: NativeImplementation["code_type"], - managedKind: NativeImplementation["managed_kind"], -): NativeImplementation["boundary_kind"] => { - if (pinvokeDeclared) return "pinvoke"; - if (codeType === "native") return "native-body"; - if (codeType === "runtime") return "runtime-provided"; - if (managedKind === "unmanaged") return "unmanaged-method"; - return "mixed-or-unknown"; -}; - -const cliNative = ( - pe: ManagedPeLayout, -): ManagedNativeBoundaryInspection["cli_native"] => { - if (pe.cli === null) - return { - il_only: false, - requires_32bit: false, - strong_name_signed: false, - native_entry_point: false, - ready_to_run_signature: false, - managed_native_header_rva: 0, - managed_native_header_size: 0, - }; - return { - il_only: (pe.cli.flags & 0x0000_0001) !== 0, - requires_32bit: (pe.cli.flags & 0x0000_0002) !== 0, - strong_name_signed: (pe.cli.flags & 0x0000_0008) !== 0, - native_entry_point: (pe.cli.flags & 0x0000_0010) !== 0, - ready_to_run_signature: pe.cli.readyToRunSignature, - managed_native_header_rva: pe.cli.managedNativeHeader.rva, - managed_native_header_size: pe.cli.managedNativeHeader.size, - }; -}; - -const unavailable = ( +const emptyInspection = ( target: BinaryTarget, bytes: Buffer, - limits: ManagedNativeBoundaryInspectionLimits, - issue: ManagedParseIssue | null, + classification: "not-managed" | "malformed" | "not-available" | "managed", + issues: readonly ManagedParseIssue[] = [], ): ManagedNativeBoundaryInspection => managedNativeBoundaryInspectionSchema.parse({ schema_version: 1, @@ -440,7 +68,14 @@ const unavailable = ( format: "pe", }, module: null, - metadata: { status: "absent", version: null, table_row_counts: {} }, + metadata: { + status: + classification === "malformed" || classification === "not-available" + ? "malformed" + : "absent", + version: null, + table_row_counts: {}, + }, identity_scope: { token_identity: "build-local", requires_artifact_sha256: target.sha256, @@ -455,13 +90,9 @@ const unavailable = ( managed_native_header_rva: 0, managed_native_header_size: 0, }, - module_refs: page([], limits.moduleRefOffset, limits.moduleRefLimit), - pinvoke_imports: page([], limits.importOffset, limits.importLimit), - native_implementations: page( - [], - limits.implementationOffset, - limits.implementationLimit, - ), + module_refs: emptyPage(), + pinvoke_imports: emptyPage(), + native_implementations: emptyPage(), summary: { module_ref_count: 0, pinvoke_import_count: 0, @@ -469,132 +100,132 @@ const unavailable = ( ready_to_run: false, mixed_mode_or_native_header: false, }, - coverage: { - state: "unavailable", - issues: issue === null ? [] : [issue], - }, + coverage: { state: classification, issues }, limitations: [ - "The PE has no admitted CLI metadata; managed/native boundary inspection is unavailable.", + "No CLI data was admitted; native boundary declarations are unavailable.", + "Static inspection does not load or execute target code, so native export resolution is not performed.", ], }); -/** Inspect PE/CLI managed/native boundary declarations without executing code. */ +const readBoundaryInventory = ( + bytes: Buffer, + pe: ManagedPeLayout, + limits: ManagedNativeBoundaryInspectionLimits, +): { + readonly layout: ManagedMetadataLayout; + readonly inventory: Inventory; +} => { + const cli = pe.cli!; + const metadataOffset = pe.rvaToOffset( + cli.metadata.rva, + cli.metadata.size, + "cli.metadata", + ); + const layout = readManagedMetadataLayout( + bytes, + metadataOffset, + cli.metadata.size, + limits.maxTableRows, + ); + const resourceDirectory: ManagedResourceDirectory = { + offset: pe.rvaToOffset( + cli.resources.rva, + cli.resources.size, + "cli.resources", + ), + size: cli.resources.size, + }; + const inventory = readManagedMetadataInventory( + bytes, + layout, + { + referenceOffset: 0, + referenceLimit: 0, + resourceOffset: 0, + resourceLimit: 0, + attributeOffset: 0, + attributeLimit: 0, + maxHeapItemBytes: limits.maxHeapItemBytes, + }, + resourceDirectory, + ); + return { layout, inventory }; +}; + +const metadataLimitIssue = ( + cli: NonNullable, + maxBytes: number, +): ManagedParseIssue => ({ + code: "limit-exceeded", + scope: "cli.metadata", + offset: cli.headerOffset + 8, + detail: `CLI metadata size exceeds max_metadata_bytes ${String(maxBytes)}`, +}); + +/** Inspect managed/native boundary declarations from PE metadata without execution. */ export const inspectManagedNativeBoundariesBytes = ( bytes: Buffer, target: BinaryTarget, limits: ManagedNativeBoundaryInspectionLimits, ): ManagedNativeBoundaryInspection => { - const pe = readManagedPeLayout(bytes); - if (pe.cli === null) return unavailable(target, bytes, limits, pe.cliIssue); - const issues: ManagedParseIssue[] = []; + let pe: ManagedPeLayout; 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, - }, - null, - ); - issues.push(...inventory.issues); - const moduleRefs = parseModuleRefs(bytes, layout, limits.maxHeapItemBytes); - const fields = parseFields(bytes, layout, limits.maxHeapItemBytes); - const methods = parseMethods(bytes, layout, limits.maxHeapItemBytes); - const members = new Map([...fields, ...methods]); - const imports = parseImplMaps( - bytes, - layout, - limits.maxHeapItemBytes, - moduleRefs, - members, - ); - const implementations = nativeImplementations( - methods.values(), - new Set( - imports - .map(({ member_token: token }) => token) - .filter((token): token is string => token !== null), - ), - ); - const native = cliNative(pe); - return managedNativeBoundaryInspectionSchema.parse({ - schema_version: 1, - artifact: { - path: target.path, - sha256: target.sha256, - byte_length: bytes.length, - format: "pe", - }, - module: inventory.module, - metadata: { - status: issues.length === 0 ? "complete" : "partial", - version: layout.version, - table_row_counts: managedTableRowCounts(layout), - }, - identity_scope: { - token_identity: "build-local", - requires_artifact_sha256: target.sha256, - requires_mvid: inventory.module?.mvid ?? null, - }, - cli_native: native, - module_refs: page( - moduleRefs, - limits.moduleRefOffset, - limits.moduleRefLimit, - ), - pinvoke_imports: page(imports, limits.importOffset, limits.importLimit), - native_implementations: page( - implementations, - limits.implementationOffset, - limits.implementationLimit, - ), - summary: { - module_ref_count: moduleRefs.length, - pinvoke_import_count: imports.length, - native_implementation_count: implementations.length, - ready_to_run: native.ready_to_run_signature, - mixed_mode_or_native_header: - native.managed_native_header_rva !== 0 || - native.managed_native_header_size !== 0 || - native.native_entry_point, - }, - coverage: { - state: issues.length === 0 ? "complete" : "partial", - issues, - }, - limitations: [ - "P/Invoke rows prove managed import declarations only; this inspection does not verify that a native library, export, thunk, or provider-qualified function exists.", - "Managed metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.", - "ReadyToRun, NativeAOT, C++/CLI, and IL2CPP native semantics require separately selected native-provider evidence; this tool does not translate managed tokens into native addresses.", - ], - }); + pe = readManagedPeLayout(bytes); } catch (cause: unknown) { - if (cause instanceof ManagedReaderFailure) - return unavailable(target, bytes, limits, cause.issue); - throw cause; + if (!(cause instanceof ManagedReaderFailure)) throw cause; + return emptyInspection(target, bytes, "malformed", [cause.issue]); } + if (pe.cli === null) + return emptyInspection( + target, + bytes, + pe.cliDirectoryPresent ? "malformed" : "not-managed", + pe.cliIssue === null ? [] : [pe.cliIssue], + ); + if (pe.cli.metadata.size > limits.maxMetadataBytes) + return emptyInspection(target, bytes, "malformed", [ + metadataLimitIssue(pe.cli, limits.maxMetadataBytes), + ]); + let layout: ManagedMetadataLayout; + let inventory: Inventory; + try { + ({ layout, inventory } = readBoundaryInventory(bytes, pe, limits)); + } catch (cause: unknown) { + if (!(cause instanceof ManagedReaderFailure)) throw cause; + return emptyInspection(target, bytes, "malformed", [cause.issue]); + } + const moduleRefs = parseModuleRefs(bytes, layout, limits.maxHeapItemBytes); + const members = new Map([ + ...parseFields(bytes, layout, limits.maxHeapItemBytes), + ...parseMethods(bytes, layout, limits.maxHeapItemBytes), + ]); + const imports = parseImplMaps({ + bytes, + layout, + maxBytes: limits.maxHeapItemBytes, + modules: moduleRefs, + members, + }); + const pinvokeTokens = new Set( + imports + .map(({ member_token }) => member_token) + .filter((token): token is string => token !== null), + ); + const implementations = nativeImplementations( + members.values(), + pinvokeTokens, + ); + return buildNativeBoundaryInspection({ + target, + bytes, + limits, + pe, + layout, + inventory, + moduleRefs, + imports, + implementations, + native: cliNative(pe), + issues: [], + }); }; diff --git a/src/dotnet/ManagedPeReader.ts b/src/dotnet/ManagedPeReader.ts index 13e5cee7c..2d09235e6 100644 --- a/src/dotnet/ManagedPeReader.ts +++ b/src/dotnet/ManagedPeReader.ts @@ -43,6 +43,29 @@ export interface ManagedPeLayout { rvaToOffset(rva: number, size: number, scope: string): number; } +interface RvaMapping { + readonly bytes: Buffer; + readonly sections: readonly PeSection[]; + readonly rva: number; + readonly size: number; + readonly scope: string; +} + +interface CoffHeader { + readonly coff: number; + readonly machine: number; + readonly sectionCount: number; + readonly optionalSize: number; + readonly characteristics: number; +} + +interface OptionalHeader { + readonly optionalOffset: number; + readonly optionalHeader: "pe32" | "pe32-plus"; + readonly directoryCountOffset: number; + readonly directoryOffset: number; +} + const requireRange = ( bytes: Buffer, offset: number, @@ -95,13 +118,8 @@ const architectureFor = (machine: number): ManagedPeLayout["architecture"] => { } }; -const mapRva = ( - bytes: Buffer, - sections: readonly PeSection[], - rva: number, - size: number, - scope: string, -): number => { +const mapRva = (mapping: RvaMapping): number => { + const { bytes, sections, rva, size, scope } = mapping; if (rva === 0 && size === 0) return 0; for (const section of sections) { const mappedSize = Math.max(section.virtualSize, section.rawSize); @@ -144,7 +162,13 @@ const readCliHeader = ( "cli.header", "CLI header is shorter than the required 72 bytes", ); - const offset = mapRva(bytes, sections, directory.rva, 72, "cli.header"); + const offset = mapRva({ + bytes, + sections, + rva: directory.rva, + size: 72, + scope: "cli.header", + }); const headerSize = bytes.readUInt32LE(offset); if (headerSize < 72 || headerSize > directory.size) throw managedFailure( @@ -165,13 +189,13 @@ const readCliHeader = ( const managedNativeHeader = readDirectory(bytes, offset + 64); let readyToRunSignature = false; if (managedNativeHeader.rva !== 0 && managedNativeHeader.size >= 4) { - const nativeOffset = mapRva( + const nativeOffset = mapRva({ bytes, sections, - managedNativeHeader.rva, - 4, - "cli.managed-native-header", - ); + rva: managedNativeHeader.rva, + size: 4, + scope: "cli.managed-native-header", + }); readyToRunSignature = bytes.readUInt32LE(nativeOffset) === 0x0052_5452; } return { @@ -189,16 +213,7 @@ const readCliHeader = ( }; }; -/** Parse the file-backed PE and CLI layout without loading target code. */ -export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => { - requireRange(bytes, 0, 64, "pe.dos-header"); - if (bytes[0] !== 0x4d || bytes[1] !== 0x5a) - throw managedFailure( - "invalid-directory", - "pe.dos-header", - "Artifact does not start with an MZ header", - 0, - ); +const readCoffHeader = (bytes: Buffer): CoffHeader => { const peOffset = bytes.readUInt32LE(0x3c); requireRange(bytes, peOffset, 24, "pe.coff-header"); if (bytes.readUInt32LE(peOffset) !== 0x0000_4550) @@ -220,9 +235,13 @@ export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => { ); const optionalSize = bytes.readUInt16LE(coff + 16); const characteristics = bytes.readUInt16LE(coff + 18); - const optionalOffset = coff + 20; - requireRange(bytes, optionalOffset, optionalSize, "pe.optional-header"); - if (optionalSize < 96) + return { coff, machine, sectionCount, optionalSize, characteristics }; +}; + +const readOptionalHeader = (bytes: Buffer, coff: CoffHeader): OptionalHeader => { + const optionalOffset = coff.coff + 20; + requireRange(bytes, optionalOffset, coff.optionalSize, "pe.optional-header"); + if (coff.optionalSize < 96) throw managedFailure( "invalid-directory", "pe.optional-header", @@ -239,16 +258,29 @@ export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => { "PE optional header magic is unsupported", optionalOffset, ); - const directoryCountOffset = - optionalOffset + (optionalHeader === "pe32" ? 92 : 108); - const directoryOffset = - optionalOffset + (optionalHeader === "pe32" ? 96 : 112); - requireRange(bytes, directoryCountOffset, 4, "pe.data-directories"); - const directoryCount = bytes.readUInt32LE(directoryCountOffset); - const sectionOffset = add(optionalOffset, optionalSize, "pe.sections"); - requireRange(bytes, sectionOffset, sectionCount * 40, "pe.sections"); + return { + optionalOffset, + optionalHeader, + directoryCountOffset: + optionalOffset + (optionalHeader === "pe32" ? 92 : 108), + directoryOffset: optionalOffset + (optionalHeader === "pe32" ? 96 : 112), + }; +}; + +const readSections = ( + bytes: Buffer, + coff: CoffHeader, + optional: OptionalHeader, +): readonly PeSection[] => { + const sectionOffset = add(optional.optionalOffset, coff.optionalSize, "pe.sections"); + requireRange( + bytes, + sectionOffset, + coff.sectionCount * 40, + "pe.sections", + ); const sections: PeSection[] = []; - for (let index = 0; index < sectionCount; index += 1) { + for (let index = 0; index < coff.sectionCount; index += 1) { const offset = sectionOffset + index * 40; const section = { virtualSize: bytes.readUInt32LE(offset + 8), @@ -265,11 +297,38 @@ export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => { ); sections.push(section); } - const cliDirectory = + return sections; +}; + +const readCliDirectory = ( + bytes: Buffer, + coff: CoffHeader, + optional: OptionalHeader, +): PeDataDirectory => { + requireRange(bytes, optional.directoryCountOffset, 4, "pe.data-directories"); + const directoryCount = bytes.readUInt32LE(optional.directoryCountOffset); + if ( directoryCount <= 14 || - optionalSize < directoryOffset - optionalOffset + 120 - ? { rva: 0, size: 0 } - : readDirectory(bytes, directoryOffset + 14 * 8); + coff.optionalSize < optional.directoryOffset - optional.optionalOffset + 120 + ) + return { rva: 0, size: 0 }; + return readDirectory(bytes, optional.directoryOffset + 14 * 8); +}; + +/** Parse the file-backed PE and CLI layout without loading target code. */ +export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => { + requireRange(bytes, 0, 64, "pe.dos-header"); + if (bytes[0] !== 0x4d || bytes[1] !== 0x5a) + throw managedFailure( + "invalid-directory", + "pe.dos-header", + "Artifact does not start with an MZ header", + 0, + ); + const coff = readCoffHeader(bytes); + const optional = readOptionalHeader(bytes, coff); + const sections = readSections(bytes, coff, optional); + const cliDirectory = readCliDirectory(bytes, coff, optional); const cliDirectoryPresent = cliDirectory.rva !== 0 || cliDirectory.size !== 0; let cli: ManagedCliHeader | null = null; let cliIssue: ManagedParseIssue | null = null; @@ -281,16 +340,16 @@ export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => { cliIssue = cause.issue; } return { - machine, - architecture: architectureFor(machine), - optionalHeader, - sectionCount, - characteristics, + machine: coff.machine, + architecture: architectureFor(coff.machine), + optionalHeader: optional.optionalHeader, + sectionCount: coff.sectionCount, + characteristics: coff.characteristics, sections, cli, cliDirectoryPresent, cliIssue, rvaToOffset: (rva, size, scope) => - mapRva(bytes, sections, rva, size, scope), + mapRva({ bytes, sections, rva, size, scope }), }; };