mirror of
https://github.com/morluto/rea
synced 2026-10-28 23:11:11 -04:00
fix(captures): preserve cleanup ownership and sensitive evidence
This commit is contained in:
@@ -65,8 +65,8 @@ export const historicalCaptureFailure = (
|
||||
origin === "owned-or-classified" &&
|
||||
cause instanceof OwnedCommandFailure
|
||||
) {
|
||||
if (cause.cleanupFailure !== null)
|
||||
return new ProviderCleanupError(
|
||||
if (cause.cleanupFailure !== null) {
|
||||
const cleanup = new ProviderCleanupError(
|
||||
input.format,
|
||||
cause.resources,
|
||||
{
|
||||
@@ -83,6 +83,14 @@ export const historicalCaptureFailure = (
|
||||
},
|
||||
{ operation: OPERATION, cause },
|
||||
);
|
||||
return cause.cause instanceof OwnedCommandFailure
|
||||
? analysisErrorWithCleanupFailure(
|
||||
historicalCaptureFailure(input, cause.cause, phase, options, heap),
|
||||
cleanup,
|
||||
OPERATION,
|
||||
)
|
||||
: cleanup;
|
||||
}
|
||||
if (cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(OPERATION, {
|
||||
cause:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FileSnapshotPartialObservation } from "../../domain/fileSnapshotObservation.js";
|
||||
import {
|
||||
decodeCaptureProtobuf,
|
||||
prepareCaptureProtobuf,
|
||||
@@ -30,7 +31,10 @@ import {
|
||||
ArtifactResourceScope,
|
||||
type ArtifactCleanupAttempt,
|
||||
} from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisCleanupLeaseGroup } from "../../artifacts/AnalysisCleanupLease.js";
|
||||
import {
|
||||
AnalysisCleanupLease,
|
||||
AnalysisCleanupLeaseGroup,
|
||||
} from "../../artifacts/AnalysisCleanupLease.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
@@ -50,7 +54,10 @@ import {
|
||||
type WebNetworkCapture,
|
||||
} from "../../domain/webNetworkCapture.js";
|
||||
import { snapshotEnvironment } from "../../process/snapshotEnvironment.js";
|
||||
import { runOwnedCommand } from "../../process/OwnedCommand.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../../process/OwnedCommand.js";
|
||||
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
|
||||
import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js";
|
||||
|
||||
@@ -124,6 +131,9 @@ export class HistoricalCaptureDecoder {
|
||||
): Promise<Result<WebNetworkCapture, AnalysisError>> {
|
||||
const selectedEnvironment = snapshotEnvironment(this.environment);
|
||||
let runtime: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
|
||||
const runtimeResources = new ArtifactResourceScope();
|
||||
let processLease: AnalysisCleanupLease | undefined;
|
||||
let sourceObservation: FileSnapshotPartialObservation | undefined;
|
||||
let result: Result<WebNetworkCapture, AnalysisError>;
|
||||
let phase: "capture-read" | "decoder" = "capture-read";
|
||||
let encodedReplyBytes = 0;
|
||||
@@ -149,6 +159,12 @@ export class HistoricalCaptureDecoder {
|
||||
undefined,
|
||||
this.#resources,
|
||||
);
|
||||
sourceObservation = {
|
||||
kind: "file-snapshot-hash",
|
||||
path: input.capture_path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
};
|
||||
phase = "decoder";
|
||||
runtime = await this.createRuntime();
|
||||
const snapshotPath = join(runtime.path, "capture.snapshot");
|
||||
@@ -201,7 +217,7 @@ export class HistoricalCaptureDecoder {
|
||||
const decodedReply = await readHistoricalCaptureReply(
|
||||
replyPath,
|
||||
input.capture_path,
|
||||
this.#resources,
|
||||
runtimeResources,
|
||||
options?.signal,
|
||||
);
|
||||
const reply = decodedReply.value;
|
||||
@@ -375,6 +391,29 @@ export class HistoricalCaptureDecoder {
|
||||
}
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof OwnedCommandFailure &&
|
||||
cause.cleanupOwner !== undefined
|
||||
) {
|
||||
const supervisor = cause.cleanupOwner;
|
||||
processLease = new AnalysisCleanupLease(async () => {
|
||||
const stopped = await supervisor.stop();
|
||||
if (stopped.status === "incomplete")
|
||||
throw new ProviderCleanupError(
|
||||
input.format,
|
||||
cause.resources,
|
||||
{
|
||||
reason: stopped.reason,
|
||||
},
|
||||
{ operation: OPERATION, cause: stopped },
|
||||
);
|
||||
}, cause.resources);
|
||||
runtimeResources.retain({
|
||||
kind: "lease",
|
||||
resource: runtime?.path ?? input.capture_path,
|
||||
lease: processLease,
|
||||
});
|
||||
}
|
||||
const error = historicalCaptureFailure(
|
||||
input,
|
||||
cause,
|
||||
@@ -401,6 +440,8 @@ export class HistoricalCaptureDecoder {
|
||||
error,
|
||||
replyFinalization ?? cause,
|
||||
);
|
||||
if (processLease !== undefined)
|
||||
new AnalysisCleanupLeaseGroup(failure, [processLease]);
|
||||
if (phase === "capture-read") {
|
||||
const snapshot = completedStableArtifactObservation(
|
||||
cause,
|
||||
@@ -408,6 +449,11 @@ export class HistoricalCaptureDecoder {
|
||||
);
|
||||
if (snapshot !== undefined) failure.retainPartialObservation(snapshot);
|
||||
}
|
||||
if (
|
||||
failure.partialObservation === undefined &&
|
||||
sourceObservation !== undefined
|
||||
)
|
||||
failure.retainPartialObservation(sourceObservation);
|
||||
result = err(redactExplicitFailure(failure, input.sensitive_values));
|
||||
}
|
||||
const completedCapture = result.ok ? result.value : undefined;
|
||||
@@ -415,12 +461,42 @@ export class HistoricalCaptureDecoder {
|
||||
| Extract<ArtifactCleanupAttempt, { readonly kind: "failed" }>
|
||||
| undefined;
|
||||
if (runtime !== undefined) {
|
||||
const cleanup = await this.#resources.release({
|
||||
kind: "private-runtime-root",
|
||||
resource: runtime.path,
|
||||
root: runtime,
|
||||
});
|
||||
if (cleanup.kind === "failed") cleanupFailure = cleanup;
|
||||
const root = runtime;
|
||||
const lease = new AnalysisCleanupLease(
|
||||
async () => {
|
||||
await runtimeResources.close();
|
||||
await root.close();
|
||||
},
|
||||
{
|
||||
get cleanupResources() {
|
||||
return [
|
||||
...new Set([root.path, ...runtimeResources.cleanupResources]),
|
||||
];
|
||||
},
|
||||
},
|
||||
);
|
||||
const owner = { kind: "lease" as const, resource: root.path, lease };
|
||||
if (runtimeResources.hasPending) {
|
||||
// Failed child cleanup is retried by the service owner, after active peers settle.
|
||||
this.#resources.retain(owner);
|
||||
cleanupFailure = {
|
||||
kind: "failed",
|
||||
owner,
|
||||
lease,
|
||||
cause: new ProviderCleanupError(
|
||||
input.format,
|
||||
lease.cleanupResources ?? [root.path],
|
||||
{
|
||||
reason:
|
||||
"Capture decoder resources must be released before runtime removal.",
|
||||
},
|
||||
{ operation: OPERATION },
|
||||
),
|
||||
};
|
||||
} else {
|
||||
const cleanup = await this.#resources.release(owner);
|
||||
if (cleanup.kind === "failed") cleanupFailure = cleanup;
|
||||
}
|
||||
}
|
||||
if (result.ok && options?.signal?.aborted)
|
||||
result = err(
|
||||
@@ -454,11 +530,10 @@ export class HistoricalCaptureDecoder {
|
||||
if (completedCapture !== undefined)
|
||||
failure.retainPartialObservation(completedCapture);
|
||||
new AnalysisCleanupLeaseGroup(cleanupError, [cleanupFailure.lease]);
|
||||
const redacted = redactExplicitFailure(failure, input.sensitive_values);
|
||||
if (!result.ok)
|
||||
redacted.bindCleanupDependencies([result.error, cleanupError]);
|
||||
new AnalysisCleanupLeaseGroup(redacted, [cleanupFailure.lease]);
|
||||
return err(redacted);
|
||||
failure.bindCleanupDependencies([result.error, cleanupError]);
|
||||
new AnalysisCleanupLeaseGroup(failure, [cleanupFailure.lease]);
|
||||
return err(redactExplicitFailure(failure, input.sensitive_values));
|
||||
}
|
||||
if (!result.ok && completedCapture !== undefined)
|
||||
result.error.retainPartialObservation(completedCapture);
|
||||
|
||||
@@ -48,6 +48,8 @@ interface CliEvidenceDelivery {
|
||||
readonly evidenceOutput: string | undefined;
|
||||
/** Derived workflows publish their selected source closure with the result. */
|
||||
readonly sourceEvidence?: readonly Evidence[];
|
||||
/** Invocation-specific exclusions also cover errors from final service cleanup. */
|
||||
readonly redactFailure?: (error: AnalysisError) => AnalysisError;
|
||||
}
|
||||
|
||||
export const cliEvidenceDeliveryInputError = (
|
||||
@@ -136,6 +138,10 @@ export const deliverCliAnalysisEvidence = async (
|
||||
providerId,
|
||||
operation,
|
||||
signal,
|
||||
undefined,
|
||||
input.redactFailure === undefined
|
||||
? {}
|
||||
: { redactFailure: input.redactFailure },
|
||||
);
|
||||
const complete = await finishAnalysisCommand(
|
||||
run,
|
||||
@@ -143,6 +149,10 @@ export const deliverCliAnalysisEvidence = async (
|
||||
providerId,
|
||||
operation,
|
||||
signal,
|
||||
undefined,
|
||||
input.redactFailure === undefined
|
||||
? {}
|
||||
: { redactFailure: input.redactFailure },
|
||||
);
|
||||
return publishCliAnalysisEvidence(complete, {
|
||||
providerId,
|
||||
|
||||
@@ -21,8 +21,14 @@ export const finishAnalysisCommand = async <
|
||||
canonicalOperation: string,
|
||||
signal: AbortSignal,
|
||||
fallbackResource?: string,
|
||||
options: { readonly failureProjection?: "initial" | "final" } = {},
|
||||
options: {
|
||||
readonly failureProjection?: "initial" | "final";
|
||||
/** Apply invocation-specific exclusions after all owned cleanup is composed. */
|
||||
readonly redactFailure?: (error: AnalysisError) => AnalysisError;
|
||||
} = {},
|
||||
): Promise<Value | ReturnType<typeof projectAnalysisError>> => {
|
||||
const projectFailure = (error: AnalysisError) =>
|
||||
projectAnalysisError(options.redactFailure?.(error) ?? error);
|
||||
let result: Result<Value, AnalysisError>;
|
||||
let cleanup: AnalysisError | undefined;
|
||||
try {
|
||||
@@ -40,7 +46,7 @@ export const finishAnalysisCommand = async <
|
||||
// close retries and mutates the producer's live cleanup status.
|
||||
const initialFailure =
|
||||
!result.ok && options.failureProjection === "initial"
|
||||
? projectAnalysisError(result.error)
|
||||
? projectFailure(result.error)
|
||||
: undefined;
|
||||
try {
|
||||
// Resource owners may resolve their retained errors during close. This
|
||||
@@ -90,5 +96,5 @@ export const finishAnalysisCommand = async <
|
||||
).bindCleanupDependencies([result.error, cleanup]),
|
||||
};
|
||||
}
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
return result.ok ? result.value : projectFailure(result.error);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { localPathStringSchema } from "../domain/localPath.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { explicitSensitiveValueOptions } from "./explicitSensitiveArguments.js";
|
||||
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
|
||||
|
||||
/** Expose offline historical capture inspection with the same selection contract as MCP. */
|
||||
export const registerWebNetworkCaptureCommands = (
|
||||
@@ -116,6 +117,8 @@ export const registerWebNetworkCaptureCommands = (
|
||||
signal,
|
||||
detail: options.detail,
|
||||
evidenceOutput: options.evidence_output,
|
||||
redactFailure: (error) =>
|
||||
redactExplicitFailure(error, options.sensitiveValue ?? []),
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
@@ -14,6 +14,39 @@ import { redactExplicitText } from "./explicitSensitiveValues.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import { ProviderAdapterError } from "./providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "./providerCleanupError.js";
|
||||
import {
|
||||
fileSnapshotPartialObservationSchema,
|
||||
type FileSnapshotPartialObservation,
|
||||
} from "./fileSnapshotObservation.js";
|
||||
|
||||
/** Exclude whole identity values; redacted text is no longer a path or digest. */
|
||||
const excludeSnapshotIdentity = (
|
||||
value: unknown,
|
||||
values: readonly string[],
|
||||
): FileSnapshotPartialObservation | undefined => {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
!("kind" in value) ||
|
||||
(value.kind !== "file-snapshot-hash" && value.kind !== "file-snapshot-read")
|
||||
)
|
||||
return undefined;
|
||||
const parsed = fileSnapshotPartialObservationSchema.safeParse(value);
|
||||
if (!parsed.success) return undefined;
|
||||
const snapshot = parsed.data;
|
||||
const exclude = (identity: string | null): string | null =>
|
||||
identity !== null &&
|
||||
values.some((literal) => literal !== "" && identity.includes(literal))
|
||||
? null
|
||||
: identity;
|
||||
const path = exclude(snapshot.path);
|
||||
if (snapshot.kind === "file-snapshot-hash") {
|
||||
const sha256 = exclude(snapshot.sha256);
|
||||
return { ...snapshot, path, sha256 };
|
||||
}
|
||||
const admitted_sha256 = exclude(snapshot.admitted_sha256);
|
||||
return { ...snapshot, path, admitted_sha256 };
|
||||
};
|
||||
|
||||
/** Keep a real JSON-pointer ancestor when a property identity is explicitly excluded. */
|
||||
export const redactExplicitPointer = (
|
||||
@@ -41,9 +74,11 @@ export const redactExplicitFailure = (
|
||||
values: readonly string[],
|
||||
): AnalysisError => {
|
||||
if (values.length === 0) return error;
|
||||
const snapshot = excludeSnapshotIdentity(error.partialObservation, values);
|
||||
if (snapshot !== undefined) error.partialObservation = snapshot;
|
||||
const redacted = redactFailure(error, values);
|
||||
// Observation owners already apply format-aware exclusions. Keep their facts
|
||||
// and identity across every diagnostic replacement, including input/capacity.
|
||||
// Other observation owners already apply their format-aware exclusions.
|
||||
// Keep those facts across diagnostic replacements, including input/capacity.
|
||||
if (redacted !== error && error.partialObservation !== undefined)
|
||||
redacted.retainPartialObservation(error.partialObservation);
|
||||
return redacted === error
|
||||
@@ -86,6 +121,8 @@ const redactFailure = (
|
||||
.map(([key, item]) => [key, visit(item)]),
|
||||
);
|
||||
const visit = (value: JsonValue): JsonValue => {
|
||||
const snapshot = excludeSnapshotIdentity(value, values);
|
||||
if (snapshot !== undefined) return snapshot;
|
||||
if (typeof value === "string") return text(value);
|
||||
if (Array.isArray(value)) return value.map(visit);
|
||||
if (typeof value === "object" && value !== null) return diagnostics(value);
|
||||
|
||||
@@ -4,19 +4,21 @@ const byteCount = z.number().int().nonnegative().safe();
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
|
||||
/** Completed immutable-file facts retained when read finalization fails. */
|
||||
// Null identity fields mean caller-declared exclusion, never an absent file or
|
||||
// sensitivity inferred from its name. Unmarked identities remain exact.
|
||||
export const fileSnapshotPartialObservationSchema = z.discriminatedUnion(
|
||||
"kind",
|
||||
[
|
||||
z.strictObject({
|
||||
kind: z.literal("file-snapshot-hash"),
|
||||
path: z.string().min(1),
|
||||
sha256,
|
||||
path: z.string().min(1).nullable(),
|
||||
sha256: sha256.nullable(),
|
||||
bytes: byteCount,
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("file-snapshot-read"),
|
||||
path: z.string().min(1),
|
||||
admitted_sha256: sha256,
|
||||
path: z.string().min(1).nullable(),
|
||||
admitted_sha256: sha256.nullable(),
|
||||
capture: z.discriminatedUnion("status", [
|
||||
z.strictObject({ status: z.literal("captured"), bytes: byteCount }),
|
||||
z.strictObject({
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js";
|
||||
import { createDeferred } from "../../fixtures/binarySession.js";
|
||||
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
access,
|
||||
@@ -219,6 +222,177 @@ fs.writeFileSync(request.reply_path, ${JSON.stringify(JSON.stringify({ ok: true,
|
||||
},
|
||||
);
|
||||
|
||||
const expectOwnedDecoderPrimary = (
|
||||
error: AnalysisError,
|
||||
outcome: "cancelled" | "process",
|
||||
reason: unknown,
|
||||
) => {
|
||||
const primary = primaryAnalysisError(error);
|
||||
if (outcome === "cancelled") {
|
||||
expect(primary._tag).toBe("AnalysisCancelledError");
|
||||
expect(primary.cause).toBe(reason);
|
||||
} else {
|
||||
expect(primary.diagnostics).toMatchObject({
|
||||
exit_code: 7,
|
||||
stderr: "authored workload failure",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
it.each(["cancelled", "process"] as const)(
|
||||
"retains an unresolved decoder and its root across concurrent inspection and shutdown (%s)",
|
||||
async (outcome) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-capture-owned-cleanup-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const capturePath = join(root, "capture.har");
|
||||
const markerPath = join(root, "started.json");
|
||||
const finishPath = join(root, "finish");
|
||||
const text = JSON.stringify(historicalHar());
|
||||
await writeFile(capturePath, text);
|
||||
const peerEntered = createDeferred<void>();
|
||||
const peerContinue = createDeferred<void>();
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel owned inert decoder" };
|
||||
let targetPid: number | undefined;
|
||||
let targetOwner: ProviderProcessSupervisor | undefined;
|
||||
let denied = true;
|
||||
let targetStops = 0;
|
||||
const stop = ProviderProcessSupervisor.prototype.stop;
|
||||
vi.spyOn(ProviderProcessSupervisor.prototype, "stop").mockImplementation(
|
||||
function (this: ProviderProcessSupervisor, options) {
|
||||
if (this.launch.process.pid === targetPid) {
|
||||
targetOwner = this;
|
||||
targetStops++;
|
||||
if (denied)
|
||||
return Promise.resolve({
|
||||
status: "incomplete",
|
||||
reason: "controlled stop refusal",
|
||||
});
|
||||
}
|
||||
return stop.call(this, options);
|
||||
},
|
||||
);
|
||||
let calls = 0;
|
||||
let roots = 0;
|
||||
let targetRuntime: string | undefined;
|
||||
let targetRootCloses = 0;
|
||||
let denyRoot = false;
|
||||
const decoder = new HistoricalCaptureDecoder(
|
||||
[
|
||||
{
|
||||
format: "har",
|
||||
identity: HAR_CAPTURE_PROVIDER_IDENTITY,
|
||||
command: async (_, runtimePath) => {
|
||||
if (calls++ === 0)
|
||||
return {
|
||||
command: process.execPath,
|
||||
arguments: [
|
||||
"-e",
|
||||
`const fs = require("node:fs"); fs.writeFileSync(process.argv[1], JSON.stringify(process.pid)); setInterval(() => { if (fs.existsSync(process.argv[2])) { process.stderr.write("authored workload failure"); process.exit(7); } }, 10)`,
|
||||
markerPath,
|
||||
finishPath,
|
||||
],
|
||||
};
|
||||
peerEntered.resolve(undefined);
|
||||
await peerContinue.promise;
|
||||
await writeFile(
|
||||
join(runtimePath, "reply.json"),
|
||||
JSON.stringify({ ok: true, value: decodeHarCapture(text, []) }),
|
||||
);
|
||||
return { command: process.execPath, arguments: ["-e", ""] };
|
||||
},
|
||||
},
|
||||
],
|
||||
process.env,
|
||||
async () => {
|
||||
const runtime = await PrivateRuntimeRoot.create({
|
||||
prefix: "capture-private-runtime-",
|
||||
});
|
||||
if (roots++ !== 0) return runtime;
|
||||
targetRuntime = runtime.path;
|
||||
return {
|
||||
path: runtime.path,
|
||||
close: async () => {
|
||||
targetRootCloses++;
|
||||
if (denyRoot) throw new Error("controlled root close refusal");
|
||||
await runtime.close();
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
denied = false;
|
||||
denyRoot = false;
|
||||
peerContinue.resolve(undefined);
|
||||
await targetOwner?.stop();
|
||||
await decoder.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
const input = inspectWebNetworkCaptureInputSchema.parse({
|
||||
capture_path: capturePath,
|
||||
format: "har",
|
||||
sensitive_values: ["capture-private-runtime"],
|
||||
});
|
||||
const target = decoder.inspect(input, { signal: controller.signal });
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
targetPid = JSON.parse(await readFile(markerPath, "utf8"));
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
const peer = decoder.inspect(input);
|
||||
await peerEntered.promise;
|
||||
if (outcome === "cancelled") controller.abort(reason);
|
||||
else await writeFile(finishPath, "finish workload");
|
||||
const failed = await target;
|
||||
if (failed.ok) throw new Error("cancelled decoder must fail");
|
||||
expect(targetRootCloses).toBe(0);
|
||||
expectOwnedDecoderPrimary(failed.error, outcome, reason);
|
||||
expect(failed.error.partialObservation).toMatchObject({
|
||||
kind: "file-snapshot-hash",
|
||||
path: capturePath,
|
||||
bytes: Buffer.byteLength(text),
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
expect(projectAnalysisError(failed.error).code).toBe("cleanup_incomplete");
|
||||
expect(JSON.stringify(projectAnalysisError(failed.error))).not.toContain(
|
||||
"capture-private-runtime",
|
||||
);
|
||||
if (targetRuntime === undefined) throw new Error("runtime missing");
|
||||
await expect(access(targetRuntime)).resolves.toBeUndefined();
|
||||
expect(targetOwner?.launch.process.exitCode).toBe(
|
||||
outcome === "cancelled" ? null : 7,
|
||||
);
|
||||
const closing = decoder.close().then(
|
||||
() => undefined,
|
||||
(cause) => cause,
|
||||
);
|
||||
peerContinue.resolve(undefined);
|
||||
expect((await peer).ok).toBe(true);
|
||||
expect(await closing).toBeInstanceOf(Error);
|
||||
expect(targetRootCloses).toBe(0);
|
||||
denied = false;
|
||||
denyRoot = true;
|
||||
await expect(decoder.close()).rejects.toBeInstanceOf(Error);
|
||||
expect(targetRootCloses).toBe(1);
|
||||
const releasedStops = targetStops;
|
||||
denyRoot = false;
|
||||
await decoder.close();
|
||||
expect(targetStops).toBe(releasedStops);
|
||||
await expect(access(targetRuntime)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
expect(projectAnalysisError(failed.error).code).not.toBe(
|
||||
"cleanup_incomplete",
|
||||
);
|
||||
expectOwnedDecoderPrimary(failed.error, outcome, reason);
|
||||
expect(projectAnalysisError(failed.error).code).toBe(
|
||||
outcome === "cancelled" ? "cancelled" : "execution_failure",
|
||||
);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it("applies a non-default capture deadline and releases its runtime", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-capture-deadline-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
@@ -859,7 +1033,11 @@ it.each([
|
||||
"no completed capture Evidence",
|
||||
);
|
||||
expect(projected.remediation.action).not.toContain("rea doctor");
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
kind: "file-snapshot-hash",
|
||||
path,
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
} else if (tag === "ProviderAdapterError") {
|
||||
expect(projected).toMatchObject({
|
||||
details: {
|
||||
@@ -1111,7 +1289,12 @@ it.each([
|
||||
: "unreadable_output",
|
||||
);
|
||||
if (reply !== "capture")
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
kind: "file-snapshot-hash",
|
||||
path,
|
||||
bytes: Buffer.byteLength(text),
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
else
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
artifact: { path },
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
const fault = vi.hoisted(() => ({
|
||||
path: "",
|
||||
blocked: true,
|
||||
handles: [] as FileHandle[],
|
||||
}));
|
||||
|
||||
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) {
|
||||
fault.handles.push(handle);
|
||||
const close = handle.close.bind(handle);
|
||||
handle.close = async () => {
|
||||
if (fault.blocked)
|
||||
throw new Error(
|
||||
`controlled selected capture close failure: ${fault.path}`,
|
||||
);
|
||||
await close();
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { HistoricalCaptureDecoder } from "../../src/browser/history/HistoricalCaptureDecoder.js";
|
||||
import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../src/browser/history/CaptureRelease.js";
|
||||
import { WebNetworkCaptureService } from "../../src/application/WebNetworkCaptureService.js";
|
||||
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
|
||||
import { fileSnapshotPartialObservationSchema } from "../../src/domain/fileSnapshotObservation.js";
|
||||
import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { historicalHar } from "../fixtures/historicalHar.js";
|
||||
import { readStableArtifact } from "../../src/artifacts/readStableArtifact.js";
|
||||
import { ArtifactOperationError } from "../../src/domain/artifactOperationError.js";
|
||||
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../src/domain/analysisErrorCleanup.js";
|
||||
import { err } from "../../src/domain/result.js";
|
||||
import { deliverCliAnalysisEvidence } from "../../src/cli/analysisEvidenceDelivery.js";
|
||||
import { redactExplicitFailure } from "../../src/domain/explicitSensitiveFailure.js";
|
||||
|
||||
const services: WebNetworkCaptureService[] = [];
|
||||
afterEach(async () => {
|
||||
fault.blocked = false;
|
||||
for (const service of services.splice(0)) await service.close();
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
fault.handles = [];
|
||||
fault.path = "";
|
||||
});
|
||||
|
||||
it.each(["path", "sha256"] as const)(
|
||||
"excludes an explicitly sensitive selected capture %s from public snapshot facts",
|
||||
async (sensitiveField) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-capture-snapshot-redaction-",
|
||||
);
|
||||
const path = join(root, "private-capture-marker.har");
|
||||
const bytes = Buffer.from(JSON.stringify(historicalHar()));
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
await writeFile(path, bytes);
|
||||
fault.path = path;
|
||||
fault.blocked = true;
|
||||
const command = vi.fn(async () => {
|
||||
throw new Error("Selected acquisition failure must precede decoding");
|
||||
});
|
||||
const service = new WebNetworkCaptureService(
|
||||
new HistoricalCaptureDecoder(
|
||||
[{ format: "har", identity: HAR_CAPTURE_PROVIDER_IDENTITY, command }],
|
||||
process.env,
|
||||
),
|
||||
);
|
||||
services.push(service);
|
||||
const literal =
|
||||
sensitiveField === "path" ? "private-capture-marker" : sha256;
|
||||
const result = await service.inspect({
|
||||
capture_path: path,
|
||||
format: "har",
|
||||
sensitive_values: [literal],
|
||||
});
|
||||
if (result.ok)
|
||||
throw new Error("Expected selected descriptor close failure");
|
||||
const projected = analysisErrorProjectionSchema.parse(
|
||||
projectAnalysisError(result.error),
|
||||
);
|
||||
expect(projected.code).toBe("cleanup_incomplete");
|
||||
expect(command).not.toHaveBeenCalled();
|
||||
expect(fault.handles).toHaveLength(1);
|
||||
expect(fault.handles[0]!.fd).toBeGreaterThanOrEqual(0);
|
||||
expect(JSON.stringify(projected)).not.toContain(literal);
|
||||
const partial = fileSnapshotPartialObservationSchema.parse(
|
||||
projected.details?.partial_observation,
|
||||
);
|
||||
expect(partial).toMatchObject({ bytes: bytes.length });
|
||||
expect(partial).toMatchObject(
|
||||
sensitiveField === "path" ? { sha256 } : { path },
|
||||
);
|
||||
fault.blocked = false;
|
||||
await service.close();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain(
|
||||
literal,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("redacts repeated selected-file close failures at final CLI delivery and retains retry ownership", async () => {
|
||||
const root = await createTestTempDirectory("rea-capture-cli-redaction-");
|
||||
const path = join(root, "private-cli-capture.har");
|
||||
const bytes = Buffer.from(JSON.stringify(historicalHar()));
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
await writeFile(path, bytes);
|
||||
fault.path = path;
|
||||
fault.blocked = true;
|
||||
const decoder = new HistoricalCaptureDecoder(
|
||||
[
|
||||
{
|
||||
format: "har",
|
||||
identity: HAR_CAPTURE_PROVIDER_IDENTITY,
|
||||
command: async () => {
|
||||
throw new Error("Acquisition must precede decoding");
|
||||
},
|
||||
},
|
||||
],
|
||||
process.env,
|
||||
);
|
||||
const service = new WebNetworkCaptureService(decoder);
|
||||
services.push(service);
|
||||
const values = ["private-cli-capture"];
|
||||
const output = analysisErrorProjectionSchema.parse(
|
||||
await deliverCliAnalysisEvidence({
|
||||
run: () =>
|
||||
service.inspect({
|
||||
capture_path: path,
|
||||
format: "har",
|
||||
sensitive_values: values,
|
||||
}),
|
||||
service,
|
||||
providerId: "web-network-capture",
|
||||
operation: "inspect_web_network_capture",
|
||||
signal: new AbortController().signal,
|
||||
detail: "complete",
|
||||
evidenceOutput: undefined,
|
||||
redactFailure: (error) => redactExplicitFailure(error, values),
|
||||
}),
|
||||
);
|
||||
expect(output.code).toBe("cleanup_incomplete");
|
||||
expect(output.details).toMatchObject({
|
||||
execution_failure: "execution_failure",
|
||||
partial_observation: {
|
||||
kind: "file-snapshot-hash",
|
||||
path: null,
|
||||
sha256,
|
||||
bytes: bytes.length,
|
||||
},
|
||||
diagnostics: {
|
||||
primary_error: { code: "cleanup_incomplete" },
|
||||
cleanup_error: { code: "cleanup_incomplete" },
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(output)).not.toContain(values[0]!);
|
||||
expect(fault.handles).toHaveLength(1);
|
||||
expect(fault.handles[0]!.fd).toBeGreaterThanOrEqual(0);
|
||||
await expect(service.close()).rejects.toThrow();
|
||||
fault.blocked = false;
|
||||
await expect(service.close()).resolves.toBeUndefined();
|
||||
expect(fault.handles[0]!.fd).toBe(-1);
|
||||
});
|
||||
|
||||
it.each(["captured", "over-capacity"] as const)(
|
||||
"preserves %s read facts through unchanged-error and cleanup-primary service redaction",
|
||||
async (status) => {
|
||||
const root = await createTestTempDirectory("rea-read-snapshot-redaction-");
|
||||
const path = join(root, "private-read-marker.bin");
|
||||
await writeFile(path, "physical snapshot bytes");
|
||||
const snapshot = await readStableArtifact(path, 1024);
|
||||
const capture =
|
||||
status === "captured"
|
||||
? { status, bytes: snapshot.bytes.length }
|
||||
: {
|
||||
status,
|
||||
source_bytes_at_least: snapshot.bytes.length,
|
||||
maximum_bytes: 1,
|
||||
};
|
||||
const partial = fileSnapshotPartialObservationSchema.parse({
|
||||
kind: "file-snapshot-read",
|
||||
path,
|
||||
admitted_sha256: snapshot.sha256,
|
||||
capture,
|
||||
});
|
||||
const primary = new ArtifactOperationError(
|
||||
"inspect_artifact",
|
||||
"io",
|
||||
undefined,
|
||||
undefined,
|
||||
{ partialObservation: partial },
|
||||
);
|
||||
const service = new WebNetworkCaptureService({
|
||||
inspect: async () => err(primary),
|
||||
});
|
||||
const input = {
|
||||
capture_path: path,
|
||||
format: "har",
|
||||
sensitive_values: ["private-read-marker", snapshot.sha256],
|
||||
};
|
||||
const result = await service.inspect(input);
|
||||
if (result.ok) throw new Error("Expected retained read failure");
|
||||
// This error type is intentionally returned unchanged by diagnostic redaction.
|
||||
expect(result.error).toBe(primary);
|
||||
const expected = {
|
||||
kind: "file-snapshot-read",
|
||||
path: null,
|
||||
admitted_sha256: null,
|
||||
capture,
|
||||
};
|
||||
expect(primary.partialObservation).toEqual(expected);
|
||||
const repeated = await service.inspect(input);
|
||||
if (repeated.ok) throw new Error("Expected repeated retained read failure");
|
||||
expect(repeated.error.partialObservation).toEqual(expected);
|
||||
|
||||
// Recreate the unsanitized source under cleanup to exercise recursive ownership.
|
||||
const nested = new ArtifactOperationError(
|
||||
"inspect_artifact",
|
||||
"io",
|
||||
undefined,
|
||||
undefined,
|
||||
{ partialObservation: partial },
|
||||
);
|
||||
const cleanup = new ProviderCleanupError("fixture", [path], {});
|
||||
const composed = analysisErrorWithCleanupFailure(nested, cleanup);
|
||||
composed.partialObservation = undefined;
|
||||
const nestedService = new WebNetworkCaptureService({
|
||||
inspect: async () => err(composed),
|
||||
});
|
||||
const nestedResult = await nestedService.inspect(input);
|
||||
if (nestedResult.ok)
|
||||
throw new Error("Expected nested retained read failure");
|
||||
const projected = analysisErrorProjectionSchema.parse(
|
||||
projectAnalysisError(nestedResult.error),
|
||||
);
|
||||
expect(
|
||||
fileSnapshotPartialObservationSchema.parse(
|
||||
nestedResult.error.cleanupPrimary?.partialObservation,
|
||||
),
|
||||
).toEqual(expected);
|
||||
expect(projected.details?.diagnostics).toMatchObject({
|
||||
primary_error: { details: { partial_observation: expected } },
|
||||
});
|
||||
expect(JSON.stringify(projected)).not.toContain("private-read-marker");
|
||||
expect(JSON.stringify(projected)).not.toContain(snapshot.sha256);
|
||||
composed.resolveCleanup();
|
||||
const finalized = projectAnalysisError(nestedResult.error);
|
||||
expect(finalized.details?.partial_observation).toEqual(expected);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user