fix(web): retain completed file identities across acquisition failures

This commit is contained in:
morluto committed 2026-10-27 02:16:08 +08:00
1 parent 620690c923
commit 82af5b5f52
9 files changed
+478 -97

No files matched your search

+56 -31
View File
@@ -2,7 +2,6 @@ import { lstat, realpath } from "node:fs/promises";
import { dirname, join } from "node:path";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
import type { WebScriptArtifactPort } from "../../application/WebScriptArtifactPort.js";
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
@@ -16,7 +15,7 @@ import type { SelectedWebScriptInput } from "../../domain/webScriptArtifacts.js"
import { webScriptExportManifestSchema } from "../../domain/webScriptExport.js";
import {
decodeWebArtifact,
readWebArtifactJson,
WebArtifactAcquisition,
webArtifactReadError,
type WebArtifactReadContext,
} from "./WebArtifactReads.js";
@@ -53,47 +52,62 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
}
async #load(input: SelectedWebScriptInput, options?: ExecutionOptions) {
const acquisition = new WebArtifactAcquisition();
let field: readonly (string | number)[] = ["manifest_path"];
let targetPath = input.manifest_path;
let callerSelected = true;
try {
const manifestBytes = await readWebArtifactJson(
const manifestBytes = await acquisition.readJson(
"manifest",
input.manifest_path,
32 * 1024 * 1024,
options?.signal,
this.resources,
);
const manifest = webScriptExportManifestSchema.parse(manifestBytes.value);
acquisition.record(
"manifest",
{
path: input.manifest_path,
sha256: manifestBytes.sha256,
bytes: manifestBytes.bytes.length,
},
"manifest-validated",
);
field = ["script_index"];
const selected = manifest.scripts[input.script_index];
// A valid manifest without this index is a caller selection fault.
if (selected === undefined)
return err(
new AnalysisInputError(this.operation, undefined, [
manifest.scripts.length === 0
? {
path: ["script_index"],
reason: "invalid_value",
message: "The selected manifest contains no scripts.",
}
: {
path: ["script_index"],
reason: "out_of_range",
minimum: 0,
maximum: manifest.scripts.length - 1,
message: `No script at index ${String(input.script_index)}; the manifest contains ${String(manifest.scripts.length)} scripts.`,
},
]),
acquisition.retain(
new AnalysisInputError(this.operation, undefined, [
manifest.scripts.length === 0
? {
path: ["script_index"],
reason: "invalid_value",
message: "The selected manifest contains no scripts.",
}
: {
path: ["script_index"],
reason: "out_of_range",
minimum: 0,
maximum: manifest.scripts.length - 1,
message: `No script at index ${String(input.script_index)}; the manifest contains ${String(manifest.scripts.length)} scripts.`,
},
]),
),
);
if (selected.content.state !== "exported")
return err(
new AnalysisCapabilityUnavailableError(
"web-module-artifacts",
this.operation,
"selected_source_unavailable",
{
userMessage: `Selected script ${String(input.script_index)} has unavailable source bytes (${selected.content.reason}): ${selected.content.message} Capture source bytes before tracing this script.`,
},
acquisition.retain(
new AnalysisCapabilityUnavailableError(
"web-module-artifacts",
this.operation,
"selected_source_unavailable",
{
userMessage: `Selected script ${String(input.script_index)} has unavailable source bytes (${selected.content.reason}): ${selected.content.message} Capture source bytes before tracing this script.`,
},
),
),
);
// The index has selected a script. Its source metadata belongs to the
@@ -116,11 +130,11 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
const sourcePath = join(root, "files", portable);
targetPath = sourcePath;
await assertContained(root, sourcePath);
const sourceBytes = await readStableArtifact(
const sourceBytes = await acquisition.read(
"source",
sourcePath,
16 * 1024 * 1024,
options?.signal,
undefined,
this.resources,
);
await assertContained(root, sourcePath);
@@ -139,6 +153,15 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
unpacked: false,
},
);
acquisition.record(
"source",
{
path: sourcePath,
sha256: sourceBytes.sha256,
bytes: sourceBytes.bytes.length,
},
"source-verified",
);
const source = decodeWebArtifact(sourceBytes.bytes);
return ok({
manifest,
@@ -156,10 +179,12 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
});
} catch (cause: unknown) {
return err(
webArtifactReadError(
cause,
{ operation: this.operation, field, targetPath, callerSelected },
options?.signal,
acquisition.retain(
webArtifactReadError(
cause,
{ operation: this.operation, field, targetPath, callerSelected },
options?.signal,
),
),
);
}
+86 -26
View File
@@ -1,3 +1,4 @@
import type { WebArtifactAcquisition as Acquisition } from "../../domain/webArtifactAcquisition.js";
import { z } from "zod";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
@@ -7,9 +8,14 @@ import {
type ArtifactFilePrimaryFacts,
} from "../../artifacts/ArtifactFileOperation.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import { completedStableArtifactObservation } from "../../artifacts/StableArtifactObservation.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
completedStableArtifact,
readStableArtifact,
} from "../../artifacts/readStableArtifact.js";
import {
AnalysisError,
primaryAnalysisError,
} from "../../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisCancelledError,
@@ -47,24 +53,83 @@ export const decodeWebArtifact = (bytes: Buffer): string => {
}
};
/** Read one stable JSON artifact with a documented byte budget. */
export const readWebArtifactJson = async (
path: string,
maximumBytes: number,
signal?: AbortSignal,
resourceScope?: ArtifactResourceScope,
) => {
const data = await readStableArtifact(
path,
maximumBytes,
signal,
undefined,
resourceScope,
);
const value = safeParseJson(decodeWebArtifact(data.bytes));
if (!value.ok) throw new WebArtifactFormatFailure(value.error);
return { ...data, value: value.value };
};
/** Own the independently completed physical reads throughout web acquisition. */
export class WebArtifactAcquisition {
readonly files: Acquisition["files"] = [];
record(
role: Acquisition["files"][number]["role"],
file: Acquisition["files"][number]["file"],
admission: Acquisition["files"][number]["admission"] = "read",
): void {
const previous = this.files.findIndex((entry) => entry.role === role);
const receipt = { role, file, admission };
if (previous < 0) this.files.push(receipt);
else this.files[previous] = receipt;
}
async read(
role: Acquisition["files"][number]["role"],
path: string,
maximumBytes: number,
signal?: AbortSignal,
scope?: ArtifactResourceScope,
) {
try {
const data = await readStableArtifact(
path,
maximumBytes,
signal,
undefined,
scope,
);
this.record(role, {
path,
sha256: data.sha256,
bytes: data.bytes.length,
});
return data;
} catch (cause: unknown) {
const completed = completedStableArtifact(cause);
if (completed !== undefined)
this.record(role, {
path,
sha256: completed.sha256,
bytes: completed.bytes.length,
});
throw cause;
}
}
async readJson(
role: Acquisition["files"][number]["role"],
path: string,
maximumBytes: number,
signal?: AbortSignal,
scope?: ArtifactResourceScope,
) {
const data = await this.read(role, path, maximumBytes, signal, scope);
const value = safeParseJson(decodeWebArtifact(data.bytes));
if (!value.ok) throw new WebArtifactFormatFailure(value.error);
return { ...data, value: value.value };
}
retain(error: AnalysisError): AnalysisError {
if (this.files.length > 0) {
const observation: Acquisition = {
kind: "web-artifact-acquisition",
analysis: "not-performed",
files: this.files.map((entry) => ({
...entry,
file: { ...entry.file },
})),
};
error.partialObservation = observation;
primaryAnalysisError(error).partialObservation = observation;
}
return error;
}
}
/** Preserve artifact format, integrity, OS I/O, and cancellation reasons. */
export const webArtifactReadError = (
@@ -77,11 +142,6 @@ export const webArtifactReadError = (
context,
signal,
);
const observation = completedStableArtifactObservation(
cause,
context.targetPath,
);
if (observation !== undefined) primary.retainPartialObservation(observation);
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
const cleanup = new ProviderCleanupError(
"captured-web-artifacts",
@@ -2,7 +2,6 @@ import type { WebSourceLocationArtifactPort } from "../../application/WebSourceL
import type { WebScriptArtifactPort } from "../../application/WebScriptArtifactPort.js";
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import {
WEB_SOURCE_MAP_LIMITS,
type WebSourceLocationInput,
@@ -10,7 +9,11 @@ import {
import { err, ok } from "../../domain/result.js";
import { WebArtifactCleanup } from "../WebArtifactCleanup.js";
import { LocalWebScriptArtifacts } from "./LocalWebScriptArtifacts.js";
import { decodeWebArtifact, webArtifactReadError } from "./WebArtifactReads.js";
import {
decodeWebArtifact,
WebArtifactAcquisition,
webArtifactReadError,
} from "./WebArtifactReads.js";
/** Read one verified capture source and the caller-selected map, without fetching. */
export class LocalWebSourceLocationArtifacts implements WebSourceLocationArtifactPort {
@@ -41,32 +44,47 @@ export class LocalWebSourceLocationArtifacts implements WebSourceLocationArtifac
/** Preserve actual map identity separately from the selected deployment URL. */
async load(input: WebSourceLocationInput, options?: ExecutionOptions) {
const acquisition = new WebArtifactAcquisition();
try {
return await this.#resources.run(() => this.#load(input, options));
return await this.#resources.run(() =>
this.#load(input, acquisition, options),
);
} catch (cause: unknown) {
return err(
webArtifactReadError(
cause,
{
operation: "trace_web_source_location",
field: ["source_map", "path"],
targetPath: input.source_map.path,
callerSelected: true,
},
options?.signal,
acquisition.retain(
webArtifactReadError(
cause,
{
operation: "trace_web_source_location",
field: ["source_map", "path"],
targetPath: input.source_map.path,
callerSelected: true,
},
options?.signal,
),
),
);
}
}
async #load(input: WebSourceLocationInput, options?: ExecutionOptions) {
async #load(
input: WebSourceLocationInput,
acquisition: WebArtifactAcquisition,
options?: ExecutionOptions,
) {
const loaded = await this.scripts.load(input, options);
if (!loaded.ok) return loaded;
const data = await readStableArtifact(
acquisition.record(
"manifest",
loaded.value.manifestFile,
"manifest-validated",
);
acquisition.record("source", loaded.value.sourceFile, "source-verified");
const data = await acquisition.read(
"source-map",
input.source_map.path,
WEB_SOURCE_MAP_LIMITS.mapBytes,
options?.signal,
undefined,
this.#resources,
);
return ok({
+32 -16
View File
@@ -11,7 +11,7 @@ import { WebArtifactCleanup } from "../WebArtifactCleanup.js";
import type { WebModuleTraceInput } from "../../domain/webModuleTrace.js";
import { LocalWebScriptArtifacts } from "../assets/LocalWebScriptArtifacts.js";
import {
readWebArtifactJson,
WebArtifactAcquisition,
webArtifactReadError,
} from "../assets/WebArtifactReads.js";
@@ -44,34 +44,50 @@ export class LocalWebModuleArtifacts implements WebModuleArtifactPort {
/** Read selected source and optional map without website requests. */
async load(input: WebModuleTraceInput, options?: ExecutionOptions) {
const acquisition = new WebArtifactAcquisition();
try {
return await this.#resources.run(() => this.#load(input, options));
return await this.#resources.run(() =>
this.#load(input, acquisition, options),
);
} catch (cause: unknown) {
return err(
webArtifactReadError(
cause,
{
operation: "trace_web_module_imports",
field:
input.import_map === undefined
? ["manifest_path"]
: ["import_map", "path"],
targetPath: input.import_map?.path ?? input.manifest_path,
callerSelected: input.import_map !== undefined,
},
options?.signal,
acquisition.retain(
webArtifactReadError(
cause,
{
operation: "trace_web_module_imports",
field:
input.import_map === undefined
? ["manifest_path"]
: ["import_map", "path"],
targetPath: input.import_map?.path ?? input.manifest_path,
callerSelected: input.import_map !== undefined,
},
options?.signal,
),
),
);
}
}
async #load(input: WebModuleTraceInput, options?: ExecutionOptions) {
async #load(
input: WebModuleTraceInput,
acquisition: WebArtifactAcquisition,
options?: ExecutionOptions,
) {
const loaded = await this.scripts.load(input, options);
if (!loaded.ok) return loaded;
acquisition.record(
"manifest",
loaded.value.manifestFile,
"manifest-validated",
);
acquisition.record("source", loaded.value.sourceFile, "source-verified");
if (input.import_map === undefined)
return ok({ ...loaded.value, importMap: null });
const selected = input.import_map;
const data = await readWebArtifactJson(
const data = await acquisition.readJson(
"import-map",
selected.path,
4 * 1024 * 1024,
options?.signal,
+3
View File
@@ -295,8 +295,11 @@ import type { WebNetworkCapture } from "./webNetworkCapture.js";
import type { FridaDeviceList, FridaProcessList } from "./fridaDiscovery.js";
import type { CutterBridgeDiscovery } from "./cutterDiscovery.js";
import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js";
/** Provider-neutral evidence collected before a typed analysis failure. */
export type AnalysisPartialObservation =
| WebArtifactAcquisition
| CutterBridgeDiscovery
| FridaDeviceList
| FridaProcessList
+19
View File
@@ -0,0 +1,19 @@
import { z } from "zod";
import { webArtifactFileSchema } from "./webScriptArtifacts.js";
/** Independently completed reads; these facts never claim a completed analysis. */
export const webArtifactAcquisitionSchema = z.strictObject({
kind: z.literal("web-artifact-acquisition"),
analysis: z.literal("not-performed"),
files: z.array(
z.strictObject({
role: z.enum(["manifest", "source", "source-map", "import-map"]),
file: webArtifactFileSchema,
// Source verification establishes declared digest/length, not text decoding.
admission: z.enum(["read", "manifest-validated", "source-verified"]),
}),
),
});
export type WebArtifactAcquisition = z.infer<
typeof webArtifactAcquisitionSchema
>;
@@ -0,0 +1,85 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { webSourceLocationFixture } from "../fixtures/webSourceLocation.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest(
"delivers completed web acquisition identities through CLI and stdio MCP when a later map is missing",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-web-acquisition-cli-");
const fixture = webSourceLocationFixture();
const manifestPath = join(root, "manifest.json");
const sourcePath = join(root, "files", "modules", "main.js");
const mapPath = join(root, "missing.map");
const manifest = JSON.stringify(fixture.manifest);
await mkdir(join(root, "files", "modules"), { recursive: true });
await writeFile(manifestPath, manifest);
await writeFile(sourcePath, fixture.source);
const environment = {
REA_LOG_LEVEL: "silent",
REA_BROWSER_EXECUTABLE: "relative-unconfigured-browser",
};
const result = await cli.run({
arguments: [
"trace-web-source-location",
manifestPath,
"0",
mapPath,
fixture.sourceMap.url,
"1",
"0",
"--json",
],
environment,
timeoutMs: 30_000,
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
partial_observation: {
kind: "web-artifact-acquisition",
analysis: "not-performed",
files: [
{
role: "manifest",
file: {
path: manifestPath,
sha256: createHash("sha256").update(manifest).digest("hex"),
bytes: Buffer.byteLength(manifest),
},
admission: "manifest-validated",
},
{
role: "source",
file: {
path: sourcePath,
sha256: createHash("sha256")
.update(fixture.source)
.digest("hex"),
bytes: Buffer.byteLength(fixture.source),
},
admission: "source-verified",
},
],
},
},
});
const { call } = await connectLocalToolsMcp(environment);
const response = await call("trace_web_source_location", {
manifest_path: manifestPath,
script_index: 0,
source_map: { path: mapPath, url: fixture.sourceMap.url },
generated_position: { line: 1, column: 0 },
});
expect(parseMcpToolError(response).error).toEqual(result.json);
expect(response.structuredContent).toBeUndefined();
expect(result.json).not.toHaveProperty("normalized_result");
},
90_000,
);
@@ -0,0 +1,123 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { LocalWebScriptArtifacts } from "../../../src/browser/assets/LocalWebScriptArtifacts.js";
import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js";
import { LocalWebSourceLocationArtifacts } from "../../../src/browser/assets/WebSourceLocationArtifacts.js";
import { webArtifactAcquisitionSchema } from "../../../src/domain/webArtifactAcquisition.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { webModuleArtifactsFixture } from "../../fixtures/webModuleTrace.js";
const identity = (path: string, bytes: Buffer) => ({
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
});
it.each([
"invalid-json",
"invalid-utf8",
"invalid-schema",
"missing-selection",
"source-mismatch",
"missing-map",
"invalid-import-map",
])(
"retains independently read web file identities when %s prevents analysis",
async (variant) => {
const root = await createTestTempDirectory("rea-web-acquisition-");
const fixture = webModuleArtifactsFixture();
const manifestPath = join(root, "manifest.json");
const sourcePath = join(root, "files", "modules", "main.js");
await mkdir(join(root, "files", "modules"), { recursive: true });
const source = Buffer.from(
variant === "source-mismatch" ? "changed" : fixture.source,
);
await writeFile(sourcePath, source);
const manifest =
variant === "invalid-json"
? Buffer.from("{")
: variant === "invalid-utf8"
? Buffer.from([255])
: variant === "invalid-schema"
? Buffer.from("{}")
: Buffer.from(JSON.stringify(fixture.manifest));
await writeFile(manifestPath, manifest);
const reader =
variant === "missing-map"
? new LocalWebSourceLocationArtifacts()
: variant === "invalid-import-map"
? new LocalWebModuleArtifacts()
: new LocalWebScriptArtifacts("trace_web_module_imports");
onTestFinished(() => reader.close());
const input = {
manifest_path: manifestPath,
script_index: variant === "missing-selection" ? 100 : 0,
};
const mapPath = join(root, "selected.map");
if (variant === "invalid-import-map") await writeFile(mapPath, "[]");
const result =
reader instanceof LocalWebSourceLocationArtifacts
? await reader.load({
...input,
source_map: { path: mapPath, url: "https://app.test/app.map" },
generated_position: { line: 1, column: 0 },
})
: reader instanceof LocalWebModuleArtifacts
? await reader.load({
...input,
import_map: { path: mapPath, base_url: "https://app.test/" },
})
: await reader.load(input);
if (result.ok) throw new Error("Expected acquisition failure");
const observation = webArtifactAcquisitionSchema.parse(
projectAnalysisError(result.error).details?.partial_observation,
);
expect(observation.files).toHaveLength(
variant === "invalid-import-map"
? 3
: ["source-mismatch", "missing-map"].includes(variant)
? 2
: 1,
);
expect(observation).toMatchObject({
kind: "web-artifact-acquisition",
analysis: "not-performed",
files: expect.arrayContaining([
{
role: "manifest",
file: identity(manifestPath, manifest),
admission:
variant.startsWith("invalid-") && variant !== "invalid-import-map"
? "read"
: "manifest-validated",
},
]),
});
if (
["source-mismatch", "missing-map", "invalid-import-map"].includes(variant)
)
expect(observation).toMatchObject({
files: expect.arrayContaining([
{
role: "source",
file: identity(sourcePath, source),
admission:
variant === "source-mismatch" ? "read" : "source-verified",
},
]),
});
if (variant === "invalid-import-map")
expect(observation).toMatchObject({
files: expect.arrayContaining([
{
role: "import-map",
file: identity(mapPath, Buffer.from("[]")),
admission: "read",
},
]),
});
},
);
@@ -64,7 +64,10 @@ 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 {
AnalysisError,
resolvedAnalysisError,
} from "../../src/domain/analysisErrorBase.js";
import { createEvidence, parseEvidence } from "../../src/domain/evidence.js";
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
import {
@@ -213,16 +216,40 @@ it.each(
if (result.ok)
throw new Error("Expected descriptor finalization failure");
expect(result.error.cleanupIncomplete).toBe(true);
const snapshot = {
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
};
expect(result.error.partialObservation).toEqual(
outcome === "physical failure"
? undefined
: {
kind: "file-snapshot-hash",
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
consumer === "source map"
? {
kind: "web-artifact-acquisition",
analysis: "not-performed",
files: [
{
role: "manifest",
file: script.manifestFile,
admission: "manifest-validated",
},
{
role: "source",
file: script.sourceFile,
admission: "source-verified",
},
...(outcome === "physical failure"
? []
: [
{ role: "source-map", file: snapshot, admission: "read" },
]),
],
}
: outcome === "physical failure"
? undefined
: { kind: "file-snapshot-hash", ...snapshot },
);
const beforeCleanup = projectAnalysisError(result.error).details
?.partial_observation;
if (outcome === "physical failure")
expect(result.error.cleanupPrimary?.cause).toBe(physical);
if (outcome === "close cancellation")
@@ -232,6 +259,11 @@ it.each(
fault.blocked.clear();
await close();
expect(result.error.cleanupIncomplete).toBe(false);
if (consumer === "source map")
expect(
projectAnalysisError(resolvedAnalysisError(result.error)).details
?.partial_observation,
).toEqual(beforeCleanup);
expect(fault.handles).toHaveLength(1);
expect(fault.handles[0]?.fd).toBe(-1);
} finally {