diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index b267b89638..894aa93f32 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -1193,3 +1193,44 @@ describe("malformed Objective-C protocol method lists", () => { }); }); }); + +describe("Objective-C method lists cut by the record budget", () => { + it("never reports a declaration decoded while the budget omitted its methods", () => { + const complete = decodeAppleDispatchMetadata(fixture(), 100, provenance); + const classKey = (item: { name: string; is_meta_class: boolean | null }) => + `${item.name}:${String(item.is_meta_class)}`; + const completeProtocols = new Map( + complete.objc_protocol_records.map((item) => [item.name, item]), + ); + const completeClasses = new Map( + complete.objc_classes.map((item) => [classKey(item), item]), + ); + const cutReasons = new Set(); + for (let budget = 1; budget < 40; budget++) { + const result = decodeAppleDispatchMetadata(fixture(), budget, provenance); + for (const record of result.objc_protocol_records) { + const full = completeProtocols.get(record.name); + if (record.decode.status === "decoded") { + expect(record.methods).toEqual(full?.methods); + expect(record.optional_methods).toEqual(full?.optional_methods); + } else if (record.decode.reason !== null) + cutReasons.add(`protocol:${record.decode.reason}`); + } + for (const record of result.objc_classes) { + const full = completeClasses.get(classKey(record)); + if (record.decode?.status === "decoded") + expect(record.methods).toEqual(full?.methods); + else if (record.decode?.reason != null) + cutReasons.add(`class:${record.decode.reason}`); + } + } + // The fixture has methods on both declaration kinds, so some budget must + // admit each declaration but stop inside its method list. + expect([...cutReasons]).toEqual( + expect.arrayContaining([ + "protocol:method_list_record_budget_exhausted", + "class:method_list_record_budget_exhausted", + ]), + ); + }); +}); diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index f81369d4dc..2fd42225ea 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -31,7 +31,7 @@ export const decodeObjcCategories = (input: { className: string, methodType: "instance" | "class", category: string, - ) => string[]; + ) => { selectors: string[]; budgetExhausted: boolean }; readonly protocols: (list: bigint) => string[]; /** Name of a class defined in this image, from its class_ro_t. */ readonly localClassName: (address: bigint) => string; @@ -71,6 +71,18 @@ export const decodeObjcCategories = (input: { ); } const owner = className ?? `(${name})`; + const instanceMethods = input.methods( + read.pointer(category + 16n), + owner, + "instance", + name, + ); + const classMethods = input.methods( + read.pointer(category + 24n), + owner, + "class", + name, + ); result.objc_categories.push({ name, class_name: className, @@ -80,18 +92,8 @@ 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, - ), + instance_methods: instanceMethods.selectors, + class_methods: classMethods.selectors, protocols: input.protocols(read.pointer(category + 32n)), properties: readObjcPropertiesOf(read, read.pointer(category + 40n), { owner: `category ${name}`, @@ -101,9 +103,14 @@ export const decodeObjcCategories = (input: { }), location: read.location(category), decode: - className === null - ? { status: "partial", reason: "category_class_unresolved" } - : { status: "decoded", reason: null }, + instanceMethods.budgetExhausted || classMethods.budgetExhausted + ? { + status: "partial", + reason: "method_list_record_budget_exhausted", + } + : className === null + ? { status: "partial", reason: "category_class_unresolved" } + : { status: "decoded", reason: null }, 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..6092915098 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -103,9 +103,9 @@ export const decodeObjcDispatchFacets = (input: { className: string, methodType: "instance" | "class", category?: string, - ): string[] => { + ): { selectors: string[]; budgetExhausted: boolean } => { const selectors: string[] = []; - if (list === 0n) return selectors; + if (list === 0n) return { selectors, budgetExhausted: false }; const flags = read.u32(list), count = read.u32(list + 4n), relative = (flags & 0x80000000) !== 0; @@ -113,7 +113,7 @@ 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()) return { selectors, budgetExhausted: true }; const entry = list + 8n + BigInt(index * stride); const rel = (field: bigint) => { const displacement = bytes.readInt32LE(read.offset(field, 4)); @@ -196,7 +196,7 @@ export const decodeObjcDispatchFacets = (input: { ), }); } - return selectors; + return { selectors, budgetExhausted: false }; }; const protocolReader = createObjcProtocolReader({ readers: { @@ -326,7 +326,11 @@ export const decodeObjcDispatchFacets = (input: { }); } } - methods(read.pointer(ro + 32n), name, meta ? "class" : "instance"); + const { budgetExhausted } = methods( + read.pointer(ro + 32n), + name, + meta ? "class" : "instance", + ); result.objc_classes.push({ name, super_class: superclass.name, @@ -362,8 +366,9 @@ export const decodeObjcDispatchFacets = (input: { location: read.location(address), superclass_address: superclass.address, metaclass_address: meta ? null : hex(read.pointer(address)), - decode: - superclass.name === null && !root + decode: budgetExhausted + ? { status: "partial", reason: "method_list_record_budget_exhausted" } + : superclass.name === null && !root ? { status: "partial", reason: "superclass_binding_unresolved" } : decoded, evidence: read.evidence( diff --git a/src/native/AppleObjcProtocols.ts b/src/native/AppleObjcProtocols.ts index 1d130b8685..b8280d57f0 100644 --- a/src/native/AppleObjcProtocols.ts +++ b/src/native/AppleObjcProtocols.ts @@ -39,6 +39,8 @@ export const createObjcProtocolReader = (input: { instance: boolean, /** Entries skipped for an empty selector, reported by the declaring protocol. */ emptySelectors: string[], + /** Set when the record budget stops the list before its last entry. */ + budget: { exhausted: boolean }, ) => { const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = []; if (address === 0n) return output; @@ -49,7 +51,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()) { + budget.exhausted = true; + break; + } const entry = address + 8n + BigInt(index * stride); let selector = relative ? entry + BigInt(read.i32(entry)) @@ -88,13 +93,14 @@ export const createObjcProtocolReader = (input: { if (protocolName.length === 0) throw new TypeError("Objective-C protocol has an empty name"); const skipped: string[] = []; + const budget = { exhausted: false }; 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, budget), + ...methods(read.pointer(address + 32n), true, false, skipped, budget), ]; 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, budget), + ...methods(read.pointer(address + 48n), false, false, skipped, budget), ]; for (const message of skipped) failures.push( @@ -112,8 +118,11 @@ export const createObjcProtocolReader = (input: { methods: required, optional_methods: optional, location: read.location(address), - decode: - skipped.length === 0 + // A budget-cut list is recoverable with a larger budget, so that + // cause is reported first; an empty list is otherwise complete. + decode: budget.exhausted + ? { status: "partial", reason: "method_list_record_budget_exhausted" } + : skipped.length === 0 ? { status: "decoded", reason: null } : { status: "partial", reason: "protocol_method_selector_empty" }, evidence: read.evidence(