diff --git a/docs/native-investigation.md b/docs/native-investigation.md index f274ee352a..ba59d727e2 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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 ` reads XML or binary Foundation keyed archives without instantiating classes. For an active diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 15e8218808..8c7f43a80d 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -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 }), diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index 3a8a053f9d..d132a1e287 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -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; 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; 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 diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 3fa6dba956..a9be0dafd9 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -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 diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 850a5edae4..72472fd465 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -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>; + } + | { 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 }, + ); } }; diff --git a/tests/boundary/filesystem/assetCatalogFinalization.test.ts b/tests/boundary/filesystem/assetCatalogFinalization.test.ts new file mode 100644 index 0000000000..4598db6e1b --- /dev/null +++ b/tests/boundary/filesystem/assetCatalogFinalization.test.ts @@ -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(); + 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(); + } + }, +);