fix(apple): retain reader outcomes and cleanup ownership

This commit is contained in:
morluto
2026-10-24 04:04:49 +08:00
parent 779242aef0
commit 59a00d77fb
6 changed files with 442 additions and 106 deletions
+5 -1
View File
@@ -475,7 +475,11 @@ complete observation.
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
their typed failure reason. Catalog-reader cleanup retains the primary
integrity, utility or cancellation failure and its cleanup resources; the
provider retains catalog, Interface Builder and keyed-archive readers until
cleanup is confirmed. A catalog failure also retains cleanup failures from
its parallel Interface Builder join. 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
+6
View File
@@ -127,6 +127,7 @@ class BundleResourceArtifactClient implements AnalysisClient {
bundlePath: this.root.canonicalPath,
targetSha256: null,
resourceRoot,
resources: this.#resourceScope,
limits: interfaceBuilderLimitsSchema.parse(parameters),
...(options?.signal === undefined
? {}
@@ -139,6 +140,7 @@ class BundleResourceArtifactClient implements AnalysisClient {
resourceRoot,
parameters,
platform: this.platform,
resources: this.#resourceScope,
...(options?.signal === undefined
? {}
: { signal: options.signal }),
@@ -149,6 +151,7 @@ class BundleResourceArtifactClient implements AnalysisClient {
targetSha256: null,
resourceRoot,
environment: this.environment,
resources: this.#resourceScope,
page: parameters,
...(options?.signal === undefined
? {}
@@ -227,6 +230,7 @@ class ArtifactClient implements AnalysisClient {
bundlePath: this.target.sourcePath ?? this.target.path,
targetSha256: this.target.sha256,
limits,
resources: this.#resourceScope,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
return ok(
@@ -267,6 +271,7 @@ class ArtifactClient implements AnalysisClient {
? { ...parameters, path: basename(this.target.path) }
: parameters,
platform: this.platform,
resources: this.#resourceScope,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
if (standalone && result.archive_sha256 !== this.target.sha256)
@@ -375,6 +380,7 @@ class ArtifactClient implements AnalysisClient {
const result = await analyzeAppleAssetCatalogs({
environment: this.environment,
bundlePath,
resources: this.#resourceScope,
targetSha256: this.target.sha256,
page: parameters,
...(options?.signal === undefined ? {} : { signal: options.signal }),
@@ -12,6 +12,9 @@ import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js
import { createHash } from "node:crypto";
import { lstat } from "node:fs/promises";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { translateArtifactFailure } from "../ArtifactOperationFailure.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ArtifactTraversalObservation } from "../ArtifactTraversalObservation.js";
import {
ArtifactReaderFailure,
@@ -48,6 +51,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly page?: unknown;
readonly signal?: AbortSignal;
readonly resources?: ArtifactResourceScope;
readonly runAssetUtil?: (
path: string,
signal?: AbortSignal,
@@ -60,6 +64,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
"Apple asset catalog inspection requires macOS assetutil",
);
const page = appleAssetCatalogInputSchema.parse(input.page ?? {});
const resources = input.resources ?? new ArtifactResourceScope();
// The Interface Builder graph only supplies resource keys for the join. Its
// bundle walk is independent of the catalog walk, so both run together; a
// catalog failure cancels it and is reported as the sequential order would.
@@ -69,6 +74,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
)({
bundlePath: input.bundlePath,
targetSha256: input.targetSha256,
resources,
...(input.resourceRoot === undefined
? {}
: { resourceRoot: input.resourceRoot }),
@@ -90,10 +96,30 @@ export const analyzeAppleAssetCatalogs = async (input: {
uninspected,
traversal,
coverage: catalogCoverage,
} = await collectAssetCatalogs(input));
} = await resources.run(() =>
collectAssetCatalogs({ ...input, resources }),
));
} catch (cause: unknown) {
interfaceBuilderCancel.abort();
await interfaceBuilderAnalysis;
const joined = await interfaceBuilderAnalysis;
if (
!joined.ok &&
((joined.error instanceof ArtifactReaderFailure &&
joined.error.cleanup !== undefined) ||
(joined.error instanceof AnalysisError &&
joined.error.cleanupIncomplete))
) {
const primary = translateArtifactFailure("inspect_asset_catalog", cause);
const cleanup = translateArtifactFailure(
"inspect_asset_catalog",
joined.error,
);
throw analysisErrorWithCleanupFailure(
primary,
cleanup,
"inspect_asset_catalog",
).bindCleanupDependencies([primary, cleanup]);
}
throw cause;
}
const analyzed = await interfaceBuilderAnalysis;
@@ -152,6 +178,7 @@ const collectAssetCatalogs = async (input: {
readonly bundlePath: string;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly signal?: AbortSignal;
readonly resources: ArtifactResourceScope;
readonly runAssetUtil?: (
path: string,
signal?: AbortSignal,
@@ -168,6 +195,9 @@ const collectAssetCatalogs = async (input: {
let symlinks = 0;
let firstSymlink: string | undefined;
let totalMetadataBytes = 0;
let outcome: { kind: "completed" } | { kind: "failed"; cause: unknown } = {
kind: "completed",
};
try {
for await (const entry of reader.entries(input.signal, {
onCoverage: traversal.onCoverage,
@@ -208,9 +238,29 @@ const collectAssetCatalogs = async (input: {
);
catalogs.push(inspected.catalog);
}
} finally {
await reader.close();
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
const released = await input.resources.release({
kind: "reader",
reader,
resource: input.bundlePath,
});
if (released.kind === "failed") {
if (outcome.kind === "failed" && outcome.cause instanceof AnalysisError) {
const cleanup = translateArtifactFailure(
"inspect_asset_catalog",
input.resources.cleanupFailure({ kind: "completed" }, [released]),
);
throw analysisErrorWithCleanupFailure(
outcome.cause,
cleanup,
"inspect_asset_catalog",
).bindCleanupDependencies([outcome.cause, cleanup]);
}
throw input.resources.cleanupFailure(outcome, [released]);
}
if (outcome.kind === "failed") throw outcome.cause;
if (symlinks > 0)
traversal.onCoverage({
// Source entries are represented even when their link targets were not
@@ -11,6 +11,7 @@ import {
isUIKitRuntimeConnectionClass,
} from "../../domain/apple/interfaceBuilderKeyedArchive.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { ArtifactTraversalObservation } from "../ArtifactTraversalObservation.js";
import {
projectPlistValue,
@@ -77,14 +78,28 @@ interface OmittedArchive {
readonly limitations: readonly string[];
}
/** Decode compiled Interface Builder archives from a local app bundle. */
export const analyzeInterfaceBuilderBundle = async (input: {
interface InterfaceBuilderBundleInput {
readonly bundlePath: string;
readonly targetSha256: string | null;
readonly resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
readonly limits?: unknown;
readonly signal?: AbortSignal;
}) => {
readonly resources?: ArtifactResourceScope;
}
/** Own decoding and directory finalization under the caller's resource scope. */
export const analyzeInterfaceBuilderBundle = (
input: InterfaceBuilderBundleInput,
) => {
const resources = input.resources ?? new ArtifactResourceScope();
return resources.run(() => decodeInterfaceBuilderBundle(input, resources));
};
/** Decode compiled archives while keeping their reader's completion distinct from cleanup. */
const decodeInterfaceBuilderBundle = async (
input: InterfaceBuilderBundleInput,
resources: ArtifactResourceScope,
) => {
const limits = interfaceBuilderLimitsSchema.parse(input.limits ?? {});
const reader = new DirectoryArtifactReader(input.bundlePath);
const documents: InterfaceBuilderDocumentInput[] = [];
@@ -107,6 +122,9 @@ export const analyzeInterfaceBuilderBundle = async (input: {
const decodeBudget = new InterfaceBuilderDecodeBudget(
MAX_AGGREGATE_DECODE_BYTES,
);
let outcome: { kind: "completed" } | { kind: "failed"; cause: unknown } = {
kind: "completed",
};
const recordOmittedArchive = (
path: string,
reason: string,
@@ -277,9 +295,17 @@ export const analyzeInterfaceBuilderBundle = async (input: {
);
}
}
} finally {
await reader.close();
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
const released = await resources.release({
kind: "reader",
reader,
resource: input.bundlePath,
});
if (released.kind === "failed")
throw resources.cleanupFailure(outcome, [released]);
if (outcome.kind === "failed") throw outcome.cause;
const result = buildInterfaceBuilderAnalysis({
targetSha256: input.targetSha256,
...(input.resourceRoot === undefined
+139 -96
View File
@@ -38,6 +38,10 @@ import {
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import type { ArtifactEntry } from "../ArtifactReader.js";
import { bindArtifactFileCleanup } from "../ArtifactFileOperation.js";
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { translateArtifactFailure } from "../ArtifactOperationFailure.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
import {
@@ -173,8 +177,7 @@ const keyedArchiveKindRemediation = (
: "Select an NSKeyedArchiver archive; inspect_keyed_archive decodes only those. inspect_plist, which reads ordinary property lists, requires a macOS host.";
};
/** Read exactly one regular, contained bundle entry without following symlinks. */
export const inspectBundleKeyedArchive = async (input: {
interface BundleKeyedArchiveInput {
bundlePath: string;
targetSha256: string | null;
resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
@@ -182,7 +185,57 @@ export const inspectBundleKeyedArchive = async (input: {
signal?: AbortSignal;
/** Host whose available workflows the remediation names. */
platform?: NodeJS.Platform;
}) => {
resources?: ArtifactResourceScope;
}
/** Keep the selected archive and its reader under the caller's cleanup owner. */
export const inspectBundleKeyedArchive = (input: BundleKeyedArchiveInput) => {
const resources = input.resources ?? new ArtifactResourceScope();
return resources.run(async () => {
const reader = new DirectoryArtifactReader(input.bundlePath);
let outcome:
| {
kind: "completed";
value: Awaited<ReturnType<typeof readSelectedKeyedArchive>>;
}
| { kind: "failed"; cause: unknown };
try {
outcome = {
kind: "completed",
value: await readSelectedKeyedArchive(input, reader),
};
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
const released = await resources.release({
kind: "reader",
reader,
resource: input.bundlePath,
});
if (released.kind === "failed") {
if (outcome.kind === "failed" && outcome.cause instanceof AnalysisError) {
const cleanup = translateArtifactFailure(
"inspect_keyed_archive",
resources.cleanupFailure({ kind: "completed" }, [released]),
);
throw analysisErrorWithCleanupFailure(
outcome.cause,
cleanup,
"inspect_keyed_archive",
).bindCleanupDependencies([outcome.cause, cleanup]);
}
throw resources.cleanupFailure(outcome, [released]);
}
if (outcome.kind === "failed") throw outcome.cause;
return outcome.value;
});
};
/** Read exactly one regular, contained bundle entry without following symlinks. */
const readSelectedKeyedArchive = async (
input: BundleKeyedArchiveInput,
reader: DirectoryArtifactReader,
) => {
const selected = keyedArchiveInputSchema.parse(input.parameters);
if (
selected.path.startsWith("/") ||
@@ -196,105 +249,95 @@ export const inspectBundleKeyedArchive = async (input: {
? "Select a keyed archive path relative to the active app bundle."
: `Archive path must be a canonical relative bundle path without empty, '.', or '..' segments: ${selected.path}`,
);
const reader = new DirectoryArtifactReader(input.bundlePath);
try {
// Match inventory spelling while retaining the raw path for I/O and evidence.
const wanted = selected.path.normalize("NFC");
let entry: ArtifactEntry | undefined;
for await (const candidate of reader.entries(input.signal, (directory) =>
wanted.startsWith(`${directory.normalize("NFC")}/`),
)) {
if (candidate.path.normalize("NFC") !== wanted) continue;
if (candidate.kind !== "file")
throw archivePathError(
"invalid_value",
`Archive path selects a ${candidate.kind}, not a regular file: ${selected.path}`,
);
if ((candidate.declaredSize ?? 0) > MAX_BYTES)
throw new ArtifactReaderFailure(
"limit",
"Keyed archive exceeds 64 MiB",
);
if (entry !== undefined)
throw archivePathError(
"invalid_value",
`Archive path matches multiple Unicode-equivalent bundle entries; selection is ambiguous: ${selected.path}`,
);
entry = candidate;
}
if (entry === undefined)
// Match inventory spelling while retaining the raw path for I/O and evidence.
const wanted = selected.path.normalize("NFC");
let entry: ArtifactEntry | undefined;
for await (const candidate of reader.entries(input.signal, (directory) =>
wanted.startsWith(`${directory.normalize("NFC")}/`),
)) {
if (candidate.path.normalize("NFC") !== wanted) continue;
if (candidate.kind !== "file")
throw archivePathError(
"invalid_value",
`No regular file exists at ${selected.path} in the active app bundle.`,
`Archive path selects a ${candidate.kind}, not a regular file: ${selected.path}`,
);
const stream = await reader
.open(entry, input.signal)
.catch((cause: unknown) => {
throw selectedArchiveReadFailure(input.bundlePath, entry, cause);
});
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of stream) {
if (input.signal?.aborted) {
stream.destroy();
throw new ArtifactReaderFailure(
"cancelled",
"Archive inspection cancelled",
);
}
const bytes = z.instanceof(Buffer).parse(chunk);
size += bytes.length;
if (size > MAX_BYTES) {
stream.destroy();
throw new ArtifactReaderFailure(
"limit",
"Keyed archive exceeds 64 MiB",
);
}
chunks.push(bytes);
}
const bytes = Buffer.concat(chunks, size);
try {
return keyedArchiveResultSchema.parse({
target_sha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resource_root: input.resourceRoot }),
archive_path: entry.path,
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
...decodeKeyedArchiveBytes(bytes, selected),
});
} catch (cause) {
if (cause instanceof AnalysisInputError) throw cause;
if (cause instanceof UnsupportedBinaryPlistUidError)
throw new AnalysisUnsupportedTargetError(
"inspect_keyed_archive",
join(input.bundlePath, entry.path),
cause.message,
{ cause },
);
// An intact file of another kind is not a damaged archive.
if (cause instanceof KeyedArchiveKindError)
throw new AnalysisUnsupportedTargetError(
"inspect_keyed_archive",
join(input.bundlePath, entry.path),
cause.message,
{
cause,
remediationAction: keyedArchiveKindRemediation(
cause.kind,
input.platform ?? process.platform,
),
},
);
if ((candidate.declaredSize ?? 0) > MAX_BYTES)
throw new ArtifactReaderFailure("limit", "Keyed archive exceeds 64 MiB");
if (entry !== undefined)
throw archivePathError(
"invalid_value",
`Archive path matches multiple Unicode-equivalent bundle entries; selection is ambiguous: ${selected.path}`,
);
entry = candidate;
}
if (entry === undefined)
throw archivePathError(
"invalid_value",
`No regular file exists at ${selected.path} in the active app bundle.`,
);
const stream = await reader
.open(entry, input.signal)
.catch((cause: unknown) => {
throw selectedArchiveReadFailure(input.bundlePath, entry, cause);
});
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of stream) {
if (input.signal?.aborted) {
stream.destroy();
throw new ArtifactReaderFailure(
cause instanceof RangeError ? "limit" : "format",
`Cannot decode selected Foundation keyed archive: ${cause instanceof Error ? cause.message : String(cause)}`,
"cancelled",
"Archive inspection cancelled",
{ cause: input.signal.reason },
);
}
const bytes = z.instanceof(Buffer).parse(chunk);
size += bytes.length;
if (size > MAX_BYTES) {
stream.destroy();
throw new ArtifactReaderFailure("limit", "Keyed archive exceeds 64 MiB");
}
chunks.push(bytes);
}
const bytes = Buffer.concat(chunks, size);
try {
return keyedArchiveResultSchema.parse({
target_sha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resource_root: input.resourceRoot }),
archive_path: entry.path,
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
...decodeKeyedArchiveBytes(bytes, selected),
});
} catch (cause) {
if (cause instanceof AnalysisInputError) throw cause;
if (cause instanceof UnsupportedBinaryPlistUidError)
throw new AnalysisUnsupportedTargetError(
"inspect_keyed_archive",
join(input.bundlePath, entry.path),
cause.message,
{ cause },
);
}
} finally {
await reader.close();
// An intact file of another kind is not a damaged archive.
if (cause instanceof KeyedArchiveKindError)
throw new AnalysisUnsupportedTargetError(
"inspect_keyed_archive",
join(input.bundlePath, entry.path),
cause.message,
{
cause,
remediationAction: keyedArchiveKindRemediation(
cause.kind,
input.platform ?? process.platform,
),
},
);
throw new ArtifactReaderFailure(
cause instanceof RangeError ? "limit" : "format",
`Cannot decode selected Foundation keyed archive: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
);
}
};
@@ -0,0 +1,207 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import * as processOutput from "../../../src/process/ExecFileOutput.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform !== "darwin").each([
["success", false, false],
["integrity", false, false],
["timeout", false, false],
["success", true, false],
["integrity", true, false],
["timeout", true, false],
["integrity", true, true],
] as const)(
"preserves asset catalog %s with reader cleanup failure %s and join cleanup %s",
async (primary, failCleanup, failJoinCleanup) => {
const bundle = await createTestTempDirectory("rea-asset-finalization-");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
const catalog = join(resources, "Assets.car");
await writeFile(catalog, "inert catalog bytes");
let catalogReader: DirectoryArtifactReader | undefined;
let denyCleanup = failCleanup;
let closeAttempts = 0;
const failedReaders = new Set<DirectoryArtifactReader>();
const originalOpen = DirectoryArtifactReader.prototype.open;
const open = vi
.spyOn(DirectoryArtifactReader.prototype, "open")
.mockImplementation(function (
this: DirectoryArtifactReader,
entry,
signal,
) {
if (entry.path.endsWith("/Assets.car") || entry.path === "Assets.car")
catalogReader = this;
return originalOpen.call(this, entry, signal);
});
const originalClose = DirectoryArtifactReader.prototype.close;
const close = vi
.spyOn(DirectoryArtifactReader.prototype, "close")
.mockImplementation(async function (this: DirectoryArtifactReader) {
if (this === catalogReader || failJoinCleanup) {
closeAttempts++;
if (denyCleanup) {
failedReaders.add(this);
throw new ArtifactReaderFailure(
"io",
"controlled catalog reader close",
{
cleanup: {
reason: "controlled catalog reader close",
resources: [resources],
},
},
);
}
}
await originalClose.call(this);
});
const utility = vi
.spyOn(processOutput, "execFileOutput")
.mockImplementationOnce(async () => {
if (primary === "integrity")
await writeFile(
catalog,
"changed catalog bytes with a different length",
);
if (primary === "timeout")
throw Object.assign(new Error("controlled assetutil deadline"), {
code: "ETIMEDOUT",
stdout: "",
stderr: "assetutil timeout detail",
signal: "SIGTERM",
});
return { stdout: "[]", stderr: "", stdoutBytes: 2, stderrBytes: 0 };
});
const client = new ArtifactProvider(
{},
"darwin",
).createBundleResourceClient({
callerPath: bundle,
canonicalPath: bundle,
infoPlistPath: join(bundle, "Contents", "Info.plist"),
layout: "contents",
executableSha256: null,
});
try {
const result = await client.execute("inspect_asset_catalog", {});
if (primary === "success" && !failCleanup) {
expect(result.ok).toBe(true);
return;
}
if (result.ok) throw new Error("Expected catalog failure");
const projected = projectAnalysisError(result.error);
expect(result.error.cleanupIncomplete).toBe(failCleanup);
if (primary === "integrity")
expect(projected).toMatchObject({
category: "integrity_mismatch",
details: {
reason: "integrity",
detail: expect.stringContaining("Asset catalog changed"),
},
});
if (primary === "timeout")
expect(projected).toMatchObject({
category: "timeout",
details: { operation: "inspect_asset_catalog", timeout_ms: 60000 },
});
if (failCleanup) {
expect(projected.code).toBe("cleanup_incomplete");
expect(result.error.cleanupResources).toContain(resources);
expect((await client.close()).ok).toBe(false);
expect(result.error.cleanupIncomplete).toBe(true);
denyCleanup = false;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
expect(closeAttempts).toBeGreaterThanOrEqual(3);
if (failJoinCleanup) expect(failedReaders.size).toBe(2);
}
} finally {
denyCleanup = false;
await client.close();
utility.mockRestore();
close.mockRestore();
open.mockRestore();
}
},
);
it.each(["decode_interface_builder", "inspect_keyed_archive"] as const)(
"retains the %s primary failure and its exact reader until cleanup succeeds",
async (operation) => {
const bundle = await createTestTempDirectory(
"rea-apple-reader-finalization-",
);
let denyCleanup = true;
const originalClose = DirectoryArtifactReader.prototype.close;
const close = vi
.spyOn(DirectoryArtifactReader.prototype, "close")
.mockImplementation(async function (this: DirectoryArtifactReader) {
if (denyCleanup)
throw new ArtifactReaderFailure(
"io",
"controlled bundle reader close",
{
cleanup: {
reason: "controlled bundle reader close",
resources: [bundle],
},
},
);
await originalClose.call(this);
});
const entries =
operation === "decode_interface_builder"
? vi
.spyOn(DirectoryArtifactReader.prototype, "entries")
.mockImplementationOnce(async function* () {
throw new ArtifactReaderFailure(
"integrity",
"controlled Interface Builder source change",
);
})
: undefined;
const client = new ArtifactProvider(
{},
process.platform,
).createBundleResourceClient({
callerPath: bundle,
canonicalPath: bundle,
infoPlistPath: join(bundle, "Info.plist"),
layout: "root",
executableSha256: null,
});
try {
const result = await client.execute(
operation,
operation === "inspect_keyed_archive" ? { path: "Missing.plist" } : {},
);
if (result.ok)
throw new Error("Expected source/selection and cleanup failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
category:
operation === "decode_interface_builder"
? "integrity_mismatch"
: "invalid_input",
});
expect(result.error.cleanupResources).toContain(bundle);
expect((await client.close()).ok).toBe(false);
expect(result.error.cleanupIncomplete).toBe(true);
denyCleanup = false;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
denyCleanup = false;
await client.close();
entries?.mockRestore();
close.mockRestore();
}
},
);