From dbbaf2abb0abbb9a86c7a5f365c296bf0502b7a5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:01:23 +0800 Subject: [PATCH] fix(apple): preserve asset failures and authored resource coverage --- docs/native-investigation.md | 8 +- .../apple/AppleAssetCatalogAnalysis.test.ts | 92 ++++++++++ .../apple/AppleAssetCatalogAnalysis.ts | 170 +++++++++++++----- .../apple/InterfaceBuilderAnalysis.ts | 21 ++- src/domain/apple/appleAssetCatalog.ts | 25 +++ .../interfaceBuilderKeyedArchive.test.ts | 33 ++++ .../apple/interfaceBuilderKeyedArchive.ts | 33 +++- 7 files changed, 331 insertions(+), 51 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 6bf57c07a5..b2792a5958 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -460,7 +460,13 @@ complete observation. `inventory_coverage` describes traversal of source entries and remains `unknown` when the reader cannot represent a filename. Represented symlinks are inventoried but their targets remain uninspected, with a limitation. - Image extraction and undocumented rendition interpretation are unsupported. + `catalog_coverage` and `resource_key_coverage` separately describe examined + catalog metadata and the UI join; an unmatched key cannot establish absence + when either source is incomplete. Resource wrappers retain their serialized + class and authored runtime class, including AppKit `NSCustomResource` names. + Invalid producer JSON and subprocess failures retain captured output with + their typed failure reason. Image extraction and undocumented rendition + interpretation are unsupported. - `inspect_keyed_archive` / `rea inspect-keyed-archive ` reads XML or binary Foundation keyed archives without instantiating classes. For an active app, provide its relative archive path as the second CLI argument or MCP diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index 96530c6375..ab09b0113d 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -54,6 +54,8 @@ describe("Apple asset catalog traversal coverage", () => { runAssetUtil: async () => "[]", }); expect(result.inventory_coverage.status).toBe("complete"); + expect(result.catalog_coverage).toMatchObject({ status: "unknown" }); + expect(result.resource_key_coverage).toMatchObject({ status: "unknown" }); expect(result.limitations).toContainEqual( expect.stringContaining("catalogs beneath them may remain uninspected"), ); @@ -184,6 +186,11 @@ describe("Apple asset catalog application workflow", () => { expect(result.catalogs.map(({ path }) => path)).toEqual([ "Contents/Resources/Assets.car", ]); + expect(result.catalog_coverage).toEqual({ + status: "partial", + reason: "catalog_entries_uninspected", + }); + expect(result.resource_key_coverage.status).toBe("partial"); expect(result.limitations).toContainEqual( expect.stringContaining("Contents/Resources/Nested/Assets.car (symlink)"), ); @@ -452,3 +459,88 @@ describe("Apple asset catalog and Interface Builder concurrency", () => { }, ); }); + +it.each(["not json", "{}", "[null]"])( + "preserves successful assetutil output failures for %s", + async (output) => { + const bundle = await createTestTempDirectory("rea-asset-output-"); + await writeFile(join(bundle, "Assets.car"), "inert catalog"); + await expect( + analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + environment: {}, + runAssetUtil: async () => output, + }), + ).rejects.toMatchObject({ + _tag: "AnalysisOutputError", + capturedOutput: { stdout: output, exit_code: 0 }, + }); + }, +); +it.each(["ETIMEDOUT", 1] as const)( + "preserves assetutil command failure %s and diagnostics", + async (code) => { + const bundle = await createTestTempDirectory("rea-asset-command-"); + await writeFile(join(bundle, "Assets.car"), "inert catalog"); + const cause = Object.assign(new Error("native refusal"), { + code, + stdout: "", + stderr: "specific catalog diagnostic", + signal: code === "ETIMEDOUT" ? "SIGTERM" : null, + }); + await expect( + analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + environment: {}, + runAssetUtil: async () => { + throw cause; + }, + }), + ).rejects.toMatchObject({ + _tag: + code === "ETIMEDOUT" ? "AnalysisTimeoutError" : "AnalysisOutputError", + capturedOutput: { stderr: "specific catalog diagnostic" }, + ...(code === "ETIMEDOUT" ? { timeoutMs: 60000 } : {}), + }); + }, +); + +it.each(["Toolbar", "MissingToolbar"])( + "retains AppKit NSCustomResource image key %s with its archive identity", + async (name) => { + const bundle = await createTestTempDirectory("rea-asset-custom-resource-"); + await writeFile( + join(bundle, "Panel.nib"), + encodeNibArchiveFixture({ + classes: ["NSIBObjectData", "NSCustomResource", "NSString"], + objects: [ + { classIndex: 0, values: { NSRoot: { ref: 1 } } }, + { + classIndex: 1, + values: { NSClassName: { ref: 2 }, NSResourceName: { ref: 3 } }, + }, + { classIndex: 2, values: { "NS.bytes": "NSImage" } }, + { classIndex: 2, values: { "NS.bytes": name } }, + ], + }), + ); + await writeFile(join(bundle, "Assets.car"), "inert catalog"); + const result = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + environment: {}, + runAssetUtil: async () => JSON.stringify([{ Name: "Toolbar" }]), + }); + expect(result.resource_key_matches).toMatchObject([ + { + field: "NSResourceName", + key: name, + source_path: "Panel.nib", + status: name === "Toolbar" ? "matched" : "unmatched", + }, + ]); + expect(result.resource_key_coverage.status).toBe("complete"); + }, +); diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index e5321507fe..21ae9e87e4 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -1,3 +1,14 @@ +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "../../domain/analysisErrorBase.js"; +import { + AnalysisOutputError, + AnalysisTimeoutError, + AnalysisCapabilityUnavailableError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js"; import { createHash } from "node:crypto"; import { lstat } from "node:fs/promises"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; @@ -13,10 +24,12 @@ import { appleResourceKeyFields, parseAppleAssetCatalogRecords, projectAppleAssetCatalogPage, + type AppleAssetAnalysisCoverage, } from "../../domain/apple/appleAssetCatalog.js"; import { ExecFileOutputCancelledError, execFileOutput, + execFileOutputFailure, } from "../../process/ExecFileOutput.js"; const ASSETUTIL = "/usr/bin/assetutil"; @@ -70,8 +83,14 @@ export const analyzeAppleAssetCatalogs = async (input: { let catalogs: Awaited>["catalogs"]; let uninspected: readonly string[]; let traversal: ArtifactReaderTraversal; + let catalogCoverage: AppleAssetAnalysisCoverage; try { - ({ catalogs, uninspected, traversal } = await collectAssetCatalogs(input)); + ({ + catalogs, + uninspected, + traversal, + coverage: catalogCoverage, + } = await collectAssetCatalogs(input)); } catch (cause: unknown) { interfaceBuilderCancel.abort(); await interfaceBuilderAnalysis; @@ -90,6 +109,13 @@ export const analyzeAppleAssetCatalogs = async (input: { : { resourceRoot: input.resourceRoot }), catalogs, inventoryCoverage: { scope: "container-entries", status: traversal.status }, + catalogCoverage, + resourceKeyCoverage: + catalogCoverage.status !== "complete" + ? catalogCoverage + : interfaceBuilder.graph.truncated + ? { status: "partial", reason: "interface_builder_graph_incomplete" } + : { status: "complete", reason: null }, offset: page.offset, limit: page.limit, resourceReferences, @@ -201,7 +227,16 @@ const collectAssetCatalogs = async (input: { "path", `Asset catalog entries are not regular files and were not inspected: ${uninspected.join(", ")}`, ); - return { catalogs, uninspected, traversal: traversal.snapshot() }; + const coverage: AppleAssetAnalysisCoverage = + traversal.snapshot().status === "unknown" || symlinks > 0 + ? { + status: "unknown", + reason: "source_names_or_symlink_targets_unexamined", + } + : uninspected.length > 0 + ? { status: "partial", reason: "catalog_entries_uninspected" } + : { status: "complete", reason: null }; + return { catalogs, uninspected, traversal: traversal.snapshot(), coverage }; }; const inspectAssetCatalogEntry = async (input: { @@ -224,7 +259,12 @@ const inspectAssetCatalogEntry = async (input: { "integrity", `Asset catalog changed before inspection: ${entry.path}`, ); - const output = await input.runAssetUtil(entry.adapterKey, signal); + let output: string; + try { + output = await input.runAssetUtil(entry.adapterKey, signal); + } catch (cause) { + throw assetUtilFailure(cause, signal); + } const after = await lstat(entry.adapterKey, { bigint: true }); if ( !after.isFile() || @@ -235,14 +275,22 @@ const inspectAssetCatalogEntry = async (input: { "integrity", `Asset catalog changed during inspection: ${entry.path}`, ); + const capturedOutput: AnalysisCapturedOutput = { + stdout: output, + stderr: "", + truncated: false, + stdout_bytes: Buffer.byteLength(output), + exit_code: 0, + signal: null, + }; let parsed: unknown; try { parsed = JSON.parse(output); } catch (cause: unknown) { - throw new ArtifactReaderFailure( - "format", + throw new AnalysisOutputError( + "inspect_asset_catalog", `assetutil returned invalid JSON for ${entry.path}`, - { cause }, + { cause, capturedOutput }, ); } try { @@ -255,11 +303,11 @@ const inspectAssetCatalogEntry = async (input: { metadataBytes: Buffer.byteLength(output, "utf8"), }; } catch (cause: unknown) { - throw new ArtifactReaderFailure( - "format", - `assetutil returned an unsupported record for ${entry.path}`, - { cause }, - ); + if (!(cause instanceof AnalysisOutputError)) throw cause; + throw new AnalysisOutputError("inspect_asset_catalog", cause.reason, { + cause, + capturedOutput, + }); } }; @@ -299,38 +347,76 @@ const runAssetUtil = async ( path: string, environment: Readonly, signal?: AbortSignal, -): Promise => { - try { - return ( - await execFileOutput(ASSETUTIL, ["--info", path], { - env: environment, - timeout: 60_000, - maxBuffer: MAX_CATALOG_BYTES, - stopSignal: "SIGTERM", - ...(signal === undefined ? {} : { signal }), - }) - ).stdout; - } catch (cause: unknown) { - if (signal?.aborted === true) - throw new ArtifactReaderFailure( - "cancelled", - "Asset catalog inspection cancelled", - { cause: cancellationCause(cause, signal) }, - ); - const code = - typeof cause === "object" && cause !== null && "code" in cause - ? cause.code - : undefined; - throw new ArtifactReaderFailure( - code === "ENOENT" - ? "unavailable" - : code === "ETIMEDOUT" || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" - ? "limit" - : "io", - `assetutil failed${code === undefined ? "" : ` (${String(code)})`}`, - { cause }, +): Promise => + ( + await execFileOutput(ASSETUTIL, ["--info", path], { + env: environment, + timeout: 60_000, + maxBuffer: MAX_CATALOG_BYTES, + stopSignal: "SIGTERM", + ...(signal === undefined ? {} : { signal }), + }) + ).stdout; + +/** Preserve the command owner's failure and captured output before artifact adaptation. */ +const assetUtilFailure = (cause: unknown, signal?: AbortSignal): unknown => { + if (cause instanceof AnalysisError) return cause; + if (signal?.aborted || cause instanceof ExecFileOutputCancelledError) + return new ArtifactReaderFailure( + "cancelled", + "Asset catalog inspection cancelled", + { cause: cancellationCause(cause, signal) }, ); - } + const output = execFileOutputFailure(cause); + const code = + cause instanceof Error && "code" in cause ? cause.code : undefined; + const capturedOutput: AnalysisCapturedOutput | undefined = + output === undefined + ? undefined + : { + stdout: output.stdout, + stderr: output.stderr, + truncated: output.outputTruncated, + ...(output.stdoutBytes === undefined + ? {} + : { stdout_bytes: output.stdoutBytes }), + ...(output.stderrBytes === undefined + ? {} + : { stderr_bytes: output.stderrBytes }), + exit_code: typeof output.code === "number" ? output.code : null, + signal: output.signal, + }; + const options = { cause, capturedOutput }; + if (code === "ETIMEDOUT") + return new AnalysisTimeoutError("inspect_asset_catalog", 60_000, options); + if (code === "ENOENT") + return new AnalysisCapabilityUnavailableError( + "artifact", + "inspect_asset_catalog", + "macOS assetutil is unavailable", + options, + ); + if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") + return new AnalysisResourceConstraintError( + "inspect_asset_catalog", + "transport", + "assetutil output exceeds the 64 MiB capture allowance", + { maximum_output_bytes: MAX_CATALOG_BYTES }, + { + ...options, + ...fixedAllowanceOptions( + "the fixed 64 MiB assetutil capture allowance", + "output", + ), + }, + ); + if (output !== undefined) + return new AnalysisOutputError( + "inspect_asset_catalog", + "assetutil did not produce usable catalog metadata", + options, + ); + return cause; }; const digestBounded = async ( diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index b70a26be73..6e683011d8 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -4,6 +4,7 @@ import { parseBinary } from "plist"; import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js"; import { interfaceBuilderConnectionFieldNames, + interfaceBuilderRuntimeClassName, requireInterfaceBuilderArchive, isInterfaceBuilderConnectionClass, isSourceLessAppKitAnnotationConnector, @@ -637,12 +638,13 @@ const projectNibArchive = ( const object = byId.get(objectId); if (object === undefined) return null; const storedClass = normalizeClassName(object.class_name); - const resolvedClass = - storedClass === "NSClassSwapper" - ? (referencedString(object.values.NSClassName) ?? storedClass) - : storedClass === "UIClassSwapper" - ? (referencedString(object.values.UIClassName) ?? storedClass) - : storedClass; + const resolvedClass = interfaceBuilderRuntimeClassName( + storedClass, + (field) => { + const value = object.values[field]; + return typeof value === "string" ? value : referencedString(value); + }, + ); runtimeClasses.set(objectId, resolvedClass); return resolvedClass; }; @@ -696,6 +698,13 @@ const projectNibArchive = ( label: name, objectID: String(object.id), nibValues, + ...(className === normalizeClassName(object.class_name) + ? {} + : { + interface_builder_archived_class_name: normalizeClassName( + object.class_name, + ), + }), ...Object.fromEntries(resourceFields), }, ]; diff --git a/src/domain/apple/appleAssetCatalog.ts b/src/domain/apple/appleAssetCatalog.ts index 5107376d2a..b4811eec3c 100644 --- a/src/domain/apple/appleAssetCatalog.ts +++ b/src/domain/apple/appleAssetCatalog.ts @@ -18,6 +18,7 @@ const RESOURCE_KEY_FIELDS = new Set([ "asset_name", "UIImageName", "NSImageName", + "NSResourceName", ]); /** Recognized direct scalar resource fields; nested dictionaries are not inferred. */ @@ -70,12 +71,26 @@ const resourceKeyMatchSchema = z.strictObject({ ), }); +const analysisCoverageSchema = z.strictObject({ + status: z.enum(["complete", "partial", "unknown"]), + reason: z.string().nullable(), +}); +export type AppleAssetAnalysisCoverage = z.infer; + /** Normalized result returned by asset catalog inspection. */ export const appleAssetCatalogResultSchema = bundleResourceBoundResultSchema( z.string().regex(/^[a-f0-9]{64}$/u), { catalogs: z.array(catalogSchema), inventory_coverage: artifactInventoryCoverageSchema, + catalog_coverage: analysisCoverageSchema.default({ + status: "unknown", + reason: "catalog_coverage_not_reported", + }), + resource_key_coverage: analysisCoverageSchema.default({ + status: "unknown", + reason: "resource_key_coverage_not_reported", + }), total_records: z.int().nonnegative(), offset: z.int().nonnegative(), limit: z.int().positive(), @@ -115,6 +130,8 @@ export const parseAppleAssetCatalogRecords = ( /** Project catalog records and apply stable global pagination. */ export const projectAppleAssetCatalogPage = (input: { readonly inventoryCoverage?: ArtifactInventoryCoverage; + readonly catalogCoverage?: AppleAssetAnalysisCoverage; + readonly resourceKeyCoverage?: AppleAssetAnalysisCoverage; readonly targetSha256: string | null; readonly resourceRoot?: import("./bundleResourceRoot.js").BundleResourceRoot; readonly catalogs: readonly { @@ -188,6 +205,14 @@ export const projectAppleAssetCatalogPage = (input: { scope: "container-entries", status: "unknown", }, + catalog_coverage: input.catalogCoverage ?? { + status: "unknown", + reason: "catalog_coverage_not_reported", + }, + resource_key_coverage: input.resourceKeyCoverage ?? { + status: "unknown", + reason: "resource_key_coverage_not_reported", + }, target_sha256: input.targetSha256, ...(input.resourceRoot === undefined ? {} diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts index 2cd4502686..898626285b 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts @@ -352,3 +352,36 @@ it.each([ }).objects[0]?.kind, ).toBe(kind); }); + +it.each(["NSCustomObject", "NSClassSwapper", "UIClassSwapper"])( + "uses the guarded authored class on %s and retains the archived class", + (archivedClass) => { + const source = archive({ + [archivedClass === "UIClassSwapper" ? "UIClassName" : "NSClassName"]: + "FixtureWindowController", + }); + source.$objects[4] = { + $classname: archivedClass, + $classes: [archivedClass, "NSObject"], + }; + expect(parseInterfaceBuilderRecords(source).objects[0]).toMatchObject({ + class_name: "FixtureWindowController", + kind: "view_controller", + attributes: { interface_builder_archived_class_name: archivedClass }, + }); + }, +); +it("retains action connectors as serialized helpers without inventing UI controls", () => { + const source = archive(); + source.$objects[5] = { + $classname: "NSNibControlConnector", + $classes: ["NSNibControlConnector", "NSNibConnector", "NSObject"], + }; + const parsed = parseInterfaceBuilderRecords(source); + expect(parsed.objects.find(({ id }) => id === "3")?.kind).toBe("other"); + expect(parsed.connections[0]).toMatchObject({ + kind: "action", + source_id: "2", + destination_id: "1", + }); +}); diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index d736a4ff07..84ad69fcf0 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -65,6 +65,23 @@ export const interfaceBuilderConnectionFieldNames = (className: string) => ? { source: "UISource", destination: "UIDestination", label: "UILabel" } : { source: "NSSource", destination: "NSDestination", label: "NSLabel" }; +/** Only Interface Builder's known placeholder records establish an authored class override. */ +export const interfaceBuilderRuntimeClassName = ( + archivedClass: string | null, + authoredName: (field: string) => string | null, +): string | null => { + switch (archivedClass) { + case "NSClassSwapper": + case "NSCustomObject": + case "NSCustomResource": + return authoredName("NSClassName") ?? archivedClass; + case "UIClassSwapper": + return authoredName("UIClassName") ?? archivedClass; + default: + return archivedClass; + } +}; + /** A decoded plist is not an Interface Builder document merely because it is named .nib. */ export const requireInterfaceBuilderArchive = (value: unknown): void => { const root = record(value); @@ -423,13 +440,20 @@ const parseKeyedArchive = ( for (const [index, raw] of objectTable.entries()) { if (index === 0) continue; const resolved = record(resolve(raw)); - const className = classNameOf(raw); + const archivedClassName = classNameOf(raw); // An archived object names its class with its own `$class` reference; // when that reference cannot be resolved the object is still observed, // with an unknown class. Scalars and class descriptors are not objects. - if (className === null && !Object.hasOwn(record(raw), "$class")) continue; + if (archivedClassName === null && !Object.hasOwn(record(raw), "$class")) + continue; objectCount += 1; const fields = { ...resolved }; + const className = interfaceBuilderRuntimeClassName( + archivedClassName, + (field) => firstString(fields[field]), + ); + if (className !== archivedClassName) + fields.interface_builder_archived_class_name = archivedClassName; // Keep the unresolvable class reference itself, e.g. a dangling UID. if (className === null) fields.$class = record(raw).$class; // Authored objectID, object-id, id and archiveUID fields are data. Only @@ -566,6 +590,11 @@ const classifyObject = ( className: string | null, classChain: readonly string[] = [], ): InterfaceBuilderObject["kind"] => { + if ( + isInterfaceBuilderConnectionClass(className ?? "") || + classChain.includes("NSNibConnector") + ) + return "other"; for (const [kind, bases] of archivedKindBases) if (bases.some((base) => classChain.includes(base))) return kind; const value = className?.toLowerCase() ?? "";