diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 4bfc2d7edf..5e532e8d69 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -501,7 +501,151 @@ describe("malformed Objective-C property records", () => { ); }); +describe("Objective-C malformed identity recovery", () => { + it.each(["class selector", "category name"] as const)( + "isolates an empty %s before result validation", + (malformed) => { + const bytes = fixture(false, "unmapped-class"); + bytes[malformed === "class selector" ? 0x460 : 0xbc0] = 0; + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_categories).toEqual([]); + if (malformed === "category name") { + expect(result.objc_classes).toHaveLength(2); + expect(result.objc_protocols).toMatchObject([ + { name: "FixtureProtocol" }, + ]); + } + expect( + result.coverage.find( + ({ facet }) => facet === "objc_properties_categories", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining( + malformed === "class selector" ? "empty selector" : "empty name", + ), + }); + }, + ); + + it("keeps an unresolved superclass address when its class name is empty", () => { + const bytes = fixture(); + bytes.writeBigUInt64LE(base + 0xc00n, 0x208); + bytes.writeBigUInt64LE(base + 0xc40n, 0xc20); + bytes.writeBigUInt64LE(base + 0xc80n, 0xc58); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_classes[0]).toMatchObject({ + name: "Fixture", + super_class: null, + }); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining("Superclass pointer 0x100000c00"), + }); + }); + + it("keeps a category whose local owner has an empty name", () => { + const bytes = fixture(false, "unmapped-class"); + bytes.writeBigUInt64LE(base + 0xc00n, 0xb48); + bytes.writeBigUInt64LE(base + 0xc40n, 0xc20); + bytes.writeBigUInt64LE(base + 0xc80n, 0xc58); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_classes.map(({ name }) => name)).toEqual([ + "Fixture", + "Fixture", + ]); + expect(result.objc_categories).toMatchObject([ + { + name: "FixtureCategory", + class_name: null, + class_source: "unresolved", + instance_methods: ["performAction:"], + decode: { status: "partial", reason: "category_class_unresolved" }, + }, + ]); + expect(result.objc_dispatch_implementations).toContainEqual( + expect.objectContaining({ + category: "FixtureCategory", + class_name: "(FixtureCategory)", + selector: "performAction:", + implementation_address: "0x100000700", + }), + ); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_properties_categories", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining("empty name"), + }); + }); + + it.each([0x920, 0x940])( + "isolates an empty protocol selector at %i while keeping adopted declarations and classes", + (selector) => { + const bytes = fixture(); + bytes[selector] = 0; + // Decode the independent adopted declaration before the malformed parent. + bytes.writeBigUInt64LE(base + 0xa00n, 0x750); + bytes.writeBigUInt64LE(1n, 0xa00); + bytes.writeBigUInt64LE(base + 0xa40n, 0xa08); + bytes.writeBigUInt64LE(base + 0xac0n, 0xa48); + bytes.writeUInt32LE(72, 0xa80); + bytes.write("AdoptedProtocol\0", 0xac0); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_classes.map(({ name }) => name)).toEqual([ + "Fixture", + "Fixture", + ]); + expect(result.objc_protocols.map(({ name }) => name)).toEqual([ + "AdoptedProtocol", + ]); + expect(result.objc_protocol_records.map(({ name }) => name)).toEqual([ + "AdoptedProtocol", + ]); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining("empty selector"), + }); + }, + ); +}); + describe("malformed Apple dispatch metadata", () => { + it("retains valid class siblings when one class has an empty ivar name", () => { + const bytes = partialSiblingsFixture(); + bytes.writeBigUInt64LE(base + 0x700n, 0x570); + bytes.writeUInt32LE(32, 0x700); + bytes.writeUInt32LE(1, 0x704); + bytes.writeBigUInt64LE(base + 0x800n, 0x708); + bytes.writeBigUInt64LE(base + 0x820n, 0x710); + bytes.writeBigUInt64LE(base + 0x840n, 0x718); + bytes.write("i\0", 0x840); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_classes.map(({ name }) => name)).toEqual([ + "KeptClass", + "LaterClass", + ]); + expect(result.objc_ivars).toEqual([]); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining("empty name"), + }); + }); + it("retains valid class siblings when one class has an empty name", () => { const result = decodeAppleDispatchMetadata( partialSiblingsFixture({ emptyMiddleClass: true }), diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index 7b3b37882a..f81369d4dc 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -50,6 +50,8 @@ export const decodeObjcCategories = (input: { try { const category = read.pointer(field); const name = read.string(read.pointer(category)); + if (name.length === 0) + throw new TypeError("Objective-C category has an empty name"); const external = boundClassName(read, category + 8n); const local = read.pointer(category + 8n); let className = external ?? null; diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index 21cda550fe..089d1906b1 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -31,9 +31,16 @@ export interface ObjcDispatchReaders { const hex = (value: bigint) => `0x${value.toString(16)}`; const decoded = { status: "decoded" as const, reason: null }; -/** Name of a class defined in this image, from its class_ro_t. */ -const localClassName = (read: ObjcDispatchReaders, address: bigint): string => - read.string(read.pointer((read.pointer(address + 32n) & ~7n) + 24n)); +/** Admit the local class identity once for classes, superclasses and categories. */ +const readLocalClass = (read: ObjcDispatchReaders, address: bigint) => { + const ro = read.pointer(address + 32n) & ~7n; + const name = read.string(read.pointer(ro + 24n)); + if (name.length === 0) + throw new TypeError( + `Objective-C class at ${hex(address)} has an empty name`, + ); + return { ro, name }; +}; /** * Resolve a class's superclass: a bind to `_OBJC_CLASS_$_Name` names a class @@ -50,12 +57,13 @@ const resolveSuperclass = ( const superclass = read.pointer(address + 8n); if (superclass === 0n) return { name: null, address: null }; try { - return { name: localClassName(read, superclass), address: hex(superclass) }; + return { + name: readLocalClass(read, superclass).name, + address: hex(superclass), + }; } catch (cause: unknown) { - // Superclass resolution failure is recorded; the cause adds no identity. - void cause; failures.push( - `Superclass pointer ${hex(superclass)} of ${className} requires unsupported binding/fixup resolution`, + `Superclass pointer ${hex(superclass)} of ${className} could not be decoded: ${cause instanceof Error ? cause.message : String(cause)}`, ); return { name: null, address: hex(superclass) }; } @@ -163,6 +171,8 @@ export const decodeObjcDispatchFacets = (input: { void cause; } const selector = read.string(selectorAddress); + if (selector.length === 0) + throw new TypeError("Objective-C method has an empty selector"); selectors.push(selector); result.objc_dispatch_implementations.push({ class_name: className, @@ -260,12 +270,7 @@ export const decodeObjcDispatchFacets = (input: { const readClass = (address: bigint, meta = false) => { if (address === 0n || visited.has(hex(address)) || !budget.admit()) return; visited.add(hex(address)); - const ro = read.pointer(address + 32n) & ~7n; - const name = read.string(read.pointer(ro + 24n)); - if (name.length === 0) - throw new TypeError( - `Objective-C class at ${hex(address)} has an empty name`, - ); + const { ro, name } = readLocalClass(read, address); const root = (read.u32(ro) & 2) !== 0; const superclass = resolveSuperclass(read, address, name, failures); if (superclass.name === null && superclass.address === null && !root) @@ -292,10 +297,13 @@ export const decodeObjcDispatchFacets = (input: { void cause; failures.push(`Unresolved ivar offset pointer ${hex(encodedOffset)}`); } + const ivarName = read.string(read.pointer(entry + 8n)); + if (ivarName.length === 0) + throw new TypeError("Objective-C ivar has an empty name"); const alignment = read.u32(entry + 24n); result.objc_ivars.push({ class_name: name, - name: read.string(read.pointer(entry + 8n)), + name: ivarName, type_encoding: read.string(read.pointer(entry + 16n)), offset: value, size: read.u32(entry + 28n), @@ -415,7 +423,7 @@ export const decodeObjcDispatchFacets = (input: { admit: () => budget.admit(), methods, protocols: (list) => protocolReader.list(list), - localClassName: (address) => localClassName(read, address), + localClassName: (address) => readLocalClass(read, address).name, }); return { failures, diff --git a/src/native/AppleObjcProtocols.ts b/src/native/AppleObjcProtocols.ts index 19d35872be..5e41e3c386 100644 --- a/src/native/AppleObjcProtocols.ts +++ b/src/native/AppleObjcProtocols.ts @@ -50,8 +50,13 @@ export const createObjcProtocolReader = (input: { : read.pointer(entry); if (relative && (flags & 0x40000000) === 0) selector = read.pointer(selector); + const selectorName = read.string(selector); + if (selectorName.length === 0) + throw new TypeError( + "Objective-C protocol method has an empty selector", + ); output.push({ - selector: read.string(selector), + selector: selectorName, method_type: instance ? "instance" : "class", address: null, is_required: required, diff --git a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts index 7aa614afc4..6d90fb57fe 100644 --- a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts +++ b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts @@ -25,7 +25,7 @@ describe.skipIf(process.platform !== "darwin")( arguments: ["inspect-native-dispatch-metadata", path, "--json"], environment: { REA_LOG_LEVEL: "silent" }, }); - expect(output.exitCode).toBe(0); + expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0); const property = { atomicity, is_readonly: readonly, @@ -86,7 +86,7 @@ describe.skipIf(process.platform !== "darwin")( arguments: ["inspect-native-dispatch-metadata", path, "--json"], environment: { REA_LOG_LEVEL: "silent" }, }); - expect(output.exitCode).toBe(0); + expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0); expect(output.json).toMatchObject({ normalized_result: { result: {