Files
rea/src/native/AppleDispatchMetadata.ts
T
morluto d0067ba34a fix(native)!: preserve Swift pointer nullability and import bindings
Resolve relative pointers using field-owned ABI encodings. Keep null, raw, rebased, imported and unknown targets distinct, and retain imported conformance rows with exact Mach-O binding provenance. Decode legacy rebase membership with source-derived memory and work bounds, preserving validated prefix facts when streams are incomplete.

Validation: check:ci, docs:check and build passed; 158 source tests and 20 schema/acceptance tests passed. Ten source-built Apple dispatch variants and four issue-specific CLI/MCP workflows passed without executing targets. Paired cold/warm CLI/MCP cost observations retained locally; model tokens and maximum-sized input runtime were not measured.

Closes #5683
Closes #5685

BREAKING CHANGE: Swift conformance type_name and protocol_name can be null when names cannot be resolved; consumers must use typed type_binding and protocol_binding evidence. MachoImportBinding replaces the former Objective-C-specific binding type and schema.
2026-10-21 08:17:12 +08:00

373 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
import {
objcSwiftMetadataSchema,
nativeDispatchMetadataResultSchema,
type ObjcSwiftMetadata,
} from "../domain/native/objcSwiftMetadata.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import {
ArtifactFileCleanupFailure,
artifactFileCancellationCause,
artifactFilePrimaryFailure,
bindArtifactFileCleanup,
withArtifactRegularFile,
} from "../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY,
} from "./NativeMacOSProviderMetadata.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import { pushDispatchCoverage } from "./AppleDispatchCoverage.js";
import { parsePointerFixups } from "./AppleMachoFixups.js";
import {
parseMachoLayout,
UnsupportedMachoMetadataFormatError,
} from "./AppleMachoSelection.js";
import { decodeObjcDispatchFacets } from "./AppleObjcDispatchFacets.js";
import {
createSwiftRelativeReader,
decodeSwiftDispatchFacets,
type DispatchRecordBudget,
} from "./AppleSwiftDispatchFacets.js";
import { decodeSwiftClassVtables } from "./AppleSwiftVtables.js";
import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js";
/** Decode validated little-endian 64-bit Apple Objective-C metadata without loading target code. */
export const decodeAppleDispatchMetadata = (
bytes: Buffer,
maxRecords: number,
provenance: { path: string; sha256: string },
architecture = "arm64",
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete = true,
): ObjcSwiftMetadata => {
const layout = parseMachoLayout(bytes, architecture);
const { segments, sections, offset } = layout;
const fixups = parsePointerFixups(
bytes,
layout.fixups,
segments,
layout.baseAddress,
);
const objcClassImports = inspectObjcClassImportBindings({
bytes,
layout,
fixups,
addresses: objcClassImportAddresses,
addressesComplete: objcClassImportAddressesComplete,
maxRecords,
});
const u32 = (address: bigint) => bytes.readUInt32LE(offset(address, 4));
const decodePointer = (address: bigint) =>
fixups.decode(address, bytes.readBigUInt64LE(offset(address, 8)));
/** In-image target of a raw or rebased pointer; a bind is not an address. */
const pointer = (address: bigint) => {
const decodedPointer = decodePointer(address);
if (decodedPointer.kind === "rebase" || decodedPointer.kind === "raw")
return decodedPointer.target;
if (decodedPointer.kind === "bind") return 0n;
// Preserve the on-disk observation for structural Objective-C metadata.
// `raw` is not promoted to an authoritative fixup result: the shared
// pointer-fixup coverage remains partial, and consumers that need to
// distinguish null from an unresolved bind use resolvePointer directly.
if (decodedPointer.kind === "unknown") return decodedPointer.raw;
throw new RangeError(decodedPointer.reason);
};
const bound = (address: bigint) => {
const decodedPointer = decodePointer(address);
return decodedPointer.kind === "bind" ? decodedPointer : undefined;
};
const location = (address: bigint) => ({
address: `0x${address.toString(16)}`,
file_offset: offset(address),
});
const evidence = (address: bigint, description: string) => [
{
kind: "binary_metadata" as const,
description,
location: location(address),
artifact_path: provenance.path,
artifact_sha256: provenance.sha256,
},
];
const string = (address: bigint) => {
const start = offset(address);
let end = start;
while (end < bytes.length && end - start < 4096 && bytes[end] !== 0) end++;
if (end === bytes.length || end - start >= 4096)
throw new RangeError("Unterminated or oversized metadata string");
offset(address, end - start + 1);
// A metadata C string owns every code point, including an initial U+FEFF.
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.subarray(start, end),
);
};
const result = objcSwiftMetadataSchema.parse({
db_save_result: null,
target_selection: {
kind: "macho-selected-image-v1",
container: layout.selectedImage.container,
offset: layout.selectedImage.offset,
byte_length: layout.selectedImage.byteLength,
cpu_type: layout.selectedImage.cpuType,
cpu_subtype: layout.selectedImage.cpuSubtype,
container_table_sha256: layout.selectedImage.containerTableSha256,
},
...(objcClassImportAddresses === undefined
? {}
: {
objc_class_import_bindings: objcClassImports.bindings,
objc_class_import_binding_coverage: {
facet: "objc_class_import_bindings",
...objcClassImports.coverage,
},
}),
});
let records = 0;
const budget: DispatchRecordBudget = {
truncated: false,
isFull: () => records >= maxRecords,
markTruncated: () => {
budget.truncated = true;
},
admit: () => {
if (records >= maxRecords) {
budget.truncated = true;
return false;
}
records++;
return true;
},
};
const readers = {
u32,
i32: (address: bigint) => bytes.readInt32LE(offset(address, 4)),
u64: (address: bigint) => bytes.readBigUInt64LE(offset(address, 8)),
pointer,
resolvePointer: decodePointer,
bound,
string,
location,
evidence,
offset,
};
const {
failures,
categoryIssues,
examined,
categoriesExamined,
objcListScanTruncated,
objcClassListScanTruncated,
} = decodeObjcDispatchFacets({
bytes,
sections,
segments,
readers,
budget,
maxListSlots: maxRecords,
result,
});
const swiftRelative = createSwiftRelativeReader({
bytes,
readers,
budget,
result,
});
const swift = decodeSwiftDispatchFacets({
sections,
segments,
readers,
relative: swiftRelative,
budget,
result,
});
const typeEntries: bigint[] = [];
for (const section of sections.filter(
({ name }) => name === "__swift5_types",
)) {
if (section.size % 4 !== 0)
throw new RangeError("Misaligned Swift type section");
for (let index = 0; index < section.size / 4 && index < maxRecords; index++)
typeEntries.push(section.address + BigInt(index * 4));
if (section.size / 4 > maxRecords) budget.truncated = true;
}
const vtables = decodeSwiftClassVtables({
entries: typeEntries,
result,
readers: {
u32,
relative: swiftRelative,
string,
location,
evidence,
admit: () => budget.admit(),
executable: (address) =>
segments.some(
(segment) =>
segment.executable &&
address >= segment.address &&
address < segment.address + segment.size,
),
},
});
pushDispatchCoverage({
result,
failures,
categoryIssues,
examined,
categoriesExamined,
objcListScanTruncated,
objcClassListScanTruncated,
truncated: budget.truncated,
fixups,
vtables,
swift,
});
return objcSwiftMetadataSchema.parse(result);
};
/**
* Read digest-verified target bytes under a 64 MiB static metadata budget.
* The caller's scope retains the target descriptor when its final close fails.
*/
export const inspectAppleDispatchMetadata = async (
target: BinaryTarget,
maxRecords: number,
resources: ArtifactResourceScope,
signal?: AbortSignal,
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete = true,
) => {
if (target.kind !== "executable" || !isMachOFormat(target.format))
throw new TypeError("Apple dispatch metadata requires a Mach-O target");
const maximumBytes = 64 * 1024 * 1024;
const capacityError = () =>
new AnalysisResourceConstraintError(
"inspect_native_dispatch_metadata",
"memory",
"Apple static metadata input exceeds its 64 MiB snapshot budget",
{ input_bytes: maximumBytes },
fixedAllowanceOptions(
"The Apple static metadata snapshot allowance",
"parser",
),
);
const cancelled = () =>
new AnalysisCancelledError("inspect_native_dispatch_metadata", {
cause: signal?.reason,
});
const { bytes, digest } = await withArtifactRegularFile(
target.path,
resources,
signal,
async (handle) => {
const before = await handle.stat();
if (before.size > maximumBytes) throw capacityError();
if (signal?.aborted) throw cancelled();
const read = await readBoundedFileBytes(handle, maximumBytes, {
signal,
cancellationFailure: () => cancelled(),
});
if (read === undefined) throw capacityError();
const sha256 = createHash("sha256").update(read).digest("hex");
if (sha256 !== target.sha256)
throw new EvidenceIntegrityError(
"Apple metadata target digest changed after session binding",
);
return { bytes: read, digest: sha256 };
},
"reject",
);
return nativeDispatchMetadataResultSchema.parse({
target_sha256: target.sha256,
target_architecture: target.architecture,
provider: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider,
analysis_profile_digest: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.digest,
result: decodeAppleDispatchMetadata(
bytes,
maxRecords,
{ path: target.path, sha256: digest },
target.architecture,
objcClassImportAddresses,
objcClassImportAddressesComplete,
),
});
};
const OPERATION = "inspect_native_dispatch_metadata";
/** Translate the owned target read, keeping a failed close beside its physical outcome. */
export const appleDispatchMetadataFailure = (
cause: unknown,
path: string,
): AnalysisError => {
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
const physical =
cause instanceof ArtifactFileCleanupFailure
? cause.fileCleanupFailure
: undefined;
const previous =
physical?.outcome.kind === "failed"
? appleDispatchMetadataFailure(artifactFilePrimaryFailure(cause), path)
: physical?.completionCancellation === undefined
? undefined
: new AnalysisCancelledError(OPERATION, {
cause: physical.completionCancellation.reason,
});
const failure = new ProviderCleanupError(
IDENTITY.id,
cause.cleanup.resources,
{
reason: cause.cleanup.reason,
previous_error:
previous === undefined ? null : projectAnalysisError(previous),
},
{ operation: OPERATION, cause, cleanupPrimary: previous },
);
bindArtifactFileCleanup(cause, failure);
return failure;
}
if (cause instanceof AnalysisError) return cause;
if (cause instanceof UnsupportedMachoMetadataFormatError)
return new AnalysisCapabilityUnavailableError(
IDENTITY.id,
OPERATION,
cause.message,
{ cause },
);
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
return new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
});
if (cause instanceof ArtifactReaderFailure && cause.reason === "unavailable")
return new AnalysisCapabilityUnavailableError(
IDENTITY.id,
OPERATION,
cause.message,
{ cause },
);
return new ProviderAdapterError(IDENTITY.id, OPERATION, {
cause,
diagnostics: {
target_path: path,
reason: cause instanceof Error ? cause.message : String(cause),
},
});
};