From a73a55f53cc2b2e5b7618effe6ecb5cee6e0cee2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:54:22 +0800 Subject: [PATCH] fix(artifacts): preserve decode coverage and cleanup ownership --- src/firmware/FirmwareFiles.ts | 22 ++-- src/native/AppleDispatchCoverage.ts | 73 ++++++++++-- src/native/AppleDispatchMetadata.test.ts | 142 ++++++++++++++++++++++- src/native/AppleMachoFixups.test.ts | 67 ++++++++++- src/native/AppleMachoFixups.ts | 39 +++++-- src/native/AppleObjcCategories.ts | 4 +- src/native/AppleObjcDispatchFacets.ts | 1 - src/native/AppleSwiftVtables.test.ts | 2 + 8 files changed, 305 insertions(+), 45 deletions(-) diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index 10201905e..d20226e76 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -213,19 +213,15 @@ export const inventoryFirmwareOutput = async ( }); if (handle === undefined) continue; const names: string[] = []; - try { - for await (const entry of handle) { - throwIfFirmwareInventoryCancelled(signal, operation); - count++; - if (count > maxEntries) - throw new AnalysisOutputError( - operation, - `Staging entry budget exceeded (${maxEntries})`, - ); - names.push(entry.name); - } - } finally { - await handle.close().catch(() => undefined); + for await (const entry of handle) { + throwIfFirmwareInventoryCancelled(signal, operation); + count++; + if (count > maxEntries) + throw new AnalysisOutputError( + operation, + `Staging entry budget exceeded (${maxEntries})`, + ); + names.push(entry.name); } for (const name of names.sort()) { const path = join(directory, name); diff --git a/src/native/AppleDispatchCoverage.ts b/src/native/AppleDispatchCoverage.ts index c49e35acc..a6c68ca51 100644 --- a/src/native/AppleDispatchCoverage.ts +++ b/src/native/AppleDispatchCoverage.ts @@ -42,14 +42,62 @@ export const pushDispatchCoverage = (input: { vtables, swift, } = input; + const classOwnedMethods = result.objc_dispatch_implementations.filter( + ({ category }) => category === undefined, + ); + const classRecords = [ + ...result.objc_classes.flatMap((record) => + record.decode?.status === "decoded" + ? [] + : [ + `class ${record.name}: ${record.decode?.reason ?? record.decode?.status ?? "decode_status_missing"}`, + ], + ), + ...result.objc_protocol_records.flatMap((record) => + record.decode.status === "decoded" + ? [] + : [ + `protocol ${record.name}: ${record.decode.reason ?? record.decode.status}`, + ], + ), + ...classOwnedMethods.flatMap((record) => + record.decode.status === "decoded" + ? [] + : [ + `method ${record.class_name} ${record.method_type} ${record.selector}: ${record.decode.reason ?? record.decode.status}`, + ], + ), + ...result.objc_ivars.flatMap((record) => + record.decode.status === "decoded" + ? [] + : [ + `ivar ${record.class_name}.${record.name}: ${record.decode.reason ?? record.decode.status}`, + ], + ), + ]; + const classReasons = [ + ...failures, + ...classRecords, + ...(truncated ? ["max_records_reached"] : []), + ]; + const categoryMethods = result.objc_dispatch_implementations.filter( + ({ category }) => category !== undefined, + ); + const categoryMethodReasons = categoryMethods.flatMap((record) => + record.decode.status === "decoded" + ? [] + : [ + `category method ${record.category} ${record.class_name} ${record.method_type} ${record.selector}: ${record.decode.reason ?? record.decode.status}`, + ], + ); result.coverage.push({ facet: "objc_class_method_ivar_metadata", - status: failures.length > 0 || truncated ? "partial" : "complete", - reason: - [...failures, ...(truncated ? ["max_records_reached"] : [])].join("; ") || - null, + status: classReasons.length > 0 ? "partial" : "complete", + reason: classReasons.join("; ") || null, examined, - decoded: result.objc_classes.length, + decoded: result.objc_classes.filter( + ({ decode }) => decode?.status === "decoded", + ).length, }); result.coverage.push({ facet: "binary_relative_pointers", @@ -79,13 +127,18 @@ export const pushDispatchCoverage = (input: { status: truncated || categoryFailures.length > 0 || - result.objc_categories.some(({ decode }) => decode.status !== "decoded") + result.objc_categories.some( + ({ decode }) => decode.status !== "decoded", + ) || + categoryMethodReasons.length > 0 ? "partial" : "complete", reason: - [...categoryFailures, ...(truncated ? ["max_records_reached"] : [])].join( - "; ", - ) || null, + [ + ...categoryFailures, + ...categoryMethodReasons, + ...(truncated ? ["max_records_reached"] : []), + ].join("; ") || null, examined: categoriesExamined, decoded: result.objc_categories.filter( ({ decode }) => decode.status === "decoded", @@ -110,7 +163,7 @@ export const pushDispatchCoverage = (input: { : "complete", reason: fixupReason.length > 0 ? fixupReason : null, examined: fixups.examined, - decoded: fixups.issues.length > 0 ? 0 : fixups.examined, + decoded: fixups.decoded, }); result.coverage.push({ facet: "swift_generic_resilient_witnesses_overrides_async_coroutines", diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 6791ba1b0..244a35aeb 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; const base = 0x100000000n; -const fixture = (relative = false) => { +const fixture = ( + relative = false, + category?: "partial-method" | "unmapped-pointer", +) => { const bytes = Buffer.alloc(4096); const u32 = (offset: number, value: number) => bytes.writeUInt32LE(value, offset); @@ -11,19 +14,26 @@ const fixture = (relative = false) => { u32(0, 0xfeedfacf); u32(4, 0x0100000c); u32(16, 1); - u32(20, 152); + u32(20, category === undefined ? 152 : 232); u32(32, 0x19); - u32(36, 152); + u32(36, category === undefined ? 152 : 232); bytes.write("__DATA", 40); ptr(56, 0); bytes.writeBigUInt64LE(4096n, 64); bytes.writeBigUInt64LE(4096n, 80); u32(92, 5); - u32(96, 1); + u32(96, category === undefined ? 1 : 2); bytes.write("__objc_classlist", 104); bytes.write("__DATA", 120); ptr(136, 0x180); bytes.writeBigUInt64LE(8n, 144); + if (category !== undefined) { + bytes.write("__objc_catlist", 184); + bytes.write("__DATA", 200); + ptr(216, 0xb00); + bytes.writeBigUInt64LE(8n, 224); + u32(232, 0xb00); + } u32(152, 0x180); ptr(0x180, 0x200); ptr(0x200, 0x280); @@ -86,10 +96,58 @@ const fixture = (relative = false) => { bytes.write("FixtureProtocol\0", 0x900); bytes.write("requiredAction:\0", 0x920); bytes.write("optionalAction:\0", 0x940); + if (category !== undefined) { + if (category === "unmapped-pointer") { + bytes.writeBigUInt64LE(0xffffffffffffffffn, 0xb00); + } else { + ptr(0xb00, 0xb40); + ptr(0xb40, 0xbc0); + ptr(0xb48, 0x200); + ptr(0xb50, 0xb80); + u32(0xb80, 24); + u32(0xb84, 1); + ptr(0xb88, 0x460); + ptr(0xb90, 0x5c0); + bytes.writeBigUInt64LE(0xffffffffffffffffn, 0xb98); + bytes.write("FixtureCategory\0", 0xbc0); + } + } return bytes; }; const provenance = { path: "/fixture/app", sha256: "a".repeat(64) }; -describe("chained Apple dispatch binary metadata", () => { +describe("Apple dispatch pointer fixup metadata", () => { + it("keeps decoded bindings and their coverage when an independent stream fails", () => { + const bytes = fixture(); + bytes.writeUInt32LE(2, 16); + bytes.writeUInt32LE(200, 20); + bytes.writeUInt32LE(0x80000022, 184); + bytes.writeUInt32LE(48, 188); + const bindings = Buffer.from([ + 0x40, + ...Buffer.from("_OBJC_CLASS_$_NSObject\0"), + 0x70, + 0x88, + 0x04, + 0x90, + 0x00, + ]); + bindings.copy(bytes, 0xc00); + bytes[0xc80] = 0xd0; + bytes.writeUInt32LE(0xc00, 200); + bytes.writeUInt32LE(bindings.length, 204); + bytes.writeUInt32LE(0xc80, 208); + bytes.writeUInt32LE(1, 212); + + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_classes[0]).toMatchObject({ + name: "Fixture", + super_class: "NSObject", + }); + expect( + result.coverage.find(({ facet }) => facet === "pointer_fixups"), + ).toMatchObject({ status: "partial", examined: 2, decoded: 1 }); + }); + it("keeps absolute metadata pointers between declared chained fixups", () => { const bytes = fixture(); bytes.writeUInt32LE(2, 16); @@ -120,7 +178,7 @@ describe("chained Apple dispatch binary metadata", () => { }); expect( result.coverage.find(({ facet }) => facet === "pointer_fixups"), - ).toMatchObject({ status: "complete" }); + ).toMatchObject({ status: "complete", examined: 1, decoded: 1 }); }); }); describe("Apple dispatch binary metadata", () => { @@ -221,6 +279,9 @@ describe("Apple dispatch binary metadata", () => { }); }, ); +}); + +describe("Apple dispatch coverage ownership", () => { it("keeps unsupported pointers and record truncation explicit", () => { const bytes = fixture(); bytes.writeBigUInt64LE(0xffffffffffffffffn, 0x498); @@ -229,6 +290,14 @@ describe("Apple dispatch binary metadata", () => { implementation_address: null, decode: { status: "partial" }, }); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining("method Fixture instance performAction:"), + }); const truncated = decodeAppleDispatchMetadata(fixture(), 1, provenance); expect(truncated.coverage[0]?.reason).toContain("max_records_reached"); expect( @@ -237,6 +306,67 @@ describe("Apple dispatch binary metadata", () => { ), ).toMatchObject({ status: "partial", reason: "max_records_reached" }); }); + it("keeps partial category methods in category coverage only", () => { + const baseline = decodeAppleDispatchMetadata(fixture(), 100, provenance); + const result = decodeAppleDispatchMetadata( + fixture(false, "partial-method"), + 100, + provenance, + ); + + expect(result.objc_dispatch_implementations).toContainEqual( + expect.objectContaining({ + category: "FixtureCategory", + decode: { + status: "partial", + reason: "implementation_pointer_unmapped_or_encoded", + }, + }), + ); + const baselineClassCoverage = baseline.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ); + const classCoverage = result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ); + expect(baselineClassCoverage).toBeDefined(); + expect(classCoverage).toEqual(baselineClassCoverage); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_properties_categories", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining("category method FixtureCategory"), + }); + }); + it("keeps malformed category pointers out of class failure coverage", () => { + const baseline = decodeAppleDispatchMetadata(fixture(), 100, provenance); + const result = decodeAppleDispatchMetadata( + fixture(false, "unmapped-pointer"), + 100, + provenance, + ); + const baselineClassCoverage = baseline.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ); + const classCoverage = result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ); + expect(baselineClassCoverage).toBeDefined(); + expect(classCoverage).toEqual(baselineClassCoverage); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_properties_categories", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining("category_decode_failed"), + }); + }); +}); + +describe("malformed Apple dispatch metadata", () => { it("rejects malformed command and section boundaries", () => { const bytes = fixture(); bytes.writeUInt32LE(7, 36); diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index 40c50c59b..bf4ae348f 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -149,6 +149,64 @@ const SEGMENTS: Segment[] = [ { address: BASE + 0x4000n, size: 0x4000n, offset: 0x4000, executable: false }, ]; +describe("independent chained segment coverage", () => { + it("counts successfully decoded chained segments alongside an unsupported segment", () => { + const starts = 28; + const startsSize = 4 + 2 * 4; + const firstInfo = starts + startsSize; + const secondInfo = firstInfo + 24; + const imports = secondInfo + 24; + const data = Buffer.alloc(0x3000); + data.writeUInt32LE(starts, 4); + data.writeUInt32LE(imports, 8); + data.writeUInt32LE(imports, 12); + data.writeUInt32LE(1, 20); + data.writeUInt32LE(2, starts); + data.writeUInt32LE(firstInfo - starts, starts + 4); + data.writeUInt32LE(secondInfo - starts, starts + 8); + for (const [info, format, segmentOffset] of [ + [firstInfo, 6, 0], + [secondInfo, 99, 0x1000], + ] as const) { + data.writeUInt32LE(24, info); + data.writeUInt16LE(0x1000, info + 4); + data.writeUInt16LE(format, info + 6); + data.writeBigUInt64LE(BigInt(segmentOffset), info + 8); + data.writeUInt16LE(1, info + 20); + data.writeUInt16LE(8, info + 22); + } + const segments: Segment[] = [ + { address: BASE, size: 0x1000n, offset: 0x1000, executable: false }, + { + address: BASE + 0x1000n, + size: 0x1000n, + offset: 0x2000, + executable: false, + }, + ]; + const fixups = parsePointerFixups( + data, + { + chained: { offset: 0, size: imports }, + binds: [], + rebases: [], + dylibs: [], + }, + segments, + BASE, + ); + + expect(fixups).toMatchObject({ examined: 2, decoded: 1 }); + expect(fixups.issues).toMatchObject([ + { code: "unsupported_pointer_format" }, + ]); + expect(fixups.decode(BASE + 8n, 0n)).toEqual({ + kind: "rebase", + target: BASE, + }); + }); +}); + describe("pointer fixup tables", () => { it("reports unsupported pointer formats even when no pointer is visited", () => { const data = chainedData(1); @@ -196,20 +254,27 @@ describe("pointer fixup tables", () => { { chained: { offset: 0, size: 128 }, binds: [], - rebases: [], + rebases: [{ offset: 8, size: 12 }], dylibs: [], }, SEGMENTS, BASE, ); + expect(fixups).toMatchObject({ + examined: 1, + decoded: 1, + unreadRebaseBytes: 12, + }); expect(fixups.decode(BASE + 0x4008n, field(1n, 63))).toMatchObject({ kind: "bind", symbol: "_OBJC_CLASS_$_NSObject", }); expect(fixups.issues).toEqual([]); + expect(fixups.decoded).toBe(1); expect(fixups.decode(BASE + 0x4008n, 1n | field(1n, 63))).toMatchObject({ kind: "unsupported", }); + expect(fixups.decoded).toBe(0); expect(fixups.issues).toMatchObject([ { code: "chained_pointer_decode_failed" }, ]); diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index 2ec711cc8..09080ec1d 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -42,6 +42,8 @@ export interface PointerFixups { readonly issues: readonly DecodeIssue[]; /** Fixup segments or bind streams this reader walked. */ readonly examined: number; + /** Fully decoded fixup segments or bind streams, in the same units as `examined`. */ + readonly decoded: number; /** Rebase opcode bytes present in the load command and not decoded. */ readonly unreadRebaseBytes: number; /** Decode the pointer stored at `address` whose raw 64-bit value is `raw`. */ @@ -260,6 +262,7 @@ const readChainedPage = ( const chainedFixups = ( image: FixupImage, range: { readonly offset: number; readonly size: number }, + unreadRebaseBytes: number, ): PointerFixups => { const { bytes, segments, dylibs, baseAddress } = image; const base = range.offset; @@ -400,19 +403,25 @@ const chainedFixups = ( ] : [], ); - const examinedViews = new Set(); - for (const chain of ranges) { + const examinedViews = new Map(); + const decodedRanges = new Set(); + for (const [chainIndex, chain] of ranges.entries()) { // Unknown formats retain their compact starts table; only a queried page // needs its NONE/presence value. Do not expand unsupported declarations. if (POINTER_FORMATS[chain.format] === undefined) continue; - const view = `${chain.pageStarts}:${chain.segment.offset}:${chain.segment.size}:${chain.segment.address}`; - if (examinedViews.has(view)) continue; - examinedViews.add(view); + const view = `${chain.pageStarts}:${chain.segment.offset}:${chain.segment.size}:${chain.segment.address}:${chain.format}:${chain.pageSize}:${chain.pageCount}`; + const prior = examinedViews.get(view); + if (prior !== undefined) { + if (prior) decodedRanges.add(chainIndex); + continue; + } + let complete = true; for (let page = 0; page < chain.pageCount; page++) { const start = bytes.readUInt16LE(chain.pageStarts + page * 2); if (start === 0xffff) continue; const facts = pageFacts(chain, page, start); if (facts.failure !== undefined) { + complete = false; issues.push( issue( "pointer_fixups", @@ -427,6 +436,8 @@ const chainedFixups = ( break; } } + examinedViews.set(view, complete); + if (complete) decodedRanges.add(chainIndex); } const failures = issues.map(({ message }) => message); return { @@ -435,13 +446,17 @@ const chainedFixups = ( failures, issues, examined: Math.max(ranges.length, 1), - unreadRebaseBytes: 0, + get decoded() { + return ranges.length === 0 ? 1 : decodedRanges.size; + }, + unreadRebaseBytes, decode(address, raw) { - const chain = ranges.find( + const chainIndex = ranges.findIndex( ({ segment }) => address >= segment.address && address < segment.address + segment.size, ); + const chain = ranges[chainIndex]; // Pointers outside fixup segments, such as in __TEXT, are stored plainly. if (chain === undefined) return { kind: "rebase", target: raw }; const within = Number(address - chain.segment.address); @@ -486,6 +501,7 @@ const chainedFixups = ( ); failures.push(reason); } + decodedRanges.delete(chainIndex); return { kind: "unsupported", reason }; } }, @@ -684,10 +700,7 @@ export const parsePointerFixups = ( 0, ); if (commands.chained !== null) { - const chained = chainedFixups(image, commands.chained); - return unreadRebaseBytes === 0 - ? chained - : { ...chained, unreadRebaseBytes }; + return chainedFixups(image, commands.chained, unreadRebaseBytes); } if (commands.binds.length === 0) return { @@ -696,14 +709,17 @@ export const parsePointerFixups = ( failures: [], issues: [], examined: 0, + decoded: 0, unreadRebaseBytes, decode: (_address, raw) => ({ kind: "rebase", target: raw }), }; const binds: BindRun[] = []; const failures: string[] = []; + let decoded = 0; for (const stream of commands.binds) try { readBindStream(image, stream, binds); + decoded++; } catch (cause: unknown) { failures.push( `${stream.stream} bind opcodes: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -717,6 +733,7 @@ export const parsePointerFixups = ( issue("pointer_fixups", "bind_stream_decode_failed", message), ), examined: commands.binds.length, + decoded, unreadRebaseBytes, decode(address, raw) { let match: BindRun | undefined; diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index bd936e0ce..7beb7fc03 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -23,7 +23,6 @@ export const decodeObjcCategories = (input: { readonly sections: readonly Section[]; readonly read: ObjcDispatchReaders; readonly result: ObjcSwiftMetadata; - readonly failures: string[]; readonly issues: DecodeIssue[]; readonly admit: () => boolean; /** Decode a method list into implementations attributed to the category. */ @@ -37,7 +36,7 @@ export const decodeObjcCategories = (input: { /** Name of a class defined in this image, from its class_ro_t. */ readonly localClassName: (address: bigint) => string; }): number => { - const { read, result, failures } = input; + const { read, result } = input; let examined = 0; for (const section of input.sections.filter( ({ name }) => name === "__objc_catlist", @@ -93,7 +92,6 @@ export const decodeObjcCategories = (input: { }); } catch (cause: unknown) { const message = cause instanceof Error ? cause.message : String(cause); - failures.push(`Category at 0x${field.toString(16)}: ${message}`); input.issues.push( issue( "objc_properties_categories", diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index 14f0b42b8..b52545840 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -335,7 +335,6 @@ export const decodeObjcDispatchFacets = (input: { sections, read, result, - failures, issues: categoryIssues, admit: () => budget.admit(), methods, diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts index dbb1c12b5..fb1615820 100644 --- a/src/native/AppleSwiftVtables.test.ts +++ b/src/native/AppleSwiftVtables.test.ts @@ -67,6 +67,7 @@ describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => { issues: [], decode: (_address, raw) => ({ kind: "rebase", target: raw }), examined: 0, + decoded: 0, unreadRebaseBytes: 0, }, swift: { @@ -181,6 +182,7 @@ describe("Swift vtable coverage facts: unsupported pointer fixup coverage", () = reason: "Unsupported chained pointer format 99", }), examined: 0, + decoded: 0, unreadRebaseBytes: 0, }, swift: {