diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index b267b89638..3a51c8a1f2 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { machoWithPropertyAttributes } from "../../tests/fixtures/objcPropertyMacho.js"; +import { objcRecordBudgetMacho } from "../../tests/fixtures/objcRecordBudgetMacho.js"; import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; const base = 0x100000000n; @@ -119,6 +120,20 @@ const fixture = ( }; const provenance = { path: "/fixture/app", sha256: "a".repeat(64) }; +const withProtocolListSection = (bytes: Buffer): Buffer => { + const pointer = (at: number, offset: number) => + bytes.writeBigUInt64LE(base + BigInt(offset), at); + bytes.writeUInt32LE(232, 20); + bytes.writeUInt32LE(232, 36); + bytes.writeUInt32LE(2, 96); + bytes.write("__objc_protolist", 184); + bytes.write("__DATA", 200); + pointer(216, 0x6c0); + bytes.writeBigUInt64LE(8n, 224); + pointer(0x6c0, 0x740); + return bytes; +}; + const partialSiblingsFixture = ( options: { readonly emptyMiddleClass?: boolean; @@ -469,6 +484,173 @@ describe("Apple dispatch coverage ownership", () => { }); }); +describe("Objective-C declaration-local record-budget coverage", () => { + it.each(["protocol", "class"] as const)( + "preserves the decoded $0 method at a sufficient budget", + (owner) => { + const result = decodeAppleDispatchMetadata( + objcRecordBudgetMacho(owner), + 100, + provenance, + ); + if (owner === "protocol") + expect(result.objc_protocol_records).toMatchObject([ + { + name: "FixtureProtocol", + methods: [{ selector: "protocolMethod" }], + decode: { status: "decoded", reason: null }, + }, + ]); + else + expect(result.objc_classes).toMatchObject([ + { + name: "FixtureClass", + methods: [{ selector: "classMethod", method_type: "instance" }], + decode: { status: "decoded", reason: null }, + }, + ]); + }, + ); + + it("marks an omitted protocol method list partial while preserving known emptiness", () => { + const truncated = decodeAppleDispatchMetadata( + withProtocolListSection(fixture()), + 1, + provenance, + ); + expect(truncated.objc_protocol_records).toMatchObject([ + { + name: "FixtureProtocol", + methods: [], + decode: { status: "partial", reason: "max_records_reached" }, + }, + ]); + + const emptyBytes = withProtocolListSection(fixture()); + emptyBytes.writeBigUInt64LE(0n, 0x758); + emptyBytes.writeBigUInt64LE(0n, 0x768); + const empty = decodeAppleDispatchMetadata(emptyBytes, 1, provenance); + expect(empty.objc_protocol_records).toMatchObject([ + { + name: "FixtureProtocol", + methods: [], + optional_methods: [], + decode: { status: "decoded", reason: null }, + }, + ]); + }); + + it("marks omitted class methods, ivars, properties, and adopted protocols partial", () => { + const methodBytes = fixture(); + methodBytes.writeUInt32LE(2, 0x300); + methodBytes.writeBigUInt64LE(0n, 0x330); + methodBytes.writeBigUInt64LE(0n, 0x340); + const methodLimited = decodeAppleDispatchMetadata( + methodBytes, + 1, + provenance, + ); + expect(methodLimited.objc_classes[0]).toMatchObject({ + name: "Fixture", + methods: [], + decode: { status: "partial", reason: "max_records_reached" }, + }); + + const ivarBytes = fixture(); + ivarBytes.writeBigUInt64LE(0n, 0x320); + ivarBytes.writeBigUInt64LE(0n, 0x328); + ivarBytes.writeBigUInt64LE(0n, 0x340); + ivarBytes.writeUInt32LE(2, 0x300); + const ivarsLimited = decodeAppleDispatchMetadata(ivarBytes, 1, provenance); + expect(ivarsLimited.objc_classes[0]).toMatchObject({ + name: "Fixture", + ivar_count: 1, + decode: { status: "partial", reason: "max_records_reached" }, + }); + expect(ivarsLimited.objc_ivars).toEqual([]); + + const propertyBytes = fixture(); + propertyBytes.writeBigUInt64LE(0n, 0x320); + propertyBytes.writeBigUInt64LE(0n, 0x328); + propertyBytes.writeBigUInt64LE(0n, 0x330); + propertyBytes.writeUInt32LE(2, 0x300); + const propertiesLimited = decodeAppleDispatchMetadata( + propertyBytes, + 1, + provenance, + ); + expect(propertiesLimited.objc_classes[0]).toMatchObject({ + name: "Fixture", + properties: [], + decode: { status: "partial", reason: "max_records_reached" }, + }); + + const adoptedBytes = fixture(); + adoptedBytes.writeBigUInt64LE(0n, 0x320); + adoptedBytes.writeBigUInt64LE(0n, 0x330); + adoptedBytes.writeBigUInt64LE(0n, 0x340); + adoptedBytes.writeUInt32LE(2, 0x300); + const adoptedLimited = decodeAppleDispatchMetadata( + adoptedBytes, + 1, + provenance, + ); + expect(adoptedLimited.objc_classes[0]).toMatchObject({ + name: "Fixture", + protocols: [], + decode: { status: "partial", reason: "max_records_reached" }, + }); + }); + + it("keeps genuinely empty class method lists decoded at the record limit", () => { + const bytes = fixture(); + bytes.writeBigUInt64LE(0n, 0x320); + bytes.writeBigUInt64LE(0n, 0x328); + bytes.writeBigUInt64LE(0n, 0x330); + bytes.writeBigUInt64LE(0n, 0x340); + bytes.writeUInt32LE(2, 0x300); + const result = decodeAppleDispatchMetadata(bytes, 1, provenance); + expect(result.objc_classes[0]).toMatchObject({ + name: "Fixture", + methods: [], + decode: { status: "decoded", reason: null }, + }); + }); +}); + +describe("Objective-C category-local record-budget coverage", () => { + it.each(["instance", "class"] as const)( + "marks category %s methods partial only when a declared entry is omitted", + (methodType) => { + const bytes = fixture(false, "partial-method"); + const listField = methodType === "instance" ? 0xb50 : 0xb58; + const methodList = bytes.readBigUInt64LE(0xb50); + bytes.writeBigUInt64LE(0n, 0xb50); + bytes.writeBigUInt64LE(methodList, listField); + bytes.writeUInt32LE(2, 0x300); + bytes.writeBigUInt64LE(0n, 0x320); + const truncated = decodeAppleDispatchMetadata(bytes, 11, provenance); + expect(truncated.objc_categories).toMatchObject([ + { + name: "FixtureCategory", + [`${methodType}_methods`]: [], + decode: { status: "partial", reason: "max_records_reached" }, + }, + ]); + + bytes.writeBigUInt64LE(0n, listField); + const empty = decodeAppleDispatchMetadata(bytes, 11, provenance); + expect(empty.objc_categories).toMatchObject([ + { + name: "FixtureCategory", + [`${methodType}_methods`]: [], + decode: { status: "decoded", reason: null }, + }, + ]); + }, + ); +}); + describe("malformed Objective-C property records", () => { it.each([ ["an unmapped name", { secondNameOffset: 0x2000 }, "first", "0x100000518"], diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index f81369d4dc..994f34b5d4 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -31,8 +31,9 @@ export const decodeObjcCategories = (input: { className: string, methodType: "instance" | "class", category: string, + onTruncated?: () => void, ) => string[]; - readonly protocols: (list: bigint) => string[]; + readonly protocols: (list: bigint, onTruncated?: () => void) => string[]; /** Name of a class defined in this image, from its class_ro_t. */ readonly localClassName: (address: bigint) => string; }): number => { @@ -71,6 +72,43 @@ export const decodeObjcCategories = (input: { ); } const owner = className ?? `(${name})`; + let budgetTruncated = false; + const onTruncated = () => { + budgetTruncated = true; + }; + const instanceMethods = input.methods( + read.pointer(category + 16n), + owner, + "instance", + name, + onTruncated, + ); + const classMethods = input.methods( + read.pointer(category + 24n), + owner, + "class", + name, + onTruncated, + ); + const protocols = input.protocols( + read.pointer(category + 32n), + onTruncated, + ); + const properties = readObjcPropertiesOf( + read, + read.pointer(category + 40n), + { + owner: `category ${name}`, + admit: input.admit, + issues: input.issues, + location: `0x${read.pointer(category + 40n).toString(16)}`, + onTruncated, + }, + ); + const decodeReasons = [ + ...(className === null ? ["category_class_unresolved"] : []), + ...(budgetTruncated ? ["max_records_reached"] : []), + ]; result.objc_categories.push({ name, class_name: className, @@ -80,30 +118,15 @@ export const decodeObjcCategories = (input: { : className === null ? "unresolved" : "local", - instance_methods: input.methods( - read.pointer(category + 16n), - owner, - "instance", - name, - ), - class_methods: input.methods( - read.pointer(category + 24n), - owner, - "class", - name, - ), - protocols: input.protocols(read.pointer(category + 32n)), - properties: readObjcPropertiesOf(read, read.pointer(category + 40n), { - owner: `category ${name}`, - admit: input.admit, - issues: input.issues, - location: `0x${read.pointer(category + 40n).toString(16)}`, - }), + instance_methods: instanceMethods, + class_methods: classMethods, + protocols, + properties, location: read.location(category), decode: - className === null - ? { status: "partial", reason: "category_class_unresolved" } - : { status: "decoded", reason: null }, + decodeReasons.length === 0 + ? { status: "decoded", reason: null } + : { status: "partial", reason: decodeReasons.join("; ") }, evidence: read.evidence(category, "Objective-C category_t record"), }); } catch (cause: unknown) { diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index 089d1906b1..5f507336e8 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -103,6 +103,7 @@ export const decodeObjcDispatchFacets = (input: { className: string, methodType: "instance" | "class", category?: string, + onTruncated?: () => void, ): string[] => { const selectors: string[] = []; if (list === 0n) return selectors; @@ -113,7 +114,10 @@ export const decodeObjcDispatchFacets = (input: { if (stride !== (relative ? 12 : 24)) throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`); for (let index = 0; index < count; index++) { - if (!budget.admit()) break; + if (!budget.admit()) { + onTruncated?.(); + break; + } const entry = list + 8n + BigInt(index * stride); const rel = (field: bigint) => { const displacement = bytes.readInt32LE(read.offset(field, 4)); @@ -279,6 +283,10 @@ export const decodeObjcDispatchFacets = (input: { ); const ivarList = read.pointer(ro + 48n); let ivarCount: number | null = meta ? null : 0; + let classListTruncated = false; + const onTruncated = () => { + classListTruncated = true; + }; if (ivarList !== 0n && !meta) { const stride = read.u32(ivarList), count = read.u32(ivarList + 4n); @@ -286,7 +294,10 @@ export const decodeObjcDispatchFacets = (input: { throw new TypeError("Unsupported Objective-C ivar entry size"); ivarCount = count; for (let index = 0; index < count; index++) { - if (!budget.admit()) break; + if (!budget.admit()) { + classListTruncated = true; + break; + } const entry = ivarList + 8n + BigInt(index * stride); const encodedOffset = read.pointer(entry); let value: number | null = null; @@ -326,7 +337,27 @@ export const decodeObjcDispatchFacets = (input: { }); } } - methods(read.pointer(ro + 32n), name, meta ? "class" : "instance"); + methods( + read.pointer(ro + 32n), + name, + meta ? "class" : "instance", + undefined, + onTruncated, + ); + const properties = readObjcPropertiesOf(read, read.pointer(ro + 64n), { + owner: name, + admit: () => budget.admit(), + issues: categoryIssues, + location: hex(read.pointer(ro + 64n)), + onTruncated, + }); + const protocols = protocolReader.list(read.pointer(ro + 40n), onTruncated); + const classDecodeReasons = [ + ...(superclass.name === null && !root + ? ["superclass_binding_unresolved"] + : []), + ...(classListTruncated ? ["max_records_reached"] : []), + ]; result.objc_classes.push({ name, super_class: superclass.name, @@ -350,22 +381,17 @@ export const decodeObjcDispatchFacets = (input: { is_required: null, is_optional: null, })), - properties: readObjcPropertiesOf(read, read.pointer(ro + 64n), { - owner: name, - admit: () => budget.admit(), - issues: categoryIssues, - location: hex(read.pointer(ro + 64n)), - }), - protocols: protocolReader.list(read.pointer(ro + 40n)), + properties, + protocols, ivar_count: ivarCount, instance_size: read.u32(ro + 8n), location: read.location(address), superclass_address: superclass.address, metaclass_address: meta ? null : hex(read.pointer(address)), decode: - superclass.name === null && !root - ? { status: "partial", reason: "superclass_binding_unresolved" } - : decoded, + classDecodeReasons.length === 0 + ? decoded + : { status: "partial", reason: classDecodeReasons.join("; ") }, evidence: read.evidence( address, "Objective-C class and class_ro_t records", @@ -422,7 +448,7 @@ export const decodeObjcDispatchFacets = (input: { issues: categoryIssues, admit: () => budget.admit(), methods, - protocols: (list) => protocolReader.list(list), + protocols: (list, onTruncated) => protocolReader.list(list, onTruncated), localClassName: (address) => readLocalClass(read, address).name, }); return { diff --git a/src/native/AppleObjcProperties.ts b/src/native/AppleObjcProperties.ts index 266d5ead62..4c820c72f0 100644 --- a/src/native/AppleObjcProperties.ts +++ b/src/native/AppleObjcProperties.ts @@ -107,6 +107,7 @@ export const readObjcPropertyList = ( read: ObjcPropertyReaders, list: bigint, admit: () => boolean, + onTruncated?: () => void, ): { readonly properties: ObjcProperty[]; readonly issues: PropertyListIssue[]; @@ -121,7 +122,10 @@ export const readObjcPropertyList = ( `Unsupported Objective-C property entry size ${entrySize}`, ); for (let index = 0; index < count; index++) { - if (!admit()) break; + if (!admit()) { + onTruncated?.(); + break; + } const entry = list + 8n + BigInt(index * entrySize); const location = `0x${entry.toString(16)}`; try { @@ -158,6 +162,7 @@ export const readObjcPropertiesOf = ( readonly admit: () => boolean; readonly issues: DecodeIssue[]; readonly location?: string; + readonly onTruncated?: () => void; }, ): ObjcProperty[] => { try { @@ -165,6 +170,7 @@ export const readObjcPropertiesOf = ( read, list, context.admit, + context.onTruncated, ); for (const item of issues) context.issues.push( diff --git a/src/native/AppleObjcProtocols.ts b/src/native/AppleObjcProtocols.ts index 1d130b8685..13bc76f8d2 100644 --- a/src/native/AppleObjcProtocols.ts +++ b/src/native/AppleObjcProtocols.ts @@ -18,7 +18,7 @@ export const createObjcProtocolReader = (input: { const { readers: read, result, failures } = input; const visited = new Set(); const name = (address: bigint) => read.string(read.pointer(address + 8n)); - const list = (address: bigint): string[] => { + const list = (address: bigint, onTruncated?: () => void): string[] => { if (address === 0n) return []; // `count` is plain uintptr_t data, not a pointer fixup. const count = read.u64(address); @@ -26,7 +26,10 @@ export const createObjcProtocolReader = (input: { throw new RangeError("Protocol list exceeds 20000 entries"); const names: string[] = []; for (let index = 0n; index < count; index++) { - if (!read.admit()) break; + if (!read.admit()) { + onTruncated?.(); + break; + } const protocol = read.pointer(address + 8n + index * 8n); names.push(name(protocol)); record(protocol); @@ -39,6 +42,7 @@ export const createObjcProtocolReader = (input: { instance: boolean, /** Entries skipped for an empty selector, reported by the declaring protocol. */ emptySelectors: string[], + onTruncated?: () => void, ) => { const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = []; if (address === 0n) return output; @@ -49,7 +53,10 @@ export const createObjcProtocolReader = (input: { if (stride !== (relative ? 12 : 24)) throw new TypeError("Unsupported Objective-C protocol method entry size"); for (let index = 0; index < count; index++) { - if (!read.admit()) break; + if (!read.admit()) { + onTruncated?.(); + break; + } const entry = address + 8n + BigInt(index * stride); let selector = relative ? entry + BigInt(read.i32(entry)) @@ -84,22 +91,54 @@ export const createObjcProtocolReader = (input: { const protocolName = name(address); // Adopted protocols are independent declarations; decode them before // rejecting a parent whose own name is empty. - const adopted = list(read.pointer(address + 16n)); + let budgetTruncated = false; + const onTruncated = () => { + budgetTruncated = true; + }; + const adopted = list(read.pointer(address + 16n), onTruncated); if (protocolName.length === 0) throw new TypeError("Objective-C protocol has an empty name"); const skipped: string[] = []; const required = [ - ...methods(read.pointer(address + 24n), true, true, skipped), - ...methods(read.pointer(address + 32n), true, false, skipped), + ...methods( + read.pointer(address + 24n), + true, + true, + skipped, + onTruncated, + ), + ...methods( + read.pointer(address + 32n), + true, + false, + skipped, + onTruncated, + ), ]; const optional = [ - ...methods(read.pointer(address + 40n), false, true, skipped), - ...methods(read.pointer(address + 48n), false, false, skipped), + ...methods( + read.pointer(address + 40n), + false, + true, + skipped, + onTruncated, + ), + ...methods( + read.pointer(address + 48n), + false, + false, + skipped, + onTruncated, + ), ]; for (const message of skipped) failures.push( `Protocol 0x${address.toString(16)}: Objective-C protocol ${protocolName} ${message}`, ); + const decodeReasons = [ + ...(skipped.length === 0 ? [] : ["protocol_method_selector_empty"]), + ...(budgetTruncated ? ["max_records_reached"] : []), + ]; result.objc_protocols.push({ name: protocolName, methods: required, @@ -113,9 +152,9 @@ export const createObjcProtocolReader = (input: { optional_methods: optional, location: read.location(address), decode: - skipped.length === 0 + decodeReasons.length === 0 ? { status: "decoded", reason: null } - : { status: "partial", reason: "protocol_method_selector_empty" }, + : { status: "partial", reason: decodeReasons.join("; ") }, evidence: read.evidence( address, "Objective-C protocol declaration and encoded method lists; IMP fields are not handler targets", diff --git a/tests/boundary/cli/nativeDispatchRecordBudget.test.ts b/tests/boundary/cli/nativeDispatchRecordBudget.test.ts new file mode 100644 index 0000000000..2d8b521226 --- /dev/null +++ b/tests/boundary/cli/nativeDispatchRecordBudget.test.ts @@ -0,0 +1,64 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect } from "vitest"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { nativeDispatchMetadataResultSchema } from "../../../src/domain/native/objcSwiftMetadata.js"; + +import { objcRecordBudgetMacho } from "../../fixtures/objcRecordBudgetMacho.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +describe.skipIf(process.platform !== "darwin")( + "native dispatch declaration-local record budget", + () => { + cliTest.for([["protocol"], ["class"]] as const)( + "marks an omitted $0 method list partial in public CLI output", + async ([owner], { cli }) => { + const directory = await createTestTempDirectory("rea-objc-budget-"); + const path = join(directory, "fixture.macho"); + await writeFile(path, objcRecordBudgetMacho(owner)); + const output = await cli.run({ + arguments: [ + "inspect-native-dispatch-metadata", + path, + "--max-records", + "1", + "--json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0); + const result = nativeDispatchMetadataResultSchema.parse( + parseEvidence(output.json).normalized_result, + ).result; + if (owner === "protocol") { + expect(result?.objc_protocol_records).toMatchObject([ + { + name: "FixtureProtocol", + methods: [], + decode: { status: "partial", reason: "max_records_reached" }, + }, + ]); + } else { + expect(result?.objc_classes).toMatchObject([ + { + name: "FixtureClass", + methods: [], + decode: { status: "partial", reason: "max_records_reached" }, + }, + ]); + } + expect(result?.coverage).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + facet: "objc_class_method_ivar_metadata", + status: "partial", + reason: expect.stringContaining("max_records_reached"), + }), + ]), + ); + }, + ); + }, +); diff --git a/tests/fixtures/objcRecordBudgetMacho.ts b/tests/fixtures/objcRecordBudgetMacho.ts new file mode 100644 index 0000000000..53329226ea --- /dev/null +++ b/tests/fixtures/objcRecordBudgetMacho.ts @@ -0,0 +1,72 @@ +const base = 0x100000000n; + +/** Minimal inert Mach-O with one class instance method and protocol method. */ +export const objcRecordBudgetMacho = (owner: "class" | "protocol"): Buffer => { + const bytes = Buffer.alloc(4096); + const u32 = (offset: number, value: number) => + bytes.writeUInt32LE(value, offset); + const pointer = (offset: number, target: number) => + bytes.writeBigUInt64LE(base + BigInt(target), offset); + const nullPointer = (offset: number) => bytes.writeBigUInt64LE(0n, offset); + + u32(0, 0xfeedfacf); + u32(4, 0x0100000c); + u32(12, 2); + u32(16, 1); + u32(20, owner === "protocol" ? 232 : 152); + u32(32, 0x19); + u32(36, owner === "protocol" ? 232 : 152); + bytes.write("__DATA", 40); + pointer(56, 0); + bytes.writeBigUInt64LE(4096n, 64); + bytes.writeBigUInt64LE(4096n, 80); + u32(88, 7); + u32(92, 5); + u32(96, owner === "protocol" ? 2 : 1); + bytes.write("__objc_classlist", 104); + bytes.write("__DATA", 120); + pointer(136, 0x180); + bytes.writeBigUInt64LE(8n, 144); + u32(152, 0x180); + if (owner === "protocol") { + bytes.write("__objc_protolist", 184); + bytes.write("__DATA", 200); + pointer(216, 0x6c0); + bytes.writeBigUInt64LE(8n, 224); + u32(232, 0x6c0); + pointer(0x6c0, 0x740); + } + + pointer(0x180, 0x200); + // class_t: zero isa/superclass; class_ro_t is root, named and has one method. + nullPointer(0x200); + nullPointer(0x208); + pointer(0x220, 0x300); + u32(0x300, 2); + u32(0x308, 8); + pointer(0x318, 0x900); + pointer(0x320, 0x500); + nullPointer(0x328); + nullPointer(0x330); + nullPointer(0x340); + bytes.write("FixtureClass\0", 0x900); + u32(0x500, 24); + u32(0x504, 1); + pointer(0x508, 0x920); + pointer(0x510, 0x940); + pointer(0x518, 0x700); + bytes.write("classMethod\0", 0x920); + bytes.write("v@:\0", 0x940); + + // Protocol method lists use the same declaration boundary and budget. + pointer(0x6c0, 0x740); + pointer(0x748, 0x980); + pointer(0x758, 0x800); + u32(0x780, 72); + u32(0x800, 24); + u32(0x804, 1); + pointer(0x808, 0x9a0); + bytes.write("FixtureProtocol\0", 0x980); + bytes.write("protocolMethod\0", 0x9a0); + return bytes; +};