Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 106f260d18 fix(swift): keep nested types with undecoded identifiers unclassified
classifySwiftSymbol stopped at the first identifier it could not decode and
kept the enclosing nominal's category. A nested type named by a punycode or
word-substituted identifier (`0...`) inside a struct was therefore filed as a
struct, so analyze_swift_types reported nested enums and classes under
structs and dropped them from a classes filter and from unclassified. Such
an identifier may name a type of any kind, so the category is now unknown.

Fixes #5553

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:04:37 +00:00
morluto 7b2087e449 test(kuna): separate composed doctor startup from cancellation timing
Allow the preceding Ghidra ownership preparation before waiting for the gated Kuna child. Keep the two-second cancellation join, exact reasons, reaping and cleanup assertions unchanged.

Refs #5568. A controlled startup delay reproduces the old failure; disabling cancellation forwarding still fails the retained join assertion. All 86 focused setup, doctor, helper and CLI checks passed on pinned Node 24.18.0/macOS arm64.
2026-10-20 22:57:21 +08:00
7 changed files with 57 additions and 94 deletions

No files matched your search

+6 -1
View File
@@ -38,7 +38,12 @@ export const classifySwiftSymbol = (
let category: SwiftTypeCategory | null = null;
for (;;) {
const end = identifierEnd(mangling, offset);
if (end === undefined) break;
if (end === undefined) {
// A word-substituted or punycode identifier (`0…`) may name a nested
// type of any kind, so the enclosing nominal no longer decides it.
if (mangling.charAt(offset) === "0") category = null;
break;
}
const kind = mangling.charAt(end);
const nominal = NOMINAL_CATEGORIES.get(kind);
if (nominal === undefined) {
+14
View File
@@ -28,6 +28,10 @@ describe("modern Swift category evidence", () => {
"$sSi4mainE7doubledSiyF",
"$s9999999999999999999999BadVMa",
"$s4main04PairVMa",
// Nested types named by punycode identifiers inside struct Envelope.
"_$s6Nested8EnvelopeV007IhuqBAbO8describeSSyF",
"_$s6Nested8EnvelopeV007jvrtDBlCAEycfC",
"_$s6Nested8EnvelopeV007jvrtDBlCMa",
];
const result = categorizeSwiftTypes(
names.map((name, index) => ({ address: String(index), name })),
@@ -52,6 +56,16 @@ describe("modern Swift category evidence", () => {
).toMatchObject({ total: 0, unclassified: [] });
});
it("keeps a nested punycode class unresolved under a class filter", () => {
const name = "_$s6Nested8EnvelopeV007jvrtDBlCMa";
expect(
categorizeSwiftTypes([{ address: "0x1", name }], { category: "classes" }),
).toMatchObject({
total: 0,
unclassified: [{ name, reason: "category_not_decoded" }],
});
});
it("deduplicates display names after selecting a category", () => {
expect(
categorizeSwiftTypes(
-41
View File
@@ -1193,44 +1193,3 @@ describe("malformed Objective-C protocol method lists", () => {
});
});
});
describe("Objective-C method lists cut by the record budget", () => {
it("never reports a declaration decoded while the budget omitted its methods", () => {
const complete = decodeAppleDispatchMetadata(fixture(), 100, provenance);
const classKey = (item: { name: string; is_meta_class: boolean | null }) =>
`${item.name}:${String(item.is_meta_class)}`;
const completeProtocols = new Map(
complete.objc_protocol_records.map((item) => [item.name, item]),
);
const completeClasses = new Map(
complete.objc_classes.map((item) => [classKey(item), item]),
);
const cutReasons = new Set<string>();
for (let budget = 1; budget < 40; budget++) {
const result = decodeAppleDispatchMetadata(fixture(), budget, provenance);
for (const record of result.objc_protocol_records) {
const full = completeProtocols.get(record.name);
if (record.decode.status === "decoded") {
expect(record.methods).toEqual(full?.methods);
expect(record.optional_methods).toEqual(full?.optional_methods);
} else if (record.decode.reason !== null)
cutReasons.add(`protocol:${record.decode.reason}`);
}
for (const record of result.objc_classes) {
const full = completeClasses.get(classKey(record));
if (record.decode?.status === "decoded")
expect(record.methods).toEqual(full?.methods);
else if (record.decode?.reason != null)
cutReasons.add(`class:${record.decode.reason}`);
}
}
// The fixture has methods on both declaration kinds, so some budget must
// admit each declaration but stop inside its method list.
expect([...cutReasons]).toEqual(
expect.arrayContaining([
"protocol:method_list_record_budget_exhausted",
"class:method_list_record_budget_exhausted",
]),
);
});
});
+16 -23
View File
@@ -31,7 +31,7 @@ export const decodeObjcCategories = (input: {
className: string,
methodType: "instance" | "class",
category: string,
) => { selectors: string[]; budgetExhausted: boolean };
) => string[];
readonly protocols: (list: bigint) => string[];
/** Name of a class defined in this image, from its class_ro_t. */
readonly localClassName: (address: bigint) => string;
@@ -71,18 +71,6 @@ export const decodeObjcCategories = (input: {
);
}
const owner = className ?? `(${name})`;
const instanceMethods = input.methods(
read.pointer(category + 16n),
owner,
"instance",
name,
);
const classMethods = input.methods(
read.pointer(category + 24n),
owner,
"class",
name,
);
result.objc_categories.push({
name,
class_name: className,
@@ -92,8 +80,18 @@ export const decodeObjcCategories = (input: {
: className === null
? "unresolved"
: "local",
instance_methods: instanceMethods.selectors,
class_methods: classMethods.selectors,
instance_methods: input.methods(
read.pointer(category + 16n),
owner,
"instance",
name,
),
class_methods: input.methods(
read.pointer(category + 24n),
owner,
"class",
name,
),
protocols: input.protocols(read.pointer(category + 32n)),
properties: readObjcPropertiesOf(read, read.pointer(category + 40n), {
owner: `category ${name}`,
@@ -103,14 +101,9 @@ export const decodeObjcCategories = (input: {
}),
location: read.location(category),
decode:
instanceMethods.budgetExhausted || classMethods.budgetExhausted
? {
status: "partial",
reason: "method_list_record_budget_exhausted",
}
: className === null
? { status: "partial", reason: "category_class_unresolved" }
: { status: "decoded", reason: null },
className === null
? { status: "partial", reason: "category_class_unresolved" }
: { status: "decoded", reason: null },
evidence: read.evidence(category, "Objective-C category_t record"),
});
} catch (cause: unknown) {
+7 -12
View File
@@ -103,9 +103,9 @@ export const decodeObjcDispatchFacets = (input: {
className: string,
methodType: "instance" | "class",
category?: string,
): { selectors: string[]; budgetExhausted: boolean } => {
): string[] => {
const selectors: string[] = [];
if (list === 0n) return { selectors, budgetExhausted: false };
if (list === 0n) return selectors;
const flags = read.u32(list),
count = read.u32(list + 4n),
relative = (flags & 0x80000000) !== 0;
@@ -113,7 +113,7 @@ export const decodeObjcDispatchFacets = (input: {
if (stride !== (relative ? 12 : 24))
throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`);
for (let index = 0; index < count; index++) {
if (!budget.admit()) return { selectors, budgetExhausted: true };
if (!budget.admit()) break;
const entry = list + 8n + BigInt(index * stride);
const rel = (field: bigint) => {
const displacement = bytes.readInt32LE(read.offset(field, 4));
@@ -196,7 +196,7 @@ export const decodeObjcDispatchFacets = (input: {
),
});
}
return { selectors, budgetExhausted: false };
return selectors;
};
const protocolReader = createObjcProtocolReader({
readers: {
@@ -326,11 +326,7 @@ export const decodeObjcDispatchFacets = (input: {
});
}
}
const { budgetExhausted } = methods(
read.pointer(ro + 32n),
name,
meta ? "class" : "instance",
);
methods(read.pointer(ro + 32n), name, meta ? "class" : "instance");
result.objc_classes.push({
name,
super_class: superclass.name,
@@ -366,9 +362,8 @@ export const decodeObjcDispatchFacets = (input: {
location: read.location(address),
superclass_address: superclass.address,
metaclass_address: meta ? null : hex(read.pointer(address)),
decode: budgetExhausted
? { status: "partial", reason: "method_list_record_budget_exhausted" }
: superclass.name === null && !root
decode:
superclass.name === null && !root
? { status: "partial", reason: "superclass_binding_unresolved" }
: decoded,
evidence: read.evidence(
+7 -16
View File
@@ -39,8 +39,6 @@ export const createObjcProtocolReader = (input: {
instance: boolean,
/** Entries skipped for an empty selector, reported by the declaring protocol. */
emptySelectors: string[],
/** Set when the record budget stops the list before its last entry. */
budget: { exhausted: boolean },
) => {
const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = [];
if (address === 0n) return output;
@@ -51,10 +49,7 @@ export const createObjcProtocolReader = (input: {
if (stride !== (relative ? 12 : 24))
throw new TypeError("Unsupported Objective-C protocol method entry size");
for (let index = 0; index < count; index++) {
if (!read.admit()) {
budget.exhausted = true;
break;
}
if (!read.admit()) break;
const entry = address + 8n + BigInt(index * stride);
let selector = relative
? entry + BigInt(read.i32(entry))
@@ -93,14 +88,13 @@ export const createObjcProtocolReader = (input: {
if (protocolName.length === 0)
throw new TypeError("Objective-C protocol has an empty name");
const skipped: string[] = [];
const budget = { exhausted: false };
const required = [
...methods(read.pointer(address + 24n), true, true, skipped, budget),
...methods(read.pointer(address + 32n), true, false, skipped, budget),
...methods(read.pointer(address + 24n), true, true, skipped),
...methods(read.pointer(address + 32n), true, false, skipped),
];
const optional = [
...methods(read.pointer(address + 40n), false, true, skipped, budget),
...methods(read.pointer(address + 48n), false, false, skipped, budget),
...methods(read.pointer(address + 40n), false, true, skipped),
...methods(read.pointer(address + 48n), false, false, skipped),
];
for (const message of skipped)
failures.push(
@@ -118,11 +112,8 @@ export const createObjcProtocolReader = (input: {
methods: required,
optional_methods: optional,
location: read.location(address),
// A budget-cut list is recoverable with a larger budget, so that
// cause is reported first; an empty list is otherwise complete.
decode: budget.exhausted
? { status: "partial", reason: "method_list_record_budget_exhausted" }
: skipped.length === 0
decode:
skipped.length === 0
? { status: "decoded", reason: null }
: { status: "partial", reason: "protocol_method_selector_empty" },
evidence: read.evidence(
@@ -176,7 +176,13 @@ it.each(["caller", "host"] as const)(
);
let closing: ReturnType<NonNullable<typeof host.close>> | undefined;
try {
await expect.poll(async () => (await fakeKunaCalls(kuna)).length).toBe(1);
// The preceding Ghidra probe may prepare Darwin process ownership before
// Kuna starts; keep startup separate from the bounded post-abort join.
await expect
.poll(async () => (await fakeKunaCalls(kuna)).length, {
timeout: 10_000,
})
.toBe(1);
const probe = (await fakeKunaCalls(kuna))[0];
if (probe === undefined) throw new Error("Missing Kuna version probe");
expect(probe.argv).toEqual(["--version"]);