diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 2d71ad69a8..4dad23b3b1 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -96,8 +96,18 @@ complete observation. So an external superclass resolves from its `_OBJC_CLASS_$_` bind (for example `NSObject`). The `pointer_fixups` coverage facet names the mechanism. - Class properties, with parsed attributes, and `__objc_catlist` categories are - also decoded. Categories record the extended class, local or external, plus + Class properties preserve each encoded attribute as a raw `{name, value}` + pair, for example `{ name: "N", value: "" }`, and report atomicity on the + property: `N` means `nonatomic`; a complete typed attribute string without + `N` means the Objective-C default `atomic`; + missing or incomplete attribute metadata leaves `atomicity` and any + unestablished read-only value null. `is_meta_class`, `is_root_class`, and + `ivar_count` are also nullable when the selected provider exposes only symbol + names. Class implementation methods have null `is_required` and `is_optional` + fields because those facts apply to protocol declarations; decoded protocol + method lists retain their observed required/optional values. Class properties + and `__objc_catlist` categories are also decoded. Categories record the + extended class, local or external, plus their methods, protocols and properties; category methods appear as implementations with a `category`. Swift field-offset globals that are only initialized at runtime stay unresolved. Generic, resilient, async and diff --git a/scripts/verify/apple/dispatch.mjs b/scripts/verify/apple/dispatch.mjs index bf86d85d65..411e3e32e8 100644 --- a/scripts/verify/apple/dispatch.mjs +++ b/scripts/verify/apple/dispatch.mjs @@ -1,3 +1,4 @@ +import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -6,6 +7,12 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js"; import { inspectAppleDispatchMetadata } from "../../../dist/native/AppleDispatchMetadata.js"; +import { + artifactCliEvidence, + artifactMcpResult, + withArtifactMcp, +} from "../../lib/artifact-e2e.mjs"; +import { nativeDispatchMetadataResultSchema } from "../../../dist/domain/native/objcSwiftMetadata.js"; if (process.platform !== "darwin") throw new Error( @@ -61,6 +68,30 @@ const checkObjc = (metadata, mode) => { const objcCoverage = metadata.coverage.find( ({ facet }) => facet === "objc_class_method_ivar_metadata", ); + assert.equal(fixture?.is_meta_class, false); + assert.equal(fixture?.is_root_class, false); + assert.equal( + fixture?.properties.find(({ name }) => name === "fixtureName")?.atomicity, + "nonatomic", + ); + assert.equal( + fixture?.properties.find(({ name }) => name === "atomicValue")?.atomicity, + "atomic", + ); + for (const method of fixture.methods) { + assert.equal(method.is_required, null); + assert.equal(method.is_optional, null); + } + for (const property of fixture.properties) + for (const attribute of property.attributes) + assert.deepEqual(Object.keys(attribute).sort(), ["name", "value"]); + const protocol = metadata.objc_protocols.find( + ({ name }) => name === "ReaDispatchProtocol", + ); + assert.equal(protocol?.methods[0]?.is_required, true); + assert.equal(protocol?.methods[0]?.is_optional, false); + assert.equal(protocol?.optional_methods[0]?.is_required, false); + assert.equal(protocol?.optional_methods[0]?.is_optional, true); if ( !metadata.objc_dispatch_implementations.some( (item) => @@ -162,6 +193,25 @@ try { await exec("/usr/bin/strip", ["-x", objcPath]); const { target, metadata } = await inspect(objcPath); checkObjc(metadata, mode); + if (mode.mode === "chained" && variant === "stripped") { + const cli = await artifactCliEvidence( + "inspect-native-dispatch-metadata", + objcPath, + ); + const cliResult = nativeDispatchMetadataResultSchema.parse( + cli.normalized_result, + ); + assert.equal(cliResult.target_sha256, target.sha256); + checkObjc(cliResult.result, mode); + await withArtifactMcp(objcPath, async (client) => { + const mcpResult = nativeDispatchMetadataResultSchema.parse( + await artifactMcpResult(client, "inspect_native_dispatch_metadata"), + ); + assert.equal(mcpResult.target_sha256, target.sha256); + checkObjc(mcpResult.result, mode); + assert.deepEqual(mcpResult.result, cliResult.result); + }); + } results.push({ variant: `objc-${mode.mode}-${variant}`, target_sha256: target.sha256, diff --git a/skill-src/reverse-engineer-anything/references/native-and-artifacts.md b/skill-src/reverse-engineer-anything/references/native-and-artifacts.md index 08e2ac8745..f47017171d 100644 --- a/skill-src/reverse-engineer-anything/references/native-and-artifacts.md +++ b/skill-src/reverse-engineer-anything/references/native-and-artifacts.md @@ -60,6 +60,15 @@ use inclusive ends and can contain gaps. Do not treat the enclosing span as code For packed targets, retain the original and separately derived artifact identities; decompiling an unpacking stub does not recover the unpacked program. +When reading Objective-C dispatch metadata, distinguish class implementation +methods from protocol requirements: class methods report null requirement +flags, while decoded protocol declarations retain required and optional facts. +Property attributes remain raw name/value tokens; use the property's +`atomicity` field for the decoded `N` marker or the default atomic behavior. +Null class/meta flags, ivar counts, atomicity, or read-only values mean the +selected provider did not establish that fact. Symbol names alone do not prove +that a class is a root class or has no ivars. + ## Managed PE/CLI Start with `inspect_managed_artifact`. REA's canonical managed inspection is diff --git a/src/domain/native/objcSwiftMetadata.test.ts b/src/domain/native/objcSwiftMetadata.test.ts index e2fa3b765b..c8a3492480 100644 --- a/src/domain/native/objcSwiftMetadata.test.ts +++ b/src/domain/native/objcSwiftMetadata.test.ts @@ -14,6 +14,15 @@ describe("ObjC/Swift metadata", () => { ); expect(result.objc_classes).toHaveLength(1); expect(result.objc_dispatch_implementations).toHaveLength(1); + expect(result.objc_classes[0]).toMatchObject({ + is_meta_class: null, + is_root_class: null, + ivar_count: null, + }); + expect(result.objc_classes[0]?.methods[0]).toMatchObject({ + is_required: null, + is_optional: null, + }); expect(result.swift_symbols).toHaveLength(0); expect(result.coverage).toContainEqual( expect.objectContaining({ diff --git a/src/domain/native/objcSwiftMetadata.ts b/src/domain/native/objcSwiftMetadata.ts index 5a0f0e0528..807c5dffdd 100644 --- a/src/domain/native/objcSwiftMetadata.ts +++ b/src/domain/native/objcSwiftMetadata.ts @@ -12,10 +12,6 @@ export const objcMethodTypeSchema = z.enum([ export const objcPropertyAttributeSchema = z.strictObject({ name: z.string().min(1), value: z.string(), - is_weak: z.boolean().default(false), - is_atomic: z.boolean().default(false), - is_copy: z.boolean().default(false), - is_strong: z.boolean().default(false), }); /** Objective-C method metadata. */ @@ -23,8 +19,8 @@ export const objcMethodSchema = z.strictObject({ selector: z.string().min(1), method_type: objcMethodTypeSchema, address: z.number().int().nullable(), - is_required: z.boolean().default(false), - is_optional: z.boolean().default(false), + is_required: z.boolean().nullable(), + is_optional: z.boolean().nullable(), }); type ObjcMethod = z.infer; @@ -32,8 +28,9 @@ type ObjcMethod = z.infer; export const objcPropertySchema = z.strictObject({ name: z.string().min(1), type_encoding: z.string().nullable(), - attributes: z.array(objcPropertyAttributeSchema).default([]), - is_readonly: z.boolean().default(false), + attributes: z.array(objcPropertyAttributeSchema), + atomicity: z.enum(["atomic", "nonatomic"]).nullable(), + is_readonly: z.boolean().nullable(), getter: z.string().nullable(), setter: z.string().nullable(), }); @@ -50,12 +47,12 @@ export const objcProtocolSchema = z.strictObject({ export const objcClassSchema = z.strictObject({ name: z.string().min(1), super_class: z.string().nullable(), - is_meta_class: z.boolean().default(false), - is_root_class: z.boolean().default(false), + is_meta_class: z.boolean().nullable(), + is_root_class: z.boolean().nullable(), methods: z.array(objcMethodSchema).default([]), properties: z.array(objcPropertySchema).default([]), protocols: z.array(z.string()).default([]), - ivar_count: z.number().int().nonnegative().default(0), + ivar_count: z.number().int().nonnegative().nullable(), instance_size: z.number().int().nonnegative().nullable(), location: z .object({ @@ -380,20 +377,20 @@ export const inspectNativeDispatchMetadata = ( selector: implementation.selector, method_type: implementation.method_type, address: parseMetadataAddress(implementation.implementation_address), - is_required: false, - is_optional: false, + is_required: null, + is_optional: null, }); methodsByClass.set(implementation.class_name, methods); } const classes: ObjcClass[] = [...classNames].sort().map((name) => ({ name, super_class: null, - is_meta_class: false, - is_root_class: false, + is_meta_class: null, + is_root_class: null, methods: methodsByClass.get(name) ?? [], properties: [], protocols: [], - ivar_count: 0, + ivar_count: null, instance_size: null, })); const decodedImplementations = [...implementations.values()].sort( diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index a69f20183c..b0758748e7 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -3,7 +3,7 @@ import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; const base = 0x100000000n; const fixture = (relative = false) => { - const bytes = Buffer.alloc(2048); + const bytes = Buffer.alloc(4096); const u32 = (offset: number, value: number) => bytes.writeUInt32LE(value, offset); const ptr = (offset: number, target: number) => @@ -16,8 +16,8 @@ const fixture = (relative = false) => { u32(36, 152); bytes.write("__DATA", 40); ptr(56, 0); - bytes.writeBigUInt64LE(2048n, 64); - bytes.writeBigUInt64LE(2048n, 80); + bytes.writeBigUInt64LE(4096n, 64); + bytes.writeBigUInt64LE(4096n, 80); u32(92, 5); u32(96, 1); bytes.write("__objc_classlist", 104); @@ -33,6 +33,8 @@ const fixture = (relative = false) => { ptr(0x318, 0x450); ptr(0x320, 0x480); ptr(0x330, 0x500); + ptr(0x328, 0x700); + ptr(0x340, 0x600); u32(0x380, 1); ptr(0x398, 0x450); bytes.write("Fixture\0", 0x450); @@ -56,6 +58,34 @@ const fixture = (relative = false) => { u32(0x580, 8); bytes.write("state\0", 0x5a0); bytes.write("i\0", 0x5c0); + u32(0x600, 16); + u32(0x604, 3); + ptr(0x608, 0x640); + ptr(0x610, 0x650); + ptr(0x618, 0x660); + ptr(0x620, 0x670); + ptr(0x628, 0x680); + ptr(0x630, 0x690); + bytes.write("atomicValue\0", 0x640); + bytes.write("Tq\0", 0x650); + bytes.write("nonatomicValue\0", 0x660); + bytes.write("Tq,N\0", 0x670); + bytes.write("unknownValue\0", 0x680); + bytes.writeBigUInt64LE(1n, 0x700); + ptr(0x708, 0x740); + ptr(0x748, 0x900); + ptr(0x758, 0x800); + ptr(0x768, 0x840); + u32(0x780, 72); + u32(0x800, 24); + u32(0x804, 1); + ptr(0x808, 0x920); + u32(0x840, 24); + u32(0x844, 1); + ptr(0x848, 0x940); + bytes.write("FixtureProtocol\0", 0x900); + bytes.write("requiredAction:\0", 0x920); + bytes.write("optionalAction:\0", 0x940); return bytes; }; const provenance = { path: "/fixture/app", sha256: "a".repeat(64) }; @@ -92,6 +122,69 @@ describe("Apple dispatch binary metadata", () => { location: { file_offset: 0x508 }, }, ]); + expect(result.objc_classes[0]?.properties).toEqual([ + { + name: "atomicValue", + type_encoding: "q", + attributes: [{ name: "T", value: "q" }], + atomicity: "atomic", + is_readonly: false, + getter: null, + setter: null, + }, + { + name: "nonatomicValue", + type_encoding: "q", + attributes: [ + { name: "T", value: "q" }, + { name: "N", value: "" }, + ], + atomicity: "nonatomic", + is_readonly: false, + getter: null, + setter: null, + }, + { + name: "unknownValue", + type_encoding: null, + attributes: [], + atomicity: null, + is_readonly: null, + getter: null, + setter: null, + }, + ]); + expect(result.objc_classes[0]).toMatchObject({ + is_meta_class: false, + is_root_class: false, + ivar_count: 1, + }); + expect(result.objc_classes[1]).toMatchObject({ + is_meta_class: true, + is_root_class: false, + ivar_count: null, + }); + expect(result.objc_classes[0]?.methods[0]).toMatchObject({ + is_required: null, + is_optional: null, + }); + expect(result.objc_protocols[0]).toMatchObject({ + name: "FixtureProtocol", + methods: [ + { + selector: "requiredAction:", + is_required: true, + is_optional: false, + }, + ], + optional_methods: [ + { + selector: "optionalAction:", + is_required: false, + is_optional: true, + }, + ], + }); }, ); it("keeps unsupported pointers and record truncation explicit", () => { diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index f9218fc7b4..d6c56e7b91 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -275,28 +275,27 @@ describe("Objective-C property attributes", () => { ).toEqual({ name: "title", type_encoding: '@"NSString"', + atomicity: "nonatomic", attributes: [ - expect.objectContaining({ - name: "T", - value: '@"NSString"', - }), - expect.objectContaining({ name: "C", is_copy: true }), - expect.objectContaining({ name: "N" }), - expect.objectContaining({ name: "G", value: "customTitle" }), - expect.objectContaining({ name: "V", value: "_title" }), + { name: "T", value: '@"NSString"' }, + { name: "C", value: "" }, + { name: "N", value: "" }, + { name: "G", value: "customTitle" }, + { name: "V", value: "_title" }, ], is_readonly: false, getter: "customTitle", setter: null, }); - expect(parsePropertyAttributes("count", "Tq,R,N")).toMatchObject({ + expect(parsePropertyAttributes("count", "Tq,R")).toMatchObject({ type_encoding: "q", is_readonly: true, + atomicity: "atomic", }); - expect( - parsePropertyAttributes("delegate", "T@,W,N,V_delegate").attributes[1], - ).toMatchObject({ - is_weak: true, - }); + expect(parsePropertyAttributes("count", "Tq,R,N").atomicity).toBe( + "nonatomic", + ); + expect(parsePropertyAttributes("count", "").atomicity).toBeNull(); + expect(parsePropertyAttributes("count", "R").atomicity).toBeNull(); }); }); diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index e23d4905d5..14f0b42b86 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -216,7 +216,7 @@ export const decodeObjcDispatchFacets = (input: { `Superclass of ${name} requires external binding resolution`, ); const ivarList = read.pointer(ro + 48n); - let ivarCount = 0; + let ivarCount: number | null = meta ? null : 0; if (ivarList !== 0n && !meta) { const stride = read.u32(ivarList), count = read.u32(ivarList + 4n); @@ -276,8 +276,8 @@ export const decodeObjcDispatchFacets = (input: { BigInt(Number.MAX_SAFE_INTEGER) ? Number(BigInt(item.implementation_address)) : null, - is_required: false, - is_optional: false, + is_required: null, + is_optional: null, })), properties: readObjcPropertiesOf(read, read.pointer(ro + 64n), { owner: name, diff --git a/src/native/AppleObjcProperties.ts b/src/native/AppleObjcProperties.ts index 07dacbc1fb..cd9c0774d8 100644 --- a/src/native/AppleObjcProperties.ts +++ b/src/native/AppleObjcProperties.ts @@ -21,20 +21,32 @@ export const parsePropertyAttributes = ( attributes: string, ): ObjcProperty => { const tokens = attributes.length === 0 ? [] : splitAttributes(attributes); + const typeAttributes = tokens.filter((token) => token[0] === "T"); + const typeAttribute = + typeAttributes.length === 1 ? typeAttributes[0] : undefined; + const typeEncoding = + typeAttribute !== undefined && typeAttribute.length > 1 + ? typeAttribute.slice(1) + : null; const value = (code: string): string | null => - tokens.find((token) => token.startsWith(code))?.slice(1) ?? null; + tokens.find((token) => token[0] === code)?.slice(1) ?? null; return { name, - type_encoding: value("T"), + type_encoding: typeEncoding, attributes: tokens.map((token) => ({ name: token.slice(0, 1), value: token.slice(1), - is_weak: token === "W", - is_atomic: false, - is_copy: token === "C", - is_strong: token === "&", })), - is_readonly: tokens.includes("R"), + atomicity: tokens.includes("N") + ? "nonatomic" + : typeEncoding === null + ? null + : "atomic", + is_readonly: tokens.includes("R") + ? true + : typeEncoding === null + ? null + : false, getter: value("G"), setter: value("S"), }; diff --git a/tests/conformance/native/dispatch.m b/tests/conformance/native/dispatch.m index 3ccb5fb7c8..2b83c1a4d1 100644 --- a/tests/conformance/native/dispatch.m +++ b/tests/conformance/native/dispatch.m @@ -10,6 +10,7 @@ int state; } @property(nonatomic, copy) NSString *fixtureName; +@property NSInteger atomicValue; - (void)performAction:(id)sender; + (int)fixtureVersion; @end