feat(javascript): recover code-split bundles from multiple inputs
recover_javascript_sources accepted a single path, and validateModuleSet bound every module's provenance to that one snapshot. A code-split app (runtime + chunks, or an Electron main/preload/renderer set) had to be recovered one file per call, and modules recovered alone could not resolve imports across chunks: cross-chunk require(id) references stayed as numeric ids with property reads. Add an optional additional_paths input. The listed files are concatenated, in order after path, into the single engine snapshot, so one Wakaru run sees every module and cross-chunk imports resolve. validateModuleSet's single-snapshot binding is unchanged. Module provenance is attributed back to the input file its first extraction range starts in, with byte ranges rebased to that file's own offsets; a module whose ranges cross an input boundary keeps the concatenated snapshot identity and offsets, recorded as an explicit limitation. Single-path behavior is byte-for-byte unchanged (one segment, offset 0). Fixes #1605
This commit is contained in:
@@ -129,4 +129,49 @@ describe("JavaScript recovery workflow", () => {
|
||||
if (cancelled.ok) throw new Error("expected cancellation");
|
||||
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
it("rejects additional_paths repeating path or each other", async () => {
|
||||
const service = new JavaScriptRecoveryService({
|
||||
recover: async () => {
|
||||
throw new Error("port must not execute");
|
||||
},
|
||||
});
|
||||
for (const additional_paths of [
|
||||
[artifact.path],
|
||||
["/tmp/shared.js", "/tmp/shared.js"],
|
||||
]) {
|
||||
const result = await service.recover({
|
||||
...input,
|
||||
additional_paths,
|
||||
});
|
||||
if (result.ok) throw new Error("expected duplicate rejection");
|
||||
expect(result.error._tag).toBe("AnalysisInputError");
|
||||
expect(result.error).toMatchObject({
|
||||
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
it("rejects relative additional_paths before acquiring the port", async () => {
|
||||
let invoked = false;
|
||||
const service = new JavaScriptRecoveryService({
|
||||
recover: async () => {
|
||||
invoked = true;
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"fixture",
|
||||
"recover_javascript_sources",
|
||||
"engine missing",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = await service.recover({
|
||||
...input,
|
||||
additional_paths: ["relative-chunk.js"],
|
||||
});
|
||||
expect(invoked).toBe(false);
|
||||
if (result.ok) throw new Error("expected invalid path");
|
||||
expect(result.error).toMatchObject({
|
||||
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,17 +44,37 @@ export class JavaScriptRecoveryService {
|
||||
const invalidPaths = (["path", "output_directory"] as const).filter(
|
||||
(field) => !isAbsolute(parsed.data[field]),
|
||||
);
|
||||
if (invalidPaths.length > 0)
|
||||
const invalidAdditional = (parsed.data.additional_paths ?? []).filter(
|
||||
(additional) => !isAbsolute(additional),
|
||||
);
|
||||
if (invalidPaths.length > 0 || invalidAdditional.length > 0)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
undefined,
|
||||
invalidPaths.map((field) => ({
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
...invalidPaths.map((field) => ({
|
||||
path: [field],
|
||||
reason: "invalid_format",
|
||||
reason: "invalid_format" as const,
|
||||
message: `${field} must be an absolute filesystem path on this host.`,
|
||||
})),
|
||||
),
|
||||
...invalidAdditional.map((additional) => ({
|
||||
path: ["additional_paths"],
|
||||
reason: "invalid_format" as const,
|
||||
message: `additional_paths entries must be absolute filesystem paths on this host: ${additional}`,
|
||||
})),
|
||||
]),
|
||||
);
|
||||
const selectedInputs = new Set([
|
||||
parsed.data.path,
|
||||
...(parsed.data.additional_paths ?? []),
|
||||
]);
|
||||
if (selectedInputs.size !== 1 + (parsed.data.additional_paths ?? []).length)
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["additional_paths"],
|
||||
reason: "invalid_format",
|
||||
message: "additional_paths must not repeat path or each other.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const execution = await this.provider.recover(parsed.data, options);
|
||||
if (!execution.ok) return execution;
|
||||
|
||||
@@ -8,6 +8,13 @@ export const javascriptRecoveryInputSchema = z.strictObject({
|
||||
path: localPathStringSchema.describe(
|
||||
"Absolute path to a UTF-8 JavaScript file; never executed",
|
||||
),
|
||||
additional_paths: z
|
||||
.array(localPathStringSchema)
|
||||
.max(16)
|
||||
.optional()
|
||||
.describe(
|
||||
"Additional absolute UTF-8 JavaScript files concatenated into the same recovery snapshot, in order, after path; lets a code-split bundle be recovered as one input so modules can resolve imports across chunks",
|
||||
),
|
||||
output_directory: localPathStringSchema.describe(
|
||||
"Absolute absent output directory; parent must exist",
|
||||
),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
snapshotRecoveryInput,
|
||||
snapshotRecoveryInputs,
|
||||
} from "./RecoveryFiles.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const newWorkspace = async (): Promise<string> => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-recovery-files-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
};
|
||||
|
||||
const writeInput = async (
|
||||
workspace: string,
|
||||
name: string,
|
||||
content: string,
|
||||
): Promise<string> => {
|
||||
const path = join(workspace, name);
|
||||
const { writeFile } = await import("node:fs/promises");
|
||||
await writeFile(path, content, { mode: 0o600 });
|
||||
return path;
|
||||
};
|
||||
|
||||
describe("snapshotRecoveryInputs", () => {
|
||||
it("concatenates inputs in order and records per-segment offsets", async () => {
|
||||
const workspace = await newWorkspace();
|
||||
const first = await writeInput(workspace, "entry.js", "console.log(1);\n");
|
||||
const second = await writeInput(workspace, "chunk.js", "export {};\n");
|
||||
const snapshotPath = join(workspace, "bundle.js");
|
||||
|
||||
const snapshot = await snapshotRecoveryInputs(
|
||||
[first, second],
|
||||
snapshotPath,
|
||||
);
|
||||
|
||||
expect(snapshot.path).toBe(first);
|
||||
expect(snapshot.bytes).toBe(
|
||||
Buffer.byteLength("console.log(1);\n") +
|
||||
Buffer.byteLength("export {};\n"),
|
||||
);
|
||||
expect(snapshot.segments).toEqual([
|
||||
{
|
||||
path: first,
|
||||
sha256: expect.any(String),
|
||||
bytes: Buffer.byteLength("console.log(1);\n"),
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
path: second,
|
||||
sha256: expect.any(String),
|
||||
bytes: Buffer.byteLength("export {};\n"),
|
||||
offset: Buffer.byteLength("console.log(1);\n"),
|
||||
},
|
||||
]);
|
||||
expect(await readFile(snapshotPath, { encoding: "utf8" })).toBe(
|
||||
"console.log(1);\nexport {};\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the single-input snapshot for one path", async () => {
|
||||
const workspace = await newWorkspace();
|
||||
const only = await writeInput(
|
||||
workspace,
|
||||
"only.js",
|
||||
"export const a = 1;\n",
|
||||
);
|
||||
const singlePath = join(workspace, "single-expected.js");
|
||||
const multiPath = join(workspace, "multi-actual.js");
|
||||
|
||||
const expected = await snapshotRecoveryInput(only, singlePath);
|
||||
const actual = await snapshotRecoveryInputs([only], multiPath);
|
||||
|
||||
expect(actual.sha256).toBe(expected.sha256);
|
||||
expect(actual.bytes).toBe(expected.bytes);
|
||||
expect(actual.segments).toEqual([
|
||||
{ path: only, sha256: expected.sha256, bytes: expected.bytes, offset: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a relative path", async () => {
|
||||
const workspace = await newWorkspace();
|
||||
const snapshotPath = join(workspace, "bundle.js");
|
||||
|
||||
await expect(
|
||||
snapshotRecoveryInputs(["relative.js"], snapshotPath),
|
||||
).rejects.toMatchObject({
|
||||
issues: [{ path: ["path"], reason: "invalid_value" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-UTF-8 input before writing the snapshot", async () => {
|
||||
const workspace = await newWorkspace();
|
||||
const path = join(workspace, "binary.js");
|
||||
const { writeFile } = await import("node:fs/promises");
|
||||
await writeFile(path, Buffer.from([0xff, 0xfe, 0x00]));
|
||||
const snapshotPath = join(workspace, "bundle.js");
|
||||
|
||||
await expect(
|
||||
snapshotRecoveryInputs([path], snapshotPath),
|
||||
).rejects.toMatchObject({
|
||||
issues: [{ path: ["path"], reason: "invalid_value" }],
|
||||
});
|
||||
await expect(readFile(snapshotPath)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,62 @@ export const snapshotRecoveryInput = async (
|
||||
};
|
||||
};
|
||||
|
||||
/** Snapshot several caller-selected UTF-8 scripts as one concatenated engine input. */
|
||||
export const snapshotRecoveryInputs = async (
|
||||
paths: readonly string[],
|
||||
snapshotPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
Awaited<ReturnType<typeof snapshotRecoveryInput>> & {
|
||||
segments: readonly {
|
||||
path: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
offset: number;
|
||||
}[];
|
||||
}
|
||||
> => {
|
||||
const segments: {
|
||||
path: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
offset: number;
|
||||
}[] = [];
|
||||
const parts: Buffer[] = [];
|
||||
let offset = 0;
|
||||
for (const path of paths) {
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readRecoveryFile(path, RECOVERY_LIMITS.inputBytes, signal);
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError) throw cause;
|
||||
throw recoveryInputFailure(
|
||||
"path",
|
||||
`Cannot snapshot UTF-8 JavaScript input ${path}: ${recoveryFailureMessage(cause)}`,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
segments.push({
|
||||
path,
|
||||
sha256: recoveryDigest(bytes),
|
||||
bytes: bytes.length,
|
||||
offset,
|
||||
});
|
||||
parts.push(bytes);
|
||||
offset += bytes.length;
|
||||
}
|
||||
const combined = Buffer.concat(parts, offset);
|
||||
await writeFile(snapshotPath, combined, { flag: "wx", mode: 0o600 });
|
||||
return {
|
||||
path: paths[0] as string,
|
||||
snapshot_path: snapshotPath,
|
||||
sha256: recoveryDigest(combined),
|
||||
bytes: combined.length,
|
||||
segments,
|
||||
};
|
||||
};
|
||||
|
||||
/** Inventory staging without following symlinks or publishing special files. */
|
||||
export const inventoryRecoveryFiles = async (
|
||||
root: string,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
inventoryRecoveryFiles,
|
||||
readRecoveryFile,
|
||||
recoveryDigest,
|
||||
type snapshotRecoveryInput,
|
||||
type snapshotRecoveryInputs,
|
||||
} from "./RecoveryFiles.js";
|
||||
import {
|
||||
runWakaruCommand,
|
||||
@@ -38,7 +38,7 @@ type RecoveryWorkspace = {
|
||||
root: string;
|
||||
tree: SafeOutputTree;
|
||||
input: JavaScriptRecoveryInput;
|
||||
source: Awaited<ReturnType<typeof snapshotRecoveryInput>>;
|
||||
source: Awaited<ReturnType<typeof snapshotRecoveryInputs>>;
|
||||
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
||||
environment: Readonly<Record<string, string | undefined>>;
|
||||
launcher: WakaruLauncher | undefined;
|
||||
@@ -126,10 +126,16 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
||||
});
|
||||
if (
|
||||
recoveryDigest(
|
||||
await readRecoveryFile(
|
||||
input.path,
|
||||
RECOVERY_LIMITS.inputBytes,
|
||||
options?.signal,
|
||||
Buffer.concat(
|
||||
await Promise.all(
|
||||
source.segments.map(async (segment) =>
|
||||
readRecoveryFile(
|
||||
segment.path,
|
||||
RECOVERY_LIMITS.inputBytes,
|
||||
options?.signal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
) !== source.sha256
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
checkRecoveryDeadline,
|
||||
recoveryFailureMessage,
|
||||
recoveryInputFailure,
|
||||
snapshotRecoveryInput,
|
||||
snapshotRecoveryInputs,
|
||||
} from "./RecoveryFiles.js";
|
||||
import {
|
||||
resolveWakaruCommand,
|
||||
@@ -109,8 +109,8 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
|
||||
const staging = join(root, "modules");
|
||||
await mkdir(inputs, { mode: 0o700 });
|
||||
await mkdir(staging, { mode: 0o700 });
|
||||
const source = await snapshotRecoveryInput(
|
||||
input.path,
|
||||
const source = await snapshotRecoveryInputs(
|
||||
[input.path, ...(input.additional_paths ?? [])],
|
||||
join(inputs, "bundle.js"),
|
||||
options?.signal,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { originatingSegment } from "./WakaruPublication.js";
|
||||
|
||||
const segments = [
|
||||
{ path: "/app/entry.js", sha256: "a".repeat(64), bytes: 10, offset: 0 },
|
||||
{
|
||||
path: "/app/shared.js",
|
||||
sha256: "b".repeat(64),
|
||||
bytes: 10,
|
||||
offset: 10,
|
||||
},
|
||||
];
|
||||
|
||||
describe("originatingSegment", () => {
|
||||
it("attributes ranges inside the second segment to that file", () => {
|
||||
const origin = originatingSegment(segments, [[12, 18]]);
|
||||
|
||||
expect(origin).toMatchObject({
|
||||
path: "/app/shared.js",
|
||||
sha256: "b".repeat(64),
|
||||
offset: 10,
|
||||
attributable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("attributes ranges in the first segment to the primary input", () => {
|
||||
const origin = originatingSegment(segments, [
|
||||
[0, 10],
|
||||
[4, 6],
|
||||
]);
|
||||
|
||||
expect(origin).toMatchObject({
|
||||
path: "/app/entry.js",
|
||||
sha256: "a".repeat(64),
|
||||
offset: 0,
|
||||
attributable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the primary snapshot identity for ranges crossing a boundary", () => {
|
||||
const origin = originatingSegment(segments, [[8, 14]]);
|
||||
|
||||
expect(origin).toMatchObject({
|
||||
path: "/app/entry.js",
|
||||
sha256: "a".repeat(64),
|
||||
offset: 0,
|
||||
attributable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the primary input as unattributable without ranges", () => {
|
||||
const origin = originatingSegment(segments, []);
|
||||
|
||||
expect(origin).toMatchObject({
|
||||
path: "/app/entry.js",
|
||||
sha256: "a".repeat(64),
|
||||
offset: 0,
|
||||
attributable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,12 @@ type PublicationContext = {
|
||||
snapshot_path: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
segments: readonly {
|
||||
path: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
offset: number;
|
||||
}[];
|
||||
};
|
||||
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
||||
admitted: Exclude<WakaruVersionAdmission, { status: "unsupported" }>;
|
||||
@@ -134,6 +140,11 @@ export const publishWakaruArtifacts = async (
|
||||
runtime_equivalence: "unknown" as const,
|
||||
limitations: [
|
||||
...RECOVERY_LIMITATIONS,
|
||||
...(context.source.segments.length > 1
|
||||
? [
|
||||
"Multiple inputs were concatenated into one recovery snapshot in the listed order; module provenance byte offsets refer to that concatenated snapshot.",
|
||||
]
|
||||
: []),
|
||||
...(context.admitted.status === "compatible"
|
||||
? [context.admitted.limitation]
|
||||
: []),
|
||||
@@ -182,6 +193,7 @@ const publishModule = async (
|
||||
"recover_javascript_sources",
|
||||
`Missing module provenance: ${module.filename}`,
|
||||
);
|
||||
const origin = originatingSegment(context.source.segments, provenance.ranges);
|
||||
return {
|
||||
reported_filename: module.filename,
|
||||
reported_status: module.status,
|
||||
@@ -189,12 +201,56 @@ const publishModule = async (
|
||||
source_map: sourceMap,
|
||||
provenance: {
|
||||
reported_input: provenance.input,
|
||||
original_path: source.path,
|
||||
original_sha256: source.sha256,
|
||||
original_path: origin.path,
|
||||
original_sha256: origin.sha256,
|
||||
extraction: provenance.extraction,
|
||||
byte_ranges: recoveryRanges(provenance.ranges),
|
||||
context_byte_ranges: recoveryRanges(provenance.context_ranges ?? []),
|
||||
byte_ranges: recoveryRanges(rangesRelativeTo(origin, provenance.ranges)),
|
||||
context_byte_ranges: recoveryRanges(
|
||||
rangesRelativeTo(origin, provenance.context_ranges ?? []),
|
||||
),
|
||||
range_semantics: "half-open-utf8-bytes",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Attribute a module to the input segment its first extraction range starts
|
||||
* in, rebasing its ranges to that file's own offsets. A module whose ranges
|
||||
* cross a segment boundary cannot be attributed to one input file; it then
|
||||
* keeps the concatenated-snapshot identity and offsets (recorded as a
|
||||
* limitation when multiple inputs were recovered).
|
||||
*/
|
||||
export const originatingSegment = (
|
||||
segments: PublicationContext["source"]["segments"],
|
||||
ranges: readonly [number, number][],
|
||||
): { path: string; sha256: string; offset: number; attributable: boolean } => {
|
||||
const primary = segments[0];
|
||||
if (primary === undefined)
|
||||
return { path: "", sha256: "", offset: 0, attributable: false };
|
||||
const start = ranges[0]?.[0];
|
||||
if (start === undefined) return { ...primary, attributable: false };
|
||||
let owner = primary;
|
||||
for (const segment of segments) {
|
||||
if (start >= segment.offset) owner = segment;
|
||||
}
|
||||
const attributable = ranges.every(
|
||||
([rangeStart, rangeEnd]) =>
|
||||
rangeStart >= owner.offset && rangeEnd <= owner.offset + owner.bytes,
|
||||
);
|
||||
return attributable
|
||||
? { ...owner, attributable }
|
||||
: { ...primary, attributable: false };
|
||||
};
|
||||
|
||||
const rangesRelativeTo = (
|
||||
origin: {
|
||||
path: string;
|
||||
sha256: string;
|
||||
offset: number;
|
||||
attributable: boolean;
|
||||
},
|
||||
ranges: readonly [number, number][],
|
||||
): [number, number][] =>
|
||||
origin.attributable
|
||||
? ranges.map(([start, end]) => [start - origin.offset, end - origin.offset])
|
||||
: [...ranges];
|
||||
|
||||
Reference in New Issue
Block a user