fix(native): share Swift context and preserve partial protocol facts

This commit is contained in:
morluto
2026-10-24 02:43:02 +08:00
parent 8b93eff452
commit 2e31d13e07
10 changed files with 282 additions and 47 deletions
@@ -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",
);
},
);
+61
View File
@@ -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");
});
+43 -1
View File
@@ -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: {
+31 -2
View File
@@ -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,
@@ -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<typeof swiftImportBinding> | 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<typeof swiftImportBinding> | 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) {
+22 -4
View File
@@ -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 }
+22
View File
@@ -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" },
});
});
+47 -29
View File
@@ -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<typeof issue>[];
const contexts = new Map<bigint, ContextFacts>();
/** One snapshot-scoped owner of nominal names and parent-context identity. */
export const createSwiftContextReader = (
read: Pick<SwiftNominalReaders, "u32" | "relative" | "string">,
) => {
const contexts = new Map<bigint, SwiftContextFacts>();
const names = new Map<bigint, string>();
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>,
): ContextFacts => {
parent: SwiftContextFacts,
facts: Partial<SwiftContextFacts>,
): 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<typeof createSwiftContextReader>;
/**
* 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<typeof issue>[];
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({
+8
View File
@@ -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();
+4 -1
View File
@@ -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)