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);