fix(apple): preserve bounded archive diagnostics (#7890)

* fix(apple): preserve bounded archive diagnostics

* test(apple): assert diagnostic facts without freezing prose
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 07:36:29 +08:00
1 parent 16cfe30dab
commit fd297b1b17
6 files changed
+119 -10

No files matched your search

@@ -517,7 +517,7 @@ it.each([
},
);
it.each(["reported carutil", "observed assetutil"])(
it.each(["reported carutil", "tab-indented carutil", "observed assetutil"])(
"attributes %s rejection of a readable nested catalog as invalid bytes",
async (variant) => {
const bundle = await createTestTempDirectory("rea-asset-rejected-");
@@ -535,9 +535,9 @@ it.each(["reported carutil", "observed assetutil"])(
// catalog. This test injects those diagnoses, without running Apple tools.
const stderr =
"BOMStreamWithFileAndSys: read: Operation not permitted\n" +
(variant === "reported carutil"
(variant !== "observed assetutil"
? `assetutil Error: Unable to read file at '${path}'\n` +
`carutil: error:\n ${path} is not a valid AssetCatalog\n`
`carutil: error:\n${variant === "tab-indented carutil" ? "\t" : " "}${path} is not a valid AssetCatalog\n`
: `assetutil: file error error exiting: 'can't read from: '${path}' Undefined error: 0' invalid file\n`);
const cause = Object.assign(new Error("assetutil exited 1"), {
code: 1,
@@ -580,6 +580,7 @@ it.each(["reported carutil", "observed assetutil"])(
it.each([
"unrecognized text",
"another catalog",
"tab-indented another catalog",
"signal",
"truncated capture",
"failed capture",
@@ -589,13 +590,15 @@ it.each([
const bundle = await createTestTempDirectory("rea-asset-unknown-rejection-");
const path = join(bundle, "Assets.car");
await writeFile(path, "readable catalog");
const rejectedPath = scenario === "another catalog" ? `${path}.other` : path;
const rejectedPath = scenario.includes("another catalog")
? `${path}.other`
: path;
const stderr =
scenario === "unrecognized text"
? "Operation not permitted; catalog may be invalid"
: scenario.startsWith("different file-error")
? `assetutil: file error error exiting: 'can't read from: '${path}' Undefined error: ${scenario === "different file-error code" ? "13" : "0"}' ${scenario === "different file-error text" ? "access denied" : "invalid file"}\n`
: `carutil: error:\n ${rejectedPath} is not a valid AssetCatalog\n`;
: `carutil: error:\n${scenario.startsWith("tab-indented") ? "\t" : " "}${rejectedPath} is not a valid AssetCatalog\n`;
const cause = Object.assign(new Error("assetutil failed"), {
code: 1,
stdout: "",
@@ -465,6 +465,7 @@ const assetUtilFailure = (cause: unknown, entry: ArtifactEntry): unknown => {
// other text, interrupted runs and incomplete captures establish no cause.
const rejectsCatalog = [
`carutil: error:\n ${entry.adapterKey} is not a valid AssetCatalog`,
`carutil: error:\n\t${entry.adapterKey} is not a valid AssetCatalog`,
`assetutil: file error error exiting: 'can't read from: '${entry.adapterKey}' Undefined error: 0' invalid file`,
].some((rejection) => {
const offset = output.stderr.indexOf(rejection);
@@ -67,6 +67,7 @@ import {
const MAX_AGGREGATE_INPUT_BYTES = 32 * 1024 * 1024;
const MAX_AGGREGATE_DECODE_BYTES = 256 * 1024 * 1024;
const MAX_REPORTED_OMITTED_ARCHIVES = 64;
const MAX_REPORTED_INVALID_ARCHIVES = 16;
interface OmittedArchive {
readonly path: string;
@@ -100,6 +101,7 @@ const decodeInterfaceBuilderBundle = async (
const reader = new DirectoryArtifactReader(input.bundlePath);
const documents: InterfaceBuilderDocumentInput[] = [];
const invalid: string[] = [];
let unreportedInvalidArchives = 0;
const omittedArchives: OmittedArchive[] = [];
const omissionCounts = new Map<string, number>();
const recordOmission = (reason: string, count = 1): void => {
@@ -280,9 +282,11 @@ const decodeInterfaceBuilderBundle = async (
recordOmittedArchive(entry.path, "unsupported_uid_exact_range");
continue;
}
invalid.push(
`${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`,
);
if (invalid.length < MAX_REPORTED_INVALID_ARCHIVES)
invalid.push(
`${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`,
);
else unreportedInvalidArchives += 1;
}
}
} catch (cause: unknown) {
@@ -315,6 +319,7 @@ const decodeInterfaceBuilderBundle = async (
nonFiniteReals,
negativeZeroReals,
invalid,
unreportedInvalidArchives,
omittedArchives,
unreportedOmittedArchives,
omissionCounts,
@@ -421,6 +426,7 @@ const finalizeHierarchyCoverage = (
nonFiniteReals: ReadonlyMap<string, number>;
negativeZeroReals: ReadonlyMap<string, number>;
invalid: readonly string[];
unreportedInvalidArchives: number;
omittedArchives: readonly OmittedArchive[];
unreportedOmittedArchives: number;
omissionCounts: ReadonlyMap<string, number>;
@@ -439,6 +445,7 @@ const finalizeHierarchyCoverage = (
nonFiniteReals,
negativeZeroReals,
invalid,
unreportedInvalidArchives,
omittedArchives,
unreportedOmittedArchives,
} = counts;
@@ -563,7 +570,12 @@ const finalizeHierarchyCoverage = (
...(invalid.length === 0
? []
: [
`Some Interface Builder archives could not be decoded: ${invalid.slice(0, 16).join("; ")}`,
`Some Interface Builder archives could not be decoded: ${invalid.join("; ")}`,
]),
...(unreportedInvalidArchives === 0
? []
: [
`${unreportedInvalidArchives} additional archive decode failure diagnostic(s) were not retained; reporting is bounded to ${MAX_REPORTED_INVALID_ARCHIVES} paths.`,
]),
...[...unknownDates].map(
([path, count]) => `${path}: ${unknownDateLimitation(count)}`,
@@ -53,6 +53,47 @@ const interfaceBuilderReports = async (cli: TestCli, app: string) => {
}));
};
cliTest(
"discloses omitted malformed-archive diagnostics through CLI and MCP",
async ({ cli }) => {
const fixture = await bundle();
for (let index = 0; index < 17; index += 1)
await writeFile(
join(fixture.resources, `bad-${String(index).padStart(2, "0")}.nib`),
"not a plist",
);
for (const { evidence, report } of await interfaceBuilderReports(
cli,
fixture.app,
)) {
expect(report.documents).toHaveLength(1);
expect(report.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "one_or_more_archives_invalid",
examined: 18,
omitted: 0,
}),
);
const omission = report.limitations.find((value) =>
value.includes("additional archive decode failure diagnostic(s)"),
);
expect(omission).toMatch(/\b1 additional\b/u);
expect(omission).toMatch(/\b16 paths\b/u);
expect(evidence.limitations).toContain(omission);
for (const limitations of [report.limitations, evidence.limitations]) {
const failures = limitations.find((value) =>
value.startsWith(
"Some Interface Builder archives could not be decoded:",
),
);
expect(failures?.match(/bad-\d{2}\.nib/gu)).toHaveLength(16);
}
}
},
);
cliTest.skipIf(process.platform === "win32")(
"reports symlinked nibs as unexamined while preserving healthy UI documents through CLI and MCP",
async ({ cli }) => {
@@ -78,7 +78,20 @@ cliTest.skipIf(process.platform !== "darwin")(
const graph = interfaceBuilderAnalysisSchema.parse(
parseEvidence(decoded.json).normalized_result,
).graph;
expect(graph.truncated).toBe(false);
// The fixture need not describe a complete view hierarchy to establish
// decoded object facts used by asset-key matching.
expect(graph.coverage).toEqual(
expect.arrayContaining([
expect.objectContaining({
facet: "archive_decode",
status: "complete",
}),
expect.objectContaining({
facet: "archive_object_decode",
status: "complete",
}),
]),
);
const resource = graph.nodes.find(
(node) =>
node.kind === "resource" && node.attributes.class_name === "NSImage",
@@ -143,11 +156,14 @@ cliTest.skipIf(process.platform !== "darwin")(
expect(binaryResult).toEqual({
target_sha256: executableSha256,
catalogs: result.catalogs,
catalog_coverage: result.catalog_coverage,
inventory_coverage: result.inventory_coverage,
total_records: result.total_records,
offset: result.offset,
limit: result.limit,
records: result.records,
resource_key_matches: result.resource_key_matches,
resource_key_coverage: result.resource_key_coverage,
next_offset: result.next_offset,
truncated: result.truncated,
limitations: result.limitations,
@@ -191,6 +191,42 @@ const encodeNibArchiveWithSharedDataValue = (input: {
return Buffer.concat([header, objects, keys, values, classes]);
};
it.each([16, 17])(
"reports the bounded sample of %i malformed archive diagnostics",
async (count) => {
const bundle = await createTestTempDirectory("rea-ib-invalid-bound-");
for (let index = 0; index < count; index += 1)
await writeFile(
join(bundle, `bad-${String(index).padStart(2, "0")}.nib`),
"not a plist",
);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "a".repeat(64),
});
const diagnostic = result.limitations.find((value) =>
value.startsWith("Some Interface Builder archives could not be decoded:"),
);
expect(diagnostic?.match(/bad-\d{2}\.nib/gu)).toHaveLength(16);
const omission = result.limitations.filter((value) =>
value.includes("additional archive decode failure diagnostic(s)"),
);
expect(omission).toHaveLength(count === 16 ? 0 : 1);
if (count === 17) {
expect(omission[0]).toMatch(/\b1 additional\b/u);
expect(omission[0]).toMatch(/\b16 paths\b/u);
}
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
examined: count,
omitted: 0,
}),
);
},
);
describe("compiled Interface Builder traversal coverage", () => {
it.skipIf(process.platform === "win32")(
"shares the 64-path omission budget between regular-document capacity and symlink source notices",