From 4610843efd3dbfa63a15677fa8c0376b1efc6fff Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:41:17 +0800 Subject: [PATCH] fix(filesystem): preserve failure provenance across cleanup owners Carry descriptor outcomes, cancellation, retention and physical origin as named facts so wrappers cannot drop ownership context. Replace raw primary failure unwrapping with provenance facts across artifact consumers, and use Result for Kuna span reads including undefined rejections. Exercise actual descriptors, rollback, cancellation and cleanup retry; update brittle snapshot and completion fixtures to use their producing boundaries. --- src/android/AndroidTargetSnapshot.ts | 25 +-- src/apkSignature/apkSignatureFailures.ts | 19 +- src/apktool/ApktoolProvider.ts | 9 +- .../ApktoolWorkspace.lifecycle.test.ts | 24 ++- src/application/RegularFileRead.ts | 10 +- src/application/SetupClientConfiguration.ts | 12 +- src/artifacts/ArtifactFileOperation.ts | 72 +++++--- src/erlang/BeamProvider.ts | 32 +++- src/filesystem/OwnedRegularFile.ts | 71 ++++--- src/ghidra/GhidraClient.ts | 5 +- src/ghidra/GhidraFileArtifacts.ts | 7 +- src/ghidra/GhidraInstallationProperties.ts | 4 +- src/ghidra/GhidraProviderClient.ts | 16 +- src/ghidra/GhidraPspExtension.ts | 4 +- src/ghidra/GhidraTransport.ts | 8 +- src/go/GoBinaryProvider.ts | 39 ++-- src/gpu/GpuCodeObjectsProvider.ts | 10 +- src/il2cpp/Il2cppMetadataProvider.ts | 28 ++- src/kuna/KunaProjectReader.ts | 45 ++--- src/model/ModelArtifactProvider.ts | 10 +- src/native/AppleDispatchMetadata.ts | 81 +++++--- src/native/NativePlistSource.ts | 112 ++++++----- src/native/elf/ElfNotesProvider.ts | 29 ++- src/native/exports/NativeExportsProvider.ts | 28 ++- src/native/pe/PeCertificatesProvider.ts | 29 ++- src/native/pe/PeResourcesProvider.ts | 29 ++- src/python/PythonPycProvider.ts | 31 +++- src/rust/RustBinaryProvider.ts | 27 ++- .../jsonFileResourceOwnership.test.ts | 8 +- .../filesystem/referenceSourceImport.test.ts | 12 +- .../regularFileCancellation.test.ts | 46 ++--- .../process/androidSnapshotClose.test.ts | 40 ++-- tests/boundary/process/jadxStartup.test.ts | 10 +- .../process/verifierFileResources.test.ts | 2 +- .../providers/ghidra/ghidraClient.test.ts | 7 +- .../ghidra/ghidraFileFailure.test.ts | 174 ++++++++++++++++++ .../kuna/kunaSpanCloseOwnership.test.ts | 109 ++++++++++- .../kuna/kunaVersionProbeOwnership.test.ts | 12 +- .../managed/managedHashCancellation.test.ts | 14 +- .../setupConfigurationRollback.test.ts | 169 ++++++++++++----- .../stableArtifactConsumers.test.ts | 165 ++++++++++++++++- 41 files changed, 1172 insertions(+), 412 deletions(-) create mode 100644 tests/boundary/providers/ghidra/ghidraFileFailure.test.ts diff --git a/src/android/AndroidTargetSnapshot.ts b/src/android/AndroidTargetSnapshot.ts index 42e7785e09..44e7e3547b 100644 --- a/src/android/AndroidTargetSnapshot.ts +++ b/src/android/AndroidTargetSnapshot.ts @@ -291,13 +291,13 @@ const openSnapshotSource = async ( return await openRegularFile(path, { symlinks: "follow", signal }); } catch (cause: unknown) { if (!(cause instanceof RegularFileAdmissionFailure)) throw cause; - throw new RegularFileCleanupFailure( + throw new RegularFileCleanupFailure({ path, - cause.owner, - { kind: "failed", cause: cause.cause }, - cause.cleanupCause, - retainAdmissionOwner(retention.retainFailedClose, cause.owner), - ); + owner: cause.owner, + outcome: { kind: "failed", cause: cause.cause }, + cleanupCause: cause.cleanupCause, + retention: retainAdmissionOwner(retention.retainFailedClose, cause.owner), + }); } }; @@ -328,15 +328,10 @@ const projectSnapshotFailure = ( return cause; const projected = projectSnapshotFailure(cause.outcome.cause, projectChanged); if (projected === cause.outcome.cause) return cause; - return new RegularFileCleanupFailure( - cause.path, - cause.owner, - { kind: "failed", cause: projected }, - cause.cleanupCause, - cause.retention, - cause.completionCancellation, - cause.operationFailure, - ); + return new RegularFileCleanupFailure({ + ...cause, + outcome: { kind: "failed", cause: projected }, + }); }; const verifySourceState = async ( diff --git a/src/apkSignature/apkSignatureFailures.ts b/src/apkSignature/apkSignatureFailures.ts index 0466b5b9c7..b625311fcc 100644 --- a/src/apkSignature/apkSignatureFailures.ts +++ b/src/apkSignature/apkSignatureFailures.ts @@ -1,6 +1,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, artifactFileCancellationCause, bindArtifactFileCleanup, } from "../artifacts/ArtifactFileOperation.js"; @@ -126,7 +127,7 @@ export const apkSignatureFailure = ( cause: unknown, path: string, ): AnalysisError => { - const primary = primaryFailure(artifactFilePrimaryFailure(cause), path); + const primary = primaryFailure(artifactFilePrimaryFacts(cause), path); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( @@ -141,9 +142,17 @@ export const apkSignatureFailure = ( }; /** Distinguish cancellation, access, integrity and limits for the primary failure. */ -const primaryFailure = (cause: unknown, path: string): AnalysisError => { - if (cause instanceof AnalysisError) return cause; - if (cause instanceof ArtifactReaderFailure) +const primaryFailure = ( + facts: ArtifactFilePrimaryFacts, + path: string, +): AnalysisError => { + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) return artifactReaderFailure(cause, path); if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 57d30d977b..40346ef531 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -8,7 +8,7 @@ import { import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { ArtifactFileCleanupFailure, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, artifactFileCancellationCause, } from "../artifacts/ArtifactFileOperation.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; @@ -695,11 +695,12 @@ export class ApktoolProvider { ), ); } catch (cause) { - const physical = artifactFilePrimaryFailure(cause); + const { cause: physical, origin } = artifactFilePrimaryFacts(cause); const primary = - physical instanceof AnalysisError + origin === "owned-or-classified" && physical instanceof AnalysisError ? physical - : physical instanceof ArtifactReaderFailure && + : origin === "owned-or-classified" && + physical instanceof ArtifactReaderFailure && physical.reason === "cancelled" ? new AnalysisCancelledError(operation, { cause: artifactFileCancellationCause(physical), diff --git a/src/apktool/ApktoolWorkspace.lifecycle.test.ts b/src/apktool/ApktoolWorkspace.lifecycle.test.ts index 95b7c22a56..b2c6dd50f8 100644 --- a/src/apktool/ApktoolWorkspace.lifecycle.test.ts +++ b/src/apktool/ApktoolWorkspace.lifecycle.test.ts @@ -5,7 +5,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { afterEach, expect, it, vi } from "vitest"; import { - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, ArtifactFileCleanupFailure, ArtifactFileCancelled, } from "../artifacts/ArtifactFileOperation.js"; @@ -212,9 +212,12 @@ it.each([false, true])( .readText(path, 100, controller.signal) .catch((cause: unknown) => cause); expect(controller.signal.aborted).toBe(true); - expect(artifactFilePrimaryFailure(failure)).toMatchObject({ + expect(artifactFilePrimaryFacts(failure).cause).toMatchObject({ _tag: "AnalysisProtocolError", }); + expect(artifactFilePrimaryFacts(failure).origin).toBe( + "owned-or-classified", + ); if (denied) await expect(workspace.close()).rejects.toBeInstanceOf( ArtifactReaderFailure, @@ -408,15 +411,25 @@ it("classifies source state changes during snapshot copying as artifact changes" const workspace = new ApktoolWorkspace(root, commands); const input = join(root.path, "source.apk"); await fs.writeFile(input, "authored-apk"); + let sourceReads = 0; vi.mocked(fs.open).mockImplementation(async (path, flags, mode) => { const handle = await actual.open(path, flags, mode); if (String(path) === input) { const stat = handle.stat.bind(handle); - let observations = 0; + const read = handle.read.bind(handle); + handle.read = (async ( + buffer: Buffer, + offset: number, + length: number, + position: number, + ) => { + const result = await read(buffer, offset, length, position); + if (result.bytesRead > 0) sourceReads += 1; + return result; + }) as typeof handle.read; handle.stat = (async (...args: Parameters) => { const value = await stat(...args); - observations += 1; - if (observations > 1) { + if (sourceReads > 0) { if ("mtimeNs" in value) value.mtimeNs += 1n; else value.mtimeMs += 1; } @@ -433,6 +446,7 @@ it("classifies source state changes during snapshot copying as artifact changes" operation: "decode_android_resources", path: input, }); + expect(sourceReads).toBeGreaterThan(0); } finally { await workspace.close(); await commands.close(); diff --git a/src/application/RegularFileRead.ts b/src/application/RegularFileRead.ts index d903712c27..25aa256afd 100644 --- a/src/application/RegularFileRead.ts +++ b/src/application/RegularFileRead.ts @@ -40,15 +40,7 @@ export class RetainedRegularFileCleanupFailure< >; readonly cleanupLease: AnalysisCleanupLease; constructor(failure: RegularFileCleanupFailure) { - super( - failure.path, - failure.owner, - failure.outcome, - failure.cleanupCause, - failure.retention, - failure.completionCancellation, - failure.operationFailure, - ); + super(failure); this.cleanupLease = failure instanceof RetainedRegularFileCleanupFailure ? failure.cleanupLease diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 2cb1204939..2409f6fd9e 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -74,17 +74,13 @@ export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanu { readonly status: "failed" } >, ) { - super( - failure.path, - failure.owner, - failure.outcome, - new AggregateError( + super({ + ...failure, + cleanupCause: new AggregateError( [failure.cleanupCause, configurationFailure], `Configuration readback cleanup and rollback failed: ${failure.path}`, ), - failure.retention, - failure.completionCancellation, - ); + }); } } diff --git a/src/artifacts/ArtifactFileOperation.ts b/src/artifacts/ArtifactFileOperation.ts index 9d78f79fc4..6b88b62d5d 100644 --- a/src/artifacts/ArtifactFileOperation.ts +++ b/src/artifacts/ArtifactFileOperation.ts @@ -33,17 +33,17 @@ export class ArtifactFileCleanupFailure< ) { const outcome = fileCleanupFailure.outcome; if ( + fileCleanupFailure.operationFailure === undefined && outcome.kind === "failed" && outcome.cause instanceof RegularFileOperationCancelled ) - fileCleanupFailure = new RegularFileCleanupFailure( - fileCleanupFailure.path, - fileCleanupFailure.owner, - { kind: "failed", cause: artifactOperationCause(outcome.cause) }, - fileCleanupFailure.cleanupCause, - fileCleanupFailure.retention, - fileCleanupFailure.completionCancellation, - ); + fileCleanupFailure = new RegularFileCleanupFailure({ + ...fileCleanupFailure, + outcome: { + kind: "failed", + cause: artifactOperationCause(outcome.cause), + }, + }); const completionCancellation = fileCleanupFailure.completionCancellation; const primary = fileCleanupFailure.outcome.kind === "failed" @@ -58,7 +58,15 @@ export class ArtifactFileCleanupFailure< }, ); const projected = ArtifactReaderFailure.withCleanup( - primary, + fileCleanupFailure.operationFailure !== undefined && + fileCleanupFailure.outcome.kind === "failed" && + Object.is(primary, fileCleanupFailure.operationFailure.operationCause) + ? new ArtifactReaderFailure( + "io", + primary instanceof Error ? primary.message : String(primary), + { cause: primary }, + ) + : primary, ArtifactReaderFailure.cleanupObservation( fileCleanupFailure.cleanupCause, fileCleanupFailure.path, @@ -80,6 +88,7 @@ export class ArtifactFileCleanupFailure< ); this.fileCleanupFailure = fileCleanupFailure; const inherited = + fileCleanupFailure.operationFailure === undefined && fileCleanupFailure.outcome.kind === "failed" ? artifactPrimaryCleanupLeases(fileCleanupFailure.outcome.cause) : []; @@ -125,31 +134,42 @@ export const mapArtifactFileFailure = ( const physical = cause.fileCleanupFailure; if (physical.outcome.kind !== "failed") return cause; const primary = + physical.operationFailure === undefined && physical.outcome.cause instanceof ArtifactFileCleanupFailure ? mapArtifactFileFailure(physical.outcome.cause, classify) : classify(physical.outcome.cause); - if (primary === physical.outcome.cause) return cause; + if (Object.is(primary, physical.outcome.cause)) return cause; return new ArtifactFileCleanupFailure( - new RegularFileCleanupFailure( - physical.path, - physical.owner, - { kind: "failed", cause: primary }, - physical.cleanupCause, - physical.retention, - physical.completionCancellation, - ), + new RegularFileCleanupFailure({ + ...physical, + outcome: { kind: "failed", cause: primary }, + }), cause.cleanupLease, ); }; -/** Read only the physical outcomes carried by known nested file owners. */ -export const artifactFilePrimaryFailure = (cause: unknown): unknown => { - while ( - cause instanceof ArtifactFileCleanupFailure && - cause.fileCleanupFailure.outcome.kind === "failed" - ) - cause = cause.fileCleanupFailure.outcome.cause; - return cause; +export interface ArtifactFilePrimaryFacts { + readonly cause: unknown; + readonly origin: "opaque-operation" | "owned-or-classified"; +} + +/** Preserve physical origin while inspecting only explicit nested operation owners. */ +export const artifactFilePrimaryFacts = ( + cause: unknown, +): ArtifactFilePrimaryFacts => { + while (cause instanceof ArtifactFileCleanupFailure) { + const physical = cause.fileCleanupFailure; + if (physical.outcome.kind !== "failed") break; + cause = physical.outcome.cause; + if (physical.operationFailure !== undefined) + return { + cause, + origin: Object.is(cause, physical.operationFailure.operationCause) + ? "opaque-operation" + : "owned-or-classified", + }; + } + return { cause, origin: "owned-or-classified" }; }; /** Cancellation after confirmed close retains the completed physical operation. */ diff --git a/src/erlang/BeamProvider.ts b/src/erlang/BeamProvider.ts index 75df250af7..637f7c9944 100644 --- a/src/erlang/BeamProvider.ts +++ b/src/erlang/BeamProvider.ts @@ -1,7 +1,8 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, artifactSelectedPathErrno, bindArtifactFileCleanup, artifactFileCancellationCause, @@ -113,8 +114,16 @@ const inputBudgetRemediation = ( } }; -const beamFailurePrimary = (cause: unknown, input: InspectErlangBeamInput) => { - if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") +const beamFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectErlangBeamInput, +) => { + const { cause, origin } = facts; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) return new AnalysisArtifactChangedError( OPERATION, input.path, @@ -123,11 +132,18 @@ const beamFailurePrimary = (cause: unknown, input: InspectErlangBeamInput) => { cause, }, ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) return new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); - if (cause instanceof ArtifactReaderFailure) { + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { if (cause.reason === "limit") { const remediationAction = inputBudgetRemediation(cause, input); return new AnalysisResourceConstraintError( @@ -176,7 +192,9 @@ const beamFailurePrimary = (cause: unknown, input: InspectErlangBeamInput) => { } return new ArtifactOperationError( OPERATION, - cause instanceof ArtifactReaderFailure ? cause.reason : "io", + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure + ? cause.reason + : "io", undefined, cause instanceof Error ? cause.message : "BEAM inspection failed.", { cause }, @@ -184,7 +202,7 @@ const beamFailurePrimary = (cause: unknown, input: InspectErlangBeamInput) => { }; const beamFailure = (cause: unknown, input: InspectErlangBeamInput) => { - const primary = beamFailurePrimary(artifactFilePrimaryFailure(cause), input); + const primary = beamFailurePrimary(artifactFilePrimaryFacts(cause), input); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/src/filesystem/OwnedRegularFile.ts b/src/filesystem/OwnedRegularFile.ts index 25c0cac6f4..f3759bba99 100644 --- a/src/filesystem/OwnedRegularFile.ts +++ b/src/filesystem/OwnedRegularFile.ts @@ -51,21 +51,39 @@ export class RegularFileCancelled extends Error { } /** A failed close transfers its exact descriptor owner and operation outcome. */ +export interface RegularFileCleanupFacts { + readonly path: string; + readonly owner: OwnedFileHandle; + readonly outcome: RegularFileOperationOutcome; + readonly cleanupCause: unknown; + readonly retention?: + | { readonly kind: "not_requested" } + | { readonly kind: "callback_returned" } + | { readonly kind: "callback_threw"; readonly cause: unknown } + | undefined; + readonly completionCancellation?: { readonly reason: unknown } | undefined; + readonly operationFailure?: OwnedRegularFileOperationFailure | undefined; +} + export class RegularFileCleanupFailure extends Error { - constructor( - readonly path: string, - readonly owner: OwnedFileHandle, - readonly outcome: RegularFileOperationOutcome, - readonly cleanupCause: unknown, - readonly retention: - | { readonly kind: "not_requested" } - | { readonly kind: "callback_returned" } - | { readonly kind: "callback_threw"; readonly cause: unknown } = { - kind: "not_requested", - }, - readonly completionCancellation?: { readonly reason: unknown }, - readonly operationFailure?: OwnedRegularFileOperationFailure, - ) { + readonly path: string; + readonly owner: OwnedFileHandle; + readonly outcome: RegularFileOperationOutcome; + readonly cleanupCause: unknown; + readonly retention: NonNullable; + readonly completionCancellation: { readonly reason: unknown } | undefined; + readonly operationFailure: OwnedRegularFileOperationFailure | undefined; + + constructor(facts: RegularFileCleanupFacts) { + const { + path, + owner, + outcome, + cleanupCause, + retention = { kind: "not_requested" }, + completionCancellation, + operationFailure, + } = facts; super(`Regular file cleanup failed for ${path}`, { cause: new AggregateError( [ @@ -77,6 +95,13 @@ export class RegularFileCleanupFailure extends Error { ), }); this.name = "RegularFileCleanupFailure"; + this.path = path; + this.owner = owner; + this.outcome = outcome; + this.cleanupCause = cleanupCause; + this.retention = retention; + this.completionCancellation = completionCancellation; + this.operationFailure = operationFailure; } } @@ -128,13 +153,13 @@ export const withOwnedRegularFile = async ( }).catch((cause: unknown) => { if (!(cause instanceof RegularFileAdmissionFailure)) throw cause; const retention = retainOwner(options.retainFailedClose, cause.owner); - throw new RegularFileCleanupFailure( + throw new RegularFileCleanupFailure({ path, - cause.owner, - { kind: "failed", cause: cause.cause }, - cause.cleanupCause, + owner: cause.owner, + outcome: { kind: "failed", cause: cause.cause }, + cleanupCause: cause.cleanupCause, retention, - ); + }); }); return withOwnedFileHandle( path, @@ -180,15 +205,15 @@ export const withOwnedFileHandle = async ( : undefined; // Retain the physical read outcome even if the caller cancelled during // close: consumers may need completed observations such as a file digest. - throw new RegularFileCleanupFailure( + throw new RegularFileCleanupFailure({ path, owner, - retainedOutcome, + outcome: retainedOutcome, cleanupCause, - retainOwner(options.retainFailedClose, owner), + retention: retainOwner(options.retainFailedClose, owner), completionCancellation, operationFailure, - ); + }); } if (outcome.kind === "failed") throw outcome.cause; if (options.signal?.aborted === true) diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index d0b07b022d..e65be01f52 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -79,7 +79,7 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { artifactFileCancellationCause, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, bindArtifactFileCleanup, withArtifactRegularFile, } from "../artifacts/ArtifactFileOperation.js"; @@ -569,8 +569,9 @@ export class GhidraClient { cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" ? { reason: artifactFileCancellationCause(cause) } : undefined; - const primary = artifactFilePrimaryFailure(cause); + const { cause: primary, origin } = artifactFilePrimaryFacts(cause); let failure = + origin === "owned-or-classified" && primary instanceof GhidraSessionError ? primary : this.#failure( diff --git a/src/ghidra/GhidraFileArtifacts.ts b/src/ghidra/GhidraFileArtifacts.ts index ff30de6cc4..fb22034b41 100644 --- a/src/ghidra/GhidraFileArtifacts.ts +++ b/src/ghidra/GhidraFileArtifacts.ts @@ -4,7 +4,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { assertArtifactReadActive, artifactFileCancellationCause, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, mapArtifactFileFailure, bindArtifactFileCleanup, withArtifactFileHandle, @@ -99,12 +99,11 @@ export const ghidraFileFailure = ( } return primary; }); - if (cause instanceof AnalysisError) return cause; - const primary = artifactFilePrimaryFailure(cause); + const { cause: primary, origin } = artifactFilePrimaryFacts(cause); const reason = primary instanceof Error ? primary.message : String(primary); const cleanup = cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; - if (primary instanceof AnalysisError) { + if (origin === "owned-or-classified" && primary instanceof AnalysisError) { if (cleanup === undefined) return primary; const failure = new ProviderCleanupError( "ghidra", diff --git a/src/ghidra/GhidraInstallationProperties.ts b/src/ghidra/GhidraInstallationProperties.ts index 3c93ceef13..3177da1242 100644 --- a/src/ghidra/GhidraInstallationProperties.ts +++ b/src/ghidra/GhidraInstallationProperties.ts @@ -4,7 +4,7 @@ import { artifactFileCompletion, mapArtifactFileFailure, ArtifactFileCleanupFailure, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, withArtifactRegularFile, } from "../artifacts/ArtifactFileOperation.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; @@ -58,7 +58,7 @@ export const readGhidraInstallationProperties = async ( ), ); } catch (cause: unknown) { - const physical = artifactFilePrimaryFailure(cause); + const { cause: physical } = artifactFilePrimaryFacts(cause); if ( !(cause instanceof ArtifactFileCleanupFailure) && physical instanceof Error && diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index a3d7a01096..7a5713de54 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; -import { artifactFilePrimaryFailure } from "../artifacts/ArtifactFileOperation.js"; +import { artifactFilePrimaryFacts } from "../artifacts/ArtifactFileOperation.js"; import { fileURLToPath } from "node:url"; import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js"; @@ -894,15 +894,23 @@ const projectPrimarySessionError = ( ? {} : { partialObservation: failure.partialObservation }), }; - const physicalCause = artifactFilePrimaryFailure(failure.cause); - if (physicalCause instanceof AnalysisAccessDeniedError) + const { cause: physicalCause, origin } = artifactFilePrimaryFacts( + failure.cause, + ); + if ( + origin === "owned-or-classified" && + physicalCause instanceof AnalysisAccessDeniedError + ) return new AnalysisAccessDeniedError( operation, physicalCause.path, physicalCause.systemCode, context, ); - if (physicalCause instanceof AnalysisArtifactChangedError) + if ( + origin === "owned-or-classified" && + physicalCause instanceof AnalysisArtifactChangedError + ) return new AnalysisArtifactChangedError( operation, physicalCause.path, diff --git a/src/ghidra/GhidraPspExtension.ts b/src/ghidra/GhidraPspExtension.ts index b418f3d49a..c85aeb9599 100644 --- a/src/ghidra/GhidraPspExtension.ts +++ b/src/ghidra/GhidraPspExtension.ts @@ -19,7 +19,7 @@ import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js"; import { assertArtifactReadActive, artifactFileCancellationCause, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, bindArtifactFileCleanup, withArtifactRegularFile, } from "../artifacts/ArtifactFileOperation.js"; @@ -236,7 +236,7 @@ const ghidraPspInspectionError = (cause: unknown): AnalysisError => { if (cause instanceof AnalysisError) return cause; const cleanup = cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; - const primary = artifactFilePrimaryFailure(cause); + const { cause: primary } = artifactFilePrimaryFacts(cause); const reason = `PSP_EXTENSION_UNAVAILABLE: ${primary instanceof Error ? primary.message : String(primary)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`; const error = cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" diff --git a/src/ghidra/GhidraTransport.ts b/src/ghidra/GhidraTransport.ts index e59454af56..3fbf752efd 100644 --- a/src/ghidra/GhidraTransport.ts +++ b/src/ghidra/GhidraTransport.ts @@ -9,7 +9,7 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { assertArtifactReadActive, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, artifactFileCancellationCause, bindArtifactFileCleanup, withArtifactRegularFile, @@ -175,9 +175,9 @@ const observationFailure = ( endpoint: GhidraEndpoint, cause: unknown, ): GhidraSessionError => { - const primary = artifactFilePrimaryFailure(cause); + const { cause: primary, origin } = artifactFilePrimaryFacts(cause); let failure = - primary instanceof GhidraSessionError + origin === "owned-or-classified" && primary instanceof GhidraSessionError ? primary : cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" ? bindGhidraSessionFailure(() => ({ @@ -189,7 +189,7 @@ const observationFailure = ( : endpointFailure( endpoint, "Ghidra endpoint observation failed", - primary, + cause, ); if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) { const cleanup = new ProviderCleanupError( diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index b888bc83e1..e2526fc38c 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -1,11 +1,11 @@ import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { readStableArtifact, - type StableArtifactSnapshot, + completedStableArtifact, } from "../artifacts/readStableArtifact.js"; import { - artifactFilePrimaryFailure, - artifactFileCompletion, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, assertArtifactReadActive, bindArtifactFileCleanup, artifactFileCancellationCause, @@ -54,13 +54,26 @@ const OPERATION = "inspect_go_binary"; const INPUT_BYTES = 256 * 1024 * 1024; const OUTPUT_BYTES = 8 * 1024 * 1024; -const failureForPrimary = (cause: unknown, path: string): AnalysisError => { - if (cause instanceof AnalysisError) return cause; - if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") +const failureForPrimary = ( + facts: ArtifactFilePrimaryFacts, + path: string, +): AnalysisError => { + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { cause, }); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) return new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); @@ -94,7 +107,10 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => { message: `Selected artifact ${path}: ${cause.message}`, }, ]); - if (cause instanceof ArtifactReaderFailure) { + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { if (cause.reason === "limit") return new AnalysisResourceConstraintError( OPERATION, @@ -135,12 +151,11 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => { }; const failureFor = (cause: unknown, path: string): AnalysisError => { - const primary = failureForPrimary(artifactFilePrimaryFailure(cause), path); - const completed = artifactFileCompletion(cause); - if (completed !== undefined) { + const primary = failureForPrimary(artifactFilePrimaryFacts(cause), path); + const snapshot = completedStableArtifact(cause); + if (snapshot !== undefined) { // This inspect boundary acquires exactly one StableArtifactSnapshot before // metadata decoding. Completion describes that read, not a decoded report. - const snapshot = completed.value as StableArtifactSnapshot; primary.retainPartialObservation( fileSnapshotPartialObservationSchema.parse({ kind: "file-snapshot-hash", diff --git a/src/gpu/GpuCodeObjectsProvider.ts b/src/gpu/GpuCodeObjectsProvider.ts index 98c9d5b02c..3a23dd507d 100644 --- a/src/gpu/GpuCodeObjectsProvider.ts +++ b/src/gpu/GpuCodeObjectsProvider.ts @@ -29,7 +29,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { withArtifactRegularFile, bindArtifactFileCleanup, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, artifactFileCancellationCause, artifactFileCompletion, } from "../artifacts/ArtifactFileOperation.js"; @@ -110,9 +110,10 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort { ), ); } catch (cause) { - const primary = artifactFilePrimaryFailure(cause); + const { cause: primary, origin } = artifactFilePrimaryFacts(cause); let error: AnalysisError; - if (primary instanceof AnalysisError) error = primary; + if (origin === "owned-or-classified" && primary instanceof AnalysisError) + error = primary; else if (primary instanceof BinaryResourceFailure) error = new AnalysisResourceConstraintError( OPERATION, @@ -142,6 +143,7 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort { constraint: "file_format_rejected", }); } else if ( + origin === "owned-or-classified" && primary instanceof ArtifactReaderFailure && primary.reason === "cancelled" ) @@ -149,6 +151,7 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort { cause: artifactFileCancellationCause(primary), }); else if ( + origin === "owned-or-classified" && primary instanceof ArtifactReaderFailure && primary.reason === "integrity" ) @@ -172,6 +175,7 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort { else if (isSelectedRegularFileError(primary)) error = new BinaryTargetError(input.path, primary.message, { cause }); else if ( + origin === "owned-or-classified" && primary instanceof ArtifactReaderFailure && primary.reason === "limit" ) diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index dce515bcd0..9567b258c1 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -11,7 +11,8 @@ import { import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, artifactFileCompletion, bindArtifactFileCleanup, artifactFileCancellationCause, @@ -66,13 +67,15 @@ const BINARY_BYTES = 1024 * 1024 * 1024; // A 12.9 MB Unity 6000.4 file with 112,232 methods serializes to 7.8 MB. const OUTPUT_BYTES = 64 * 1024 * 1024; const failureForPrimary = ( - cause: unknown, + facts: ArtifactFilePrimaryFacts, path: string, field: "path" | "binary_path" = "path", maximum = INPUT_BYTES, metadataPath = path, ): AnalysisError => { - if (cause instanceof AnalysisError) return cause; + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; if (cause instanceof Il2cppBinaryFormatFailure && cause.kind === "capacity") return new AnalysisResourceConstraintError( OPERATION, @@ -109,15 +112,26 @@ const failureForPrimary = ( }, ]); } - if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { cause, }); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) return new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); - if (cause instanceof ArtifactReaderFailure) { + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { if (cause.reason === "limit") return new AnalysisResourceConstraintError( OPERATION, @@ -169,7 +183,7 @@ const failureFor = ( metadataPath = path, ): AnalysisError => { const primary = failureForPrimary( - artifactFilePrimaryFailure(cause), + artifactFilePrimaryFacts(cause), path, field, maximum, diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index c8e7b76f1d..da04b7609e 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -15,6 +15,7 @@ import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { RegularFileCancelled, RegularFileCleanupFailure, + OwnedRegularFileOperationFailure, withOwnedFileHandle, } from "../filesystem/OwnedRegularFile.js"; import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js"; @@ -98,16 +99,15 @@ const readSpan = async ( : await ownership.admitSpan(operation, signal, () => open(path, "r")); if (!opened.ok) return opened; const owner = new OwnedFileHandle(opened.value); - let text: string | undefined; - let readFailure: unknown; + let read: Result; try { const buffer = Buffer.alloc(length); const { bytesRead } = await owner.handle.read(buffer, 0, length, offset); if (bytesRead !== length) throw new Error(`${path} is shorter than its recorded span`); - text = buffer.toString("utf8"); + read = ok(buffer.toString("utf8")); } catch (cause: unknown) { - readFailure = cause; + read = err(cause); } const finish = () => withOwnedFileHandle( @@ -116,9 +116,7 @@ const readSpan = async ( { // A failed read keeps its own diagnostic. A completed read records // cancellation when the caller aborts before the queued close settles. - ...(readFailure === undefined && signal !== undefined - ? { signal } - : {}), + ...(read.ok && signal !== undefined ? { signal } : {}), ...(ownership.retainFailedClose === undefined ? {} : { @@ -127,8 +125,8 @@ const readSpan = async ( }), }, async () => { - if (readFailure !== undefined) throw readFailure; - return text ?? ""; + if (!read.ok) throw new OwnedRegularFileOperationFailure(read.error); + return read.value; }, ); try { @@ -138,6 +136,12 @@ const readSpan = async ( : await ownership.finishSpan(finish); return ok(value); } catch (cause: unknown) { + if (cause instanceof OwnedRegularFileOperationFailure) + return err( + new ProviderAdapterError(KUNA_PROVIDER_ID, operation, { + cause: cause.operationCause, + }), + ); if (cause instanceof RegularFileCleanupFailure) return err( spanCloseFailure( @@ -145,7 +149,7 @@ const readSpan = async ( path, offset, length, - spanCloseAfterRead(cause, text, readFailure, signal), + spanCloseAfterRead(cause, read, signal), ownership, ), ); @@ -171,27 +175,23 @@ const readSpan = async ( */ const spanCloseAfterRead = ( failure: RegularFileCleanupFailure, - text: string | undefined, - readFailure: unknown, + read: Result, signal: AbortSignal | undefined, ): RegularFileCleanupFailure => { if ( failure.completionCancellation !== undefined || - text === undefined || - readFailure !== undefined || + !read.ok || signal?.aborted !== true || failure.outcome.kind !== "failed" || !(failure.outcome.cause instanceof RegularFileOperationCancelled) ) return failure; - return new RegularFileCleanupFailure( - failure.path, - failure.owner, - { kind: "completed", value: text }, - failure.cleanupCause, - failure.retention, - { reason: signal.reason }, - ); + return new RegularFileCleanupFailure({ + ...failure, + outcome: { kind: "completed", value: read.value }, + completionCancellation: { reason: signal.reason }, + operationFailure: undefined, + }); }; /** Keep the span text or the short-read diagnostic with the exact handle. */ @@ -233,6 +233,7 @@ const spanCloseFailure = ( if (failure.outcome.kind !== "failed") return marker; const primaryCause = failure.outcome.cause; const primary = + failure.operationFailure === undefined && primaryCause instanceof AnalysisError ? primaryCause : new ProviderAdapterError(KUNA_PROVIDER_ID, operation, { diff --git a/src/model/ModelArtifactProvider.ts b/src/model/ModelArtifactProvider.ts index e1b72afce4..023219995b 100644 --- a/src/model/ModelArtifactProvider.ts +++ b/src/model/ModelArtifactProvider.ts @@ -30,7 +30,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { withArtifactRegularFile, bindArtifactFileCleanup, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, artifactFileCancellationCause, artifactFileCompletion, } from "../artifacts/ArtifactFileOperation.js"; @@ -111,9 +111,10 @@ export class ModelArtifactProvider implements ModelArtifactPort { ), ); } catch (cause) { - const primary = artifactFilePrimaryFailure(cause); + const { cause: primary, origin } = artifactFilePrimaryFacts(cause); let error: AnalysisError; - if (primary instanceof AnalysisError) error = primary; + if (origin === "owned-or-classified" && primary instanceof AnalysisError) + error = primary; else if (primary instanceof ModelFormatFailure) { const limit = primary.kind === "capacity" ? primary.limit : undefined; error = @@ -141,6 +142,7 @@ export class ModelArtifactProvider implements ModelArtifactPort { constraint: "file_format_rejected", }); } else if ( + origin === "owned-or-classified" && primary instanceof ArtifactReaderFailure && primary.reason === "cancelled" ) @@ -148,6 +150,7 @@ export class ModelArtifactProvider implements ModelArtifactPort { cause: artifactFileCancellationCause(primary), }); else if ( + origin === "owned-or-classified" && primary instanceof ArtifactReaderFailure && primary.reason === "integrity" ) @@ -169,6 +172,7 @@ export class ModelArtifactProvider implements ModelArtifactPort { { cause: primary }, ); else if ( + origin === "owned-or-classified" && primary instanceof ArtifactReaderFailure && primary.reason === "limit" ) diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 81c192d3a5..6e3dc53c0a 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -23,7 +23,8 @@ import { import { ArtifactFileCleanupFailure, artifactFileCancellationCause, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, bindArtifactFileCleanup, withArtifactRegularFile, } from "../artifacts/ArtifactFileOperation.js"; @@ -412,7 +413,10 @@ export const appleDispatchMetadataFailure = ( : undefined; const previous = physical?.outcome.kind === "failed" - ? appleDispatchMetadataFailure(artifactFilePrimaryFailure(cause), path) + ? appleDispatchMetadataPrimaryFailure( + artifactFilePrimaryFacts(cause), + path, + ) : physical?.completionCancellation === undefined ? undefined : new AnalysisCancelledError(OPERATION, { @@ -431,35 +435,52 @@ export const appleDispatchMetadataFailure = ( bindArtifactFileCleanup(cause, failure); return failure; } - if (cause instanceof AnalysisError) return cause; - if (cause instanceof UnsupportedMachoMetadataFormatError) - return new AnalysisCapabilityUnavailableError( - IDENTITY.id, - OPERATION, - cause.message, - { cause }, - ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") - return new AnalysisCancelledError(OPERATION, { - cause: artifactFileCancellationCause(cause), - }); - if (cause instanceof ArtifactReaderFailure && cause.reason === "unavailable") - return new AnalysisCapabilityUnavailableError( - IDENTITY.id, - OPERATION, - cause.message, - { cause }, - ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") - return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { - cause, - }); - if (cause instanceof ArtifactReaderFailure && cause.reason === "path") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_value", message: cause.message }, - ]); + return appleDispatchMetadataPrimaryFailure( + artifactFilePrimaryFacts(cause), + path, + ); +}; + +const appleDispatchMetadataPrimaryFailure = ( + { cause, origin }: ArtifactFilePrimaryFacts, + path: string, +): AnalysisError => { + if (origin === "owned-or-classified") { + if (cause instanceof AnalysisError) return cause; + if (cause instanceof UnsupportedMachoMetadataFormatError) + return new AnalysisCapabilityUnavailableError( + IDENTITY.id, + OPERATION, + cause.message, + { cause }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if ( + cause instanceof ArtifactReaderFailure && + cause.reason === "unavailable" + ) + return new AnalysisCapabilityUnavailableError( + IDENTITY.id, + OPERATION, + cause.message, + { cause }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { + cause, + }); + if (cause instanceof ArtifactReaderFailure && cause.reason === "path") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_value", message: cause.message }, + ]); + } const readCause = - cause instanceof ArtifactReaderFailure && cause.reason === "io" + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "io" ? cause.cause : cause; if ( diff --git a/src/native/NativePlistSource.ts b/src/native/NativePlistSource.ts index dd112a6272..eb2086220c 100644 --- a/src/native/NativePlistSource.ts +++ b/src/native/NativePlistSource.ts @@ -28,7 +28,8 @@ import { sameRegularFileState } from "../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { ArtifactFileCleanupFailure, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, artifactFileCancellationCause, bindArtifactFileCleanup, assertArtifactReadActive, @@ -155,8 +156,8 @@ export const nativePlistFailure = ( : undefined; const previous = physical?.outcome.kind === "failed" - ? nativePlistFailure( - artifactFilePrimaryFailure(cause), + ? nativePlistPrimaryFailure( + artifactFilePrimaryFacts(cause), selection, capturedOutput, ) @@ -198,50 +199,67 @@ export const nativePlistFailure = ( bindArtifactFileCleanup(cause, failure); return failure; } - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") - return new AnalysisCancelledError("inspect_plist", { - cause: artifactFileCancellationCause(cause), - capturedOutput, - }); - if (cause instanceof ArtifactReaderFailure && cause.reason === "unavailable") - return new AnalysisCapabilityUnavailableError( - IDENTITY.id, - "inspect_plist", - cause.message, - { cause, capturedOutput }, - ); - if ( - selection !== undefined && - cause instanceof ArtifactReaderFailure && - cause.reason === "integrity" - ) - return new AnalysisArtifactChangedError( - "inspect_plist", - selection.path, - cause.message, - { cause, capturedOutput }, - ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") { - const resource = cause.boundary ?? "memory"; - return resource === "file-size" - ? new AnalysisResourceConstraintError( - "inspect_plist", - resource, - cause.message, - { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES }, - { - cause, - capturedOutput, - ...fixedAllowanceOptions(NATIVE_PLIST_INPUT_ALLOWANCE, "input"), - }, - ) - : new AnalysisResourceConstraintError( - "inspect_plist", - resource, - cause.message, - null, - { cause, capturedOutput, retryable: false }, - ); + return nativePlistPrimaryFailure( + artifactFilePrimaryFacts(cause), + selection, + capturedOutput, + ); +}; + +const nativePlistPrimaryFailure = ( + { cause, origin }: ArtifactFilePrimaryFacts, + selection: NativePlistSelection | undefined, + capturedOutput: AnalysisCapturedOutput | undefined, +): AnalysisError => { + if (origin === "owned-or-classified") { + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + return new AnalysisCancelledError("inspect_plist", { + cause: artifactFileCancellationCause(cause), + capturedOutput, + }); + if ( + cause instanceof ArtifactReaderFailure && + cause.reason === "unavailable" + ) + return new AnalysisCapabilityUnavailableError( + IDENTITY.id, + "inspect_plist", + cause.message, + { cause, capturedOutput }, + ); + if ( + selection !== undefined && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + "inspect_plist", + selection.path, + cause.message, + { cause, capturedOutput }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") { + const resource = cause.boundary ?? "memory"; + return resource === "file-size" + ? new AnalysisResourceConstraintError( + "inspect_plist", + resource, + cause.message, + { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES }, + { + cause, + capturedOutput, + ...fixedAllowanceOptions(NATIVE_PLIST_INPUT_ALLOWANCE, "input"), + }, + ) + : new AnalysisResourceConstraintError( + "inspect_plist", + resource, + cause.message, + null, + { cause, capturedOutput, retryable: false }, + ); + } } if (selection === undefined) return new ProviderAdapterError(IDENTITY.id, "inspect_plist", { diff --git a/src/native/elf/ElfNotesProvider.ts b/src/native/elf/ElfNotesProvider.ts index 896e5c36bd..7c8ec6529a 100644 --- a/src/native/elf/ElfNotesProvider.ts +++ b/src/native/elf/ElfNotesProvider.ts @@ -2,7 +2,8 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, bindArtifactFileCleanup, artifactFileCancellationCause, } from "../../artifacts/ArtifactFileOperation.js"; @@ -112,9 +113,10 @@ const sectionNameBudgetRemediation = ( : `The source and decoded ELF section names require ${String(cause.minimumMaxFileBytes)} bytes, above this operation's ${String(MAX_INSPECT_ELF_NOTE_FILE_BYTES)}-byte maximum; select an image with a smaller section-name table.`; const elfNotesFailurePrimary = ( - cause: unknown, + facts: ArtifactFilePrimaryFacts, input: InspectElfNotesInput, ) => { + const { cause, origin } = facts; if (cause instanceof ElfNoteSectionNameBudgetFailure) return new AnalysisResourceConstraintError( OPERATION, @@ -145,7 +147,11 @@ const elfNotesFailurePrimary = ( message: cause.message, }, ]); - if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) return new AnalysisArtifactChangedError( OPERATION, input.path, @@ -154,11 +160,18 @@ const elfNotesFailurePrimary = ( cause, }, ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) return new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); - if (cause instanceof ArtifactReaderFailure) { + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { if (cause.reason === "limit") { // Caller-selected read and entry budgets name the bound to raise. // The note text, hash, and scan ceiling equals the admitted image @@ -213,7 +226,9 @@ const elfNotesFailurePrimary = ( } return new ArtifactOperationError( OPERATION, - cause instanceof ArtifactReaderFailure ? cause.reason : "io", + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure + ? cause.reason + : "io", undefined, cause instanceof Error ? cause.message : "ELF note inspection failed.", { cause }, @@ -222,7 +237,7 @@ const elfNotesFailurePrimary = ( const elfNotesFailure = (cause: unknown, input: InspectElfNotesInput) => { const primary = elfNotesFailurePrimary( - artifactFilePrimaryFailure(cause), + artifactFilePrimaryFacts(cause), input, ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts index 8931e41b6c..f361be9cec 100644 --- a/src/native/exports/NativeExportsProvider.ts +++ b/src/native/exports/NativeExportsProvider.ts @@ -2,7 +2,8 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, assertArtifactReadActive, bindArtifactFileCleanup, artifactFileCancellationCause, @@ -93,18 +94,28 @@ const fileSizeRemediation = (input: InspectNativeExportsInput): string => : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; const nativeExportsFailurePrimary = ( - cause: unknown, + facts: ArtifactFilePrimaryFacts, input: InspectNativeExportsInput, ): AnalysisError => { - if (cause instanceof AnalysisError) return cause; - if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) return new AnalysisArtifactChangedError( OPERATION, input.path, cause.message, { cause }, ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) return new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); @@ -139,7 +150,10 @@ const nativeExportsFailurePrimary = ( message: `Selected artifact ${input.path}: ${cause.message}`, }, ]); - if (cause instanceof ArtifactReaderFailure) { + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { if (cause.reason === "limit") return new AnalysisResourceConstraintError( OPERATION, @@ -185,7 +199,7 @@ const nativeExportsFailure = ( input: InspectNativeExportsInput, ): AnalysisError => { const primary = nativeExportsFailurePrimary( - artifactFilePrimaryFailure(cause), + artifactFilePrimaryFacts(cause), input, ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts index 5f2c70a740..45b339c80c 100644 --- a/src/native/pe/PeCertificatesProvider.ts +++ b/src/native/pe/PeCertificatesProvider.ts @@ -2,7 +2,8 @@ import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, bindArtifactFileCleanup, artifactFileCancellationCause, } from "../../artifacts/ArtifactFileOperation.js"; @@ -96,10 +97,15 @@ const inputBudgetRemediation = ( : `Raise max_entries above ${String(input.max_entries)} or select a smaller artifact; the limit is the caller-selected certificate entry budget.`; const peCertificatesFailurePrimary = ( - cause: unknown, + facts: ArtifactFilePrimaryFacts, input: InspectPeCertificatesInput, ) => { - if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + const { cause, origin } = facts; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) return new AnalysisArtifactChangedError( OPERATION, input.path, @@ -108,11 +114,18 @@ const peCertificatesFailurePrimary = ( cause, }, ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) return new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); - if (cause instanceof ArtifactReaderFailure) { + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { if (cause.reason === "limit") return new AnalysisResourceConstraintError( OPERATION, @@ -160,7 +173,9 @@ const peCertificatesFailurePrimary = ( } return new ArtifactOperationError( OPERATION, - cause instanceof ArtifactReaderFailure ? cause.reason : "io", + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure + ? cause.reason + : "io", undefined, cause instanceof Error ? cause.message @@ -174,7 +189,7 @@ const peCertificatesFailure = ( input: InspectPeCertificatesInput, ) => { const primary = peCertificatesFailurePrimary( - artifactFilePrimaryFailure(cause), + artifactFilePrimaryFacts(cause), input, ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index 0266f962ba..b3a9f5d58f 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -3,7 +3,8 @@ import { fixedNativeAllowance } from "../../domain/native/nativeDeadlines.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, bindArtifactFileCleanup, artifactFileCancellationCause, } from "../../artifacts/ArtifactFileOperation.js"; @@ -104,23 +105,35 @@ export const inputBudgetRemediation = ( : `Raise max_entries above ${String(input.max_entries)} or select a smaller artifact; the limit is the caller-selected resource directory entry budget.`; const peResourcesFailurePrimary = ( - cause: unknown, + facts: ArtifactFilePrimaryFacts, input: InspectPeResourcesInput, ) => { + const { cause, origin } = facts; const operation = "inspect_pe_resources"; - if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) return new AnalysisArtifactChangedError( operation, input.path, cause.message, { cause }, ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) return new AnalysisCancelledError(operation, { cause: artifactFileCancellationCause(cause), }); - if (cause instanceof ArtifactReaderFailure) { + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { // An input byte budget is a file-size bound, not a memory constraint, and // an entry budget is a capacity bound. Report what was actually exceeded. if (cause.reason === "limit") { @@ -185,7 +198,9 @@ const peResourcesFailurePrimary = ( return new ArtifactOperationError( operation, - cause instanceof ArtifactReaderFailure ? cause.reason : "io", + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure + ? cause.reason + : "io", undefined, cause instanceof Error ? cause.message : "PE artifact inspection failed.", { cause }, @@ -194,7 +209,7 @@ const peResourcesFailurePrimary = ( const peResourcesFailure = (cause: unknown, input: InspectPeResourcesInput) => { const primary = peResourcesFailurePrimary( - artifactFilePrimaryFailure(cause), + artifactFilePrimaryFacts(cause), input, ); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) diff --git a/src/python/PythonPycProvider.ts b/src/python/PythonPycProvider.ts index 84d8acf5f3..b463bb8186 100644 --- a/src/python/PythonPycProvider.ts +++ b/src/python/PythonPycProvider.ts @@ -1,7 +1,8 @@ import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { artifactFileCancellationCause, - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, bindArtifactFileCleanup, } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; @@ -65,16 +66,29 @@ const parserLimitRecovery = ( }; }; -const failureForPrimary = (cause: unknown, input: InspectPythonPycInput) => { - if (cause instanceof AnalysisError) return cause; - if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") +const failureForPrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectPythonPycInput, +) => { + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) return new AnalysisArtifactChangedError( OPERATION, input.path, cause.message, { cause }, ); - if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) return new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); @@ -101,7 +115,10 @@ const failureForPrimary = (cause: unknown, input: InspectPythonPycInput) => { message: `Selected pyc ${input.path}: ${cause.message}`, }, ]); - if (cause instanceof ArtifactReaderFailure) { + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { if (cause.reason === "limit") return new AnalysisResourceConstraintError( OPERATION, @@ -146,7 +163,7 @@ const failureForPrimary = (cause: unknown, input: InspectPythonPycInput) => { }; const failureFor = (cause: unknown, input: InspectPythonPycInput) => { - const primary = failureForPrimary(artifactFilePrimaryFailure(cause), input); + const primary = failureForPrimary(artifactFilePrimaryFacts(cause), input); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 5766e6e0e6..f67fdc4264 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -1,7 +1,8 @@ import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { - artifactFilePrimaryFailure, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, artifactFileCancellationCause, assertArtifactReadActive, bindArtifactFileCleanup, @@ -238,8 +239,13 @@ const rustcLimitation = ( ]; }; -const failureForPrimary = (cause: unknown, path: string): AnalysisError => { - if (cause instanceof AnalysisError) return cause; +const failureForPrimary = ( + facts: ArtifactFilePrimaryFacts, + path: string, +): AnalysisError => { + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; if (cause instanceof RustAuditableResourceFailure) return new AnalysisResourceConstraintError( OPERATION, @@ -274,11 +280,11 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => { message: `Selected artifact ${path}: ${cause.message}`, }, ]); - return artifactFailure(cause, path); + return artifactFailure(facts, path); }; const failureFor = (cause: unknown, path: string): AnalysisError => { - const primary = failureForPrimary(artifactFilePrimaryFailure(cause), path); + const primary = failureForPrimary(artifactFilePrimaryFacts(cause), path); if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) return primary; const cleanup = new ProviderCleanupError( @@ -292,8 +298,15 @@ const failureFor = (cause: unknown, path: string): AnalysisError => { return error; }; -const artifactFailure = (cause: unknown, path: string): AnalysisError => { - if (cause instanceof ArtifactReaderFailure) +const artifactFailure = ( + facts: ArtifactFilePrimaryFacts, + path: string, +): AnalysisError => { + const { cause, origin } = facts; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) return artifactReaderFailure(cause, path); if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/tests/boundary/filesystem/jsonFileResourceOwnership.test.ts b/tests/boundary/filesystem/jsonFileResourceOwnership.test.ts index 3f6a178850..5468550ae9 100644 --- a/tests/boundary/filesystem/jsonFileResourceOwnership.test.ts +++ b/tests/boundary/filesystem/jsonFileResourceOwnership.test.ts @@ -16,12 +16,12 @@ it("keeps a cleanup-shaped caller cancellation opaque before JSON admission", as const path = join(root, "input.json"); await writeFile(path, "{}"); const owner = new OwnedFileHandle(await open(path, "r")); - const reason = new RegularFileCleanupFailure( + const reason = new RegularFileCleanupFailure({ path, owner, - { kind: "completed", value: null }, - new Error("foreign owner"), - ); + outcome: { kind: "completed", value: null }, + cleanupCause: new Error("foreign owner"), + }); const controller = new AbortController(); controller.abort(reason); const close = vi.spyOn(owner, "close"); diff --git a/tests/boundary/filesystem/referenceSourceImport.test.ts b/tests/boundary/filesystem/referenceSourceImport.test.ts index 6188f8f13f..b6a1d6c0c3 100644 --- a/tests/boundary/filesystem/referenceSourceImport.test.ts +++ b/tests/boundary/filesystem/referenceSourceImport.test.ts @@ -331,12 +331,12 @@ describe("reference source ignore policy cleanup ownership", () => { await writeFile(path, "ignored/\n"); await writeFile(foreignPath, "foreign bytes"); const foreignOwner = new OwnedFileHandle(await open(foreignPath, "r")); - const reason = new RegularFileCleanupFailure( - foreignPath, - foreignOwner, - { kind: "completed", value: "foreign value" }, - new Error("foreign cleanup failure"), - ); + const reason = new RegularFileCleanupFailure({ + path: foreignPath, + owner: foreignOwner, + outcome: { kind: "completed", value: "foreign value" }, + cleanupCause: new Error("foreign cleanup failure"), + }); const controller = new AbortController(); const probe = await open(path, "r"); const identity = await probe.stat(); diff --git a/tests/boundary/filesystem/regularFileCancellation.test.ts b/tests/boundary/filesystem/regularFileCancellation.test.ts index d69a3d0b48..1f4f9dc2ff 100644 --- a/tests/boundary/filesystem/regularFileCancellation.test.ts +++ b/tests/boundary/filesystem/regularFileCancellation.test.ts @@ -114,12 +114,12 @@ it.each( outcome, reason: nestedReason, }) - : new RegularFileCleanupFailure( - "/caller-selected-path", - callerOwner, + : new RegularFileCleanupFailure({ + path: "/caller-selected-path", + owner: callerOwner, outcome, - nestedReason, - ); + cleanupCause: nestedReason, + }); const controller = new AbortController(); if (stage === "before admission") controller.abort(reason); const resources = scoped ? new ArtifactResourceScope() : undefined; @@ -164,12 +164,12 @@ it("preserves a cleanup-shaped physical read rejection without borrowing its own await writeFile(input, "input"); await writeFile(foreignPath, "foreign owner bytes"); const foreignOwner = new OwnedFileHandle(await open(foreignPath, "r")); - const reason = new RegularFileCleanupFailure( - foreignPath, - foreignOwner, - { kind: "completed", value: "foreign value" }, - new Error("foreign cleanup failure"), - ); + const reason = new RegularFileCleanupFailure({ + path: foreignPath, + owner: foreignOwner, + outcome: { kind: "completed", value: "foreign value" }, + cleanupCause: new Error("foreign cleanup failure"), + }); const probe = await open(input, "r"); const inputIdentity = await probe.stat(); const prototype = probe.constructor.prototype as { @@ -204,12 +204,12 @@ it("retries only the owned close when a cleanup-shaped physical read remains pen await writeFile(input, "input"); await writeFile(foreignPath, "foreign owner bytes"); const foreignOwner = new OwnedFileHandle(await open(foreignPath, "r")); - const reason = new RegularFileCleanupFailure( - foreignPath, - foreignOwner, - { kind: "completed", value: "foreign value" }, - new Error("foreign cleanup failure"), - ); + const reason = new RegularFileCleanupFailure({ + path: foreignPath, + owner: foreignOwner, + outcome: { kind: "completed", value: "foreign value" }, + cleanupCause: new Error("foreign cleanup failure"), + }); const controller = new AbortController(); const probe = await open(input, "r"); const inputIdentity = await probe.stat(); @@ -276,12 +276,12 @@ it("retains physical read provenance for an opted-in caller after close retry", await writeFile(input, "input"); await writeFile(foreignPath, "foreign owner bytes"); const foreignOwner = new OwnedFileHandle(await open(foreignPath, "r")); - const reason = new RegularFileCleanupFailure( - foreignPath, - foreignOwner, - { kind: "completed", value: "foreign value" }, - new Error("foreign cleanup failure"), - ); + const reason = new RegularFileCleanupFailure({ + path: foreignPath, + owner: foreignOwner, + outcome: { kind: "completed", value: "foreign value" }, + cleanupCause: new Error("foreign cleanup failure"), + }); const controller = new AbortController(); const probe = await open(input, "r"); const inputIdentity = await probe.stat(); diff --git a/tests/boundary/process/androidSnapshotClose.test.ts b/tests/boundary/process/androidSnapshotClose.test.ts index 4b94506c62..c5f5948a6b 100644 --- a/tests/boundary/process/androidSnapshotClose.test.ts +++ b/tests/boundary/process/androidSnapshotClose.test.ts @@ -546,15 +546,15 @@ test("keeps the snapshot hash when cancellation wraps the finished copy", () => } as FileHandle), { outcome: { kind: "completed", value: copy }, reason }, ); - const source = new RegularFileCleanupFailure( - copy.sourcePath, - new OwnedFileHandle({ + const source = new RegularFileCleanupFailure({ + path: copy.sourcePath, + owner: new OwnedFileHandle({ fd: 8, close: async () => undefined, } as FileHandle), - { kind: "failed", cause: destination }, - new Error("source EIO"), - ); + outcome: { kind: "failed", cause: destination }, + cleanupCause: new Error("source EIO"), + }); const error = captureJadxFailure({ cause: source, request: { @@ -587,26 +587,26 @@ test("classifies a nested snapshot close as cancellation", () => { snapshotPath: "/workspace/snap/target.apk", bytes: 12, }; - const destination = new RegularFileCleanupFailure( - copy.snapshotPath, - new OwnedFileHandle({ + const destination = new RegularFileCleanupFailure({ + path: copy.snapshotPath, + owner: new OwnedFileHandle({ fd: 7, close: async () => undefined, } as FileHandle), - { kind: "completed", value: copy }, - new Error("dest EIO"), - { kind: "not_requested" }, - { reason }, - ); - const source = new RegularFileCleanupFailure( - copy.sourcePath, - new OwnedFileHandle({ + outcome: { kind: "completed", value: copy }, + cleanupCause: new Error("dest EIO"), + retention: { kind: "not_requested" }, + completionCancellation: { reason }, + }); + const source = new RegularFileCleanupFailure({ + path: copy.sourcePath, + owner: new OwnedFileHandle({ fd: 8, close: async () => undefined, } as FileHandle), - { kind: "failed", cause: destination }, - new Error("source EIO"), - ); + outcome: { kind: "failed", cause: destination }, + cleanupCause: new Error("source EIO"), + }); const error = captureJadxFailure({ cause: source, request: { diff --git a/tests/boundary/process/jadxStartup.test.ts b/tests/boundary/process/jadxStartup.test.ts index f5008e24e3..c53b38e4f7 100644 --- a/tests/boundary/process/jadxStartup.test.ts +++ b/tests/boundary/process/jadxStartup.test.ts @@ -439,12 +439,12 @@ it("keeps a foreign file-cleanup abort reason opaque at the session checkpoint", const caller = new AbortController(); const handle = await open(f.apk, "r"); const owner = new OwnedFileHandle(handle); - const reason = new RegularFileCleanupFailure( - f.apk, + const reason = new RegularFileCleanupFailure({ + path: f.apk, owner, - { kind: "failed", cause: new Error("foreign read failed") }, - new Error("foreign close failed"), - ); + outcome: { kind: "failed", cause: new Error("foreign read failed") }, + cleanupCause: new Error("foreign close failed"), + }); const execute = JadxSession.prototype.execute; const checkpoint = vi .spyOn(JadxSession.prototype, "execute") diff --git a/tests/boundary/process/verifierFileResources.test.ts b/tests/boundary/process/verifierFileResources.test.ts index 8c1471ee92..c80ae9361b 100644 --- a/tests/boundary/process/verifierFileResources.test.ts +++ b/tests/boundary/process/verifierFileResources.test.ts @@ -297,7 +297,7 @@ it.each(["late-close", "retry-close", "retained-close", "opaque-cancellation"])( if (mode === 'opaque-cancellation') { const path = join(root, 'foreign'); await writeFile(path, 'foreign'); foreign = new OwnedFileHandle(await open(path, 'r')); - controller.abort(new RegularFileCleanupFailure(path, foreign, { kind: 'completed', value: 'opaque' }, new Error('not this inspection owner'))); + controller.abort(new RegularFileCleanupFailure({ path, owner: foreign, outcome: { kind: 'completed', value: 'opaque' }, cleanupCause: new Error('not this inspection owner') })); } let entered, release; const entering = new Promise(resolve => { entered = resolve; }); diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index 8bf670007b..095ee10b42 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -2,7 +2,7 @@ import { parseConfig } from "../../../../src/config/parseConfig.js"; import { GhidraProvider } from "../../../../src/ghidra/GhidraProvider.js"; import { fixtureDosLoadImage } from "../../../../src/ghidra/GhidraLoadImage.fixture.js"; import { jsonValueSchema } from "../../../../src/domain/jsonValue.js"; -import { artifactFilePrimaryFailure } from "../../../../src/artifacts/ArtifactFileOperation.js"; +import { artifactFilePrimaryFacts } from "../../../../src/artifacts/ArtifactFileOperation.js"; import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; import { spawn, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; @@ -741,7 +741,10 @@ it("keeps a snapshot read failure primary across late cancellation and failed cl if (result.ok) throw new Error("Expected read and cleanup failure"); expect(result.error.kind).toBe("protocol"); expect(result.error.cancellation).toBeUndefined(); - expect(artifactFilePrimaryFailure(result.error.cause)).toBe(physical); + expect(artifactFilePrimaryFacts(result.error.cause)).toEqual({ + cause: physical, + origin: "owned-or-classified", + }); expect(result.error.partialObservation).toBeUndefined(); expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true); allowClose = true; diff --git a/tests/boundary/providers/ghidra/ghidraFileFailure.test.ts b/tests/boundary/providers/ghidra/ghidraFileFailure.test.ts new file mode 100644 index 0000000000..fdfaa35c80 --- /dev/null +++ b/tests/boundary/providers/ghidra/ghidraFileFailure.test.ts @@ -0,0 +1,174 @@ +import { open, writeFile, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { ghidraFileFailure } from "../../../../src/ghidra/GhidraFileArtifacts.js"; +import { + ArtifactFileCleanupFailure, + mapArtifactFileFailure, + withArtifactRegularFile, +} from "../../../../src/artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; +import { + AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../../../../src/artifacts/AnalysisCleanupLease.js"; +import { OwnedRegularFileOperationFailure } from "../../../../src/filesystem/OwnedRegularFile.js"; +import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, +} from "../../../../src/domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js"; +import { createEvidence } from "../../../../src/domain/evidence.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { nativePlistFailure } from "../../../../src/native/NativePlistSource.js"; +import { appleDispatchMetadataFailure } from "../../../../src/native/AppleDispatchMetadata.js"; + +const retainFailedDescriptor = async (path: string, primary: unknown) => { + const resources = new ArtifactResourceScope(); + let allowClose = false; + let handle: FileHandle | undefined; + const failure: unknown = await resources + .run(() => + withArtifactRegularFile(path, resources, undefined, async (file) => { + handle = file; + const close = file.close.bind(file); + file.close = async () => { + if (!allowClose) + throw new Error("controlled descriptor close refusal"); + await close(); + }; + throw new OwnedRegularFileOperationFailure(primary); + }), + ) + .catch((cause: unknown) => cause); + if (!(failure instanceof ArtifactFileCleanupFailure)) { + allowClose = true; + await resources.close(); + throw new Error("Expected the owned descriptor's cleanup failure"); + } + return { + failure, + resources, + get descriptor() { + return handle; + }, + release: async () => { + allowClose = true; + await resources.close(); + }, + }; +}; + +const retainForeignFailure = async (path: string) => { + await writeFile(path, "foreign bytes"); + const owner = new OwnedFileHandle(await open(path, "r")); + const lease = AnalysisCleanupLease.forCloseOwner(owner, path); + const partial = createEvidence( + undefined, + { id: "foreign", name: "foreign", version: null }, + { + operation: "inspect_artifact", + parameters: {}, + result: { foreign: true }, + }, + ); + const capturedOutput = { + stdout: "foreign output", + stderr: "foreign diagnostics", + truncated: false, + }; + const error = new ProviderCleanupError( + "foreign", + [path], + { reason: "foreign descriptor remains owned" }, + { + operation: "foreign-operation", + partialObservation: partial, + capturedOutput, + }, + ); + new AnalysisCleanupLeaseGroup(error, [lease]); + return { owner, lease, error, partial, capturedOutput }; +}; + +it.each(["opaque", "errno", "authored"] as const)( + "keeps Ghidra file provenance and local cleanup for a marked %s primary", + async (kind) => { + const root = await createTestTempDirectory("rea-ghidra-file-primary-"); + const path = join(root, "owned.txt"); + await writeFile(path, "owned bytes"); + const foreign = await retainForeignFailure(join(root, "foreign.txt")); + const raw = + kind === "errno" + ? Object.assign(new Error("controlled read denial"), { + code: "EACCES", + syscall: "read", + path, + }) + : foreign.error; + const owned = await retainFailedDescriptor(path, raw); + const authored = new AnalysisArtifactChangedError( + "resolve_analysis_profile", + path, + "authored identity classification", + { cause: raw }, + ); + try { + const projected = + kind === "authored" + ? mapArtifactFileFailure(owned.failure, () => authored) + : owned.failure; + const error = ghidraFileFailure(projected, "resolve_analysis_profile", { + path, + }); + const plist = nativePlistFailure(projected, { path, origin: "selected" }); + const dispatch = appleDispatchMetadataFailure(projected, path); + const errors = [error, plist, dispatch]; + for (const failure of errors) { + expect(failure.cleanupResources).toEqual([path]); + expect(failure.cleanupIncomplete).toBe(true); + expect(failure.partialObservation).toBeUndefined(); + expect(failure.capturedOutput).toBeUndefined(); + expect(failure.cleanupPrimary).not.toBe(foreign.error); + } + if (kind === "opaque") { + expect(error.cleanupPrimary).toBeUndefined(); + expect(error.cause).toBe(owned.failure); + } else if (kind === "errno") { + expect(error.cleanupPrimary).toBeInstanceOf(AnalysisAccessDeniedError); + expect(error.cleanupPrimary).toMatchObject({ + path, + systemCode: "EACCES", + cause: raw, + }); + } else expect(error.cleanupPrimary).toBe(authored); + expect(owned.descriptor?.fd).toBeGreaterThanOrEqual(0); + await expect(owned.resources.close()).rejects.toThrow(); + await owned.release(); + expect(owned.descriptor?.fd).toBe(-1); + for (const failure of errors) { + expect(failure.cleanupIncomplete).toBe(false); + expect(failure.cleanupResources).toEqual([]); + } + if (kind === "errno") { + expect(projectAnalysisError(error).code).toBe("access_denied"); + expect(dispatch.cleanupPrimary).toBeInstanceOf( + AnalysisAccessDeniedError, + ); + expect(plist.cleanupPrimary?.message).toContain("permission denied"); + } + if (kind === "authored") expect(error.cleanupPrimary).toBe(authored); + expect(foreign.owner.handle.fd).toBeGreaterThanOrEqual(0); + expect(foreign.lease.released).toBe(false); + expect(foreign.error.cleanupIncomplete).toBe(true); + expect(foreign.error.partialObservation).toBe(foreign.partial); + expect(foreign.error.capturedOutput).toEqual(foreign.capturedOutput); + } finally { + await owned.release(); + await foreign.lease.release(); + } + }, +); diff --git a/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts b/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts index 6565c83db5..91269af03a 100644 --- a/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts +++ b/tests/boundary/providers/kuna/kunaSpanCloseOwnership.test.ts @@ -1,5 +1,5 @@ -import { readdir, type FileHandle } from "node:fs/promises"; -import { join } from "node:path"; +import { readdir, writeFile, open, type FileHandle } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; @@ -7,6 +7,11 @@ import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourc import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js"; +import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; +import { + RegularFileCleanupFailure, + RegularFileCancelled, +} from "../../../../src/filesystem/OwnedRegularFile.js"; import { run, session } from "./kunaSessionFixture.js"; const fault = vi.hoisted(() => ({ @@ -21,6 +26,7 @@ const fault = vi.hoisted(() => ({ hold: undefined as Promise | undefined, releaseHold: (): void => undefined, afterRead: (): void => undefined, + readRejection: undefined as { readonly cause: unknown } | undefined, handles: [] as FileHandle[], cause: Object.assign(new Error("controlled close refusal"), { code: "EIO" }), })); @@ -42,6 +48,8 @@ vi.mock("node:fs/promises", async (importOriginal) => { const read = await Reflect.apply(target.read, target, readArgs); fault.reads += 1; fault.afterRead(); + if (fault.readRejection !== undefined) + throw fault.readRejection.cause; return fault.short ? { ...read, bytesRead: 0 } : read; }; if (key === "close") @@ -73,6 +81,7 @@ afterEach(() => { fault.hold = undefined; fault.releaseHold = () => undefined; fault.afterRead = () => undefined; + fault.readRejection = undefined; fault.handles = []; }); @@ -172,6 +181,102 @@ it.each([false, true])( }, ); +it.each( + [ + "undefined", + "foreign cleanup", + "foreign cancellation", + "foreign typed cancellation", + ].flatMap((shape) => + [false, true].map((closeFails) => ({ shape, closeFails })), + ), +)( + "preserves a physical $shape rejection when local close fails: $closeFails", + async ({ shape, closeFails }) => { + const { cacheRoot, open: createClient } = await session( + "rea-kuna-span-physical-", + ); + const client = createClient(); + const foreignPath = join(dirname(cacheRoot), "foreign.txt"); + await writeFile(foreignPath, "foreign bytes"); + const foreign = new OwnedFileHandle(await open(foreignPath, "r")); + try { + await run(client, "procedure_assembly", { procedure: "main" }); + const [key] = await readdir(join(cacheRoot, "projects")); + fault.span = join( + cacheRoot, + "projects", + key ?? "", + "project", + "program.asm", + ); + const raw = + shape === "foreign cleanup" + ? new RegularFileCleanupFailure({ + path: foreignPath, + owner: foreign, + outcome: { kind: "completed", value: "foreign bytes" }, + cleanupCause: new Error("foreign close failure"), + }) + : shape === "foreign cancellation" + ? new RegularFileCancelled(foreignPath, foreign, { + outcome: { kind: "completed", value: "foreign bytes" }, + reason: "foreign cancellation reason", + }) + : shape === "foreign typed cancellation" + ? new AnalysisCancelledError("foreign_operation", { + cause: "foreign cancellation reason", + cleanup: { + reason: "foreign cleanup", + resources: [foreignPath], + }, + }) + : undefined; + fault.readRejection = { cause: raw }; + fault.fail = closeFails; + const controller = new AbortController(); + fault.afterRead = () => controller.abort("late caller abort"); + const result = await client.execute( + "procedure_assembly", + { procedure: "main" }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected physical span failure"); + const primary = closeFails ? result.error.cleanupPrimary : result.error; + expect(primary?._tag).toBe("ProviderAdapterError"); + expect(primary?.cause).toBe(raw); + expect(result.error.cleanupIncomplete).toBe(closeFails); + expect(result.error.cleanupResources).toEqual( + closeFails ? [fault.span] : [], + ); + expect(fault.handles).toHaveLength(1); + if (closeFails) expect(fault.handles[0]!.fd).toBeGreaterThanOrEqual(0); + else expect(fault.handles[0]!.fd).toBe(-1); + expect(foreign.handle.fd).toBeGreaterThanOrEqual(0); + fault.fail = false; + fault.readRejection = undefined; + fault.afterRead = () => undefined; + const recovered = await client.execute( + "procedure_assembly", + { procedure: "main" }, + {}, + ); + expect(recovered.ok).toBe(true); + expect(fault.handles[0]!.fd).toBe(-1); + expect(foreign.handle.fd).toBeGreaterThanOrEqual(0); + } finally { + fault.fail = false; + fault.readRejection = undefined; + fault.afterRead = () => undefined; + await client.close(); + await foreign.close(); + for (const handle of fault.handles) + if (handle.fd >= 0) await handle.close(); + } + }, +); + it("does not open a second span while a peer still holds a failed close", async () => { const { cacheRoot, open: createClient } = await session( "rea-kuna-span-close-gate-", diff --git a/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts index 5d23bf7d7a..0c394bbc8b 100644 --- a/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts +++ b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts @@ -382,12 +382,12 @@ it("keeps a file-operation primary linked to every retained file and process own resources: ["/tmp/kuna-test-source"], }, }); - const fileFailure = new RegularFileCleanupFailure( - "/tmp/kuna-test-source", - fileHandle, - { kind: "failed", cause: primary }, - new Error("descriptor close refused"), - ); + const fileFailure = new RegularFileCleanupFailure({ + path: "/tmp/kuna-test-source", + owner: fileHandle, + outcome: { kind: "failed", cause: primary }, + cleanupCause: new Error("descriptor close refused"), + }); kunaResources.bindFailure(fileFailure, primary); expect(primary.cleanupIncomplete).toBe(true); expect(primary.cleanupResources).toEqual([ diff --git a/tests/boundary/providers/managed/managedHashCancellation.test.ts b/tests/boundary/providers/managed/managedHashCancellation.test.ts index 13edc9e336..d917a6e6a2 100644 --- a/tests/boundary/providers/managed/managedHashCancellation.test.ts +++ b/tests/boundary/providers/managed/managedHashCancellation.test.ts @@ -40,12 +40,14 @@ it.each([ const controller = new AbortController(); const reason = reasonKind === "cleanup" - ? new RegularFileCleanupFailure( - join(directory, "other-input.dll"), - new OwnedFileHandle(admitted), - { kind: "completed", value: null }, - new Error("opaque caller reason, not this operation's cleanup"), - ) + ? new RegularFileCleanupFailure({ + path: join(directory, "other-input.dll"), + owner: new OwnedFileHandle(admitted), + outcome: { kind: "completed", value: null }, + cleanupCause: new Error( + "opaque caller reason, not this operation's cleanup", + ), + }) : Object.assign( new Error("cancel after actual managed source bytes arrive"), reasonKind === "filesystem" ? { code: "EACCES" } : {}, diff --git a/tests/process-global/setupConfigurationRollback.test.ts b/tests/process-global/setupConfigurationRollback.test.ts index 07ef98f844..e2182c03c1 100644 --- a/tests/process-global/setupConfigurationRollback.test.ts +++ b/tests/process-global/setupConfigurationRollback.test.ts @@ -140,7 +140,13 @@ vi.mock("../../src/application/RegularFileRead.js", async (importOriginal) => { }); import { ClientConfigurationPublication } from "../../src/application/ClientConfigurationPublication.js"; -import { configureClientConfiguration } from "../../src/application/SetupClientConfiguration.js"; +import { + configureClientConfiguration, + ClientConfigurationCleanupRollbackFailure, +} from "../../src/application/SetupClientConfiguration.js"; +import { RegularFileReadFailure } from "../../src/application/RegularFileRead.js"; +import { retryClientConfigurationCleanup } from "../../src/application/ClientConfigurationCleanup.js"; +import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; import { RegularFileCleanupFailure } from "../../src/filesystem/OwnedRegularFile.js"; import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js"; import type { DoctorHost } from "../../src/application/Doctor.js"; @@ -268,12 +274,12 @@ it("does not roll back or remove config when its initial read cleanup fails", as await import("node:fs/promises").then(({ writeFile }) => writeFile(path, original), ); - const failure = new RegularFileCleanupFailure( + const failure = new RegularFileCleanupFailure({ path, - await ownReadHandle(path), - { kind: "completed", value: original }, - new Error("injected initial close failure"), - ); + owner: await ownReadHandle(path), + outcome: { kind: "completed", value: original }, + cleanupCause: new Error("injected initial close failure"), + }); state.initialCleanupFailure = failure; await expect( @@ -327,12 +333,12 @@ it("retains readback cleanup ownership when rollback also fails", async () => { writeFile(path, original), ); const owner = await ownReadHandle(path); - const cleanupFailure = new RegularFileCleanupFailure( + const cleanupFailure = new RegularFileCleanupFailure({ path, owner, - { kind: "completed", value: "" }, - new Error("injected readback close failure"), - ); + outcome: { kind: "completed", value: "" }, + cleanupCause: new Error("injected readback close failure"), + }); state.readbackCleanupFailure = cleanupFailure; state.rejectRestore = true; @@ -442,12 +448,12 @@ it("retains and retries both readback and publication descriptors after mixed cl if (rejectReadClose) throw new Error("injected readback close failure"); await closeRead(); }; - state.readbackCleanupFailure = new RegularFileCleanupFailure( + state.readbackCleanupFailure = new RegularFileCleanupFailure({ path, - readOwner, - { kind: "completed", value: "" }, - new Error("injected readback close failure"), - ); + owner: readOwner, + outcome: { kind: "completed", value: "" }, + cleanupCause: new Error("injected readback close failure"), + }); state.rejectPublicationClose = true; const host = systemSetupHost( { platform: "linux", homeDirectory: root } as DoctorHost, @@ -545,12 +551,12 @@ it("does not adopt a descriptor named by an opaque publication close rejection", const foreignOwner = await ownReadHandle(unrelated); state.failReadback = false; state.rejectPublicationClose = true; - state.publicationCloseFailure = new RegularFileCleanupFailure( - unrelated, - foreignOwner, - { kind: "completed", value: "unrelated" }, - new Error("opaque close rejection"), - ); + state.publicationCloseFailure = new RegularFileCleanupFailure({ + path: unrelated, + owner: foreignOwner, + outcome: { kind: "completed", value: "unrelated" }, + cleanupCause: new Error("opaque close rejection"), + }); const host = systemSetupHost( { platform: "linux", homeDirectory: root } as DoctorHost, { HOME: root }, @@ -640,12 +646,12 @@ it("uninstall retains both readback and publication owners until cleanup retry", if (rejectReadClose) throw new Error("injected readback close failure"); await closeRead(); }; - state.readbackCleanupFailure = new RegularFileCleanupFailure( + state.readbackCleanupFailure = new RegularFileCleanupFailure({ path, - readOwner, - { kind: "completed", value: "" }, - new Error("readback close failed"), - ); + owner: readOwner, + outcome: { kind: "completed", value: "" }, + cleanupCause: new Error("readback close failed"), + }); state.rejectPublicationClose = true; const host = systemUninstallHost(root); try { @@ -691,12 +697,12 @@ it("uninstall retains the exact owner of a failed initial config read", async () if (rejectClose) throw new Error("initial close denied"); await close(); }; - state.initialCleanupFailure = new RegularFileCleanupFailure( + state.initialCleanupFailure = new RegularFileCleanupFailure({ path, owner, - { kind: "completed", value: original }, - new Error("initial close denied"), - ); + outcome: { kind: "completed", value: original }, + cleanupCause: new Error("initial close denied"), + }); const host = systemUninstallHost(root); try { expect( @@ -768,6 +774,74 @@ it("uninstall close joins a publication already admitted by its host", async () ).rejects.toThrow("closed"); }); +it("preserves physical read origin through failed readback cleanup and rollback", async () => { + const root = await createTestTempDirectory("rea-config-physical-rollback-"); + const path = join(root, "client.json"); + const other = join(root, "foreign.json"); + const fs = + await vi.importActual( + "node:fs/promises", + ); + await fs.writeFile(path, '{"keep":true}\n'); + await fs.writeFile(other, "foreign bytes"); + const owned = await ownReadHandle(path); + const foreign = await ownReadHandle(other); + const opaque = new RegularFileCleanupFailure({ + path: other, + owner: foreign, + outcome: { kind: "completed", value: "foreign bytes" }, + cleanupCause: new Error("foreign cleanup-shaped physical rejection"), + }); + const physical = new RegularFileReadFailure(opaque); + const close = owned.handle.close.bind(owned.handle); + let denyClose = true; + owned.handle.close = async () => { + if (denyClose) throw new Error("readback close denied"); + await close(); + }; + state.readbackCleanupFailure = new RegularFileCleanupFailure({ + path, + owner: owned, + outcome: { kind: "failed", cause: opaque }, + operationFailure: physical, + cleanupCause: new Error("readback close denied"), + }); + state.rejectRestore = true; + const resources = new ArtifactResourceScope(); + try { + const failure: unknown = await configureClientConfiguration( + { name: "cursor", configPath: path, format: "json" }, + {}, + ["rea", "mcp"], + ).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(ClientConfigurationCleanupRollbackFailure); + if (!(failure instanceof ClientConfigurationCleanupRollbackFailure)) + throw new Error("Expected failed readback cleanup and rollback"); + expect(failure.operationFailure).toBe(physical); + expect(failure.outcome).toEqual({ kind: "failed", cause: opaque }); + expect(failure.configurationFailure).toMatchObject({ + status: "failed", + reason: "rollback", + failure: { kind: "restore", detail: "injected restore write failure" }, + }); + const retried = await retryClientConfigurationCleanup(failure, resources); + expect(retried.primary).toBe(opaque); + expect(retried.failures).toHaveLength(1); + expect(retried.cleanup?.resources).toEqual([path]); + expect(owned.handle.fd).toBeGreaterThanOrEqual(0); + expect(foreign.handle.fd).toBeGreaterThanOrEqual(0); + denyClose = false; + await resources.close(); + expect(owned.handle.fd).toBe(-1); + expect(foreign.handle.fd).toBeGreaterThanOrEqual(0); + expect(await fs.readFile(other, "utf8")).toBe("foreign bytes"); + } finally { + denyClose = false; + await resources.close(); + await owned.close(); + } +}); + it("cleanup retry never adopts an opaque cancellation reason as another owner", async () => { const root = await createTestTempDirectory( "rea-cleanup-cancellation-authority-", @@ -782,12 +856,12 @@ it("cleanup retry never adopts an opaque cancellation reason as another owner", await fs.writeFile(other, "other"); const owned = await ownReadHandle(path); const foreign = await ownReadHandle(other); - const opaque = new RegularFileCleanupFailure( - other, - foreign, - { kind: "completed", value: "other" }, - new Error("opaque reason"), - ); + const opaque = new RegularFileCleanupFailure({ + path: other, + owner: foreign, + outcome: { kind: "completed", value: "other" }, + cleanupCause: new Error("opaque reason"), + }); const { RegularFileOperationCancelled } = await import("../../src/filesystem/RegularFile.js"); const { ArtifactResourceScope } = @@ -796,12 +870,15 @@ it("cleanup retry never adopts an opaque cancellation reason as another owner", await import("../../src/application/RegularFileRead.js"); const resources = new ArtifactResourceScope(); const outcome = await retryRegularFileCleanupFailures( - new RegularFileCleanupFailure( + new RegularFileCleanupFailure({ path, - owned, - { kind: "failed", cause: new RegularFileOperationCancelled(opaque) }, - new Error("owned close failed"), - ), + owner: owned, + outcome: { + kind: "failed", + cause: new RegularFileOperationCancelled(opaque), + }, + cleanupCause: new Error("owned close failed"), + }), resources, ); expect(outcome.primary).toBe(opaque); @@ -847,12 +924,12 @@ it("uninstall progresses when a peer leaves a denied-close descriptor pending", let timeout: ReturnType | undefined; try { await arrived.promise; - state.initialCleanupFailure = new RegularFileCleanupFailure( - other, + state.initialCleanupFailure = new RegularFileCleanupFailure({ + path: other, owner, - { kind: "completed", value: "{}" }, - new Error("peer close denied"), - ); + outcome: { kind: "completed", value: "{}" }, + cleanupCause: new Error("peer close denied"), + }); expect( await host.inspectClient({ name: "other", diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index b166e9015f..ef6205e1b4 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -1,4 +1,4 @@ -import { writeFile, type FileHandle } from "node:fs/promises"; +import { open, writeFile, type FileHandle } from "node:fs/promises"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; @@ -63,7 +63,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { import { RustBinaryProvider } from "../../src/rust/RustBinaryProvider.js"; import { RustBinaryService } from "../../src/application/rust/RustBinaryService.js"; import { AnalysisError } from "../../src/domain/analysisErrorBase.js"; -import { parseEvidence } from "../../src/domain/evidence.js"; +import { createEvidence, parseEvidence } from "../../src/domain/evidence.js"; import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { AnalysisCancelledError, @@ -74,9 +74,21 @@ import { } from "../../src/domain/analysisErrorCore.js"; import { ArtifactFileCleanupFailure, + mapArtifactFileFailure, + artifactFilePrimaryFacts, withArtifactRegularFile, } from "../../src/artifacts/ArtifactFileOperation.js"; +import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js"; +import { translateArtifactFailure } from "../../src/artifacts/ArtifactOperationFailure.js"; +import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js"; +import { + OwnedRegularFileOperationFailure, + RegularFileCleanupFailure, + regularFileCancellation, +} from "../../src/filesystem/OwnedRegularFile.js"; +import { RegularFileOperationCancelled } from "../../src/filesystem/RegularFile.js"; import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { readStableArtifact } from "../../src/artifacts/readStableArtifact.js"; import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js"; import { GoBinaryProvider } from "../../src/go/GoBinaryProvider.js"; import { PythonPycProvider } from "../../src/python/PythonPycProvider.js"; @@ -697,6 +709,148 @@ it("preserves upstream resource-bound facts through an unconfirmed descriptor cl } }); +it.each([ + "physical cancellation", + "mapped foreign artifact", + "foreign nested artifact", +] as const)( + "preserves %s provenance while retrying only the owned descriptor", + async (shape) => { + const path = await selectedImage(); + const foreignPath = join(path, "..", "foreign.txt"); + await writeFile(foreignPath, "foreign owner bytes"); + const foreign = new OwnedFileHandle(await open(foreignPath, "r")); + const foreignLease = AnalysisCleanupLease.forCloseOwner( + foreign, + foreignPath, + ); + const foreignPartial = createEvidence( + undefined, + { id: "foreign", name: "foreign owner", version: null }, + { + operation: "inspect_artifact", + parameters: {}, + result: { foreign: true }, + confidence: "observed", + }, + ); + const foreignReason = { source: "foreign cancellation" }; + const foreignFailure = new ArtifactReaderFailure( + "cancelled", + "foreign artifact failure", + { + cause: foreignReason, + cancellation: { reason: foreignReason }, + cleanup: { resources: [foreignPath], reason: "foreign cleanup" }, + cleanupLeases: [foreignLease], + partialObservation: foreignPartial, + }, + ); + const raw = + shape === "physical cancellation" + ? new RegularFileOperationCancelled(foreignReason) + : shape === "foreign nested artifact" + ? new ArtifactFileCleanupFailure( + new RegularFileCleanupFailure({ + path: foreignPath, + owner: foreign, + outcome: { kind: "failed", cause: foreignFailure }, + cleanupCause: new Error("foreign descriptor close failure"), + }), + foreignLease, + ) + : foreignFailure; + const marker = new OwnedRegularFileOperationFailure(raw); + const controller = new AbortController(); + const lateReason = { source: "late owned-close abort" }; + fault.onClose = () => controller.abort(lateReason); + const scope = new ArtifactResourceScope(); + try { + const failure = await scope + .run(() => + withArtifactRegularFile(path, scope, controller.signal, async () => { + throw marker; + }), + ) + .catch((cause: unknown) => cause); + if (!(failure instanceof ArtifactFileCleanupFailure)) + throw new Error("Expected the actual input descriptor's failed close"); + expect(artifactFilePrimaryFacts(failure)).toEqual({ + cause: raw, + origin: "opaque-operation", + }); + const projected = mapArtifactFileFailure(failure, (primary) => { + expect(primary).toBe(raw); + return shape === "mapped foreign artifact" + ? new ArtifactReaderFailure( + "unavailable", + "authored permission classification", + { cause: raw }, + ) + : primary; + }); + if (!(projected instanceof ArtifactFileCleanupFailure)) + throw new Error( + "Expected the same owned descriptor envelope after projection", + ); + for (const envelope of [failure, projected]) { + expect(envelope.reason).toBe( + envelope === projected && shape === "mapped foreign artifact" + ? "unavailable" + : "io", + ); + expect(envelope.cancellation).toBeUndefined(); + expect(envelope.partialObservation).toBeUndefined(); + expect(envelope.cleanup?.resources).toEqual([path]); + expect( + regularFileCancellation(envelope.fileCleanupFailure), + ).toBeUndefined(); + } + expect(failure.fileCleanupFailure.outcome).toEqual({ + kind: "failed", + cause: raw, + }); + expect(projected.fileCleanupFailure.operationFailure).toBe(marker); + expect(artifactFilePrimaryFacts(projected).cause).toBe( + projected.fileCleanupFailure.outcome.kind === "failed" + ? projected.fileCleanupFailure.outcome.cause + : undefined, + ); + expect(artifactFilePrimaryFacts(projected).origin).toBe( + shape === "mapped foreign artifact" + ? "owned-or-classified" + : "opaque-operation", + ); + expect(marker.operationCause).toBe(raw); + expect(projected.cleanupLease).toBe(failure.cleanupLease); + expect(projected.cleanupLeases).toEqual([failure.cleanupLease]); + const error = translateArtifactFailure("inspect_artifact", projected); + expect(error.cleanupResources).toEqual([path]); + expect(error.cleanupIncomplete).toBe(true); + expect(error.partialObservation).toBeUndefined(); + expect(controller.signal.reason).toBe(lateReason); + expect(fault.handles).toHaveLength(1); + expect(fault.handles[0]!.fd).toBeGreaterThanOrEqual(0); + await expect(scope.close()).rejects.toThrow(); + expect(foreign.handle.fd).toBeGreaterThanOrEqual(0); + fault.blocked.clear(); + await scope.close(); + expect(fault.handles[0]!.fd).toBe(-1); + expect(error.cleanupIncomplete).toBe(false); + expect(error.cleanupResources).toEqual([]); + expect(foreign.handle.fd).toBeGreaterThanOrEqual(0); + expect(foreignLease.released).toBe(false); + expect(foreignFailure.cleanup?.resources).toEqual([foreignPath]); + expect(foreignFailure.partialObservation).toBe(foreignPartial); + } finally { + fault.onClose = undefined; + fault.blocked.clear(); + await scope.close(); + await foreign.close(); + } + }, +); + const stableArtifactProviders = [ ...staticProviders, ["ELF notes", () => new ElfNotesProvider()], @@ -859,11 +1013,12 @@ it.each( try { const failure = await scope .run(() => - withArtifactRegularFile( + readStableArtifact( path, - scope, + 1024 * 1024, timing === "recorded" ? controller.signal : undefined, - async () => undefined, + undefined, + scope, ), ) .catch((cause: unknown) => cause);