fix(native)!: preserve Swift pointer nullability and import bindings

Resolve relative pointers using field-owned ABI encodings. Keep null, raw, rebased, imported and unknown targets distinct, and retain imported conformance rows with exact Mach-O binding provenance. Decode legacy rebase membership with source-derived memory and work bounds, preserving validated prefix facts when streams are incomplete.

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

Closes #5683
Closes #5685

BREAKING CHANGE: Swift conformance type_name and protocol_name can be null when names cannot be resolved; consumers must use typed type_binding and protocol_binding evidence. MachoImportBinding replaces the former Objective-C-specific binding type and schema.
This commit is contained in:
morluto
2026-10-21 08:17:12 +08:00
parent 9258df02cc
commit d0067ba34a
15 changed files with 2026 additions and 314 deletions
+12 -1
View File
@@ -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.
+3 -1
View File
@@ -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) =>
+21 -17
View File
@@ -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<typeof machoImportBindingSchema>;
/** 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(),
+6 -9
View File
@@ -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<MachoImportBinding, "requested_address"> & {
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<string, ObjcClassImportBinding[]>();
const groupedBindings = new Map<string, MachoImportBinding[]>();
for (const binding of bindings) {
const key = bindingKey(binding);
const existing = groupedBindings.get(key);
+16 -13
View File
@@ -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. */
+261
View File
@@ -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) => {
+11 -2
View File
@@ -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,
+356 -14
View File
@@ -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,
});
});
+475 -76
View File
@@ -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<DecodedPointer, { readonly kind: "rebase" }>
| {
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<Record<number, string>> = {
@@ -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<string, boolean>();
const decodedRanges = new Set<number>();
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);
},
};
};
@@ -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" },
+7 -3
View File
@@ -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<string>();
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({
+286 -34
View File
@@ -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<SwiftRelativeField, SwiftRelativeFieldContract>;
/** 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<PointerResolution, { readonly kind: "bind" }>;
readonly requestedAddress: bigint;
}
| { readonly kind: "unknown"; readonly reason: string };
/** Keep ABI null, unavailable values, and unresolved imports distinct. */
export const unresolvedSwiftRelativeReason = (
value: Exclude<SwiftRelativeValue, { readonly kind: "target" }>,
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<PointerResolution, { readonly kind: "bind" }>,
) => ({
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<typeof issue>[];
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 ?? "<unknown type>"}: ${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,
@@ -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" },
});
});
+218 -137
View File
@@ -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<bigint, number>([
[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<bigint, number>([
[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<bigint, number>([
[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<bigint, number>([
[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<bigint, bigint>([
[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: {
+44 -7
View File
@@ -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)}`,
),
);