fix(apple): preserve asset failures and authored resource coverage

This commit is contained in:
morluto committed 2026-10-24 03:11:07 +08:00
1 parent b488a10d96
commit dbbaf2abb0
7 files changed
+331 -51

No files matched your search

+7 -1
View File
@@ -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 <plist>` 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
@@ -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");
},
);
+128 -42
View File
@@ -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<ReturnType<typeof collectAssetCatalogs>>["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<NodeJS.ProcessEnv>,
signal?: AbortSignal,
): Promise<string> => {
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<string> =>
(
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 (
@@ -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),
},
];
+25
View File
@@ -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<typeof analysisCoverageSchema>;
/** 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
? {}
@@ -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",
});
});
@@ -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() ?? "";