fix(artifacts): preserve decode coverage and cleanup ownership

This commit is contained in:
morluto 2026-10-13 12:54:22 +08:00
parent c74142e6bd
commit a73a55f53c
8 changed files with 305 additions and 45 deletions

View File

@ -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);

View File

@ -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",

View File

@ -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);

View File

@ -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" },
]);

View File

@ -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<string>();
for (const chain of ranges) {
const examinedViews = new Map<string, boolean>();
const decodedRanges = new Set<number>();
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;

View File

@ -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",

View File

@ -335,7 +335,6 @@ export const decodeObjcDispatchFacets = (input: {
sections,
read,
result,
failures,
issues: categoryIssues,
admit: () => budget.admit(),
methods,

View File

@ -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: {