From fd297b1b171e2fe829f07385e59cffc57ff41d12 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:36:29 +0800 Subject: [PATCH] fix(apple): preserve bounded archive diagnostics (#7890) * fix(apple): preserve bounded archive diagnostics * test(apple): assert diagnostic facts without freezing prose --- .../apple/AppleAssetCatalogAnalysis.test.ts | 13 +++--- .../apple/AppleAssetCatalogAnalysis.ts | 1 + .../apple/InterfaceBuilderAnalysis.ts | 20 +++++++-- .../appleBundleTraversalCoverage.test.ts | 41 +++++++++++++++++++ .../assetCatalogResourceKeys.test.ts | 18 +++++++- .../filesystem/interfaceBuilderBundle.test.ts | 36 ++++++++++++++++ 6 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index 01cd079cd7..7268a55ccd 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -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: "", diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index 14473e10ec..c400ed5452 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -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); diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index ce5d3fec36..b4b7975e9e 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -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(); 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; negativeZeroReals: ReadonlyMap; invalid: readonly string[]; + unreportedInvalidArchives: number; omittedArchives: readonly OmittedArchive[]; unreportedOmittedArchives: number; omissionCounts: ReadonlyMap; @@ -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)}`, diff --git a/tests/acceptance/applications/appleBundleTraversalCoverage.test.ts b/tests/acceptance/applications/appleBundleTraversalCoverage.test.ts index e2a0ce2cb8..a2a5127697 100644 --- a/tests/acceptance/applications/appleBundleTraversalCoverage.test.ts +++ b/tests/acceptance/applications/appleBundleTraversalCoverage.test.ts @@ -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 }) => { diff --git a/tests/acceptance/applications/assetCatalogResourceKeys.test.ts b/tests/acceptance/applications/assetCatalogResourceKeys.test.ts index 289ad3d253..42f69ecf22 100644 --- a/tests/acceptance/applications/assetCatalogResourceKeys.test.ts +++ b/tests/acceptance/applications/assetCatalogResourceKeys.test.ts @@ -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, diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 8aacf6f065..b127b73a35 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -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",