mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)}`,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user