diff --git a/docs/native-investigation.md b/docs/native-investigation.md index f28cf671d4..125d03ea4c 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -730,9 +730,10 @@ complete observation. examined type records and fully decoded declarations. Class vtable recovery reads the same descriptors, so an unreadable type record also keeps `swift_class_vtable_descriptors` partial. - Nominal declarations, conformance type references and class vtables share one - snapshot-scoped name/context reader. Conformance `type_name` and dispatch - `owner` retain enclosing named types; conformances carry the known module. + Nominal declarations, conformance type and protocol references, and class + vtables share one snapshot-scoped name/context reader. Conformance `type_name`, + `protocol_name`, and dispatch `owner` retain enclosing named types; + conformances carry the known module. Unresolved parents preserve the leaf name and a partial identity reason, while independently readable witness implementations remain available. Protocol instance properties use the same property-list reader as classes. diff --git a/scripts/verify/apple/dispatch.mjs b/scripts/verify/apple/dispatch.mjs index 14a34b2a6f..6f6dd4bf34 100644 --- a/scripts/verify/apple/dispatch.mjs +++ b/scripts/verify/apple/dispatch.mjs @@ -100,6 +100,12 @@ const checkObjc = (metadata, mode) => { const protocol = metadata.objc_protocols.find( ({ name }) => name === "ReaDispatchProtocol", ); + const protocolProperty = protocol?.properties.find( + ({ name }) => name === "fixtureName", + ); + assert.equal(protocolProperty?.name, "fixtureName"); + assert.equal(protocolProperty?.type_encoding, '@"NSString"'); + assert.equal(protocolProperty?.atomicity, "nonatomic"); 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); @@ -161,6 +167,31 @@ const checkObjc = (metadata, mode) => { const checkSwift = (metadata, mode) => { assert.equal(fixupCoverage(metadata)?.status, "complete"); + for (const context of ["ReaNestedA", "ReaNestedB"]) { + const type = `${context}.Implementation`; + const protocol = `${context}.Requirement`; + assert.ok( + metadata.swift_conformances.some( + (item) => + item.type_name === type && + item.protocol_name === protocol && + item.module === "ReaWitnessFixture" && + item.decode.status === "decoded", + ), + `Nested Swift conformance ${type}: ${protocol} missing (${mode.mode})`, + ); + assert.ok( + metadata.swift_dispatch_slots.some( + (item) => + item.table_kind === "witness_table" && + item.owner === `${type}: ${protocol}` && + item.implementation_address !== null && + item.decode.status === "decoded", + ), + `Nested Swift witness ${type}: ${protocol} missing (${mode.mode})`, + ); + } + if ( !metadata.swift_dispatch_slots.some( (slot) => @@ -204,6 +235,10 @@ const EXPECTED_SWIFT_DECLS = [ ["struct", "ReaDeclFixtureOuter.Inner", false], ["struct", "ReaDeclFixtureGeneric", true], ["actor", "ReaDeclFixtureActor", false], + ["enum", "ReaNestedA", false], + ["struct", "ReaNestedA.Implementation", false], + ["enum", "ReaNestedB", false], + ["struct", "ReaNestedB.Implementation", false], ]; /** Stable declaration facts compared across the symbols and stripped builds. */ @@ -336,6 +371,26 @@ try { await checkSwiftSymbolAddresses(swiftPath, declarations); unstrippedDeclarations = declarations; } else assert.deepEqual(declarations, unstrippedDeclarations); + if (mode.mode === "chained" && variant === "stripped") { + const cli = await artifactCliEvidence( + "inspect-native-dispatch-metadata", + swiftPath, + ); + const cliResult = nativeDispatchMetadataResultSchema.parse( + cli.normalized_result, + ); + assert.equal(cliResult.target_sha256, target.sha256); + checkSwift(cliResult.result, mode); + await withArtifactMcp(swiftPath, async (client) => { + const mcpResult = nativeDispatchMetadataResultSchema.parse( + await artifactMcpResult(client, "inspect_native_dispatch_metadata"), + ); + assert.equal(mcpResult.target_sha256, target.sha256); + checkSwift(mcpResult.result, mode); + assert.deepEqual(mcpResult.result, cliResult.result); + }); + } + results.push({ variant: `swift-${mode.mode}-${variant}`, target_sha256: target.sha256, diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 8fe3a34950..b7f00de5b0 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -336,8 +336,13 @@ const swiftExternalSwiftBindingImage = ( bytes.writeBigUInt64LE(0n, 0xb00); // Bound slot has no in-image pointer. bytes.writeUInt32LE(16, 0xc00); bytes.writeInt32LE(0x1f8, 0xc08); + bytes.writeInt32LE(0xc40 - 0xc04, 0xc04); + bytes.writeUInt32LE(0, 0xc40); + bytes.writeInt32LE(0xe40 - 0xc48, 0xc48); + bytes.write("FixtureModule\0", 0xe40); if (boundField !== "protocol") { bytes.writeUInt32LE(3, 0xd00); + bytes.writeInt32LE(0xc40 - 0xd04, 0xd04); bytes.writeInt32LE(0xf8, 0xd08); bytes.write("Equatable\0", 0xe00); } else { @@ -399,6 +404,7 @@ const swiftBoundWitnessSlotImage = () => { bytes.writeInt32LE(0xec0 - 0xc48, 0xc48); bytes.write("FixtureModule\0", 0xec0); bytes.writeUInt32LE(3, 0xd00); + bytes.writeInt32LE(0xc40 - 0xd04, 0xd04); bytes.writeInt32LE(0x148, 0xd08); bytes.writeUInt32LE(0, 0xd0c); bytes.writeUInt32LE(1, 0xd10); @@ -740,6 +746,10 @@ describe("Swift conformance type-reference kinds", () => { bytes.writeInt32LE(0xdc, 0x224); // Indirect class cell at 0x300. bytes.writeUInt32LE(3 << 3, 0x22c); bytes.writeUInt32LE(3, 0x260); // Protocol context descriptor. + bytes.writeInt32LE(0x320 - 0x264, 0x264); // Parent module context. + bytes.writeUInt32LE(0, 0x320); + bytes.writeInt32LE(0x340 - 0x328, 0x328); + bytes.write("FixtureModule\0", 0x340); bytes.writeInt32LE(0x18, 0x268); bytes.write("FixtureProtocol\0", 0x280); bytes.writeBigUInt64LE(target, 0x300); @@ -2379,3 +2389,36 @@ it("retains the enclosing Swift type and module in a conformance owner", () => { }); expect(result.swift_dispatch_slots[0]?.owner).toBe("Outer.Widget: Equatable"); }); + +it.each([false, true])( + "preserves a nested protocol identity and reports unread parent context (%s)", + (unreadParent) => { + const bytes = swiftBoundWitnessSlotImage(); + // This identity regression uses a local executable witness, not an import. + bytes.writeUInt32LE(0, 280); + bytes.writeUInt32LE(0, 284); + bytes.writeBigUInt64LE(base + 0xb00n, 0xe08); + bytes.writeInt32LE(0xc20 - 0xd04, 0xd04); + bytes.writeUInt32LE(17, 0xc20); + bytes.writeInt32LE((unreadParent ? 0x5000 : 0xc40) - 0xc24, 0xc24); + bytes.writeInt32LE(0xea0 - 0xc28, 0xc28); + bytes.write("Outer\0", 0xea0); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.swift_conformances[0]).toMatchObject({ + type_name: "Widget", + protocol_name: unreadParent ? "Equatable" : "Outer.Equatable", + module: "FixtureModule", + decode: { status: unreadParent ? "partial" : "decoded" }, + }); + expect(result.swift_dispatch_slots[0]).toMatchObject({ + owner: unreadParent ? "Widget: Equatable" : "Widget: Outer.Equatable", + implementation_address: "0x100000b00", + decode: { status: "decoded" }, + }); + expect( + result.coverage.find( + ({ facet }) => facet === "swift_conformances_static_witness_slots", + )?.status, + ).toBe(unreadParent ? "partial" : "complete"); + }, +); diff --git a/src/native/AppleSwiftDispatchFacets.ts b/src/native/AppleSwiftDispatchFacets.ts index 31d0609aaa..8d223a5b97 100644 --- a/src/native/AppleSwiftDispatchFacets.ts +++ b/src/native/AppleSwiftDispatchFacets.ts @@ -1,4 +1,7 @@ -import type { SwiftContextReader } from "./AppleSwiftNominalTypes.js"; +import { + createSwiftContextReader, + type SwiftContextReader, +} from "./AppleSwiftNominalTypes.js"; import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; import type { PointerResolution } from "./AppleMachoFixups.js"; import type { Segment, Section } from "./AppleMachoSelection.js"; @@ -109,13 +112,10 @@ interface SwiftProtocolFacts { const readSwiftProtocolFacts = (input: { readonly target: bigint | null; - readonly relative: ( - field: bigint, - kind: SwiftRelativeField, - ) => SwiftRelativeValue; + readonly contextReader: SwiftContextReader; readonly read: SwiftDispatchReaders; }): SwiftProtocolFacts => { - const { target, relative, read } = input; + const { target, read } = input; if (target === null) return { name: null, @@ -136,24 +136,14 @@ const readSwiftProtocolFacts = (input: { readable: false, reason: "Unsupported Swift protocol descriptor kind", }; - let nameAddress: bigint; try { - nameAddress = requiredSwiftRelativeTarget( - relative, - target + 8n, - "protocolName", - unresolvedSwiftRelativeReason, - ); - } catch (cause) { - const reason = expectedSwiftMetadataReadFailure(cause); - if (reason === null) throw cause; - return { name: null, readable: false, reason }; - } - try { - const name = read.string(nameAddress); - if (name.length === 0) - throw new RangeError("Swift protocol descriptor name is empty"); - return { name, readable: true, reason: null }; + const name = input.contextReader.protocolName(target); + const context = input.contextReader.contextOf(target); + return { + name: context.path ?? name, + readable: true, + reason: context.reason, + }; } catch (cause) { const reason = expectedSwiftMetadataReadFailure(cause); if (reason === null) throw cause; @@ -326,6 +316,8 @@ export const decodeSwiftDispatchFacets = (input: { result: ObjcSwiftMetadata; }): FacetDecodeFacts => { const { sections, segments, readers: read, relative, budget, result } = input; + const contextReader = + input.contextReader ?? createSwiftContextReader({ ...read, relative }); const issues = [] as ReturnType[]; let swiftExamined = 0; let decodedRecords = 0; @@ -358,7 +350,7 @@ export const decodeSwiftDispatchFacets = (input: { : null; const protocolFacts = readSwiftProtocolFacts({ target: protocol, - relative, + contextReader, read, }); const protocolName = protocolFacts.name; @@ -373,9 +365,7 @@ export const decodeSwiftDispatchFacets = (input: { descriptor, flags, read, - ...(input.contextReader === undefined - ? {} - : { contextReader: input.contextReader }), + contextReader, relative, unresolvedReason: unresolvedSwiftRelativeReason, }); @@ -410,17 +400,18 @@ export const decodeSwiftDispatchFacets = (input: { descriptor, ); } - const conformanceReason = witnessReason ?? typeReference.reason; + const conformanceReason = + witnessReason ?? typeReference.reason ?? protocolFacts.reason; const conformanceDecode = conformanceReason === null ? { status: "decoded" as const, reason: null } : { status: "partial" as const, reason: conformanceReason }; - if (witnessReason === null && typeReference.reason !== null) + if (witnessReason === null && conformanceReason !== null) issues.push( issue( "swift_conformances_static_witness_slots", "conformance_identity_partial", - typeReference.reason, + conformanceReason, hex(descriptor), ), ); diff --git a/src/native/AppleSwiftNominalTypes.ts b/src/native/AppleSwiftNominalTypes.ts index 49135b614b..b2156aa96b 100644 --- a/src/native/AppleSwiftNominalTypes.ts +++ b/src/native/AppleSwiftNominalTypes.ts @@ -54,6 +54,7 @@ const ENUM = 18; const MODULE = 0; const EXTENSION = 1; const ANONYMOUS = 2; +const PROTOCOL = 3; const GENERIC = 0x80; const CLASS_IS_ACTOR = 0x80; /** Swift nesting is shallow; a longer chain is malformed or cyclic. */ @@ -102,15 +103,20 @@ export const createSwiftContextReader = ( const contexts = new Map(); const names = new Map(); - const typeName = (descriptor: bigint): string => { + const namedContext = ( + descriptor: bigint, + field: "typeName" | "protocolName", + ): string => { const known = names.get(descriptor); if (known !== undefined) return known; - const value = read.relative(descriptor + 8n, "typeName"); + const value = read.relative(descriptor + 8n, field); if (value.kind !== "target") - throw new RangeError(unresolvedSwiftRelativeReason(value, "typeName")); + throw new RangeError(unresolvedSwiftRelativeReason(value, field)); const name = read.string(value.target); if (name.length === 0) - throw new RangeError("Swift type descriptor name is empty"); + throw new RangeError( + `Swift ${field === "protocolName" ? "protocol" : "type"} descriptor name is empty`, + ); names.set(descriptor, name); return name; }; @@ -168,10 +174,14 @@ export const createSwiftContextReader = ( walkLimited: false, }; } + case PROTOCOL: case CLASS: case STRUCT: case ENUM: { - const name = typeName(context); + const name = namedContext( + context, + kind === PROTOCOL ? "protocolName" : "typeName", + ); const parent = parentOf(context, walk); return child(parent, { path: @@ -220,7 +230,9 @@ export const createSwiftContextReader = ( }; return { - typeName, + typeName: (descriptor: bigint) => namedContext(descriptor, "typeName"), + protocolName: (descriptor: bigint) => + namedContext(descriptor, "protocolName"), contextOf: (descriptor: bigint) => contextOf(descriptor, 0), }; }; diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts index 68465e35f8..2351c2451c 100644 --- a/src/native/AppleSwiftVtables.test.ts +++ b/src/native/AppleSwiftVtables.test.ts @@ -226,6 +226,7 @@ it("returns a located issue for an emitted unresolved witness slot", () => { const values = new Map([ [0x20cn, 0], // simple, unconditional conformance [0x500n, 3], // protocol descriptor kind + [0x800n, 0], // module context [0x50cn, 0], // signature count [0x510n, 1], // witness requirement count [0x2b0n, 16], // nominal type descriptor kind @@ -279,6 +280,8 @@ it("returns a located issue for an emitted unresolved witness slot", () => { [0x100n, 0x200n], [0x200n, 0x500n], [0x508n, 0x600n], + [0x504n, 0x800n], + [0x808n, 0x820n], [0x204n, 0x2b0n], [0x2b8n, 0x700n], [0x208n, 0x380n], diff --git a/tests/conformance/native/dispatch.m b/tests/conformance/native/dispatch.m index 2b83c1a4d1..86e4596aeb 100644 --- a/tests/conformance/native/dispatch.m +++ b/tests/conformance/native/dispatch.m @@ -1,6 +1,7 @@ #import @protocol ReaDispatchProtocol +@property(nonatomic, copy) NSString *fixtureName; - (void)performAction:(id)sender; @optional - (int)optionalFixtureValue; diff --git a/tests/conformance/native/dispatch.swift b/tests/conformance/native/dispatch.swift index 5946b85be7..78a2a84458 100644 --- a/tests/conformance/native/dispatch.swift +++ b/tests/conformance/native/dispatch.swift @@ -22,3 +22,20 @@ public struct ReaDeclFixtureGeneric { public actor ReaDeclFixtureActor { public init() {} } + + +// Distinct same-leaf protocols and conforming types exercise both parent chains. +public enum ReaNestedA { + public protocol Requirement { func nestedValue() -> Int } + public struct Implementation: Requirement { + public init() {} + public func nestedValue() -> Int { 31 } + } +} +public enum ReaNestedB { + public protocol Requirement { func nestedValue() -> Int } + public struct Implementation: Requirement { + public init() {} + public func nestedValue() -> Int { 37 } + } +}