diff --git a/docs/native-investigation.md b/docs/native-investigation.md index f17f5bbc2f..60105b42c1 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -432,7 +432,18 @@ complete observation. Pointers are decoded through the image's fixups: - `LC_DYLD_CHAINED_FIXUPS` rebases and binds, including the authenticated arm64e formats; - - legacy `LC_DYLD_INFO` bind opcodes. + - legacy `LC_DYLD_INFO` rebase and bind opcodes. + + A Swift conformance whose type or protocol reference is an external bind + keeps its name as `null` and carries the raw dyld identity in + `type_binding` or `protocol_binding`; that record and its facet coverage are + partial. Consumers of this output must handle nullable conformance names + and use the typed binding fields instead of substituting a symbol as a + display name. Indirect relative-pointer records also retain their binding + identity. A decoded rebase to virtual address zero remains an address; only + an unfixed zero in a nullable ABI field is treated as null. Incomplete or + conflicting fixup streams keep affected pointers unresolved; coverage reports + parsing and resource limits while retaining validated prefix facts. So an external superclass resolves from its `_OBJC_CLASS_$_` bind (for example `NSObject`). The `pointer_fixups` coverage facet names the mechanism. diff --git a/scripts/verify/apple/dispatch.mjs b/scripts/verify/apple/dispatch.mjs index cd222e0a50..316437266d 100644 --- a/scripts/verify/apple/dispatch.mjs +++ b/scripts/verify/apple/dispatch.mjs @@ -30,7 +30,7 @@ const LINK_MODES = [ { mode: "dyld-info", flags: ["-Wl,-no_fixup_chains"], - fixups: "LC_DYLD_INFO rebase opcodes were not decoded", + fixups: "LC_DYLD_INFO rebase opcodes", swift: true, }, { mode: "chained", flags: [], fixups: "chained fixups:", swift: true }, @@ -70,6 +70,7 @@ const inspect = async (path) => { }; const checkObjc = (metadata, mode) => { + assert.equal(fixupCoverage(metadata)?.status, "complete"); const fixture = metadata.objc_classes.find( ({ name, is_meta_class: meta }) => name === "ReaDispatchFixture" && !meta, ); @@ -159,6 +160,7 @@ const checkObjc = (metadata, mode) => { }; const checkSwift = (metadata, mode) => { + assert.equal(fixupCoverage(metadata)?.status, "complete"); if ( !metadata.swift_dispatch_slots.some( (slot) => diff --git a/src/domain/native/objcSwiftMetadata.ts b/src/domain/native/objcSwiftMetadata.ts index 546a801aca..fca04b09c7 100644 --- a/src/domain/native/objcSwiftMetadata.ts +++ b/src/domain/native/objcSwiftMetadata.ts @@ -199,10 +199,24 @@ export const objcCategorySchema = z.strictObject({ evidence: z.array(nativeMetadataEvidenceSchema).min(1), }); +/** Exact Mach-O import binding observed at a pointer slot. */ +export const machoImportBindingSchema = z.strictObject({ + requested_address: z.string().min(1), + symbol: z.string().min(1), + binding_source: z.enum(["chained", "bind", "weak", "lazy"]), + library_ordinal: z.number().int().min(-3).max(Number.MAX_SAFE_INTEGER), + library: z.string().nullable(), + weak: z.boolean(), + addend: z.string().regex(/^-?\d+$/u), +}); +export type MachoImportBinding = z.infer; + /** Swift protocol conformance and its associated witness-table location. */ export const swiftConformanceSchema = z.strictObject({ - type_name: z.string().min(1), - protocol_name: z.string().min(1), + type_name: z.string().min(1).nullable(), + type_binding: machoImportBindingSchema.nullable().optional(), + protocol_name: z.string().min(1).nullable(), + protocol_binding: machoImportBindingSchema.nullable().optional(), module: z.string().nullable(), witness_table: nativeMetadataLocationSchema, location: nativeMetadataLocationSchema, @@ -218,6 +232,7 @@ export const swiftDispatchSlotSchema = z.strictObject({ requirement: z.string().nullable(), implementation: z.string().nullable(), implementation_address: z.string().nullable(), + implementation_binding: machoImportBindingSchema.nullable().optional(), thunk_address: z.string().nullable(), location: nativeMetadataLocationSchema, decode: nativeMetadataDecodeSchema, @@ -231,6 +246,8 @@ export const relativePointerSchema = z.strictObject({ displacement: z.number().int(), indirectable: z.boolean(), indirect: z.boolean().nullable(), + encoding_tag: z.number().int().nonnegative().optional(), + binding: machoImportBindingSchema.optional(), decode: nativeMetadataDecodeSchema, evidence: z.array(nativeMetadataEvidenceSchema).min(1), }); @@ -253,17 +270,7 @@ export const nativeMetadataCoverageSchema = z.strictObject({ decoded: z.number().int().nonnegative(), }); -/** Exact Mach-O import binding observed at a caller-selected pointer slot. */ -export const objcClassImportBindingSchema = z.strictObject({ - requested_address: z.string().min(1), - symbol: z.string().min(1), - binding_source: z.enum(["chained", "bind", "weak", "lazy"]), - library_ordinal: z.number().int().min(-3).max(Number.MAX_SAFE_INTEGER), - library: z.string().nullable(), - weak: z.boolean(), - addend: z.string().regex(/^-?\d+$/u), -}); -export const objcClassImportIdentitySchema = objcClassImportBindingSchema.omit({ +export const objcClassImportIdentitySchema = machoImportBindingSchema.omit({ requested_address: true, }); export const objcClassImportBindingCoverageSchema = z.strictObject({ @@ -273,9 +280,6 @@ export const objcClassImportBindingCoverageSchema = z.strictObject({ examined: z.number().int().nonnegative(), decoded: z.number().int().nonnegative(), }); -export type ObjcClassImportBinding = z.infer< - typeof objcClassImportBindingSchema ->; export type ObjcClassImportBindingCoverage = z.infer< typeof objcClassImportBindingCoverageSchema >; @@ -348,7 +352,7 @@ export const objcSwiftMetadataSchema = z.strictObject({ swift_conformances: z.array(swiftConformanceSchema).default([]), swift_dispatch_slots: z.array(swiftDispatchSlotSchema).default([]), swift_symbols: z.array(swiftSymbolSchema).default([]), - objc_class_import_bindings: z.array(objcClassImportBindingSchema).optional(), + objc_class_import_bindings: z.array(machoImportBindingSchema).optional(), objc_class_import_binding_coverage: objcClassImportBindingCoverageSchema.optional(), target_selection: machoTargetSelectionSchema.optional(), diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index f23e1ab8e3..838ac62cc1 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -1,7 +1,7 @@ import type { JsonValue } from "./jsonValue.js"; import type { AddressedName } from "./hopperValues.js"; import type { - ObjcClassImportBinding, + MachoImportBinding, ObjcClassImportBindingCoverage, } from "./native/objcSwiftMetadata.js"; import { @@ -31,7 +31,7 @@ export interface ObjcClassDiscoveryCoverage { } export interface ObjcClassImportBindingScan { - readonly bindings: readonly ObjcClassImportBinding[]; + readonly bindings: readonly MachoImportBinding[]; readonly coverage: ObjcClassDiscoveryCoverage; } @@ -247,22 +247,19 @@ const uniqueByAddressAndName = ( }; interface ObjcClassOutputEntry extends AddressedName { - readonly binding_identity?: Omit< - ObjcClassImportBinding, - "requested_address" - > & { + readonly binding_identity?: Omit & { readonly slot_addresses: readonly string[]; }; } const coalesceBoundClassAliases = ( candidates: readonly AddressedName[], - bindings: readonly ObjcClassImportBinding[], + bindings: readonly MachoImportBinding[], ): ObjcClassOutputEntry[] => { const bindingsByAddress = new Map( bindings.map((binding) => [binding.requested_address, binding]), ); - const bindingKey = (binding: ObjcClassImportBinding) => + const bindingKey = (binding: MachoImportBinding) => JSON.stringify([ binding.symbol, binding.binding_source, @@ -271,7 +268,7 @@ const coalesceBoundClassAliases = ( binding.weak, binding.addend, ]); - const groupedBindings = new Map(); + const groupedBindings = new Map(); for (const binding of bindings) { const key = bindingKey(binding); const existing = groupedBindings.get(key); diff --git a/src/native/AppleDispatchCoverage.ts b/src/native/AppleDispatchCoverage.ts index 44c6b9f369..3d5ef4afd3 100644 --- a/src/native/AppleDispatchCoverage.ts +++ b/src/native/AppleDispatchCoverage.ts @@ -4,19 +4,22 @@ import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js"; import type { FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js"; const describeFixups = (fixups: PointerFixups): string => { - const unread = - fixups.unreadRebaseBytes > 0 - ? `LC_DYLD_INFO rebase opcodes were not decoded (${fixups.unreadRebaseBytes} bytes)` - : undefined; - const kind = - fixups.kind === "chained" - ? `chained fixups: ${fixups.formats.join(", ") || "no fixup segments"}` - : fixups.kind === "dyld-info" - ? "LC_DYLD_INFO bind opcodes" - : unread === undefined - ? "no fixup load commands" - : undefined; - return [unread, kind].filter((line) => line !== undefined).join("; "); + const descriptions: string[] = []; + if (fixups.unreadRebaseBytes > 0) + descriptions.push( + `LC_DYLD_INFO rebase opcodes were not decoded (${fixups.unreadRebaseBytes} bytes)`, + ); + if (fixups.kind === "chained") + descriptions.push( + `chained fixups: ${fixups.formats.join(", ") || "no fixup segments"}`, + ); + if (fixups.legacyRebaseStreams > 0) + descriptions.push("LC_DYLD_INFO rebase opcodes"); + if (fixups.legacyBindStreams > 0) + descriptions.push("LC_DYLD_INFO bind opcodes"); + return descriptions.length === 0 + ? "no fixup load commands" + : descriptions.join("; "); }; /** Append the per-facet coverage of one Apple dispatch metadata decode. */ diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 6a46b9bac8..92250d71bc 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -120,6 +120,236 @@ const fixture = ( }; const provenance = { path: "/fixture/app", sha256: "a".repeat(64) }; +const swiftExternalSwiftBindingImage = ( + boundField: "protocol" | "type" = "protocol", +) => { + const bytes = fixture(); + const sectionCommand = 32; + bytes.writeUInt32LE(232, sectionCommand + 4); + bytes.writeUInt32LE(2, sectionCommand + 64); + bytes.write("__swift5_proto", 184); + bytes.write("__DATA", 200); + bytes.writeBigUInt64LE(base + 0xa00n, 216); + bytes.writeBigUInt64LE(4n, 224); + bytes.writeUInt32LE(0xa00, 232); + bytes.writeUInt32LE(2, 236); + + const dyldInfo = sectionCommand + 232; + bytes.writeUInt32LE(0x80000022, dyldInfo); + bytes.writeUInt32LE(48, dyldInfo + 4); + bytes.writeUInt32LE(0xf00, dyldInfo + 16); + const bind = Buffer.from([ + 0x11, // SET_DYLIB_ORDINAL_IMM 1 + 0x40, + ...Buffer.from(`${boundField === "protocol" ? "_$sSQMp" : "_$sSiMn"}\0`), + 0x70, + 0x80, + 0x16, // SET_SEGMENT_AND_OFFSET_ULEB segment 0 + offset 0xb00 + 0x90, + 0x00, + ]); + bytes.writeUInt32LE(bind.length, dyldInfo + 20); + const dylibCommand = dyldInfo + 48; + bytes.writeUInt32LE(0xc, dylibCommand); // LC_LOAD_DYLIB + bytes.writeUInt32LE(56, dylibCommand + 4); + bytes.writeUInt32LE(24, dylibCommand + 8); + bytes.write("/usr/lib/libswiftCore.dylib\0", dylibCommand + 24); + bytes.writeUInt32LE(3, 16); // segment, dyld info, and dependent dylib + bytes.writeUInt32LE(336, 20); + bind.copy(bytes, 0xf00); + + // Conformance at 0xa10: one reference is an indirect relative pointer to + // the import slot at 0xb00; the sibling reference points to local metadata. + bytes.writeInt32LE(0x10, 0xa00); + bytes.writeInt32LE(boundField === "protocol" ? 0x1ec : 0xec, 0xa14); + bytes.writeInt32LE(0, 0xa18); // Nullable witness-table pattern. + bytes.writeUInt32LE(boundField === "protocol" ? 0 : 8, 0xa1c); + bytes.writeInt32LE(boundField === "protocol" ? 0xf1 : 0x2f0, 0xa10); // Protocol reference: imported indirect slot or local descriptor. + bytes.writeBigUInt64LE(0n, 0xb00); // Bound slot has no in-image pointer. + bytes.writeUInt32LE(16, 0xc00); + bytes.writeInt32LE(0x1f8, 0xc08); + if (boundField === "type") { + bytes.writeUInt32LE(3, 0xd00); + bytes.writeInt32LE(0xf8, 0xd08); + bytes.write("Equatable\0", 0xe00); + } else { + bytes.write("Widget\0", 0xe00); + } + return bytes; +}; + +const swiftBoundWitnessSlotImage = () => { + const bytes = fixture(); + const sectionCommand = 32; + bytes.writeUInt32LE(232, sectionCommand + 4); + bytes.writeUInt32LE(2, sectionCommand + 64); + bytes.write("__swift5_proto", 184); + bytes.write("__DATA", 200); + bytes.writeBigUInt64LE(base + 0xa00n, 216); + bytes.writeBigUInt64LE(4n, 224); + bytes.writeUInt32LE(0xa00, 232); + bytes.writeUInt32LE(2, 236); + + const dyldInfo = sectionCommand + 232; + bytes.writeUInt32LE(0x80000022, dyldInfo); + bytes.writeUInt32LE(48, dyldInfo + 4); + bytes.writeUInt32LE(0xf20, dyldInfo + 8); + bytes.writeUInt32LE(6, dyldInfo + 12); + bytes.writeUInt32LE(0xf00, dyldInfo + 16); + const bind = Buffer.from([ + 0x11, + 0x40, + ...Buffer.from("_externalWitnessImpl\0"), + 0x70, + 0x88, + 0x1c, // SET_SEGMENT_AND_OFFSET_ULEB segment 0 + witness slot 0xe08 + 0x90, + 0x00, + ]); + bytes.writeUInt32LE(bind.length, dyldInfo + 20); + const dylibCommand = dyldInfo + 48; + bytes.writeUInt32LE(0xc, dylibCommand); + bytes.writeUInt32LE(56, dylibCommand + 4); + bytes.writeUInt32LE(24, dylibCommand + 8); + bytes.write("/usr/lib/libswiftCore.dylib\0", dylibCommand + 24); + bytes.writeUInt32LE(3, 16); + bytes.writeUInt32LE(336, 20); + bind.copy(bytes, 0xf00); + // One unrelated, valid local rebase proves unmatched structural pointers + // stay known-raw after the complete legacy stream is walked. + Buffer.from([0x11, 0x20, 0xa0, 0x1c, 0x51, 0]).copy(bytes, 0xf20); + + bytes.writeInt32LE(0x10, 0xa00); + bytes.writeInt32LE(0x1ec, 0xa14); + bytes.writeInt32LE(0x3e8, 0xa18); + bytes.writeUInt32LE(0, 0xa1c); + bytes.writeInt32LE(0x2f0, 0xa10); + bytes.writeUInt32LE(16, 0xc00); + bytes.writeInt32LE(0x238, 0xc08); + bytes.writeUInt32LE(3, 0xd00); + bytes.writeInt32LE(0x148, 0xd08); + bytes.writeUInt32LE(0, 0xd0c); + bytes.writeUInt32LE(1, 0xd10); + bytes.writeUInt32LE(1, 0xd18); + bytes.writeBigUInt64LE(base + 0xa10n, 0xe00); + bytes.write("Widget\0", 0xe40); + bytes.write("Equatable\0", 0xe50); + return bytes; +}; + +describe("Swift imported protocol references", () => { + it("preserves bind identity without reading VM address zero and marks coverage partial", () => { + const result = decodeAppleDispatchMetadata( + swiftExternalSwiftBindingImage(), + 100, + provenance, + ); + + expect(result.swift_conformances).toMatchObject([ + { + type_name: "Widget", + protocol_name: null, + protocol_binding: { + symbol: "_$sSQMp", + requested_address: "0x100000b00", + }, + decode: { status: "partial" }, + }, + ]); + expect( + result.relative_pointers.find( + ({ field }) => field.address === "0x100000a10", + ), + ).toMatchObject({ + target: { address: null, file_offset: null }, + binding: { symbol: "_$sSQMp" }, + decode: { status: "partial" }, + }); + expect( + result.coverage.find( + ({ facet }) => facet === "swift_conformances_static_witness_slots", + ), + ).toMatchObject({ status: "partial" }); + }); + + it("preserves an indirect imported type reference as a typed binding", () => { + const result = decodeAppleDispatchMetadata( + swiftExternalSwiftBindingImage("type"), + 100, + provenance, + ); + + expect(result.swift_conformances).toMatchObject([ + { + type_name: null, + type_binding: { + symbol: "_$sSiMn", + requested_address: "0x100000b00", + }, + protocol_name: "Equatable", + protocol_binding: null, + decode: { status: "partial" }, + }, + ]); + expect( + result.coverage.find( + ({ facet }) => facet === "swift_conformances_static_witness_slots", + ), + ).toMatchObject({ status: "partial" }); + }); + + it("reports only the legacy stream mechanisms actually examined", () => { + const bytes = swiftBoundWitnessSlotImage(); + const dyldInfo = 32 + 232; + bytes.writeUInt32LE(0, dyldInfo + 16); + bytes.writeUInt32LE(0, dyldInfo + 20); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect( + result.coverage.find(({ facet }) => facet === "pointer_fixups"), + ).toEqual({ + facet: "pointer_fixups", + status: "complete", + reason: "LC_DYLD_INFO rebase opcodes", + examined: 1, + decoded: 1, + }); + }); + + it("retains an unmatched local witness header beside a legacy rebase and preserves a bound implementation", () => { + const result = decodeAppleDispatchMetadata( + swiftBoundWitnessSlotImage(), + 100, + provenance, + ); + + expect(result.swift_conformances).toMatchObject([ + { + type_name: "Widget", + protocol_name: "Equatable", + witness_table: { address: "0x100000e00" }, + decode: { status: "decoded" }, + }, + ]); + expect(result.swift_dispatch_slots).toMatchObject([ + { + table_kind: "witness_table", + slot_index: 1, + implementation_address: null, + implementation_binding: { + symbol: "_externalWitnessImpl", + requested_address: "0x100000e08", + }, + decode: { status: "partial" }, + }, + ]); + expect( + result.coverage.find( + ({ facet }) => facet === "swift_conformances_static_witness_slots", + ), + ).toMatchObject({ status: "partial" }); + }); +}); + const withProtocolListSection = (bytes: Buffer): Buffer => { const pointer = (at: number, offset: number) => bytes.writeBigUInt64LE(base + BigInt(offset), at); @@ -254,6 +484,37 @@ describe("Apple dispatch pointer fixup metadata", () => { }); }); describe("Apple dispatch binary metadata", () => { + it("retains structural Objective-C records when legacy rebase coverage is unknown", () => { + const bytes = fixture(); + // Add LC_DYLD_INFO_ONLY with a nonempty rebase stream. Legacy rebase + // stream is malformed, so provenance is unknown while readable + // nonzero structural pointer words must remain useful. + bytes.writeUInt32LE(2, 16); + bytes.writeUInt32LE(200, 20); + bytes.writeUInt32LE(0x80000022, 184); + bytes.writeUInt32LE(48, 188); + bytes.writeUInt32LE(4000, 192); + bytes.writeUInt32LE(1, 196); + bytes.writeUInt8(0xf0, 4000); + + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_classes[0]?.name).toBe("Fixture"); + expect(result.objc_classes[0]?.methods).toMatchObject([ + { selector: "performAction:" }, + ]); + expect(result.objc_classes[0]?.decode).toMatchObject({ + status: "partial", + }); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ), + ).toMatchObject({ status: "partial" }); + expect( + result.coverage.find(({ facet }) => facet === "pointer_fixups"), + ).toMatchObject({ status: "partial" }); + }); + it.each([false, true])( "decodes stripped absolute/relative method lists (relative=%s) with exact offsets", (relative) => { diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index dfcbb5f924..007e44f8b5 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -76,10 +76,18 @@ export const decodeAppleDispatchMetadata = ( const u32 = (address: bigint) => bytes.readUInt32LE(offset(address, 4)); const decodePointer = (address: bigint) => fixups.decode(address, bytes.readBigUInt64LE(offset(address, 8))); - /** Rebased target of a pointer; a bind to another image reads as 0. */ + /** In-image target of a raw or rebased pointer; a bind is not an address. */ const pointer = (address: bigint) => { const decodedPointer = decodePointer(address); - return decodedPointer.kind === "rebase" ? decodedPointer.target : 0n; + 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); @@ -152,6 +160,7 @@ export const decodeAppleDispatchMetadata = ( i32: (address: bigint) => bytes.readInt32LE(offset(address, 4)), u64: (address: bigint) => bytes.readBigUInt64LE(offset(address, 8)), pointer, + resolvePointer: decodePointer, bound, string, location, diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index 8653b73e74..41f9c8b4ed 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -155,6 +155,347 @@ const SEGMENTS: Segment[] = [ { address: BASE + 0x4000n, size: 0x4000n, offset: 0x4000, executable: false }, ]; +const legacyRebaseInput = (stream: Buffer) => { + const data = Buffer.alloc(0x8000); + stream.copy(data, 0x7000); + return { + data, + fixups: parsePointerFixups( + data, + { + chained: null, + binds: [], + rebases: [{ offset: 0x7000, size: stream.length }], + dylibs: [], + }, + [{ address: BASE, size: 0x2000n, offset: 0, executable: false }], + BASE, + ), + }; +}; + +it("marks only opcode-selected pointer slots rebased, including target zero", () => { + // SET_TYPE(pointer), SET_SEGMENT(offset 0x100), DO once, + // ADD_ADDR(0x10), DO once, DONE. + const { data, fixups } = legacyRebaseInput( + Buffer.from([0x11, 0x20, 0x80, 0x02, 0x51, 0x30, 0x10, 0x51, 0]), + ); + data.writeBigUInt64LE(BASE + 0x200n, 0x100); + data.writeBigUInt64LE(0n, 0x118); + + expect(fixups.decode(BASE + 0x100n, BASE + 0x200n)).toEqual({ + kind: "rebase", + target: BASE + 0x200n, + }); + expect(fixups.decode(BASE + 0x118n, 0n)).toEqual({ + kind: "rebase", + target: 0n, + }); + expect(fixups.decode(BASE + 0x110n, 0n)).toEqual({ + kind: "raw", + target: 0n, + }); + expect(fixups).toMatchObject({ + kind: "dyld-info", + examined: 1, + decoded: 1, + unreadRebaseBytes: 0, + }); +}); + +it("applies count-and-skip opcodes to their emitted pointer locations", () => { + // SET_TYPE(pointer), SET_SEGMENT(offset 0x100), DO 1 skipping 8, + // DO_REBASE_ADD_ADDR(8), DO once, DONE. The selected slots are 0x100, 0x110, + // and 0x120; the skipped words remain raw. + const { fixups } = legacyRebaseInput( + Buffer.from([ + 0x11, 0x20, 0x80, 0x02, 0x80, 0x01, 0x08, 0x70, 0x08, 0x51, 0, + ]), + ); + + expect(fixups.decode(BASE + 0x100n, 0x200n)).toEqual({ + kind: "rebase", + target: 0x200n, + }); + expect(fixups.decode(BASE + 0x110n, 0x210n)).toEqual({ + kind: "rebase", + target: 0x210n, + }); + expect(fixups.decode(BASE + 0x120n, 0x220n)).toEqual({ + kind: "rebase", + target: 0x220n, + }); + expect(fixups.decode(BASE + 0x108n, 0n)).toEqual({ + kind: "raw", + target: 0n, + }); + expect(fixups.decode(BASE + 0x118n, 0n)).toEqual({ + kind: "raw", + target: 0n, + }); +}); + +it("retains known prefix rebases and leaves unmatched locations unknown on an invalid tail", () => { + const { fixups } = legacyRebaseInput( + Buffer.from([0x11, 0x20, 0x80, 0x02, 0x51, 0xf0]), + ); + + expect(fixups.decode(BASE + 0x100n, BASE + 0x200n)).toEqual({ + kind: "rebase", + target: BASE + 0x200n, + }); + expect(fixups.decode(BASE + 0x108n, BASE + 0x300n)).toMatchObject({ + kind: "unknown", + raw: BASE + 0x300n, + reason: expect.stringContaining("Unsupported rebase opcode 0xf0"), + }); + expect(fixups.issues).toMatchObject([ + { code: "rebase_stream_decode_failed" }, + ]); +}); + +it("does not treat 32-bit text rebases as 64-bit metadata pointers", () => { + const { fixups } = legacyRebaseInput( + Buffer.from([0x12, 0x20, 0x80, 0x02, 0x51, 0]), + ); + + expect(fixups.decode(BASE + 0x100n, BASE + 0x200n)).toMatchObject({ + kind: "unsupported", + reason: "Legacy non-pointer rebase is not a 64-bit metadata pointer", + }); + expect(fixups.decode(BASE + 0x108n, BASE + 0x300n)).toMatchObject({ + kind: "raw", + target: BASE + 0x300n, + }); +}); + +it("retains unaligned pointer locations accepted by dyld's rebase state checks", () => { + const { fixups } = legacyRebaseInput( + Buffer.from([0x11, 0x20, 0x81, 0x02, 0x51, 0]), + ); + + expect(fixups.decode(BASE + 0x101n, 0x1234n)).toEqual({ + kind: "rebase", + target: 0x1234n, + }); + expect(fixups.decode(BASE + 0x100n, 0n)).toEqual({ + kind: "raw", + target: 0n, + }); +}); + +it("rejects count expansion outside the selected file-backed segment without expanding it", () => { + const { fixups } = legacyRebaseInput( + Buffer.from([0x11, 0x20, 0x00, 0x60, 0x80, 0x80, 0x80, 0x80, 0x10, 0x00]), + ); + + expect(fixups.unreadRebaseBytes).toBeGreaterThan(0); + expect(fixups.decode(BASE + 0x100n, 0n)).toMatchObject({ + kind: "unknown", + reason: expect.stringContaining("leaves its file-backed segment"), + }); +}); + +it("bounds cumulative work across repeated full-range runs and retains the prefix", () => { + const data = Buffer.alloc(0x10000); + const repeatedRun = Buffer.from([ + 0x11, // SET_TYPE(pointer) + 0x20, + 0, // SET_SEGMENT_AND_OFFSET(0, 0) + 0x60, + 0x80, + 0x40, // DO_REBASE_ULEB_TIMES(8192) + ]); + const stream = Buffer.concat([ + ...Array.from({ length: 100 }, () => repeatedRun), + Buffer.from([0]), + ]); + stream.copy(data); + const fixups = parsePointerFixups( + data, + { + chained: null, + binds: [], + rebases: [{ offset: 0, size: stream.length }], + dylibs: [], + }, + [ + { + address: BASE, + size: BigInt(data.length), + offset: 0, + executable: false, + }, + ], + BASE, + ); + + expect(fixups.decoded).toBe(0); + expect(fixups.failures.join("\n")).toContain("source-derived budget"); + expect(fixups.decode(BASE + 0x100n, 0x1234n)).toEqual({ + kind: "rebase", + target: 0x1234n, + }); + expect(fixups.decode(BASE + 0x101n, 0n)).toMatchObject({ + kind: "unknown", + raw: 0n, + }); +}); + +it("shares the expansion budget across separate rebase streams", () => { + const data = Buffer.alloc(0x10000); + const stream = Buffer.from([0x11, 0x20, 0, 0x60, 0x80, 0x40, 0]); + const controlData = Buffer.alloc(0x10000); + stream.copy(controlData); + const singleRun = parsePointerFixups( + controlData, + { + chained: null, + binds: [], + rebases: [{ offset: 0, size: stream.length }], + dylibs: [], + }, + [ + { + address: BASE, + size: BigInt(controlData.length), + offset: 0, + executable: false, + }, + ], + BASE, + ); + expect(singleRun).toMatchObject({ examined: 1, decoded: 1, failures: [] }); + + const rebases = Array.from({ length: 17 }, (_, index) => { + const offset = index * 16; + stream.copy(data, offset); + return { offset, size: stream.length }; + }); + const fixups = parsePointerFixups( + data, + { + chained: null, + binds: [], + rebases, + dylibs: [], + }, + [ + { + address: BASE, + size: BigInt(data.length), + offset: 0, + executable: false, + }, + ], + BASE, + ); + + expect(fixups).toMatchObject({ examined: 17, decoded: 15 }); + expect(fixups.failures.join("\n")).toContain("source-derived budget"); + expect(fixups.decode(BASE + 0x100n, 0x1234n)).toEqual({ + kind: "rebase", + target: 0x1234n, + }); +}); + +describe("incomplete legacy bind applicability", () => { + it("keeps unmatched and known-rebase slots unknown when a bind tail is malformed", () => { + const data = Buffer.alloc(0x2000); + const bind = Buffer.from([0x10, 0x40, 0x78, 0, 0x70, 0, 0xf0, 0x90]); + const rebase = Buffer.from([0x11, 0x20, 0x80, 0x20, 0x51, 0]); + bind.copy(data, 0); + rebase.copy(data, 16); + const fixups = parsePointerFixups( + data, + { + chained: null, + binds: [{ offset: 0, size: bind.length, stream: "bind" }], + rebases: [{ offset: 16, size: rebase.length }], + dylibs: [], + }, + [ + { + address: BASE, + size: BigInt(data.length), + offset: 0, + executable: false, + }, + ], + BASE, + ); + + expect(fixups.decode(BASE + 0x1000n, 0n)).toMatchObject({ + kind: "unknown", + raw: 0n, + reason: expect.stringContaining("could overlap this rebase location"), + }); + expect(fixups.decode(BASE + 0x1008n, 0n)).toMatchObject({ + kind: "unknown", + raw: 0n, + reason: expect.stringContaining("pointer-fixup applicability"), + }); + expect(fixups.issues).toMatchObject([ + { code: "bind_stream_decode_failed" }, + ]); + }); + + it("retains a positive bind prefix while leaving unmatched slots unknown", () => { + const opcodes = Buffer.from([ + 0x11, + 0x40, + ...Buffer.from("_x\0"), + 0x71, + 0x08, + 0x90, + 0xf0, + ]); + const fixups = parsePointerFixups( + opcodes, + { + chained: null, + binds: [{ offset: 0, size: opcodes.length, stream: "bind" }], + rebases: [], + dylibs: ["/library-1"], + }, + SEGMENTS, + BASE, + ); + + expect(fixups.decode(BASE + 0x4008n, 0n)).toMatchObject({ + kind: "bind", + symbol: "_x", + }); + expect(fixups.decode(BASE + 0x4010n, 0n)).toMatchObject({ + kind: "unknown", + raw: 0n, + }); + }); + + it("keeps unmatched chained-image slots unknown when a legacy bind tail fails", () => { + const chained = chainedData(1); + const bind = Buffer.from([0xf0, 0x90]); + const data = Buffer.concat([chained, bind]); + const fixups = parsePointerFixups( + data, + { + chained: { offset: 0, size: chained.length }, + binds: [{ offset: chained.length, size: bind.length, stream: "bind" }], + rebases: [], + dylibs: ["/library-1", "/library-2"], + }, + SEGMENTS, + BASE, + ); + + expect(fixups.decode(BASE + 0x4010n, 0n)).toMatchObject({ + kind: "unknown", + raw: 0n, + reason: expect.stringContaining("pointer-fixup applicability"), + }); + }); +}); + describe("independent chained segment coverage", () => { it("counts successfully decoded chained segments alongside an unsupported segment", () => { const starts = 28; @@ -232,13 +573,14 @@ describe("pointer fixup tables", () => { }); it("keeps unread LC_DYLD_INFO rebase bytes when chained fixups are present", () => { - const data = chainedData(1); + const chained = chainedData(1); + const data = Buffer.concat([chained, Buffer.from([0xf0])]); const fixups = parsePointerFixups( data, { - chained: { offset: 0, size: data.length }, + chained: { offset: 0, size: chained.length }, binds: [], - rebases: [{ offset: 8, size: 12 }], + rebases: [{ offset: chained.length, size: 1 }], dylibs: ["/library-1", "/library-2"], }, SEGMENTS, @@ -246,7 +588,7 @@ describe("pointer fixup tables", () => { ); expect(fixups).toMatchObject({ kind: "chained", - unreadRebaseBytes: 12, + unreadRebaseBytes: 1, }); }); @@ -260,7 +602,7 @@ describe("pointer fixup tables", () => { { chained: { offset: 0, size: 128 }, binds: [], - rebases: [{ offset: 8, size: 12 }], + rebases: [], dylibs: ["/library-1", "/library-2"], }, SEGMENTS, @@ -269,7 +611,7 @@ describe("pointer fixup tables", () => { expect(fixups).toMatchObject({ examined: 1, decoded: 1, - unreadRebaseBytes: 12, + unreadRebaseBytes: 0, }); expect(fixups.decode(BASE + 0x4008n, field(1n, 63))).toMatchObject({ kind: "bind", @@ -313,7 +655,7 @@ describe("pointer fixup tables", () => { }); // Pointers outside fixup segments are stored plainly. expect(fixups.decode(BASE + 0x10n, 0x1234n)).toEqual({ - kind: "rebase", + kind: "raw", target: 0x1234n, }); }); @@ -381,7 +723,7 @@ describe("parsed chained pointer membership", () => { ); const plain = data.readBigUInt64LE(0x400b); expect(fixups.decode(BASE + 0x400bn, plain)).toEqual({ - kind: "rebase", + kind: "raw", target: plain, }); }); @@ -664,11 +1006,11 @@ describe("chained pointer membership", () => { symbol: "_free", }); expect(fixups.decode(BASE + 0x4010n, plain)).toEqual({ - kind: "rebase", + kind: "raw", target: plain, }); expect(fixups.decode(BASE + 0x4030n, 0n)).toEqual({ - kind: "rebase", + kind: "raw", target: 0n, }); }, @@ -693,11 +1035,11 @@ describe("chained pointer membership", () => { data.writeUInt16LE(0x1000, 44); const unknown = parsePointerFixups(data, commands, SEGMENTS, BASE); expect(unknown.decode(BASE + 0x4008n, BASE + 8n)).toEqual({ - kind: "rebase", + kind: "raw", target: BASE + 8n, }); expect(unknown.decode(BASE + 0x5008n, BASE + 16n)).toEqual({ - kind: "rebase", + kind: "raw", target: BASE + 16n, }); }); @@ -940,7 +1282,7 @@ describe("legacy pointer fixup streams", () => { library: "/usr/lib/libSystem.B.dylib", }); expect(fixups.decode(BASE + 0x4018n, 0x99n)).toEqual({ - kind: "rebase", + kind: "raw", target: 0x99n, }); }); @@ -981,7 +1323,7 @@ describe("legacy pointer fixup streams", () => { library: "/usr/lib/libobjc.A.dylib", }); expect(fixups.decode(BASE + 0x4018n, 0x99n)).toEqual({ - kind: "rebase", + kind: "raw", target: 0x99n, }); }); diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index 50bb241477..78de93e90c 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -37,6 +37,18 @@ export type DecodedPointer = } | { readonly kind: "unsupported"; readonly reason: string }; +/** Pointer-slot outcome, including whether a rebase was actually applied. */ +export type PointerResolution = + | { readonly kind: "raw"; readonly target: bigint } + | { readonly kind: "rebase"; readonly target: bigint } + | Exclude + | { + readonly kind: "unknown"; + /** Preserved on-disk word; not an authoritative in-image target. */ + readonly raw: bigint; + readonly reason: string; + }; + /** Pointer decoding for one Mach-O slice. */ export interface PointerFixups { readonly kind: "chained" | "dyld-info" | "none"; @@ -44,14 +56,17 @@ export interface PointerFixups { readonly formats: readonly string[]; readonly failures: readonly string[]; readonly issues: readonly DecodeIssue[]; - /** Fixup segments or bind streams this reader walked. */ + /** Chained segments, legacy rebase streams, or bind streams this reader walked. */ readonly examined: number; /** Fully decoded fixup segments or bind streams, in the same units as `examined`. */ readonly decoded: number; - /** Rebase opcode bytes present in the load command and not decoded. */ + /** Declared legacy opcode streams examined by this owner. */ + readonly legacyRebaseStreams: number; + readonly legacyBindStreams: number; + /** Bytes in rebase streams that could not be completely decoded. */ readonly unreadRebaseBytes: number; /** Decode the pointer stored at `address` whose raw 64-bit value is `raw`. */ - decode(address: bigint, raw: bigint): DecodedPointer; + decode(address: bigint, raw: bigint): PointerResolution; } const POINTER_FORMATS: Readonly> = { @@ -86,6 +101,15 @@ interface BindRun { | { readonly kind: "unsupported"; readonly reason: string }; } +interface BindFacts { + readonly runs: readonly BindRun[]; + readonly failures: readonly string[]; + readonly issues: readonly DecodeIssue[]; + readonly examined: number; + readonly decoded: number; + readonly complete: boolean; +} + /** Resolve only valid dylib ordinals; preserve special scopes as raw ordinals. */ const libraryName = ( ordinal: number, @@ -283,7 +307,8 @@ const readChainedPage = ( const chainedFixups = ( image: FixupImage, range: { readonly offset: number; readonly size: number }, - unreadRebaseBytes: number, + rebases: RebaseFacts, + binds: BindFacts, ): PointerFixups => { const { bytes, segments, dylibs, baseAddress } = image; const base = range.offset; @@ -412,18 +437,22 @@ const chainedFixups = ( ranges.map(({ format }) => POINTER_FORMATS[format] ?? `format-${format}`), ), ]; - const issues = ranges.flatMap(({ format, segment }) => - POINTER_FORMATS[format] === undefined - ? [ - issue( - "pointer_fixups", - "unsupported_pointer_format", - `Unsupported chained pointer format ${format}`, - `0x${segment.address.toString(16)}`, - ), - ] - : [], - ); + const issues = [ + ...rebases.issues, + ...binds.issues, + ...ranges.flatMap(({ format, segment }) => + POINTER_FORMATS[format] === undefined + ? [ + issue( + "pointer_fixups", + "unsupported_pointer_format", + `Unsupported chained pointer format ${format}`, + `0x${segment.address.toString(16)}`, + ), + ] + : [], + ), + ]; const examinedViews = new Map(); const decodedRanges = new Set(); for (const [chainIndex, chain] of ranges.entries()) { @@ -461,17 +490,58 @@ const chainedFixups = ( if (complete) decodedRanges.add(chainIndex); } const failures = issues.map(({ message }) => message); + const applicabilityFailures = [...rebases.failures, ...binds.failures]; + const applicabilityComplete = rebases.complete && binds.complete; return { kind: "chained", formats, failures, issues, - examined: Math.max(ranges.length, 1), + examined: Math.max(ranges.length, 1) + rebases.examined + binds.examined, get decoded() { - return ranges.length === 0 ? 1 : decodedRanges.size; + return ( + (ranges.length === 0 ? 1 : decodedRanges.size) + + rebases.decoded + + binds.decoded + ); }, - unreadRebaseBytes, + legacyRebaseStreams: rebases.examined, + legacyBindStreams: binds.examined, + unreadRebaseBytes: rebases.unreadBytes, decode(address, raw) { + const rawPointer = (): PointerResolution => + applicabilityComplete + ? { kind: "raw", target: raw } + : { + kind: "unknown", + raw, + reason: `LC_DYLD_INFO pointer-fixup applicability was not completely decoded: ${applicabilityFailures.join("; ")}`, + }; + const legacyBind = resolveLegacyBind(binds.runs, address); + const legacyRebase = resolveLegacyRebase( + segments, + rebases.membership, + address, + raw, + ); + if (legacyBind?.kind === "unsupported") return legacyBind; + if (legacyBind !== undefined && legacyRebase !== undefined) + return { + kind: "unsupported", + reason: `Both bind and rebase records target pointer location 0x${address.toString(16)}`, + }; + if (legacyBind !== undefined && !rebases.complete) + return { + kind: "unknown", + raw, + reason: `Legacy rebase opcodes could overlap this bind location: ${rebases.failures.join("; ")}`, + }; + if (legacyRebase !== undefined && !binds.complete) + return { + kind: "unknown", + raw, + reason: `Legacy bind opcodes could overlap this rebase location: ${binds.failures.join("; ")}`, + }; const chainIndex = ranges.findIndex( ({ segment }) => address >= segment.address && @@ -479,12 +549,14 @@ const chainedFixups = ( ); const chain = ranges[chainIndex]; // Pointers outside fixup segments, such as in __TEXT, are stored plainly. - if (chain === undefined) return { kind: "rebase", target: raw }; + if (chain === undefined) + return legacyBind ?? legacyRebase ?? rawPointer(); const within = Number(address - chain.segment.address); const page = Math.floor(within / chain.pageSize); - if (page >= chain.pageCount) return { kind: "rebase", target: raw }; + if (page >= chain.pageCount) + return legacyBind ?? legacyRebase ?? rawPointer(); const start = bytes.readUInt16LE(chain.pageStarts + page * 2); - if (start === 0xffff) return { kind: "rebase", target: raw }; + if (start === 0xffff) return legacyBind ?? legacyRebase ?? rawPointer(); if (POINTER_FORMATS[chain.format] === undefined) return { kind: "unsupported", @@ -494,14 +566,37 @@ const chainedFixups = ( const facts = pageFacts(chain, page, start); const at = within % chain.pageSize; const locations = facts.locations; - if (((locations[at >>> 3] ?? 0) & (1 << (at & 7))) !== 0) - return decodeChainedPointer(raw, chain.format, baseAddress, imports); + if (((locations[at >>> 3] ?? 0) & (1 << (at & 7))) !== 0) { + if ( + legacyBind !== undefined || + legacyRebase !== undefined || + !applicabilityComplete + ) { + if (legacyBind !== undefined || legacyRebase !== undefined) + return { + kind: "unsupported", + reason: `Both chained and legacy fixup records target pointer location 0x${address.toString(16)}`, + }; + return { + kind: "unknown", + raw, + reason: `Incomplete legacy fixup streams could overlap chained pointer location 0x${address.toString(16)}: ${applicabilityFailures.join("; ")}`, + }; + } + const decoded = decodeChainedPointer( + raw, + chain.format, + baseAddress, + imports, + ); + return decoded; + } if ( facts.failure !== undefined && (facts.lastOffset === undefined || at > facts.lastOffset) ) throw new RangeError(facts.failure); - return { kind: "rebase", target: raw }; + return legacyBind ?? legacyRebase ?? rawPointer(); } catch (cause: unknown) { const reason = cause instanceof Error ? cause.message : String(cause); const location = `0x${address.toString(16)}`; @@ -529,15 +624,29 @@ const chainedFixups = ( }; }; -const uleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => { +const uleb = ( + bytes: Buffer, + cursor: { at: number }, + end: number, + stream: "bind" | "rebase" = "bind", + rebaseWork?: { workRemaining: number }, +): bigint => { let value = 0n; let shift = 0; for (;;) { - if (cursor.at >= end) throw new RangeError("Truncated bind opcode operand"); + if (cursor.at >= end) + throw new RangeError(`Truncated ${stream} opcode operand`); + if (rebaseWork !== undefined) { + if (rebaseWork.workRemaining === 0) + throw new RangeError( + "Legacy rebase parsing exceeded the source-derived work budget", + ); + rebaseWork.workRemaining--; + } const byte = bytes[cursor.at++] ?? 0; const slice = byte & 0x7f; if ((shift === 63 && slice > 1) || (shift === 64 && slice !== 0)) - throw new RangeError("Oversized bind opcode operand"); + throw new RangeError(`Oversized ${stream} opcode operand`); if (shift < 64) value |= BigInt(slice) << BigInt(shift); // Padding may extend the encoding, but never the represented integer. shift = Math.min(64, shift + 7); @@ -545,6 +654,235 @@ const uleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => { } }; +interface RebaseFacts { + /** Two-bit state per source byte; allocated lazily only for emitted rebases. */ + readonly membership: Uint8Array | null; + readonly failures: readonly string[]; + readonly issues: readonly DecodeIssue[]; + readonly examined: number; + readonly decoded: number; + readonly unreadBytes: number; + readonly complete: boolean; +} + +const REBASE_POINTER_SIZE = 8n; +const REBASE_POINTER = 1; +const REBASE_NON_POINTER = 2; +const REBASE_CONFLICT = 3; + +const markRebaseLocation = ( + index: { bitmap: Uint8Array | null; workRemaining: number }, + fileOffset: number, + type: number, + fileBytes: number, +): void => { + index.bitmap ??= new Uint8Array(Math.ceil(fileBytes / 4)); + const at = Math.floor(fileOffset / 4); + const shift = (fileOffset % 4) * 2; + const previous = ((index.bitmap[at] ?? 0) >> shift) & 3; + const next = previous === 0 || previous === type ? type : REBASE_CONFLICT; + index.bitmap[at] = + ((index.bitmap[at] ?? 0) & ~(3 << shift)) | (next << shift); +}; + +/** Interpret one bounded LC_DYLD_INFO rebase stream into file-backed membership. */ +const readRebaseStream = ( + image: FixupImage, + range: FixupCommands["rebases"][number], + index: { bitmap: Uint8Array | null; workRemaining: number }, +): void => { + const { bytes, segments } = image; + const end = range.offset + range.size; + if ( + !Number.isSafeInteger(range.offset) || + !Number.isSafeInteger(range.size) || + range.offset < 0 || + range.size < 0 || + !Number.isSafeInteger(end) || + end > bytes.length + ) + throw new RangeError("Rebase opcodes exceed the target"); + const cursor = { at: range.offset }; + let segmentIndex = -1; + let segmentOffset = 0n; + let type = 0; + const addRun = (count: bigint, skip: bigint): void => { + if (count === 0n) return; + if (type === 0 || type > 3) + throw new RangeError(`Unsupported legacy rebase type ${type}`); + const segment = segments[segmentIndex]; + if (segment === undefined) + throw new RangeError("Rebase location has no selected segment"); + const stride = REBASE_POINTER_SIZE + skip; + const lastOffset = segmentOffset + (count - 1n) * stride; + if (segmentOffset < 0n || lastOffset + REBASE_POINTER_SIZE > segment.size) + throw new RangeError("Rebase expansion leaves its file-backed segment"); + if (!Number.isSafeInteger(segment.offset) || segment.offset < 0) + throw new RangeError("Rebase segment file offset is invalid"); + const firstFileOffset = BigInt(segment.offset) + segmentOffset; + if ( + firstFileOffset < 0n || + lastOffset + BigInt(segment.offset) + REBASE_POINTER_SIZE > + BigInt(bytes.length) + ) + throw new RangeError("Rebase location exceeds the source file"); + const fact = type === 1 ? REBASE_POINTER : REBASE_NON_POINTER; + const admitted = + count < BigInt(index.workRemaining) ? count : BigInt(index.workRemaining); + for (let indexOffset = 0n; indexOffset < admitted; indexOffset++) { + const fileOffset = Number(firstFileOffset + indexOffset * stride); + markRebaseLocation(index, fileOffset, fact, bytes.length); + } + index.workRemaining -= Number(admitted); + if (admitted < count) + throw new RangeError( + "Legacy rebase opcode and expansion work exceeded the source-derived budget", + ); + // dyld advances after the final record too; that cursor may legally be + // one past the segment because it is only bounds-checked on the next emit. + segmentOffset += count * stride; + }; + while (cursor.at < end) { + if (index.workRemaining === 0) + throw new RangeError( + "Legacy rebase parsing exceeded the source-derived work budget", + ); + index.workRemaining--; + const byte = bytes[cursor.at++] ?? 0; + const immediate = byte & 0x0f; + switch (byte & 0xf0) { + case 0x00: // REBASE_OPCODE_DONE; up to 15 trailing bytes are alignment. + if (end - cursor.at > 15) + throw new RangeError( + "Rebase opcodes terminated before the stream end", + ); + cursor.at = end; + break; + case 0x10: // REBASE_OPCODE_SET_TYPE_IMM + type = immediate; + break; + case 0x20: { + // REBASE_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB + if (segments[immediate] === undefined) + throw new RangeError("Rebase segment index is out of range"); + segmentIndex = immediate; + segmentOffset = uleb(bytes, cursor, end, "rebase", index); + break; + } + case 0x30: // REBASE_OPCODE_ADD_ADDR_ULEB + segmentOffset += uleb(bytes, cursor, end, "rebase", index); + break; + case 0x40: // REBASE_OPCODE_ADD_ADDR_IMM_SCALED + segmentOffset += BigInt(immediate) * REBASE_POINTER_SIZE; + break; + case 0x50: // REBASE_OPCODE_DO_REBASE_IMM_TIMES + addRun(BigInt(immediate), 0n); + break; + case 0x60: { + // REBASE_OPCODE_DO_REBASE_ULEB_TIMES + const count = uleb(bytes, cursor, end, "rebase", index); + addRun(count, 0n); + break; + } + case 0x70: { + // REBASE_OPCODE_DO_REBASE_ADD_ADDR_ULEB + addRun(1n, 0n); + segmentOffset += uleb(bytes, cursor, end, "rebase", index); + break; + } + case 0x80: { + // REBASE_OPCODE_DO_REBASE_ULEB_TIMES_SKIPPING_ULEB + const count = uleb(bytes, cursor, end, "rebase", index); + const skip = uleb(bytes, cursor, end, "rebase", index); + addRun(count, skip); + break; + } + default: + throw new RangeError( + `Unsupported rebase opcode 0x${byte.toString(16)}`, + ); + } + } +}; + +const resolveLegacyRebase = ( + segments: readonly Segment[], + membership: Uint8Array | null, + address: bigint, + raw: bigint, +): PointerResolution | undefined => { + if (membership === null) return undefined; + let fileOffset: number | undefined; + for (const segment of segments) { + const relative = address - segment.address; + if (relative < 0n || relative + REBASE_POINTER_SIZE > segment.size) + continue; + if (fileOffset !== undefined) + return { + kind: "unsupported", + reason: `Ambiguous segments map legacy rebase pointer location 0x${address.toString(16)}`, + }; + const mapped = BigInt(segment.offset) + relative; + if (mapped < 0n || mapped > BigInt(Number.MAX_SAFE_INTEGER)) + return { + kind: "unsupported", + reason: "Legacy rebase file offset exceeds the safe integer range", + }; + fileOffset = Number(mapped); + } + if (fileOffset === undefined) return undefined; + const at = Math.floor(fileOffset / 4); + const shift = (fileOffset % 4) * 2; + const state = ((membership[at] ?? 0) >> shift) & 3; + if (state === 0) return undefined; + if (state === REBASE_POINTER) return { kind: "rebase", target: raw }; + return { + kind: "unsupported", + reason: + state === REBASE_CONFLICT + ? `Conflicting legacy rebase kinds target pointer location 0x${address.toString(16)}` + : "Legacy non-pointer rebase is not a 64-bit metadata pointer", + }; +}; + +const parseLegacyRebases = ( + image: FixupImage, + streams: FixupCommands["rebases"], +): RebaseFacts => { + // Dyld does not require pointer alignment here, so up to one starting byte + // per source byte is possible. Bound opcode scanning plus slot visits by 2N. + const workBudget = Math.min(Number.MAX_SAFE_INTEGER, image.bytes.length * 2); + const index: { bitmap: Uint8Array | null; workRemaining: number } = { + bitmap: null, + workRemaining: workBudget, + }; + const failures: string[] = []; + let decoded = 0; + let unreadBytes = 0; + for (const stream of streams) { + try { + readRebaseStream(image, stream, index); + decoded++; + } catch (cause: unknown) { + failures.push( + `rebase opcodes: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + unreadBytes += stream.size; + } + } + return { + membership: index.bitmap, + failures, + issues: failures.map((message) => + issue("pointer_fixups", "rebase_stream_decode_failed", message), + ), + examined: streams.length, + decoded, + unreadBytes, + complete: failures.length === 0, + }; +}; + const sleb = (bytes: Buffer, cursor: { at: number }, end: number): bigint => { let value = 0n; let shift = 0; @@ -759,6 +1097,62 @@ const readBindStream = ( } }; +const resolveLegacyBind = ( + runs: readonly BindRun[], + address: bigint, +): PointerResolution | undefined => { + let match: BindRun | undefined; + for (const run of runs) { + const distance = address - run.start; + if ( + distance >= 0n && + distance % run.stride === 0n && + distance / run.stride < run.count + ) { + if (match !== undefined) + return { + kind: "unsupported", + reason: `Ambiguous bind records target pointer location 0x${address.toString(16)}`, + }; + match = run; + } + } + return match === undefined + ? undefined + : match.binding.kind === "unsupported" + ? match.binding + : { kind: "bind", ...match.binding.value }; +}; + +const parseLegacyBinds = ( + image: FixupImage, + streams: FixupCommands["binds"], +): BindFacts => { + const runs: BindRun[] = []; + const failures: string[] = []; + let decoded = 0; + for (const stream of streams) { + try { + readBindStream(image, stream, runs); + decoded++; + } catch (cause: unknown) { + failures.push( + `${stream.stream} bind opcodes: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + } + return { + runs, + failures, + issues: failures.map((message) => + issue("pointer_fixups", "bind_stream_decode_failed", message), + ), + examined: streams.length, + decoded, + complete: failures.length === 0, + }; +}; + /** Build pointer decoding for one slice from its fixup load commands. */ export const parsePointerFixups = ( bytes: Buffer, @@ -767,14 +1161,22 @@ export const parsePointerFixups = ( baseAddress: bigint, ): PointerFixups => { const image = { bytes, segments, dylibs: commands.dylibs, baseAddress }; - const unreadRebaseBytes = commands.rebases.reduce( - (total, rebase) => total + rebase.size, - 0, - ); + const rebases = parseLegacyRebases(image, commands.rebases); + const binds = parseLegacyBinds(image, commands.binds); if (commands.chained !== null) { - return chainedFixups(image, commands.chained, unreadRebaseBytes); + return chainedFixups(image, commands.chained, rebases, binds); } - if (commands.binds.length === 0) + const applicabilityFailures = [...rebases.failures, ...binds.failures]; + const applicabilityComplete = rebases.complete && binds.complete; + const rawPointer = (raw: bigint): PointerResolution => + applicabilityComplete + ? { kind: "raw", target: raw } + : { + kind: "unknown", + raw, + reason: `LC_DYLD_INFO pointer-fixup applicability was not completely decoded: ${applicabilityFailures.join("; ")}`, + }; + if (commands.binds.length === 0 && commands.rebases.length === 0) return { kind: "none", formats: [], @@ -782,54 +1184,51 @@ export const parsePointerFixups = ( issues: [], examined: 0, decoded: 0, - unreadRebaseBytes, - decode: (_address, raw) => ({ kind: "rebase", target: raw }), + legacyRebaseStreams: 0, + legacyBindStreams: 0, + unreadRebaseBytes: 0, + decode: (_address, raw) => rawPointer(raw), }; - const binds: BindRun[] = []; - const failures: string[] = []; - let decoded = 0; - for (const stream of commands.binds) - try { - readBindStream(image, stream, binds); - decoded++; - } catch (cause: unknown) { - failures.push( - `${stream.stream} bind opcodes: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } + const failures = [...rebases.failures, ...binds.failures]; + const issues = [...rebases.issues, ...binds.issues]; return { kind: "dyld-info", formats: [], failures, - issues: failures.map((message) => - issue("pointer_fixups", "bind_stream_decode_failed", message), - ), - examined: commands.binds.length, - decoded, - unreadRebaseBytes, + issues, + examined: binds.examined + rebases.examined, + decoded: binds.decoded + rebases.decoded, + legacyRebaseStreams: rebases.examined, + legacyBindStreams: binds.examined, + unreadRebaseBytes: rebases.unreadBytes, decode(address, raw) { - let match: BindRun | undefined; - for (const run of binds) { - const { start, count, stride } = run; - const distance = address - start; - if ( - distance >= 0n && - distance % stride === 0n && - distance / stride < count - ) { - if (match !== undefined) - return { - kind: "unsupported", - reason: `Ambiguous bind records target pointer location 0x${address.toString(16)}`, - }; - match = run; - } - } - return match === undefined - ? { kind: "rebase", target: raw } - : match.binding.kind === "unsupported" - ? match.binding - : { kind: "bind", ...match.binding.value }; + const match = resolveLegacyBind(binds.runs, address); + const rebase = resolveLegacyRebase( + segments, + rebases.membership, + address, + raw, + ); + if (match?.kind === "unsupported") return match; + if (match !== undefined && rebase !== undefined) + return { + kind: "unsupported", + reason: `Both bind and rebase records target pointer location 0x${address.toString(16)}`, + }; + if (match !== undefined && !rebases.complete) + return { + kind: "unknown", + raw, + reason: `Legacy rebase opcodes could overlap this bind location: ${rebases.failures.join("; ")}`, + }; + if (match !== undefined) return match; + if (rebase !== undefined && !binds.complete) + return { + kind: "unknown", + raw, + reason: `Legacy bind opcodes could overlap this rebase location: ${binds.failures.join("; ")}`, + }; + return rebase ?? rawPointer(raw); }, }; }; diff --git a/src/native/AppleObjcClassImportBindings.test.ts b/src/native/AppleObjcClassImportBindings.test.ts index 89cf06bcfa..f63330a4aa 100644 --- a/src/native/AppleObjcClassImportBindings.test.ts +++ b/src/native/AppleObjcClassImportBindings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { PointerFixups } from "./AppleMachoFixups.js"; +import { parsePointerFixups } from "./AppleMachoFixups.js"; import type { MachoLayout } from "./AppleMachoSelection.js"; import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js"; @@ -15,6 +16,8 @@ const createInput = ( issues: [], examined: 1, decoded: 1, + legacyRebaseStreams: 0, + legacyBindStreams: 0, unreadRebaseBytes: 0, decode: (address) => address === 0x1000n @@ -133,6 +136,39 @@ describe("Objective-C import binding address inspection", () => { }); }); + it("marks selected slots partial when legacy rebase ownership is unknown", () => { + const input = createInput(); + const streams = Buffer.from([0x20, 0x80, 0]); // Truncated rebase operand; valid bind DONE. + const fixups = parsePointerFixups( + streams, + { + chained: null, + binds: [{ offset: 2, size: 1, stream: "bind" }], + rebases: [{ offset: 0, size: 2 }], + dylibs: [], + }, + [{ address: 0x1000n, size: 0x1000n, offset: 0, executable: false }], + 0n, + ); + expect( + inspectObjcClassImportBindings({ + ...input, + addresses: ["0x1000"], + fixups, + }), + ).toMatchObject({ + bindings: [], + coverage: { + status: "partial", + reason: expect.stringContaining( + "LC_DYLD_INFO pointer-fixup applicability was not completely decoded", + ), + examined: 1, + decoded: 0, + }, + }); + }); + it("does not infer absent imports from unsupported fixup coverage", () => { const input = createInput({ fixups: { ...createInput().fixups, kind: "none" }, diff --git a/src/native/AppleObjcClassImportBindings.ts b/src/native/AppleObjcClassImportBindings.ts index d6fa3fcdce..e55eea7257 100644 --- a/src/native/AppleObjcClassImportBindings.ts +++ b/src/native/AppleObjcClassImportBindings.ts @@ -1,9 +1,9 @@ import type { PointerFixups } from "./AppleMachoFixups.js"; import type { MachoLayout } from "./AppleMachoSelection.js"; -import type { ObjcClassImportBinding } from "../domain/native/objcSwiftMetadata.js"; +import type { MachoImportBinding } from "../domain/native/objcSwiftMetadata.js"; export interface ObjcClassImportBindingScan { - readonly bindings: readonly ObjcClassImportBinding[]; + readonly bindings: readonly MachoImportBinding[]; readonly coverage: { readonly status: "complete" | "partial" | "unsupported" | "not_requested"; readonly reason: string | null; @@ -51,7 +51,7 @@ export const inspectObjcClassImportBindings = (input: { ); for (const failure of fixups.failures) reasons.add(failure); - const bindings: ObjcClassImportBinding[] = []; + const bindings: MachoImportBinding[] = []; const examinedAddresses = new Set(); const candidates = addresses.slice(0, maxRecords); if (candidates.length < addresses.length) @@ -76,6 +76,10 @@ export const inspectObjcClassImportBindings = (input: { reasons.add(`${requestedAddress}: ${decoded.reason}`); continue; } + if (decoded.kind === "unknown") { + reasons.add(`${requestedAddress}: ${decoded.reason}`); + continue; + } if (decoded.kind !== "bind" || !isObjcClassImport(decoded.symbol)) continue; bindings.push({ diff --git a/src/native/AppleSwiftDispatchFacets.ts b/src/native/AppleSwiftDispatchFacets.ts index c22683a9f8..3c41cf7007 100644 --- a/src/native/AppleSwiftDispatchFacets.ts +++ b/src/native/AppleSwiftDispatchFacets.ts @@ -1,4 +1,5 @@ import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; +import type { PointerResolution } from "./AppleMachoFixups.js"; import type { Segment, Section } from "./AppleMachoSelection.js"; import { issue, type FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js"; @@ -25,10 +26,69 @@ export interface DispatchRecordBudget { admit(): boolean; } +/** Field-owned Swift ABI pointer encodings used by the readers below. */ +export type SwiftRelativeField = + | "conformanceRecord" + | "conformanceProtocol" + | "protocolName" + | "conformanceType" + | "typeName" + | "witnessTablePattern" + | "typeMetadataRecord" + | "classDescriptorName" + | "methodImplementation"; + +type SwiftRelativeFieldContract = { + encoding: "direct" | "indirectable" | "taggedDirect" | "compactFunction"; + nullability: "nullable" | "nonnull"; +}; + +/** Nullability and storage encoding follow the owning fields in Swift ABI. */ +const relativeFieldContracts = { + conformanceRecord: { encoding: "direct", nullability: "nonnull" }, + conformanceProtocol: { + encoding: "indirectable", + nullability: "nullable", + }, + protocolName: { encoding: "direct", nullability: "nonnull" }, + conformanceType: { encoding: "direct", nullability: "nullable" }, + typeName: { encoding: "direct", nullability: "nonnull" }, + witnessTablePattern: { encoding: "direct", nullability: "nullable" }, + typeMetadataRecord: { encoding: "taggedDirect", nullability: "nonnull" }, + classDescriptorName: { encoding: "direct", nullability: "nonnull" }, + methodImplementation: { + encoding: "compactFunction", + nullability: "nullable", + }, +} as const satisfies Record; + +/** Resolved address or a null encoding permitted by its Swift ABI field. */ +export type SwiftRelativeTarget = bigint | null; +export type SwiftRelativeValue = + | { readonly kind: "target"; readonly target: bigint } + | { readonly kind: "null" } + | { + readonly kind: "binding"; + readonly binding: Extract; + readonly requestedAddress: bigint; + } + | { readonly kind: "unknown"; readonly reason: string }; + +/** Keep ABI null, unavailable values, and unresolved imports distinct. */ +export const unresolvedSwiftRelativeReason = ( + value: Exclude, + field: SwiftRelativeField, +): string => { + if (value.kind === "null") return `Null Swift relative pointer for ${field}`; + if (value.kind === "unknown") return value.reason; + return `Swift relative pointer for ${field} has an unresolved external binding at ${hex(value.requestedAddress)}`; +}; + /** Byte readers for Swift relative-pointer and conformance decode facets. */ export interface SwiftDispatchReaders { u32(address: bigint): number; pointer(address: bigint): bigint; + resolvePointer(address: bigint): PointerResolution; string(address: bigint): string; location(address: bigint): ResolvedMetadataLocation; evidence(address: bigint, description: string): BinaryMetadataEvidence; @@ -37,6 +97,18 @@ export interface SwiftDispatchReaders { const hex = (value: bigint) => `0x${value.toString(16)}`; const decoded = { status: "decoded" as const, reason: null }; +const importBinding = ( + requestedAddress: bigint, + binding: Extract, +) => ({ + requested_address: hex(requestedAddress), + symbol: binding.symbol, + binding_source: binding.binding_source, + library_ordinal: binding.library_ordinal, + library: binding.library, + weak: binding.weak, + addend: binding.addend.toString(), +}); /** Create a Swift ABI signed-relative pointer decoder that records relative_pointers. */ export const createSwiftRelativeReader = (input: { @@ -46,18 +118,93 @@ export const createSwiftRelativeReader = (input: { result: ObjcSwiftMetadata; }) => { const { bytes, readers: read, budget, result } = input; - return (field: bigint, indirectable = false): bigint => { + return (field: bigint, kind: SwiftRelativeField): SwiftRelativeValue => { + const contract = relativeFieldContracts[kind]; + const indirectable = contract.encoding === "indirectable"; + const taggedDirect = contract.encoding === "taggedDirect"; + const nullable = contract.nullability === "nullable"; const displacement = bytes.readInt32LE(read.offset(field, 4)); + const encodingTag = taggedDirect ? displacement & 3 : undefined; + if (encodingTag !== undefined && encodingTag !== 0) { + const reason = `Unsupported Swift type metadata record tag ${encodingTag}`; + if (budget.admit()) + result.relative_pointers.push({ + field: read.location(field), + target: { address: null, file_offset: null }, + displacement, + indirectable, + indirect: false, + encoding_tag: encodingTag, + decode: { status: "unsupported", reason }, + evidence: read.evidence( + field, + "Swift ABI tagged type metadata record", + ), + }); + return { kind: "unknown", reason }; + } const indirect = indirectable && (displacement & 1) !== 0; - let target = - displacement === 0 - ? 0n - : field + BigInt(indirectable ? displacement & ~1 : displacement); + let target: SwiftRelativeTarget = + nullable && displacement === 0 + ? null + : field + + BigInt( + taggedDirect + ? displacement & ~3 + : indirectable + ? displacement & ~1 + : displacement, + ); let fileOffset: number | null = null; let reason: string | null = null; try { - if (indirect && target !== 0n) target = read.pointer(target); - if (target !== 0n) fileOffset = read.offset(target); + if (indirect && target !== null) { + const pointer = read.resolvePointer(target); + if (pointer.kind === "bind") { + if (budget.admit()) + result.relative_pointers.push({ + field: read.location(field), + target: { address: null, file_offset: null }, + displacement, + indirectable, + indirect, + binding: importBinding(target, pointer), + decode: { status: "partial", reason: "External pointer binding" }, + evidence: read.evidence( + field, + "Swift ABI indirect relative pointer and Mach-O import binding", + ), + }); + return { + kind: "binding", + binding: pointer, + requestedAddress: target, + }; + } + if (pointer.kind === "unknown" || pointer.kind === "unsupported") { + const unresolved = pointer.reason; + if (budget.admit()) + result.relative_pointers.push({ + field: read.location(field), + target: { address: null, file_offset: null }, + displacement, + indirectable, + indirect, + decode: { status: "partial", reason: unresolved }, + evidence: read.evidence( + field, + "Swift ABI indirect relative pointer", + ), + }); + return { kind: "unknown", reason: unresolved }; + } + const indirectTarget = pointer.target; + target = + pointer.kind === "raw" && indirectTarget === 0n + ? null + : indirectTarget; + } + if (target !== null) fileOffset = read.offset(target); } catch (cause) { reason = cause instanceof Error ? cause.message : String(cause); } @@ -65,20 +212,20 @@ export const createSwiftRelativeReader = (input: { result.relative_pointers.push({ field: read.location(field), target: { - address: target === 0n ? null : hex(target), + address: target === null ? null : hex(target), file_offset: fileOffset, }, displacement, indirectable, indirect, + ...(encodingTag === undefined ? {} : { encoding_tag: encodingTag }), decode: reason === null ? decoded : { status: "partial", reason }, evidence: read.evidence( field, "Swift ABI signed-relative metadata pointer", ), }); - if (reason !== null) throw new RangeError(reason); - return target; + return target === null ? { kind: "null" } : { kind: "target", target }; }; }; @@ -87,13 +234,22 @@ export const decodeSwiftDispatchFacets = (input: { sections: readonly Section[]; segments: readonly Segment[]; readers: SwiftDispatchReaders; - relative: (field: bigint, indirectable?: boolean) => bigint; + relative: (field: bigint, kind: SwiftRelativeField) => SwiftRelativeValue; budget: DispatchRecordBudget; result: ObjcSwiftMetadata; }): FacetDecodeFacts => { const { sections, segments, readers: read, relative, budget, result } = input; const swiftFailures: string[] = []; const issues = [] as ReturnType[]; + const requiredRelative = ( + field: bigint, + kind: SwiftRelativeField, + ): bigint => { + const value = relative(field, kind); + if (value.kind !== "target") + throw new RangeError(unresolvedSwiftRelativeReason(value, kind)); + return value.target; + }; let swiftExamined = 0; let decodedRecords = 0; for (const section of sections.filter( @@ -106,43 +262,113 @@ export const decodeSwiftDispatchFacets = (input: { swiftExamined++; const field = section.address + BigInt(index * 4); try { - const descriptor = relative(field); + const descriptor = requiredRelative(field, "conformanceRecord"); const flags = read.u32(descriptor + 12n); - const protocol = relative(descriptor, true); - if ((read.u32(protocol) & 31) !== 3) + const protocolValue = relative(descriptor, "conformanceProtocol"); + const protocol = + protocolValue.kind === "target" ? protocolValue.target : null; + const unresolvedProtocolReason = + protocolValue.kind === "unknown" || protocolValue.kind === "binding" + ? unresolvedSwiftRelativeReason( + protocolValue, + "conformanceProtocol", + ) + : null; + if (protocol !== null && (read.u32(protocol) & 31) !== 3) throw new TypeError("Unsupported Swift protocol descriptor kind"); - const protocolName = read.string(relative(protocol + 8n)); + const protocolName = + protocol === null + ? null + : read.string(requiredRelative(protocol + 8n, "protocolName")); + const protocolBinding = + protocolValue.kind === "binding" + ? importBinding( + protocolValue.requestedAddress, + protocolValue.binding, + ) + : null; const typeKind = (flags >>> 3) & 7; if (typeKind !== 0 && typeKind !== 1) throw new TypeError( "Unsupported Swift conformance type-reference kind", ); - let type = relative(descriptor + 4n); - if (typeKind === 1) type = read.pointer(type); - if (![16, 17, 18].includes(read.u32(type) & 31)) + const typeValue = relative(descriptor + 4n, "conformanceType"); + let type = typeValue.kind === "target" ? typeValue.target : null; + let typeBinding = + typeValue.kind === "binding" + ? importBinding(typeValue.requestedAddress, typeValue.binding) + : null; + let unresolvedTypeReason = + typeValue.kind === "unknown" ? typeValue.reason : null; + if (typeKind === 1 && type !== null) { + const pointer = read.resolvePointer(type); + if (pointer.kind === "bind") { + typeBinding = importBinding(type, pointer); + type = null; + } else if (pointer.kind === "raw" && pointer.target === 0n) { + type = null; + unresolvedTypeReason = + "Null indirect Swift conformance type reference"; + } else if (pointer.kind === "rebase" || pointer.kind === "raw") { + type = pointer.target; + } else { + type = null; + unresolvedTypeReason = pointer.reason; + } + } + if (type !== null && ![16, 17, 18].includes(read.u32(type) & 31)) throw new TypeError("Unsupported Swift type descriptor kind"); - const typeName = read.string(relative(type + 8n)); - const witness = relative(descriptor + 8n); + const typeName = + type === null + ? null + : read.string(requiredRelative(type + 8n, "typeName")); + const witnessValue = + protocol !== null && type !== null + ? relative(descriptor + 8n, "witnessTablePattern") + : { kind: "null" as const }; + const witness = + witnessValue.kind === "target" ? witnessValue.target : null; + const unresolvedWitnessReason = + witnessValue.kind === "unknown" || witnessValue.kind === "binding" + ? unresolvedSwiftRelativeReason(witnessValue, "witnessTablePattern") + : null; const staticWitness = - witness !== 0n && + witness !== null && + protocol !== null && + type !== null && (flags & ~0x38) === 0 && (read.u32(type) & 0x80) === 0; let witnessReason: string | null = staticWitness ? null - : "Conditional, generic, resilient or missing static witness table is not decoded"; - if (staticWitness && read.pointer(witness) !== descriptor) - witnessReason = - "Witness-table conformance header is unresolved or encoded"; + : (unresolvedProtocolReason ?? + unresolvedTypeReason ?? + unresolvedWitnessReason ?? + (protocolBinding !== null || typeBinding !== null + ? "Conformance references an external Mach-O binding" + : "Conditional, generic, resilient or missing static witness table is not decoded")); + if (staticWitness) { + const header = read.resolvePointer(witness); + if (header.kind !== "raw" && header.kind !== "rebase") + witnessReason = + header.kind === "unknown" || header.kind === "unsupported" + ? header.reason + : "Witness-table conformance header is an external binding"; + else if (header.target !== descriptor) + witnessReason = + "Witness-table conformance header is unresolved or encoded"; + } const conformanceDecode = witnessReason === null ? { status: "decoded" as const, reason: null } : { status: "partial" as const, reason: witnessReason }; result.swift_conformances.push({ type_name: typeName, + type_binding: typeBinding, protocol_name: protocolName, + protocol_binding: protocolBinding, module: null, witness_table: - witness === 0n + witness === null ? { address: null, file_offset: null } : read.location(witness), location: read.location(descriptor), @@ -153,33 +379,46 @@ export const decodeSwiftDispatchFacets = (input: { ), }); if (conformanceDecode.status === "decoded") decodedRecords++; - if (witnessReason !== null) { - swiftFailures.push(`${typeName}: ${witnessReason}`); + if ( + witnessReason !== null || + witness === null || + typeName === null || + protocolName === null + ) { + const reason = + witnessReason ?? "Missing static witness table is not decoded"; + swiftFailures.push(`${typeName ?? ""}: ${reason}`); issues.push( issue( "swift_conformances_static_witness_slots", "conformance_decode_partial", - witnessReason, + reason, hex(descriptor), ), ); continue; } - const count = read.u32(protocol + 16n); - const signatureCount = read.u32(protocol + 12n); + const count = read.u32(protocol! + 16n); + const signatureCount = read.u32(protocol! + 12n); if (count > 20_000 || signatureCount > 20_000) throw new RangeError("Swift witness requirement count exceeds 20000"); for (let slot = 0; slot < count; slot++) { if (!budget.admit()) break; const entry = witness + BigInt((slot + 1) * 8); - const implementation = read.pointer(entry); + const implementationResolution = read.resolvePointer(entry); + const implementation = + implementationResolution.kind === "raw" || + implementationResolution.kind === "rebase" + ? implementationResolution.target + : null; const requirementFlags = read.u32( - protocol + 24n + BigInt(signatureCount * 12 + slot * 8), + protocol! + 24n + BigInt(signatureCount * 12 + slot * 8), ); const kind = requirementFlags & 15; const synchronousFunction = kind >= 1 && kind <= 4 && (requirementFlags & 0x20) === 0; const executable = + implementation !== null && synchronousFunction && segments.some( (segment) => @@ -192,7 +431,12 @@ export const decodeSwiftDispatchFacets = (input: { : { status: "partial" as const, reason: - "Witness slot is non-function, async, nil, external, authenticated or encoded; implementation target is unresolved", + implementationResolution.kind === "bind" + ? "Witness implementation is an external Mach-O binding" + : implementationResolution.kind === "unknown" || + implementationResolution.kind === "unsupported" + ? implementationResolution.reason + : "Witness slot is non-function, async, nil, external, authenticated or encoded; implementation target is unresolved", }; result.swift_dispatch_slots.push({ owner: `${typeName}: ${protocolName}`, @@ -201,6 +445,14 @@ export const decodeSwiftDispatchFacets = (input: { requirement: null, implementation: null, implementation_address: executable ? hex(implementation) : null, + ...(implementationResolution.kind === "bind" + ? { + implementation_binding: importBinding( + entry, + implementationResolution, + ), + } + : {}), thunk_address: null, location: read.location(entry), decode: slotDecode, diff --git a/src/native/AppleSwiftRelativePointer.test.ts b/src/native/AppleSwiftRelativePointer.test.ts new file mode 100644 index 0000000000..e394a8ddd2 --- /dev/null +++ b/src/native/AppleSwiftRelativePointer.test.ts @@ -0,0 +1,274 @@ +import { expect, it } from "vitest"; + +import { objcSwiftMetadataSchema } from "../domain/native/objcSwiftMetadata.js"; +import { + createSwiftRelativeReader, + type DispatchRecordBudget, +} from "./AppleSwiftDispatchFacets.js"; + +const readers = (bytes: Buffer) => ({ + u32: (address: bigint) => bytes.readUInt32LE(Number(address)), + pointer: (address: bigint) => bytes.readBigUInt64LE(Number(address)), + resolvePointer: (address: bigint) => ({ + kind: "raw" as const, + target: bytes.readBigUInt64LE(Number(address)), + }), + string: () => "fixture", + location: (address: bigint) => ({ + address: `0x${address.toString(16)}`, + file_offset: Number(address), + }), + evidence: (address: bigint, description: string) => [ + { + kind: "binary_metadata" as const, + description, + location: { + address: `0x${address.toString(16)}`, + file_offset: Number(address), + }, + artifact_path: "/fixture", + artifact_sha256: "a".repeat(64), + }, + ], + offset: (address: bigint, size = 1) => { + if (address < 0n || address + BigInt(size) > BigInt(bytes.length)) + throw new RangeError("outside fixture bytes"); + return Number(address); + }, +}); + +const budget = (): DispatchRecordBudget => ({ + truncated: false, + isFull: () => false, + markTruncated: () => undefined, + admit: () => true, +}); + +it("keeps an encoded null distinct from a nonzero relative offset to address zero", () => { + const bytes = Buffer.alloc(0x80); + bytes.writeInt32LE(0, 0x20); + bytes.writeInt32LE(-0x40, 0x40); + bytes.writeInt32LE(0x25, 0x24); + bytes.writeBigUInt64LE(0n, 0x48); + bytes.writeInt32LE(0x25, 0x2c); + bytes.writeBigUInt64LE(0x60n, 0x50); + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const relative = createSwiftRelativeReader({ + bytes, + readers: readers(bytes), + budget: budget(), + result, + }); + + expect(relative(0x20n, "methodImplementation")).toEqual({ kind: "null" }); + expect(relative(0x40n, "methodImplementation")).toEqual({ + kind: "target", + target: 0n, + }); + expect(relative(0x24n, "conformanceProtocol")).toEqual({ kind: "null" }); + expect(relative(0x2cn, "conformanceProtocol")).toEqual({ + kind: "target", + target: 0x60n, + }); + expect(result.relative_pointers).toMatchObject([ + { displacement: 0, target: { address: null, file_offset: null } }, + { displacement: -0x40, target: { address: "0x0", file_offset: 0 } }, + { displacement: 0x25, indirect: true, target: { address: null } }, + { displacement: 0x25, indirect: true, target: { address: "0x60" } }, + ]); +}); + +it("resolves zero offsets for nonnullable fields to their own field address", () => { + const bytes = Buffer.alloc(0x80); + bytes.writeInt32LE(0, 0x20); + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const relative = createSwiftRelativeReader({ + bytes, + readers: readers(bytes), + budget: budget(), + result, + }); + + for (const kind of [ + "conformanceRecord", + "protocolName", + "typeName", + "typeMetadataRecord", + "classDescriptorName", + ] as const) { + expect(relative(0x20n, kind)).toEqual({ kind: "target", target: 0x20n }); + } + expect(result.relative_pointers).toHaveLength(5); + expect(result.relative_pointers).toMatchObject( + Array.from({ length: 5 }, () => ({ + target: { address: "0x20", file_offset: 0x20 }, + })), + ); +}); + +it("accepts encoded zero only for nullable direct Swift fields", () => { + const bytes = Buffer.alloc(0x80); + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const relative = createSwiftRelativeReader({ + bytes, + readers: readers(bytes), + budget: budget(), + result, + }); + + for (const kind of [ + "conformanceType", + "witnessTablePattern", + "methodImplementation", + ] as const) { + expect(relative(0x20n, kind)).toEqual({ kind: "null" }); + } +}); + +it("masks only the verified direct type metadata tag and keeps other tags unsupported", () => { + const bytes = Buffer.alloc(0x80); + bytes.writeInt32LE(0x24, 0x20); + bytes.writeInt32LE(0x25, 0x28); + bytes.writeInt32LE(0x26, 0x2c); + bytes.writeInt32LE(0x27, 0x30); + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const relative = createSwiftRelativeReader({ + bytes, + readers: readers(bytes), + budget: budget(), + result, + }); + + expect(relative(0x20n, "typeMetadataRecord")).toEqual({ + kind: "target", + target: 0x44n, + }); + for (const [field, tag] of [ + [0x28n, 1], + [0x2cn, 2], + [0x30n, 3], + ] as const) { + expect(relative(field, "typeMetadataRecord")).toEqual({ + kind: "unknown", + reason: `Unsupported Swift type metadata record tag ${tag}`, + }); + } + expect(result.relative_pointers.slice(-4)).toMatchObject([ + { displacement: 0x24, encoding_tag: 0, target: { address: "0x44" } }, + { + displacement: 0x25, + encoding_tag: 1, + decode: { status: "unsupported" }, + }, + { + displacement: 0x26, + encoding_tag: 2, + decode: { status: "unsupported" }, + }, + { + displacement: 0x27, + encoding_tag: 3, + decode: { status: "unsupported" }, + }, + ]); +}); + +it("treats a zero pointer loaded through a nullable indirect field as null", () => { + const bytes = Buffer.alloc(0x80); + bytes.writeInt32LE(-0x23, 0x24); // odd-tagged displacement to cell address 0 + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const relative = createSwiftRelativeReader({ + bytes, + readers: readers(bytes), + budget: budget(), + result, + }); + + expect(relative(0x24n, "conformanceProtocol")).toEqual({ kind: "null" }); + expect(result.relative_pointers[0]).toMatchObject({ + indirect: true, + target: { address: null, file_offset: null }, + }); +}); + +it("keeps a fixup rebase to VM address zero as an address, not null", () => { + const bytes = Buffer.alloc(0x80); + bytes.writeInt32LE(-0x23, 0x24); + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const relative = createSwiftRelativeReader({ + bytes, + readers: { + ...readers(bytes), + resolvePointer: () => ({ kind: "rebase", target: 0n }), + offset: (address, size = 1) => { + if (address === 0n) throw new RangeError("unmapped target address"); + return readers(bytes).offset(address, size); + }, + }, + budget: budget(), + result, + }); + + expect(relative(0x24n, "conformanceProtocol")).toEqual({ + kind: "target", + target: 0n, + }); + expect(result.relative_pointers[0]).toMatchObject({ + target: { address: "0x0", file_offset: null }, + decode: { status: "partial", reason: "unmapped target address" }, + }); +}); + +it("preserves an indirect import binding instead of reading VM address zero", () => { + const bytes = Buffer.alloc(0x80); + bytes.writeInt32LE(-0x23, 0x24); + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const relative = createSwiftRelativeReader({ + bytes, + readers: { + ...readers(bytes), + resolvePointer: () => ({ + kind: "bind", + symbol: "_$sSQMp", + binding_source: "chained", + library_ordinal: 1, + library: "/System/Library/libswiftCore.dylib", + weak: false, + addend: 0n, + }), + }, + budget: budget(), + result, + }); + + expect(relative(0x24n, "conformanceProtocol")).toMatchObject({ + kind: "binding", + binding: { symbol: "_$sSQMp" }, + }); + expect(result.relative_pointers[0]).toMatchObject({ + target: { address: null, file_offset: null }, + binding: { symbol: "_$sSQMp", requested_address: "0x0" }, + decode: { status: "partial" }, + }); +}); + +it("retains unsafe target addresses as partial decode facts", () => { + const bytes = Buffer.alloc(0x80); + bytes.writeInt32LE(0x100, 0x20); + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const relative = createSwiftRelativeReader({ + bytes, + readers: readers(bytes), + budget: budget(), + result, + }); + + expect(relative(0x20n, "protocolName")).toEqual({ + kind: "target", + target: 0x120n, + }); + expect(result.relative_pointers[0]).toMatchObject({ + target: { address: "0x120", file_offset: null }, + decode: { status: "partial", reason: "outside fixture bytes" }, + }); +}); diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts index 5873de9335..c1e5fb4b8f 100644 --- a/src/native/AppleSwiftVtables.test.ts +++ b/src/native/AppleSwiftVtables.test.ts @@ -7,140 +7,219 @@ import { decodeSwiftDispatchFacets } from "./AppleSwiftDispatchFacets.js"; import type { DispatchRecordBudget } from "./AppleSwiftDispatchFacets.js"; import { issue } from "./AppleDispatchDecodeFacts.js"; -describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => { - it("counts examined slots and reports an unresolved async slot", () => { - const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); - const values = new Map([ - [0x200n, 0x80000010], // class descriptor with a vtable - [0x22cn, 0], // table offset - [0x230n, 2], // slot count - [0x21cn, 2], // descriptor positive words - [0x234n, 0x40], // async method flag - ]); - const facts = decodeSwiftClassVtables({ - entries: [0x100n], - result, - readers: { - u32: (address) => values.get(address) ?? 0, - relative: (address) => - address === 0x100n ? 0x200n : address === 0x208n ? 0x300n : 0x400n, - string: () => "Widget", - location: (address) => ({ - address: `0x${address.toString(16)}`, - file_offset: Number(address), - }), - evidence: (address, description) => [ - { - kind: "binary_metadata", - description, - location: { - address: `0x${address.toString(16)}`, - file_offset: Number(address), - }, - artifact_path: "/fixture", - artifact_sha256: "a".repeat(64), - }, - ], - executable: (address) => address === 0x400n, - admit: () => true, - }, - }); - - expect(result.swift_dispatch_slots[0]?.decode.status).toBe("partial"); - expect(result.swift_dispatch_slots[1]?.decode.status).toBe("decoded"); - expect(facts).toMatchObject({ - facet: "swift_class_vtable_descriptors", - examined: 2, - decoded: 1, - exhaustive: false, - issues: [{ code: "vtable_implementation_unresolved", location: "0x234" }], - }); - - pushDispatchCoverage({ - result, - failures: [], - categoryIssues: [], - examined: 0, - categoriesExamined: 0, - truncated: false, - objcListScanTruncated: false, - objcClassListScanTruncated: false, - fixups: { - kind: "none", - formats: [], - failures: [], - issues: [], - decode: (_address, raw) => ({ kind: "rebase", target: raw }), - examined: 0, - decoded: 0, - unreadRebaseBytes: 0, - }, - swift: { - facet: "swift_conformances_static_witness_slots", - examined: 0, - decoded: 0, - exhaustive: true, - issues: [], - }, - vtables: facts, - }); - - expect( - result.coverage.find( - ({ facet }) => facet === "swift_class_vtable_descriptors", - ), - ).toMatchObject({ - status: "partial", - examined: 2, - decoded: 1, - reason: expect.stringContaining("0x234"), - }); +it("preserves unsupported type-metadata tag reasons instead of reporting null", () => { + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const facts = decodeSwiftClassVtables({ + entries: [0x100n], + result, + readers: { + u32: () => 0, + relative: () => ({ + kind: "unknown", + reason: "Unsupported Swift type metadata record tag 2", + }), + string: () => "unused", + location: (address) => ({ + address: `0x${address.toString(16)}`, + file_offset: Number(address), + }), + evidence: () => [], + executable: () => false, + admit: () => true, + }, }); - it("returns a located issue for an emitted unresolved witness slot", () => { - const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); - const values = new Map([ - [0x20cn, 0], // simple, unconditional conformance - [0x500n, 3], // protocol descriptor kind - [0x50cn, 0], // signature count - [0x510n, 1], // witness requirement count - [0x2b0n, 16], // nominal type descriptor kind - [0x518n, 1], // function witness requirement - ]); - const budget: DispatchRecordBudget = { + expect(facts.issues).toMatchObject([ + { + code: "vtable_decode_failed", + message: "Unsupported Swift type metadata record tag 2", + }, + ]); + expect(facts.issues[0]?.message).not.toContain("Null Swift"); +}); + +it("preserves a required conformance pointer's unknown reason", () => { + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const facts = decodeSwiftDispatchFacets({ + sections: [{ name: "__swift5_proto", address: 0x100n, size: 4 }], + segments: [], + readers: { + u32: () => 0, + pointer: () => 0n, + resolvePointer: () => ({ kind: "raw", target: 0n }), + string: () => "unused", + location: (address) => ({ + address: `0x${address.toString(16)}`, + file_offset: Number(address), + }), + evidence: () => [], + offset: (address) => Number(address), + }, + relative: () => ({ + kind: "unknown", + reason: "Unsupported Swift conformance record tag 3", + }), + budget: { truncated: false, isFull: () => false, - markTruncated: () => { - budget.truncated = true; - }, + markTruncated: () => {}, admit: () => true, - }; - const facts = decodeSwiftDispatchFacets({ - sections: [{ name: "__swift5_proto", address: 0x100n, size: 4 }], - segments: [], - readers: { - u32: (address) => values.get(address) ?? 0, - pointer: (address) => (address === 0x380n ? 0x200n : 0x900n), - string: (address) => (address === 0x600n ? "Proto" : "Widget"), - location: (address) => ({ - address: `0x${address.toString(16)}`, - file_offset: Number(address), - }), - evidence: (address, description) => [ - { - kind: "binary_metadata", - description, - location: { - address: `0x${address.toString(16)}`, - file_offset: Number(address), - }, - artifact_path: "/fixture", - artifact_sha256: "a".repeat(64), + }, + result, + }); + + expect(facts.issues).toMatchObject([ + { + code: "conformance_decode_failed", + message: "Unsupported Swift conformance record tag 3", + }, + ]); +}); + +it("counts examined slots and reports an unresolved async slot", () => { + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const values = new Map([ + [0x200n, 0x80000010], // class descriptor with a vtable + [0x22cn, 0], // table offset + [0x230n, 2], // slot count + [0x21cn, 2], // descriptor positive words + [0x234n, 0x40], // async method flag + ]); + const facts = decodeSwiftClassVtables({ + entries: [0x100n], + result, + readers: { + u32: (address) => values.get(address) ?? 0, + relative: (address) => ({ + kind: "target", + target: + address === 0x100n ? 0x200n : address === 0x208n ? 0x300n : 0x400n, + }), + string: () => "Widget", + location: (address) => ({ + address: `0x${address.toString(16)}`, + file_offset: Number(address), + }), + evidence: (address, description) => [ + { + kind: "binary_metadata", + description, + location: { + address: `0x${address.toString(16)}`, + file_offset: Number(address), }, - ], - offset: (address) => Number(address), - }, - relative: (field) => + artifact_path: "/fixture", + artifact_sha256: "a".repeat(64), + }, + ], + executable: (address) => address === 0x400n, + admit: () => true, + }, + }); + + expect(result.swift_dispatch_slots[0]?.decode.status).toBe("partial"); + expect(result.swift_dispatch_slots[1]?.decode.status).toBe("decoded"); + expect(facts).toMatchObject({ + facet: "swift_class_vtable_descriptors", + examined: 2, + decoded: 1, + exhaustive: false, + issues: [{ code: "vtable_implementation_unresolved", location: "0x234" }], + }); + + pushDispatchCoverage({ + result, + failures: [], + categoryIssues: [], + examined: 0, + categoriesExamined: 0, + truncated: false, + objcListScanTruncated: false, + objcClassListScanTruncated: false, + fixups: { + kind: "none", + formats: [], + failures: [], + issues: [], + decode: (_address, raw) => ({ kind: "rebase", target: raw }), + examined: 0, + decoded: 0, + legacyRebaseStreams: 0, + legacyBindStreams: 0, + unreadRebaseBytes: 0, + }, + swift: { + facet: "swift_conformances_static_witness_slots", + examined: 0, + decoded: 0, + exhaustive: true, + issues: [], + }, + vtables: facts, + }); + + expect( + result.coverage.find( + ({ facet }) => facet === "swift_class_vtable_descriptors", + ), + ).toMatchObject({ + status: "partial", + examined: 2, + decoded: 1, + reason: expect.stringContaining("0x234"), + }); +}); + +it("returns a located issue for an emitted unresolved witness slot", () => { + const result = objcSwiftMetadataSchema.parse({ db_save_result: null }); + const values = new Map([ + [0x20cn, 0], // simple, unconditional conformance + [0x500n, 3], // protocol descriptor kind + [0x50cn, 0], // signature count + [0x510n, 1], // witness requirement count + [0x2b0n, 16], // nominal type descriptor kind + [0x518n, 1], // function witness requirement + ]); + const budget: DispatchRecordBudget = { + truncated: false, + isFull: () => false, + markTruncated: () => { + budget.truncated = true; + }, + admit: () => true, + }; + const facts = decodeSwiftDispatchFacets({ + sections: [{ name: "__swift5_proto", address: 0x100n, size: 4 }], + segments: [], + readers: { + u32: (address) => values.get(address) ?? 0, + pointer: (address) => (address === 0x380n ? 0x200n : 0x900n), + resolvePointer: (address) => ({ + kind: "raw", + target: address === 0x380n ? 0x200n : 0x900n, + }), + string: (address) => (address === 0x600n ? "Proto" : "Widget"), + location: (address) => ({ + address: `0x${address.toString(16)}`, + file_offset: Number(address), + }), + evidence: (address, description) => [ + { + kind: "binary_metadata", + description, + location: { + address: `0x${address.toString(16)}`, + file_offset: Number(address), + }, + artifact_path: "/fixture", + artifact_sha256: "a".repeat(64), + }, + ], + offset: (address) => Number(address), + }, + relative: (field) => ({ + kind: "target", + target: new Map([ [0x100n, 0x200n], [0x200n, 0x500n], @@ -149,16 +228,16 @@ describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => { [0x2b8n, 0x700n], [0x208n, 0x380n], ]).get(field) ?? 0n, - budget, - result, - }); + }), + budget, + result, + }); - expect(result.swift_dispatch_slots[0]?.decode.status).toBe("partial"); - expect(facts).toMatchObject({ - facet: "swift_conformances_static_witness_slots", - exhaustive: false, - issues: [{ code: "witness_slot_unresolved", location: "0x388" }], - }); + expect(result.swift_dispatch_slots[0]?.decode.status).toBe("partial"); + expect(facts).toMatchObject({ + facet: "swift_conformances_static_witness_slots", + exhaustive: false, + issues: [{ code: "witness_slot_unresolved", location: "0x388" }], }); }); @@ -192,6 +271,8 @@ describe("Swift vtable coverage facts: unsupported pointer fixup coverage", () = }), examined: 0, decoded: 0, + legacyRebaseStreams: 0, + legacyBindStreams: 0, unreadRebaseBytes: 0, }, swift: { diff --git a/src/native/AppleSwiftVtables.ts b/src/native/AppleSwiftVtables.ts index a680957ac2..fa563cb013 100644 --- a/src/native/AppleSwiftVtables.ts +++ b/src/native/AppleSwiftVtables.ts @@ -3,12 +3,17 @@ import type { NativeMetadataEvidence, NativeMetadataLocation, } from "../domain/native/objcSwiftMetadata.js"; +import type { + SwiftRelativeField, + SwiftRelativeValue, +} from "./AppleSwiftDispatchFacets.js"; +import { unresolvedSwiftRelativeReason } from "./AppleSwiftDispatchFacets.js"; import { issue, type FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js"; /** Byte readers shared with the validated Mach-O metadata boundary. */ export interface SwiftMetadataReaders { u32(address: bigint): number; - relative(address: bigint): bigint; + relative(address: bigint, kind: SwiftRelativeField): SwiftRelativeValue; string(address: bigint): string; location(address: bigint): NativeMetadataLocation; evidence(address: bigint, description: string): NativeMetadataEvidence[]; @@ -43,7 +48,12 @@ export const decodeSwiftClassVtables = (input: { break; } try { - const descriptor = read.relative(field); + const descriptorValue = read.relative(field, "typeMetadataRecord"); + if (descriptorValue.kind !== "target") + throw new RangeError( + unresolvedSwiftRelativeReason(descriptorValue, "typeMetadataRecord"), + ); + const descriptor = descriptorValue.target; const flags = read.u32(descriptor); if ((flags & 31) !== 16) continue; const specific = flags >>> 16; @@ -66,7 +76,13 @@ export const decodeSwiftClassVtables = (input: { throw new RangeError( "Swift vtable count or offset exceeds declared metadata bounds", ); - const owner = read.string(read.relative(descriptor + 8n)); + const ownerValue = read.relative(descriptor + 8n, "classDescriptorName"); + if (ownerValue.kind !== "target") + throw new RangeError( + unresolvedSwiftRelativeReason(ownerValue, "classDescriptorName"), + ); + const ownerAddress = ownerValue.target; + const owner = read.string(ownerAddress); if (owner.length === 0) throw new RangeError( "Swift class vtable descriptor class name is empty", @@ -90,8 +106,25 @@ export const decodeSwiftClassVtables = (input: { // Async slots reference async descriptors; coroutine and future kinds need separate decoders. const synchronous = (methodFlags & 0x40) === 0 && (methodFlags & 15) <= 3; - const implementation = read.relative(entry + 4n); - const resolved = synchronous && read.executable(implementation); + const implementationValue = read.relative( + entry + 4n, + "methodImplementation", + ); + const implementation = + implementationValue.kind === "target" + ? implementationValue.target + : null; + const implementationReason = + implementationValue.kind === "target" + ? null + : unresolvedSwiftRelativeReason( + implementationValue, + "methodImplementation", + ); + const resolved = + implementation !== null && + synchronous && + read.executable(implementation); result.swift_dispatch_slots.push({ owner, table_kind: "class_vtable", @@ -108,7 +141,9 @@ export const decodeSwiftClassVtables = (input: { : { status: "partial", reason: - "Async, coroutine, external or unmapped vtable implementation is unresolved", + implementation === null + ? implementationReason + : "Async, coroutine, external or unmapped vtable implementation is unresolved", }, evidence: read.evidence( entry, @@ -121,7 +156,9 @@ export const decodeSwiftClassVtables = (input: { issue( "swift_class_vtable_descriptors", "vtable_implementation_unresolved", - "Async, coroutine, external or unmapped vtable implementation is unresolved", + implementation === null + ? implementationReason! + : "Async, coroutine, external or unmapped vtable implementation is unresolved", `0x${entry.toString(16)}`, ), );