diff --git a/src/native/AppleDispatchMetadata.failure.test.ts b/src/native/AppleDispatchMetadata.failure.test.ts new file mode 100644 index 0000000000..11d187ac97 --- /dev/null +++ b/src/native/AppleDispatchMetadata.failure.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from "vitest"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { appleDispatchMetadataFailure } from "./AppleDispatchMetadata.js"; + +it.each(["integrity", "path"] as const)( + "keeps %s source-read refusals distinct from provider execution failures", + (reason) => { + const cause = new ArtifactReaderFailure(reason, "source boundary refused"); + expect(appleDispatchMetadataFailure(cause, "/selected")._tag).toBe( + reason === "integrity" + ? "AnalysisArtifactChangedError" + : "AnalysisInputError", + ); + }, +); +it.each(["EACCES", "EPERM", "ENOENT"])( + "preserves the filesystem outcome %s for a selected metadata target", + (code) => { + const physical = Object.assign(new Error("selected read refused"), { + code, + }); + const cause = new ArtifactReaderFailure("io", "could not read", { + cause: physical, + }); + expect(appleDispatchMetadataFailure(cause, "/selected")._tag).toBe( + code === "ENOENT" ? "AnalysisInputError" : "AnalysisAccessDeniedError", + ); + }, +); diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index da6a488fa2..e6a9b8ef57 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -277,6 +277,10 @@ const swiftBoundWitnessSlotImage = () => { bytes.writeInt32LE(0x2f0, 0xa10); bytes.writeUInt32LE(16, 0xc00); bytes.writeInt32LE(0x238, 0xc08); + bytes.writeInt32LE(0xc40 - 0xc04, 0xc04); + bytes.writeUInt32LE(0, 0xc40); + bytes.writeInt32LE(0xec0 - 0xc48, 0xc48); + bytes.write("FixtureModule\0", 0xec0); bytes.writeUInt32LE(3, 0xd00); bytes.writeInt32LE(0x148, 0xd08); bytes.writeUInt32LE(0, 0xd0c); @@ -2102,3 +2106,60 @@ describe("malformed Objective-C protocol method lists", () => { }); }); }); + +it("decodes protocol instance properties through the same property-list owner as classes", () => { + const bytes = fixture(); + bytes.writeBigUInt64LE(base + 0x600n, 0x778); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_protocols[0]?.properties).toEqual( + result.objc_classes[0]?.properties, + ); + expect(result.objc_protocols[0]?.properties).toHaveLength(3); + expect(result.objc_protocol_records[0]?.decode).toEqual({ + status: "decoded", + reason: null, + }); +}); +it("retains protocol methods when its instance-property list is unreadable", () => { + const bytes = fixture(); + bytes.writeBigUInt64LE(base + 0x5000n, 0x778); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_protocols[0]).toMatchObject({ + name: "FixtureProtocol", + methods: [{ selector: "requiredAction:" }], + properties: [], + }); + expect(result.objc_protocol_records[0]?.decode.status).toBe("partial"); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + )?.status, + ).toBe("partial"); +}); +it("retains conformance facts beside an empty Swift protocol name", () => { + const bytes = swiftConformanceTypeReferenceImage(2); + bytes[0xe50] = 0; + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.swift_conformances[0]).toMatchObject({ + type_name: "NSObject", + protocol_name: null, + decode: { status: "partial" }, + }); + expect(result.objc_classes).not.toHaveLength(0); +}); + +it("retains the enclosing Swift type and module in a conformance owner", () => { + const bytes = swiftBoundWitnessSlotImage(); + bytes.writeInt32LE(0xc20 - 0xc04, 0xc04); + bytes.writeUInt32LE(17, 0xc20); + bytes.writeInt32LE(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: "Outer.Widget", + module: "FixtureModule", + decode: { status: "decoded" }, + }); + expect(result.swift_dispatch_slots[0]?.owner).toBe("Outer.Widget: Equatable"); +}); diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index d0f3f4d805..81c192d3a5 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { DEFAULT_DISPATCH_METADATA_INPUT_BYTES, MAXIMUM_DISPATCH_METADATA_INPUT_BYTES, @@ -13,6 +14,9 @@ import { } from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, + AnalysisInputError, + AnalysisArtifactChangedError, + AnalysisAccessDeniedError, AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; @@ -49,7 +53,10 @@ import { type SwiftRelativeTargetLocation, } from "./AppleSwiftDispatchFacets.js"; import { createSwiftClassVtableDecoder } from "./AppleSwiftVtables.js"; -import { decodeSwiftNominalTypes } from "./AppleSwiftNominalTypes.js"; +import { + decodeSwiftNominalTypes, + createSwiftContextReader, +} from "./AppleSwiftNominalTypes.js"; import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js"; /** Decode validated little-endian 64-bit Apple Objective-C metadata without loading target code. */ @@ -204,7 +211,13 @@ export const decodeAppleDispatchMetadata = ( budget, result, }); + const contextReader = createSwiftContextReader({ + u32, + string, + relative: swiftRelative, + }); const swift = decodeSwiftDispatchFacets({ + contextReader, sections, segments, readers, @@ -242,6 +255,7 @@ export const decodeAppleDispatchMetadata = ( }, }); const declarations = decodeSwiftNominalTypes({ + contextReader, entries: typeEntries, result, visitor: vtables.visitor, @@ -436,6 +450,34 @@ export const appleDispatchMetadataFailure = ( cause.message, { cause }, ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { + cause, + }); + if (cause instanceof ArtifactReaderFailure && cause.reason === "path") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_value", message: cause.message }, + ]); + const readCause = + cause instanceof ArtifactReaderFailure && cause.reason === "io" + ? cause.cause + : cause; + if ( + readCause instanceof Error && + "code" in readCause && + (readCause.code === "EACCES" || readCause.code === "EPERM") + ) + return new AnalysisAccessDeniedError(OPERATION, path, readCause.code, { + cause, + }); + if (isSelectedRegularFileError(readCause)) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected metadata target could not be read (${String(readCause.code)}): ${path}`, + }, + ]); return new ProviderAdapterError(IDENTITY.id, OPERATION, { cause, diagnostics: { diff --git a/src/native/AppleObjcProtocols.ts b/src/native/AppleObjcProtocols.ts index 70de17c2a3..97a548d221 100644 --- a/src/native/AppleObjcProtocols.ts +++ b/src/native/AppleObjcProtocols.ts @@ -1,3 +1,4 @@ +import { readObjcPropertyList } from "./AppleObjcProperties.js"; import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; import type { SwiftNominalReaders } from "./AppleSwiftNominalTypes.js"; @@ -86,7 +87,8 @@ export const createObjcProtocolReader = (input: { if (address === 0n || visited.has(address) || !read.admit()) return; visited.add(address); try { - if (read.u32(address + 64n) < 72) + const protocolSize = read.u32(address + 64n); + if (protocolSize < 72) throw new RangeError("Truncated Objective-C protocol declaration"); const protocolName = name(address); // Adopted protocols are independent declarations; decode them before @@ -135,7 +137,34 @@ export const createObjcProtocolReader = (input: { failures.push( `Protocol 0x${address.toString(16)}: Objective-C protocol ${protocolName} ${message}`, ); + let properties: ObjcSwiftMetadata["objc_protocols"][number]["properties"] = + []; + const propertyReasons: string[] = []; + try { + const decoded = readObjcPropertyList( + read, + read.pointer(address + 56n), + () => read.admit(), + onTruncated, + ); + properties = decoded.properties; + for (const problem of decoded.issues) + propertyReasons.push(problem.message); + // Older protocol revisions end at size/flags. The later class-property + // list has no class/instance discriminator in the public property shape. + if (protocolSize >= 96 && read.pointer(address + 88n) !== 0n) + propertyReasons.push("protocol class properties are not decoded"); + } catch (cause) { + propertyReasons.push( + cause instanceof Error ? cause.message : String(cause), + ); + } + for (const message of propertyReasons) + failures.push( + `Protocol 0x${address.toString(16)}: Objective-C protocol ${protocolName} properties: ${message}`, + ); const decodeReasons = [ + ...propertyReasons, ...(skipped.length === 0 ? [] : ["protocol_method_selector_empty"]), ...(budgetTruncated ? ["max_records_reached"] : []), ]; @@ -143,7 +172,7 @@ export const createObjcProtocolReader = (input: { name: protocolName, methods: required, optional_methods: optional, - properties: [], + properties, }); result.objc_protocol_records.push({ name: protocolName, diff --git a/src/native/AppleSwiftConformanceTypeReference.ts b/src/native/AppleSwiftConformanceTypeReference.ts index 5410e8ea28..756bde90c1 100644 --- a/src/native/AppleSwiftConformanceTypeReference.ts +++ b/src/native/AppleSwiftConformanceTypeReference.ts @@ -1,3 +1,7 @@ +import { + createSwiftContextReader, + type SwiftContextReader, +} from "./AppleSwiftNominalTypes.js"; import type { PointerResolution } from "./AppleMachoFixups.js"; import type { SwiftDispatchReaders, @@ -26,6 +30,7 @@ export interface SwiftConformanceTypeReference { /** Descriptor, inline class-name, local ObjC class pointer, or raw target. */ readonly target: bigint | null; readonly name: string | null; + readonly module: string | null; readonly binding: ReturnType | null; readonly reason: string | null; readonly staticWitnessIdentity: boolean; @@ -36,6 +41,7 @@ interface SwiftTypeReferenceInput { readonly descriptor: bigint; readonly flags: number; readonly read: SwiftDispatchReaders; + readonly contextReader?: SwiftContextReader; readonly relative: ( field: bigint, kind: SwiftRelativeField, @@ -50,6 +56,7 @@ const typeReferenceResult = (input: { readonly kind: number; readonly target: bigint | null; readonly name?: string | null; + readonly module?: string | null; readonly binding?: ReturnType | null; readonly reason?: string | null; readonly generic?: boolean | null; @@ -57,6 +64,7 @@ const typeReferenceResult = (input: { kind: input.kind, target: input.target, name: input.name ?? null, + module: input.module ?? null, binding: input.binding ?? null, reason: input.reason ?? null, staticWitnessIdentity: @@ -121,20 +129,17 @@ const decodeNominalTypeReference = ( reason: "Unsupported Swift type descriptor kind", }); - const name = read.string( - requiredSwiftRelativeTarget( - relative, - target + 8n, - "typeName", - input.unresolvedReason, - ), - ); + const contextReader = + input.contextReader ?? createSwiftContextReader({ ...read, relative }); + const name = contextReader.typeName(target); + const context = contextReader.contextOf(target); return typeReferenceResult({ kind, target, - name: name.length === 0 ? null : name, + name: context.path ?? name, + module: context.module, binding, - reason: name.length === 0 ? "Empty Swift conformance type name" : null, + reason: context.reason, generic: (descriptorFlags & 0x80) !== 0, }); } catch (cause) { diff --git a/src/native/AppleSwiftDispatchFacets.ts b/src/native/AppleSwiftDispatchFacets.ts index 0d5950d7c6..31d0609aaa 100644 --- a/src/native/AppleSwiftDispatchFacets.ts +++ b/src/native/AppleSwiftDispatchFacets.ts @@ -1,3 +1,4 @@ +import 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"; @@ -149,7 +150,10 @@ const readSwiftProtocolFacts = (input: { return { name: null, readable: false, reason }; } try { - return { name: read.string(nameAddress), readable: true, reason: null }; + const name = read.string(nameAddress); + if (name.length === 0) + throw new RangeError("Swift protocol descriptor name is empty"); + return { name, readable: true, reason: null }; } catch (cause) { const reason = expectedSwiftMetadataReadFailure(cause); if (reason === null) throw cause; @@ -313,6 +317,7 @@ export const createSwiftRelativeReader = (input: { /** Decode `__swift5_proto` conformances and static witness slots into `result`. */ export const decodeSwiftDispatchFacets = (input: { + contextReader?: SwiftContextReader; sections: readonly Section[]; segments: readonly Segment[]; readers: SwiftDispatchReaders; @@ -368,6 +373,9 @@ export const decodeSwiftDispatchFacets = (input: { descriptor, flags, read, + ...(input.contextReader === undefined + ? {} + : { contextReader: input.contextReader }), relative, unresolvedReason: unresolvedSwiftRelativeReason, }); @@ -402,10 +410,20 @@ export const decodeSwiftDispatchFacets = (input: { descriptor, ); } + const conformanceReason = witnessReason ?? typeReference.reason; const conformanceDecode = - witnessReason === null + conformanceReason === null ? { status: "decoded" as const, reason: null } - : { status: "partial" as const, reason: witnessReason }; + : { status: "partial" as const, reason: conformanceReason }; + if (witnessReason === null && typeReference.reason !== null) + issues.push( + issue( + "swift_conformances_static_witness_slots", + "conformance_identity_partial", + typeReference.reason, + hex(descriptor), + ), + ); result.swift_conformances.push({ type_name: typeReference.name, type_reference_kind: typeReference.kind, @@ -419,7 +437,7 @@ export const decodeSwiftDispatchFacets = (input: { ? {} : { protocol_target: read.targetLocation(protocol) }), protocol_binding: protocolBinding, - module: null, + module: typeReference.module, witness_table: witness === null ? { address: null, file_offset: null } diff --git a/src/native/AppleSwiftNominalTypes.test.ts b/src/native/AppleSwiftNominalTypes.test.ts index 3d300f7da6..88878aaff8 100644 --- a/src/native/AppleSwiftNominalTypes.test.ts +++ b/src/native/AppleSwiftNominalTypes.test.ts @@ -480,3 +480,25 @@ describe("Swift nominal type declaration bounds", () => { ).toMatchObject({ status: "complete" }); }); }); + +it("uses qualified nominal context for a nested class vtable owner", () => { + const bytes = nominalImage((image) => { + context(image, MODULE, 0, null, { at: 0x700, value: "ReaMod" }); + context(image, 0x340, 0x51, MODULE, { at: 0x710, value: "Outer" }); + context(image, 0x380, 0x80000050, 0x340, { at: 0x720, value: "Inner" }); + image.u32(0x380 + 28, 1); + image.u32(0x380 + 48, 1); + image.rel(0x380 + 56, 0x900); + return [0x380]; + }); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.swift_decls[0]).toMatchObject({ + name: "Inner", + qualified_name: "Outer.Inner", + module: "ReaMod", + }); + expect(result.swift_dispatch_slots[0]).toMatchObject({ + owner: "Outer.Inner", + decode: { status: "decoded" }, + }); +}); diff --git a/src/native/AppleSwiftNominalTypes.ts b/src/native/AppleSwiftNominalTypes.ts index 1bb2c74c6e..49135b614b 100644 --- a/src/native/AppleSwiftNominalTypes.ts +++ b/src/native/AppleSwiftNominalTypes.ts @@ -28,6 +28,7 @@ export interface SwiftNominalDescriptor { readonly descriptor: bigint; readonly flags: number; readonly name: string; + readonly context: SwiftContextFacts; } /** A `__swift5_types` record that may be a class whose name is unknown. */ @@ -37,7 +38,7 @@ export interface SwiftNominalRecordFailure { } /** - * Receives each descriptor as soon as its kind and name are read, so a + * Receives each descriptor with its shared context facts, so a * consumer spends the record budget in record order rather than after every * declaration. */ @@ -66,7 +67,7 @@ const hex = (value: bigint) => `0x${value.toString(16)}`; * Facts of one context. `depth` counts contexts up to and including its * module, so a cached result is valid wherever a walk reaches it. */ -interface ContextFacts { +export interface SwiftContextFacts { readonly module: string | null; /** Dotted named path below the module; null when unresolved. */ readonly path: string | null; @@ -77,7 +78,10 @@ interface ContextFacts { readonly walkLimited: boolean; } -const unresolved = (reason: string, walkLimited = false): ContextFacts => ({ +const unresolved = ( + reason: string, + walkLimited = false, +): SwiftContextFacts => ({ module: null, path: null, depth: 0, @@ -86,28 +90,17 @@ const unresolved = (reason: string, walkLimited = false): ContextFacts => ({ walkLimited, }); -const tooDeep = (): ContextFacts => +const tooDeep = (): SwiftContextFacts => unresolved( `Swift context parent chain exceeds ${MAXIMUM_CONTEXT_DEPTH} contexts or is cyclic`, ); -/** - * Decode `__swift5_types` nominal type descriptors into `swift_decls`. - * Each context is read once, so shared parents add one relative-pointer - * record regardless of how many types they contain. - */ -export const decodeSwiftNominalTypes = (input: { - readers: SwiftNominalReaders; - entries: readonly bigint[]; - result: ObjcSwiftMetadata; - visitor: SwiftNominalVisitor; -}): FacetDecodeFacts => { - const { readers: read, result, visitor } = input; - const issues = [] as ReturnType[]; - const contexts = new Map(); +/** One snapshot-scoped owner of nominal names and parent-context identity. */ +export const createSwiftContextReader = ( + read: Pick, +) => { + const contexts = new Map(); const names = new Map(); - let examined = 0; - let decoded = 0; const typeName = (descriptor: bigint): string => { const known = names.get(descriptor); @@ -122,7 +115,7 @@ export const decodeSwiftNominalTypes = (input: { return name; }; - const parentOf = (context: bigint, walk: number): ContextFacts => { + const parentOf = (context: bigint, walk: number): SwiftContextFacts => { const value = read.relative(context + 4n, "contextParent"); if (value.kind === "target") return contextOf(value.target, walk + 1); return unresolved( @@ -134,9 +127,9 @@ export const decodeSwiftNominalTypes = (input: { /** One level below `parent`, bounded in depth and qualified-name length. */ const child = ( - parent: ContextFacts, - facts: Partial, - ): ContextFacts => { + parent: SwiftContextFacts, + facts: Partial, + ): SwiftContextFacts => { if (parent.depth + 1 > MAXIMUM_CONTEXT_DEPTH) return { ...tooDeep(), walkLimited: parent.walkLimited }; const next = { @@ -156,7 +149,7 @@ export const decodeSwiftNominalTypes = (input: { : next; }; - const readContext = (context: bigint, walk: number): ContextFacts => { + const readContext = (context: bigint, walk: number): SwiftContextFacts => { const kind = read.u32(context) & 31; switch (kind) { case MODULE: { @@ -207,14 +200,14 @@ export const decodeSwiftNominalTypes = (input: { } }; - const contextOf = (context: bigint, walk: number): ContextFacts => { + const contextOf = (context: bigint, walk: number): SwiftContextFacts => { const known = contexts.get(context); if (known !== undefined) return known; // Bound recursion on cyclic or over-deep chains; this stop depends on // where the walk began, so neither it nor its descendants are cached. if (walk > MAXIMUM_CONTEXT_DEPTH) return { ...tooDeep(), walkLimited: true }; - let facts: ContextFacts; + let facts: SwiftContextFacts; try { facts = readContext(context, walk); } catch (cause) { @@ -226,6 +219,31 @@ export const decodeSwiftNominalTypes = (input: { return facts; }; + return { + typeName, + contextOf: (descriptor: bigint) => contextOf(descriptor, 0), + }; +}; +export type SwiftContextReader = ReturnType; + +/** + * Decode `__swift5_types` nominal type descriptors into `swift_decls`. + * Each context is read once, so shared parents add one relative-pointer + * record regardless of how many types they contain. + */ +export const decodeSwiftNominalTypes = (input: { + readers: SwiftNominalReaders; + entries: readonly bigint[]; + result: ObjcSwiftMetadata; + visitor: SwiftNominalVisitor; + contextReader: SwiftContextReader; +}): FacetDecodeFacts => { + const { readers: read, result, visitor } = input; + const issues = [] as ReturnType[]; + const { typeName, contextOf } = input.contextReader; + let examined = 0; + let decoded = 0; + const accessorOf = ( descriptor: bigint, ): { address: string | null; reason: string | null } => { @@ -296,8 +314,8 @@ export const decodeSwiftNominalTypes = (input: { issues.push(issue(FACET, "type_decode_failed", message, hex(record))); continue; } - visitor.descriptor({ record, descriptor, flags, name }); - const context = contextOf(descriptor, 0); + const context = contextOf(descriptor); + visitor.descriptor({ record, descriptor, flags, name, context }); const accessor = accessorOf(descriptor); const reason = context.reason ?? accessor.reason; result.swift_decls.push({ diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts index 24f490d6ab..900436dfed 100644 --- a/src/native/AppleSwiftVtables.test.ts +++ b/src/native/AppleSwiftVtables.test.ts @@ -143,6 +143,14 @@ it("counts examined slots and reports an unresolved async slot", () => { descriptor: 0x200n, flags: 0x80000010, name: "Widget", + context: { + module: "Test", + path: "Widget", + depth: 2, + anonymous: false, + reason: null, + walkLimited: false, + }, }); const facts = vtables.facts(); diff --git a/src/native/AppleSwiftVtables.ts b/src/native/AppleSwiftVtables.ts index 1286e08854..c72eeecb41 100644 --- a/src/native/AppleSwiftVtables.ts +++ b/src/native/AppleSwiftVtables.ts @@ -54,9 +54,12 @@ export const createSwiftClassVtableDecoder = (input: { record, descriptor, flags, - name: owner, + name, + context, }: SwiftNominalDescriptor): void => { if ((flags & 31) !== 16) return; + const owner = context.path ?? name; + if (context.reason !== null) failure({ record, message: context.reason }); try { const specific = flags >>> 16; if ((flags & 0x80) !== 0 || (specific & 0x2000) !== 0)