fix(filesystem): preserve failure provenance across cleanup owners

Carry descriptor outcomes, cancellation, retention and physical origin as named facts so wrappers cannot drop ownership context. Replace raw primary failure unwrapping with provenance facts across artifact consumers, and use Result for Kuna span reads including undefined rejections.

Exercise actual descriptors, rollback, cancellation and cleanup retry; update brittle snapshot and completion fixtures to use their producing boundaries.
This commit is contained in:
morluto
2026-10-24 07:41:17 +08:00
parent a062529bb2
commit 4610843efd
41 changed files with 1172 additions and 412 deletions
+10 -15
View File
@@ -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 (
+14 -5
View File
@@ -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")
+5 -4
View File
@@ -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),
+19 -5
View File
@@ -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<typeof stat>) => {
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();
+1 -9
View File
@@ -40,15 +40,7 @@ export class RetainedRegularFileCleanupFailure<
>;
readonly cleanupLease: AnalysisCleanupLease;
constructor(failure: RegularFileCleanupFailure<Value>) {
super(
failure.path,
failure.owner,
failure.outcome,
failure.cleanupCause,
failure.retention,
failure.completionCancellation,
failure.operationFailure,
);
super(failure);
this.cleanupLease =
failure instanceof RetainedRegularFileCleanupFailure
? failure.cleanupLease
+4 -8
View File
@@ -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,
);
});
}
}
+46 -26
View File
@@ -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. */
+25 -7
View File
@@ -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(
+48 -23
View File
@@ -51,21 +51,39 @@ export class RegularFileCancelled<Value = unknown> extends Error {
}
/** A failed close transfers its exact descriptor owner and operation outcome. */
export interface RegularFileCleanupFacts<Value = unknown> {
readonly path: string;
readonly owner: OwnedFileHandle;
readonly outcome: RegularFileOperationOutcome<Value>;
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<Value = unknown> extends Error {
constructor(
readonly path: string,
readonly owner: OwnedFileHandle,
readonly outcome: RegularFileOperationOutcome<Value>,
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<Value>;
readonly cleanupCause: unknown;
readonly retention: NonNullable<RegularFileCleanupFacts["retention"]>;
readonly completionCancellation: { readonly reason: unknown } | undefined;
readonly operationFailure: OwnedRegularFileOperationFailure | undefined;
constructor(facts: RegularFileCleanupFacts<Value>) {
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<Value = unknown> 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 <Value>(
}).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 <Value>(
: 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)
+3 -2
View File
@@ -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(
+3 -4
View File
@@ -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",
+2 -2
View File
@@ -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 &&
+12 -4
View File
@@ -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,
+2 -2
View File
@@ -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"
+4 -4
View File
@@ -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(
+27 -12
View File
@@ -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",
+7 -3
View File
@@ -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"
)
+21 -7
View File
@@ -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,
+23 -22
View File
@@ -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<string, unknown>;
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<string>,
text: string | undefined,
readFailure: unknown,
read: Result<string, unknown>,
signal: AbortSignal | undefined,
): RegularFileCleanupFailure<string> => {
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, {
+7 -3
View File
@@ -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"
)
+51 -30
View File
@@ -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 (
+65 -47
View File
@@ -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", {
+22 -7
View File
@@ -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)
+21 -7
View File
@@ -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)
+22 -7
View File
@@ -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)
+22 -7
View File
@@ -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)
+24 -7
View File
@@ -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(
+20 -7
View File
@@ -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")
@@ -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");
@@ -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();
@@ -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();
@@ -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: {
+5 -5
View File
@@ -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")
@@ -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; });
@@ -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;
@@ -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();
}
},
);
@@ -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<void> | 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-",
@@ -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([
@@ -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" } : {},
@@ -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<typeof import("node:fs/promises")>(
"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<typeof setTimeout> | 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",
@@ -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);