mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(artifacts): preserve completed caller reads through finalization
Retain file identity after Rust, web artifact, shared selected-input and historical capture reads complete before close or cancellation fails. Preserve descriptor-set snapshots in diagnostics alongside sanitized capture observations. Classify canonical physical capture failures before cleanup composition; source permission errors and bare syscall failures keep their meaning through late cancellation and retry. Keep explicit sensitive-value redaction on retained facts. Validation: 247 focused cases passed, two Linux-only cases skipped; build, typecheck, lint and formatting passed. Real descriptors with controlled finalization faults establish reader ownership and facts. Worker reply and multi-file database completion semantics remain separate audit work.
This commit is contained in:
1 parent
31e8492faf
commit
939009f6f9
8 files changed
+461
-12
No files matched your search
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../../artifacts/ArtifactFileOperation.js";
|
} from "../../artifacts/ArtifactFileOperation.js";
|
||||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||||
|
import { completedStableArtifactObservation } from "../../artifacts/StableArtifactObservation.js";
|
||||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import {
|
import {
|
||||||
AnalysisAccessDeniedError,
|
AnalysisAccessDeniedError,
|
||||||
@@ -76,6 +77,11 @@ export const webArtifactReadError = (
|
|||||||
context,
|
context,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
|
const observation = completedStableArtifactObservation(
|
||||||
|
cause,
|
||||||
|
context.targetPath,
|
||||||
|
);
|
||||||
|
if (observation !== undefined) primary.retainPartialObservation(observation);
|
||||||
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
|
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
|
||||||
const cleanup = new ProviderCleanupError(
|
const cleanup = new ProviderCleanupError(
|
||||||
"captured-web-artifacts",
|
"captured-web-artifacts",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorClean
|
|||||||
import {
|
import {
|
||||||
bindArtifactFileCleanup,
|
bindArtifactFileCleanup,
|
||||||
artifactFileCancellationCause,
|
artifactFileCancellationCause,
|
||||||
|
artifactFilePrimaryFacts,
|
||||||
} from "../../artifacts/ArtifactFileOperation.js";
|
} from "../../artifacts/ArtifactFileOperation.js";
|
||||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import {
|
import {
|
||||||
@@ -44,12 +45,14 @@ export const CAPTURE_PROCESS_MEMORY_LIMIT_REASON =
|
|||||||
/** Preserve the origin and typed cause of a historical capture failure. */
|
/** Preserve the origin and typed cause of a historical capture failure. */
|
||||||
export const historicalCaptureFailure = (
|
export const historicalCaptureFailure = (
|
||||||
input: InspectWebNetworkCaptureInput,
|
input: InspectWebNetworkCaptureInput,
|
||||||
cause: unknown,
|
rawCause: unknown,
|
||||||
phase: "capture-read" | "descriptor-read" | "decoder",
|
phase: "capture-read" | "descriptor-read" | "decoder",
|
||||||
options?: ExecutionOptions,
|
options?: ExecutionOptions,
|
||||||
heap: CaptureDecoderHeap = DEFAULT_CAPTURE_DECODER_HEAP,
|
heap: CaptureDecoderHeap = DEFAULT_CAPTURE_DECODER_HEAP,
|
||||||
): AnalysisError => {
|
): AnalysisError => {
|
||||||
if (cause instanceof AnalysisError) return cause;
|
const { cause, origin } = artifactFilePrimaryFacts(rawCause);
|
||||||
|
if (origin === "owned-or-classified" && cause instanceof AnalysisError)
|
||||||
|
return cause;
|
||||||
const descriptorRead = phase === "descriptor-read";
|
const descriptorRead = phase === "descriptor-read";
|
||||||
const sourcePath = descriptorRead
|
const sourcePath = descriptorRead
|
||||||
? (input.protobuf?.schema?.descriptor_set_path ?? input.capture_path)
|
? (input.protobuf?.schema?.descriptor_set_path ?? input.capture_path)
|
||||||
@@ -58,7 +61,10 @@ export const historicalCaptureFailure = (
|
|||||||
? ["protobuf", "schema", "descriptor_set_path"]
|
? ["protobuf", "schema", "descriptor_set_path"]
|
||||||
: ["capture_path"];
|
: ["capture_path"];
|
||||||
const sourceLabel = descriptorRead ? "descriptor set" : "capture";
|
const sourceLabel = descriptorRead ? "descriptor set" : "capture";
|
||||||
if (cause instanceof OwnedCommandFailure) {
|
if (
|
||||||
|
origin === "owned-or-classified" &&
|
||||||
|
cause instanceof OwnedCommandFailure
|
||||||
|
) {
|
||||||
if (cause.cleanupFailure !== null)
|
if (cause.cleanupFailure !== null)
|
||||||
return new ProviderCleanupError(
|
return new ProviderCleanupError(
|
||||||
input.format,
|
input.format,
|
||||||
@@ -128,6 +134,7 @@ export const historicalCaptureFailure = (
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
phase !== "decoder" &&
|
phase !== "decoder" &&
|
||||||
|
origin === "owned-or-classified" &&
|
||||||
cause instanceof ArtifactReaderFailure &&
|
cause instanceof ArtifactReaderFailure &&
|
||||||
cause.reason === "integrity"
|
cause.reason === "integrity"
|
||||||
)
|
)
|
||||||
@@ -137,12 +144,17 @@ export const historicalCaptureFailure = (
|
|||||||
cause.message,
|
cause.message,
|
||||||
{ cause },
|
{ cause },
|
||||||
);
|
);
|
||||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
|
if (
|
||||||
|
origin === "owned-or-classified" &&
|
||||||
|
cause instanceof ArtifactReaderFailure &&
|
||||||
|
cause.reason === "cancelled"
|
||||||
|
)
|
||||||
return new AnalysisCancelledError(OPERATION, {
|
return new AnalysisCancelledError(OPERATION, {
|
||||||
cause: artifactFileCancellationCause(cause),
|
cause: artifactFileCancellationCause(cause),
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
phase !== "decoder" &&
|
phase !== "decoder" &&
|
||||||
|
origin === "owned-or-classified" &&
|
||||||
cause instanceof ArtifactReaderFailure &&
|
cause instanceof ArtifactReaderFailure &&
|
||||||
(cause.reason === "limit" ||
|
(cause.reason === "limit" ||
|
||||||
cause.reason === "format" ||
|
cause.reason === "format" ||
|
||||||
@@ -191,6 +203,10 @@ export const historicalCaptureFailure = (
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
if (
|
if (
|
||||||
|
// Stable source reads report owned cancellation with ArtifactReaderFailure.
|
||||||
|
// Bare syscall rejections remain physical even when they equal the abort reason.
|
||||||
|
phase === "decoder" &&
|
||||||
|
origin === "owned-or-classified" &&
|
||||||
options?.signal?.aborted === true &&
|
options?.signal?.aborted === true &&
|
||||||
(cause === options.signal.reason ||
|
(cause === options.signal.reason ||
|
||||||
(cause instanceof Error &&
|
(cause instanceof Error &&
|
||||||
@@ -210,10 +226,13 @@ export const historicalCaptureFailure = (
|
|||||||
? { source_path: sourcePath, input_path: inputPath }
|
? { source_path: sourcePath, input_path: inputPath }
|
||||||
: {}),
|
: {}),
|
||||||
reason: cause instanceof Error ? cause.message : String(cause),
|
reason: cause instanceof Error ? cause.message : String(cause),
|
||||||
...(cause instanceof ArtifactReaderFailure
|
...(origin === "owned-or-classified" &&
|
||||||
|
cause instanceof ArtifactReaderFailure
|
||||||
? { failure_kind: cause.reason }
|
? { failure_kind: cause.reason }
|
||||||
: {}),
|
: {}),
|
||||||
...(cause instanceof OwnedCommandFailure && cause.snapshot !== null
|
...(origin === "owned-or-classified" &&
|
||||||
|
cause instanceof OwnedCommandFailure &&
|
||||||
|
cause.snapshot !== null
|
||||||
? {
|
? {
|
||||||
exit_code: cause.snapshot.exitCode ?? null,
|
exit_code: cause.snapshot.exitCode ?? null,
|
||||||
signal: cause.snapshot.signal ?? null,
|
signal: cause.snapshot.signal ?? null,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { writeFile } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||||
|
import { completedStableArtifactObservation } from "../../artifacts/StableArtifactObservation.js";
|
||||||
import type { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
import type { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||||
import {
|
import {
|
||||||
ArtifactResourceScope,
|
ArtifactResourceScope,
|
||||||
@@ -392,12 +393,18 @@ export class HistoricalCaptureDecoder {
|
|||||||
reply_finalization_error: projectAnalysisError(cancellation),
|
reply_finalization_error: projectAnalysisError(cancellation),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
result = err(
|
const failure = withCaptureReaderCleanup(
|
||||||
redactExplicitFailure(
|
error,
|
||||||
withCaptureReaderCleanup(error, replyFinalization ?? cause),
|
replyFinalization ?? cause,
|
||||||
input.sensitive_values,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
if (phase === "capture-read") {
|
||||||
|
const snapshot = completedStableArtifactObservation(
|
||||||
|
cause,
|
||||||
|
input.capture_path,
|
||||||
|
);
|
||||||
|
if (snapshot !== undefined) failure.retainPartialObservation(snapshot);
|
||||||
|
}
|
||||||
|
result = err(redactExplicitFailure(failure, input.sensitive_values));
|
||||||
}
|
}
|
||||||
const completedCapture = result.ok ? result.value : undefined;
|
const completedCapture = result.ok ? result.value : undefined;
|
||||||
let cleanupFailure:
|
let cleanupFailure:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "../CaptureRedaction.js";
|
} from "../CaptureRedaction.js";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { readStableArtifact } from "../../../artifacts/readStableArtifact.js";
|
import { readStableArtifact } from "../../../artifacts/readStableArtifact.js";
|
||||||
|
import { completedStableArtifactObservation } from "../../../artifacts/StableArtifactObservation.js";
|
||||||
import {
|
import {
|
||||||
historicalCaptureFailure,
|
historicalCaptureFailure,
|
||||||
withCaptureReaderCleanup,
|
withCaptureReaderCleanup,
|
||||||
@@ -546,7 +547,7 @@ export const decodeCaptureProtobuf = async (
|
|||||||
undefined,
|
undefined,
|
||||||
resources,
|
resources,
|
||||||
).catch((cause: unknown) => {
|
).catch((cause: unknown) => {
|
||||||
throw withCaptureReaderCleanup(
|
const failure = withCaptureReaderCleanup(
|
||||||
historicalCaptureFailure(
|
historicalCaptureFailure(
|
||||||
input,
|
input,
|
||||||
cause,
|
cause,
|
||||||
@@ -555,6 +556,11 @@ export const decodeCaptureProtobuf = async (
|
|||||||
),
|
),
|
||||||
cause,
|
cause,
|
||||||
);
|
);
|
||||||
|
const snapshot = completedStableArtifactObservation(cause, path);
|
||||||
|
// The caller retains the sanitized capture as its partial observation.
|
||||||
|
if (snapshot !== undefined)
|
||||||
|
failure.retainDiagnostics({ descriptor_set_snapshot: snapshot });
|
||||||
|
throw failure;
|
||||||
});
|
});
|
||||||
const { ProtobufSchema } = await import("./ProtobufSchema.js");
|
const { ProtobufSchema } = await import("./ProtobufSchema.js");
|
||||||
const admitted = await ProtobufSchema.read(
|
const admitted = await ProtobufSchema.read(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||||
|
import { completedStableArtifactObservation } from "../artifacts/StableArtifactObservation.js";
|
||||||
import {
|
import {
|
||||||
AnalysisError,
|
AnalysisError,
|
||||||
type AnalysisCapturedOutput,
|
type AnalysisCapturedOutput,
|
||||||
@@ -176,6 +177,9 @@ export class ArtifactAnalysisResources {
|
|||||||
): AnalysisError {
|
): AnalysisError {
|
||||||
const { path, maximumBytes, allowanceName, observedOutput } = input;
|
const { path, maximumBytes, allowanceName, observedOutput } = input;
|
||||||
if (cause instanceof AnalysisError) return cause;
|
if (cause instanceof AnalysisError) return cause;
|
||||||
|
const observation = completedStableArtifactObservation(cause, path);
|
||||||
|
if (cause instanceof ArtifactReaderFailure && observation !== undefined)
|
||||||
|
cause.retainPartialObservation(observation);
|
||||||
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
|
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
|
||||||
return this.cleanupFailure(
|
return this.cleanupFailure(
|
||||||
cause,
|
cause,
|
||||||
@@ -189,6 +193,9 @@ export class ArtifactAnalysisResources {
|
|||||||
...(observedOutput === undefined
|
...(observedOutput === undefined
|
||||||
? {}
|
? {}
|
||||||
: { capturedOutput: observedOutput }),
|
: { capturedOutput: observedOutput }),
|
||||||
|
...(cause.partialObservation === undefined
|
||||||
|
? {}
|
||||||
|
: { partialObservation: cause.partialObservation }),
|
||||||
});
|
});
|
||||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit")
|
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit")
|
||||||
@@ -467,6 +474,8 @@ export class ArtifactAnalysisResources {
|
|||||||
partialObservation: cause.partialObservation,
|
partialObservation: cause.partialObservation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (cause.partialObservation !== undefined)
|
||||||
|
(primary ?? failure).retainPartialObservation(cause.partialObservation);
|
||||||
return primary === undefined
|
return primary === undefined
|
||||||
? failure
|
? failure
|
||||||
: analysisErrorWithCleanupFailure(
|
: analysisErrorWithCleanupFailure(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { isSelectedRegularFileError } from "../filesystem/RegularFile.js";
|
import { isSelectedRegularFileError } from "../filesystem/RegularFile.js";
|
||||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||||
|
import { completedStableArtifactObservation } from "../artifacts/StableArtifactObservation.js";
|
||||||
import {
|
import {
|
||||||
artifactFilePrimaryFacts,
|
artifactFilePrimaryFacts,
|
||||||
type ArtifactFilePrimaryFacts,
|
type ArtifactFilePrimaryFacts,
|
||||||
@@ -285,6 +286,8 @@ const failureForPrimary = (
|
|||||||
|
|
||||||
const failureFor = (cause: unknown, path: string): AnalysisError => {
|
const failureFor = (cause: unknown, path: string): AnalysisError => {
|
||||||
const primary = failureForPrimary(artifactFilePrimaryFacts(cause), path);
|
const primary = failureForPrimary(artifactFilePrimaryFacts(cause), path);
|
||||||
|
const observation = completedStableArtifactObservation(cause, path);
|
||||||
|
if (observation !== undefined) primary.retainPartialObservation(observation);
|
||||||
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
|
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
|
||||||
return primary;
|
return primary;
|
||||||
const cleanup = new ProviderCleanupError(
|
const cleanup = new ProviderCleanupError(
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import type { FileHandle } from "node:fs/promises";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fault = vi.hoisted(() => ({
|
||||||
|
path: "",
|
||||||
|
mode: "close" as
|
||||||
|
| "close"
|
||||||
|
| "cancel"
|
||||||
|
| "early"
|
||||||
|
| "early-cancel-close"
|
||||||
|
| "early-abort-close",
|
||||||
|
blocked: true,
|
||||||
|
controller: undefined as AbortController | undefined,
|
||||||
|
reason: { caller: "selected source finalization" },
|
||||||
|
physical: Object.assign(new Error("selected descriptor stat denied"), {
|
||||||
|
code: "EACCES",
|
||||||
|
}),
|
||||||
|
handles: [] as { handle: FileHandle; close: () => Promise<void> }[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||||
|
const filesystem = await importOriginal<typeof import("node:fs/promises")>();
|
||||||
|
return {
|
||||||
|
...filesystem,
|
||||||
|
open: async (...args: Parameters<typeof filesystem.open>) => {
|
||||||
|
const handle = await filesystem.open(...args);
|
||||||
|
if (String(args[0]) !== fault.path) return handle;
|
||||||
|
const close = handle.close.bind(handle);
|
||||||
|
fault.handles.push({ handle, close });
|
||||||
|
if (
|
||||||
|
fault.mode === "early" ||
|
||||||
|
fault.mode === "early-cancel-close" ||
|
||||||
|
fault.mode === "early-abort-close"
|
||||||
|
)
|
||||||
|
handle.stat = async () => {
|
||||||
|
if (fault.mode === "early-cancel-close")
|
||||||
|
fault.controller?.abort(fault.reason);
|
||||||
|
if (fault.mode === "early-abort-close")
|
||||||
|
fault.controller?.abort(fault.physical);
|
||||||
|
throw fault.physical;
|
||||||
|
};
|
||||||
|
handle.close = async () => {
|
||||||
|
if (
|
||||||
|
fault.blocked &&
|
||||||
|
(fault.mode === "close" ||
|
||||||
|
fault.mode === "early-cancel-close" ||
|
||||||
|
fault.mode === "early-abort-close")
|
||||||
|
)
|
||||||
|
throw new Error("controlled selected descriptor close failure");
|
||||||
|
await close();
|
||||||
|
if (fault.mode === "cancel") fault.controller?.abort(fault.reason);
|
||||||
|
};
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { HistoricalCaptureDecoder } from "../../src/browser/history/HistoricalCaptureDecoder.js";
|
||||||
|
import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../src/browser/history/CaptureRelease.js";
|
||||||
|
import { decodeHarCapture } from "../../src/browser/history/HarCapture.js";
|
||||||
|
import { inspectWebNetworkCaptureInputSchema } from "../../src/domain/webNetworkCapture.js";
|
||||||
|
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
|
||||||
|
import { primaryAnalysisError } from "../../src/domain/analysisErrorBase.js";
|
||||||
|
import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js";
|
||||||
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||||
|
import { historicalHar } from "../fixtures/historicalHar.js";
|
||||||
|
|
||||||
|
const decoders: HistoricalCaptureDecoder[] = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
fault.blocked = false;
|
||||||
|
for (const decoder of decoders.splice(0)) await decoder.close();
|
||||||
|
for (const { handle, close } of fault.handles.splice(0))
|
||||||
|
if (handle.fd >= 0) await close();
|
||||||
|
fault.path = "";
|
||||||
|
fault.controller = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSource = async (
|
||||||
|
source: "capture" | "descriptor",
|
||||||
|
mode: typeof fault.mode,
|
||||||
|
sensitive = false,
|
||||||
|
) => {
|
||||||
|
const root = await createTestTempDirectory("rea-capture-source-snapshot-");
|
||||||
|
const capturePath = join(root, "selected.har");
|
||||||
|
const descriptorPath = join(root, "private-descriptor.pb");
|
||||||
|
const text = JSON.stringify(historicalHar());
|
||||||
|
const descriptor = Buffer.from([10, 0]);
|
||||||
|
await writeFile(capturePath, text);
|
||||||
|
await writeFile(descriptorPath, descriptor);
|
||||||
|
const path = source === "capture" ? capturePath : descriptorPath;
|
||||||
|
const bytes = source === "capture" ? Buffer.from(text) : descriptor;
|
||||||
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
fault.path = path;
|
||||||
|
fault.mode = mode;
|
||||||
|
fault.physical =
|
||||||
|
mode === "early-abort-close"
|
||||||
|
? Object.assign(
|
||||||
|
new Error("physical descriptor failure with abort shape"),
|
||||||
|
{
|
||||||
|
name: "AbortError",
|
||||||
|
code: "ABORT_ERR",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Object.assign(new Error("selected descriptor stat denied"), {
|
||||||
|
code: "EACCES",
|
||||||
|
});
|
||||||
|
fault.blocked = true;
|
||||||
|
fault.controller = new AbortController();
|
||||||
|
const command = vi.fn(async (_request: string, runtimePath: string) => {
|
||||||
|
await writeFile(
|
||||||
|
join(runtimePath, "reply.json"),
|
||||||
|
JSON.stringify({ ok: true, value: decodeHarCapture(text, []) }),
|
||||||
|
);
|
||||||
|
return { command: process.execPath, arguments: ["-e", "process.exit(0)"] };
|
||||||
|
});
|
||||||
|
const decoder = new HistoricalCaptureDecoder(
|
||||||
|
[{ format: "har", identity: HAR_CAPTURE_PROVIDER_IDENTITY, command }],
|
||||||
|
process.env,
|
||||||
|
);
|
||||||
|
decoders.push(decoder);
|
||||||
|
const result = await decoder.inspect(
|
||||||
|
inspectWebNetworkCaptureInputSchema.parse({
|
||||||
|
capture_path: capturePath,
|
||||||
|
format: "har",
|
||||||
|
...(source === "descriptor"
|
||||||
|
? {
|
||||||
|
record_ordinals: [0],
|
||||||
|
protobuf: {
|
||||||
|
schema: {
|
||||||
|
descriptor_set_path: descriptorPath,
|
||||||
|
message_type: "fixture.Message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(sensitive
|
||||||
|
? { sensitive_values: ["private-descriptor", sha256] }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
{ signal: fault.controller.signal },
|
||||||
|
);
|
||||||
|
if (result.ok)
|
||||||
|
throw new Error("Expected selected source acquisition failure");
|
||||||
|
return {
|
||||||
|
decoder,
|
||||||
|
command,
|
||||||
|
error: result.error,
|
||||||
|
path,
|
||||||
|
bytes,
|
||||||
|
sha256,
|
||||||
|
capturePath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(["capture", "descriptor"] as const)(
|
||||||
|
"retains completed %s identity through real descriptor cleanup and retry",
|
||||||
|
async (source) => {
|
||||||
|
const { decoder, command, error, path, bytes, sha256, capturePath } =
|
||||||
|
await selectedSource(source, "close");
|
||||||
|
const projected = analysisErrorProjectionSchema.parse(
|
||||||
|
projectAnalysisError(error),
|
||||||
|
);
|
||||||
|
expect(projected.code).toBe("cleanup_incomplete");
|
||||||
|
const snapshot = {
|
||||||
|
kind: "file-snapshot-hash",
|
||||||
|
path,
|
||||||
|
sha256,
|
||||||
|
bytes: bytes.length,
|
||||||
|
};
|
||||||
|
if (source === "capture") {
|
||||||
|
expect(projected.details?.partial_observation).toEqual(snapshot);
|
||||||
|
expect(command).not.toHaveBeenCalled();
|
||||||
|
} else {
|
||||||
|
expect(projected.details?.diagnostics).toMatchObject({
|
||||||
|
descriptor_set_snapshot: snapshot,
|
||||||
|
});
|
||||||
|
expect(projected.details?.partial_observation).toMatchObject({
|
||||||
|
artifact: { path: capturePath },
|
||||||
|
records: [{ ordinal: 0 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(fault.handles).toHaveLength(1);
|
||||||
|
expect(fault.handles[0]!.handle.fd).toBeGreaterThanOrEqual(0);
|
||||||
|
fault.blocked = false;
|
||||||
|
await decoder.close();
|
||||||
|
expect(error.cleanupIncomplete).toBe(false);
|
||||||
|
expect(fault.handles[0]!.handle.fd).toBe(-1);
|
||||||
|
expect(projectAnalysisError(error).code).not.toBe("cleanup_incomplete");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["capture", "descriptor"] as const)(
|
||||||
|
"retains %s identity and the exact caller reason after confirmed close cancellation",
|
||||||
|
async (source) => {
|
||||||
|
const { error, path, sha256, bytes, capturePath } = await selectedSource(
|
||||||
|
source,
|
||||||
|
"cancel",
|
||||||
|
);
|
||||||
|
expect(projectAnalysisError(error).code).toBe("cancelled");
|
||||||
|
expect(primaryAnalysisError(error).cause).toBe(fault.reason);
|
||||||
|
const snapshot = {
|
||||||
|
kind: "file-snapshot-hash",
|
||||||
|
path,
|
||||||
|
sha256,
|
||||||
|
bytes: bytes.length,
|
||||||
|
};
|
||||||
|
const projected = projectAnalysisError(error);
|
||||||
|
if (source === "capture")
|
||||||
|
expect(projected.details?.partial_observation).toEqual(snapshot);
|
||||||
|
else {
|
||||||
|
expect(projected.details?.diagnostics).toMatchObject({
|
||||||
|
descriptor_set_snapshot: snapshot,
|
||||||
|
});
|
||||||
|
expect(projected.details?.partial_observation).toMatchObject({
|
||||||
|
artifact: { path: capturePath },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(fault.handles[0]!.handle.fd).toBe(-1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["capture", "descriptor"] as const)(
|
||||||
|
"does not invent completed %s identity after an early physical failure",
|
||||||
|
async (source) => {
|
||||||
|
const { error } = await selectedSource(source, "early");
|
||||||
|
const projected = projectAnalysisError(error);
|
||||||
|
expect(projected.code).toBe("access_denied");
|
||||||
|
expect(primaryAnalysisError(error).cause).toBe(fault.physical);
|
||||||
|
expect(projected.details?.diagnostics ?? {}).not.toHaveProperty(
|
||||||
|
"descriptor_set_snapshot",
|
||||||
|
);
|
||||||
|
if (source === "capture")
|
||||||
|
expect(projected.details?.partial_observation).toBeUndefined();
|
||||||
|
else
|
||||||
|
expect(projected.details?.partial_observation).toHaveProperty("records");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["capture", "descriptor"] as const)(
|
||||||
|
"keeps %s physical failure primary when cancellation and failed close follow",
|
||||||
|
async (source) => {
|
||||||
|
const { decoder, error } = await selectedSource(
|
||||||
|
source,
|
||||||
|
"early-cancel-close",
|
||||||
|
);
|
||||||
|
expect(projectAnalysisError(error).code).toBe("cleanup_incomplete");
|
||||||
|
expect(primaryAnalysisError(error).cause).toBe(fault.physical);
|
||||||
|
fault.blocked = false;
|
||||||
|
await decoder.close();
|
||||||
|
expect(projectAnalysisError(error).code).toBe("access_denied");
|
||||||
|
expect(
|
||||||
|
projectAnalysisError(error).details?.diagnostics ?? {},
|
||||||
|
).not.toHaveProperty("descriptor_set_snapshot");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("redacts explicitly sensitive descriptor path and hash diagnostics without displacing capture facts", async () => {
|
||||||
|
const { error, sha256, capturePath } = await selectedSource(
|
||||||
|
"descriptor",
|
||||||
|
"close",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const projected = projectAnalysisError(error);
|
||||||
|
expect(JSON.stringify(projected)).not.toContain("private-descriptor");
|
||||||
|
expect(JSON.stringify(projected)).not.toContain(sha256);
|
||||||
|
expect(projected.details?.diagnostics).toHaveProperty(
|
||||||
|
"descriptor_set_snapshot",
|
||||||
|
);
|
||||||
|
expect(projected.details?.partial_observation).toMatchObject({
|
||||||
|
artifact: { path: capturePath },
|
||||||
|
records: [{ ordinal: 0 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["capture", "descriptor"] as const)(
|
||||||
|
"preserves a physical %s failure even when it is also the abort reason",
|
||||||
|
async (source) => {
|
||||||
|
const { decoder, error } = await selectedSource(
|
||||||
|
source,
|
||||||
|
"early-abort-close",
|
||||||
|
);
|
||||||
|
expect(projectAnalysisError(error).code).toBe("cleanup_incomplete");
|
||||||
|
expect(primaryAnalysisError(error).cause).toBe(fault.physical);
|
||||||
|
fault.blocked = false;
|
||||||
|
await decoder.close();
|
||||||
|
expect(projectAnalysisError(error).code).toBe("execution_failure");
|
||||||
|
expect(projectAnalysisError(error).details?.diagnostics).toMatchObject({
|
||||||
|
reason: fault.physical.message,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
projectAnalysisError(error).details?.diagnostics ?? {},
|
||||||
|
).not.toHaveProperty("descriptor_set_snapshot");
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -91,6 +91,14 @@ import {
|
|||||||
import { RegularFileOperationCancelled } from "../../src/filesystem/RegularFile.js";
|
import { RegularFileOperationCancelled } from "../../src/filesystem/RegularFile.js";
|
||||||
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||||
import { readStableArtifact } from "../../src/artifacts/readStableArtifact.js";
|
import { readStableArtifact } from "../../src/artifacts/readStableArtifact.js";
|
||||||
|
import { ArtifactAnalysisResources } from "../../src/process/ArtifactAnalysisResources.js";
|
||||||
|
import { readLinuxResourceLimits } from "../../src/process/LinuxResourceLimits.js";
|
||||||
|
import { LocalWebSourceLocationArtifacts } from "../../src/browser/assets/WebSourceLocationArtifacts.js";
|
||||||
|
import { ok } from "../../src/domain/result.js";
|
||||||
|
import {
|
||||||
|
webSourceLocationArgs,
|
||||||
|
webSourceLocationFixture,
|
||||||
|
} from "../fixtures/webSourceLocation.js";
|
||||||
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
|
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
|
||||||
import { GoBinaryProvider } from "../../src/go/GoBinaryProvider.js";
|
import { GoBinaryProvider } from "../../src/go/GoBinaryProvider.js";
|
||||||
import { PythonPycProvider } from "../../src/python/PythonPycProvider.js";
|
import { PythonPycProvider } from "../../src/python/PythonPycProvider.js";
|
||||||
@@ -140,6 +148,100 @@ const staticProviders = [
|
|||||||
["IL2CPP metadata", () => new Il2cppMetadataProvider()],
|
["IL2CPP metadata", () => new Il2cppMetadataProvider()],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
["Rust service", "selected producer input", "source map"].flatMap(
|
||||||
|
(consumer) =>
|
||||||
|
["close failure", "close cancellation", "physical failure"].map(
|
||||||
|
(outcome) => ({ consumer, outcome }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)(
|
||||||
|
"retains completed caller input for $consumer through $outcome",
|
||||||
|
async ({ consumer, outcome }) => {
|
||||||
|
const path = await selectedImage();
|
||||||
|
const bytes = Buffer.from("selected completed input");
|
||||||
|
await writeFile(path, bytes);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reason = { source: "caller input finalization" };
|
||||||
|
const physical = Object.assign(new Error("input metadata denied"), {
|
||||||
|
code: "EACCES",
|
||||||
|
});
|
||||||
|
if (outcome !== "close failure")
|
||||||
|
fault.onClose = () => controller.abort(reason);
|
||||||
|
if (outcome === "physical failure") fault.readFailure = { cause: physical };
|
||||||
|
const { sourceMap: _sourceMap, ...script } = webSourceLocationFixture();
|
||||||
|
const rust = new RustBinaryService(new RustBinaryProvider());
|
||||||
|
const web = new LocalWebSourceLocationArtifacts({
|
||||||
|
load: () => Promise.resolve(ok(script)),
|
||||||
|
});
|
||||||
|
const resources = new ArtifactAnalysisResources(
|
||||||
|
"selected-test",
|
||||||
|
"inspect_signal_recording",
|
||||||
|
readStableArtifact,
|
||||||
|
readLinuxResourceLimits,
|
||||||
|
{ message: "busy", details: {} },
|
||||||
|
);
|
||||||
|
const close = () =>
|
||||||
|
consumer === "Rust service"
|
||||||
|
? rust.close()
|
||||||
|
: consumer === "source map"
|
||||||
|
? web.close()
|
||||||
|
: resources.close();
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
consumer === "Rust service"
|
||||||
|
? await rust.inspect({ path }, { signal: controller.signal })
|
||||||
|
: consumer === "source map"
|
||||||
|
? await web.load(
|
||||||
|
{
|
||||||
|
...webSourceLocationArgs,
|
||||||
|
source_map: { ...webSourceLocationArgs.source_map, path },
|
||||||
|
},
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
: await resources.run(async () =>
|
||||||
|
ok(
|
||||||
|
await resources.readSelectedArtifact({
|
||||||
|
path,
|
||||||
|
maximumBytes: 1024,
|
||||||
|
allowanceName: "fixture input",
|
||||||
|
observedOutput: undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result.ok)
|
||||||
|
throw new Error("Expected descriptor finalization failure");
|
||||||
|
expect(result.error.cleanupIncomplete).toBe(true);
|
||||||
|
expect(result.error.partialObservation).toEqual(
|
||||||
|
outcome === "physical failure"
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
kind: "file-snapshot-hash",
|
||||||
|
path,
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
bytes: bytes.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (outcome === "physical failure")
|
||||||
|
expect(result.error.cleanupPrimary?.cause).toBe(physical);
|
||||||
|
if (outcome === "close cancellation")
|
||||||
|
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
||||||
|
await expect(close()).rejects.toBeDefined();
|
||||||
|
expect(result.error.cleanupIncomplete).toBe(true);
|
||||||
|
fault.blocked.clear();
|
||||||
|
await close();
|
||||||
|
expect(result.error.cleanupIncomplete).toBe(false);
|
||||||
|
expect(fault.handles).toHaveLength(1);
|
||||||
|
expect(fault.handles[0]?.fd).toBe(-1);
|
||||||
|
} finally {
|
||||||
|
fault.onClose = undefined;
|
||||||
|
fault.blocked.clear();
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const expectIl2cppSource = (
|
const expectIl2cppSource = (
|
||||||
error: AnalysisError,
|
error: AnalysisError,
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
Reference in new issue
Block a user