feat(javascript): recover code-split bundles from multiple inputs (#1686)

* 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

* fix(javascript): preserve source identity in multi-input recovery

---------

Co-authored-by: JingHao-Leon <w15909130000@gmail.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
JingHao-Leon
2026-10-11 18:39:49 +08:00
committed by GitHub
parent ce845ba428
commit 12c416005f
23 changed files with 695 additions and 121 deletions
+80 -6
View File
@@ -1,8 +1,9 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, opendir, writeFile } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { lstat, mkdir, open, opendir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { TextDecoder } from "node:util";
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
import {
AnalysisCancelledError,
AnalysisInputError,
@@ -89,12 +90,22 @@ export const readRecoveryFile = async (
return Buffer.concat(chunks, result.bytes);
};
/** Snapshot one caller-selected UTF-8 script before invoking the engine. */
export const snapshotRecoveryInput = async (
export type RecoveryInputSnapshot = {
path: string;
snapshot_path: string;
relative_path: string;
sha256: string;
bytes: number;
};
/** Snapshot one script within the remaining operation-wide input budget. */
const snapshotRecoveryInput = async (
path: string,
snapshotPath: string,
relativePath: string,
maximum: number,
signal?: AbortSignal,
) => {
): Promise<RecoveryInputSnapshot> => {
if (!isAbsolute(path))
throw recoveryInputFailure(
"path",
@@ -102,7 +113,7 @@ export const snapshotRecoveryInput = async (
);
let bytes: Buffer;
try {
bytes = await readRecoveryFile(path, RECOVERY_LIMITS.inputBytes, signal);
bytes = await readRecoveryFile(path, maximum, signal);
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) throw cause;
@@ -112,15 +123,78 @@ export const snapshotRecoveryInput = async (
cause,
);
}
await mkdir(dirname(snapshotPath), { recursive: true, mode: 0o700 });
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
return {
path,
snapshot_path: snapshotPath,
relative_path: relativePath,
sha256: recoveryDigest(bytes),
bytes: bytes.length,
};
};
/** Preserve each selected file and its relative input layout without concatenation. */
export const snapshotRecoveryInputs = async (
paths: readonly [string, ...string[]],
directory: string,
signal?: AbortSignal,
): Promise<readonly [RecoveryInputSnapshot, ...RecoveryInputSnapshot[]]> => {
let commonDirectory = dirname(resolve(paths[0]));
for (const path of paths) {
while (true) {
const fromCommon = relative(commonDirectory, resolve(path));
if (
fromCommon !== ".." &&
!fromCommon.startsWith(`..${sep}`) &&
!isAbsolute(fromCommon)
)
break;
const parent = dirname(commonDirectory);
if (parent === commonDirectory)
throw recoveryInputFailure(
"additional_paths",
"Selected files must share a filesystem root",
);
commonDirectory = parent;
}
}
const snapshots: RecoveryInputSnapshot[] = [];
let remaining = RECOVERY_LIMITS.inputBytes;
for (const path of paths) {
const relativePath =
paths.length === 1
? "bundle.js"
: relative(commonDirectory, resolve(path)).split(sep).join("/");
try {
if (normalizeArtifactPath(relativePath) !== relativePath)
throw new TypeError(
"Input names must already use NFC Unicode spelling",
);
} catch (cause: unknown) {
throw recoveryInputFailure(
"path",
`Cannot preserve selected input path in published artifacts: ${path}: ${recoveryFailureMessage(cause)}`,
cause,
);
}
const snapshot = await snapshotRecoveryInput(
path,
join(directory, relativePath),
relativePath,
remaining,
signal,
);
snapshots.push(snapshot);
remaining -= snapshot.bytes;
}
// The selected-input tuple guarantees a primary snapshot.
const [primary, ...additional] = snapshots;
if (primary === undefined)
throw recoveryInputFailure("path", "Select a JavaScript input");
return [primary, ...additional];
};
/** Inventory staging without following symlinks or publishing special files. */
export const inventoryRecoveryFiles = async (
root: string,
+33 -27
View File
@@ -14,8 +14,7 @@ import {
fingerprintRecoveryFile,
inventoryRecoveryFiles,
readRecoveryFile,
recoveryDigest,
type snapshotRecoveryInput,
type snapshotRecoveryInputs,
} from "./RecoveryFiles.js";
import {
runWakaruCommand,
@@ -38,7 +37,7 @@ type RecoveryWorkspace = {
root: string;
tree: SafeOutputTree;
input: JavaScriptRecoveryInput;
source: Awaited<ReturnType<typeof snapshotRecoveryInput>>;
sources: Awaited<ReturnType<typeof snapshotRecoveryInputs>>;
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
environment: Readonly<Record<string, string | undefined>>;
launcher: WakaruLauncher | undefined;
@@ -48,7 +47,7 @@ type RecoveryWorkspace = {
/** Consume the pinned producer representation in a private workspace. */
const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
const { root, input, source, engine, environment, launcher, options } =
const { root, input, sources, engine, environment, launcher, options } =
context;
const staging = join(root, "modules");
const commandContext = {
@@ -81,7 +80,7 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
...commandContext,
staging,
arguments: [
source.snapshot_path,
...sources.map((source) => source.snapshot_path),
`--unpack=${MODES[input.extraction_mode]}`,
`--level=${input.rewrite_level}`,
"--provenance",
@@ -92,16 +91,20 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
],
});
const files = await inventoryRecoveryFiles(staging, options?.signal);
const snapshot = await readRecoveryFile(
source.snapshot_path,
RECOVERY_LIMITS.inputBytes,
options?.signal,
);
const snapshots = new Map<string, Buffer>();
for (const source of sources)
snapshots.set(
source.snapshot_path,
await readRecoveryFile(
source.snapshot_path,
source.bytes,
options?.signal,
),
);
const parsed = await parseWakaruReports({
execution: run,
provenancePath: join(staging, "provenance.json"),
snapshotPath: source.snapshot_path,
sourceBytes: snapshot,
snapshots,
files,
});
return { version, admitted, run, parsed };
@@ -109,7 +112,8 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
/** Publish validated recovery and retain exact source and engine bindings. */
export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
const { root, tree, input, source, engine, options } = context;
const { root, tree, input, sources, engine, options } = context;
const [source] = sources;
const staging = join(root, "modules");
const { version, admitted, run, parsed } =
await executeWakaruRecovery(context);
@@ -117,26 +121,25 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
tree,
staging,
input,
source,
sources,
engine,
admitted,
parsed,
execution: run,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
if (
recoveryDigest(
await readRecoveryFile(
input.path,
RECOVERY_LIMITS.inputBytes,
options?.signal,
),
) !== source.sha256
)
throw new AnalysisOutputError(
OPERATION,
`Original input changed during recovery: ${input.path}`,
for (const selected of sources) {
const current = await fingerprintRecoveryFile(
selected.path,
RECOVERY_LIMITS.inputBytes,
options?.signal,
);
if (current.sha256 !== selected.sha256 || current.bytes !== selected.bytes)
throw new AnalysisOutputError(
OPERATION,
`Original input changed during recovery: ${selected.path}`,
);
}
if (
(
await fingerprintRecoveryFile(
@@ -166,7 +169,10 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
format: "javascript",
},
locations: [
{ kind: "artifact-path", path: source.path },
...sources.map((selected) => ({
kind: "artifact-path" as const,
path: selected.path,
})),
...result.modules.map((module) => ({
kind: "artifact-path" as const,
path: module.artifact.path,
+6 -6
View File
@@ -22,7 +22,7 @@ import {
checkRecoveryDeadline,
recoveryFailureMessage,
recoveryInputFailure,
snapshotRecoveryInput,
snapshotRecoveryInputs,
} from "./RecoveryFiles.js";
import {
resolveWakaruCommand,
@@ -55,7 +55,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
readonly launcher?: WakaruLauncher,
) {}
/** Recover one script using validated upstream reports and verified output bytes. */
/** Recover selected scripts using validated reports and verified output bytes. */
recover(
input: JavaScriptRecoveryInput,
options?: ExecutionOptions,
@@ -109,9 +109,9 @@ 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,
join(inputs, "bundle.js"),
const sources = await snapshotRecoveryInputs(
[input.path, ...(input.additional_paths ?? [])],
inputs,
options?.signal,
);
try {
@@ -128,7 +128,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
root,
tree,
input,
source,
sources,
engine,
environment: this.environment,
launcher: this.launcher,
+40 -22
View File
@@ -7,7 +7,11 @@ import {
type JavaScriptRecoveryInput,
type JavaScriptRecoveryResult,
} from "../../domain/javascript/javascriptRecovery.js";
import { readRecoveryFile, recoveryDigest } from "./RecoveryFiles.js";
import {
readRecoveryFile,
recoveryDigest,
type RecoveryInputSnapshot,
} from "./RecoveryFiles.js";
import {
recoveryRanges,
validateRecoverySourceMap,
@@ -28,12 +32,7 @@ type PublicationContext = {
tree: SafeOutputTree;
staging: string;
input: JavaScriptRecoveryInput;
source: {
path: string;
snapshot_path: string;
sha256: string;
bytes: number;
};
sources: readonly [RecoveryInputSnapshot, ...RecoveryInputSnapshot[]];
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
admitted: Exclude<WakaruVersionAdmission, { status: "unsupported" }>;
parsed: Awaited<ReturnType<typeof parseWakaruReports>>;
@@ -47,7 +46,7 @@ type RecoveryWriter = (path: string, bytes: Buffer) => Promise<PublishedFile>;
export const publishWakaruArtifacts = async (
context: PublicationContext,
): Promise<JavaScriptRecoveryResult> => {
const { tree, source, parsed, staging, signal } = context;
const { tree, sources, parsed, staging, signal } = context;
const write = async (path: string, bytes: Buffer) => {
const sha256 = recoveryDigest(bytes);
const file = await tree.write(
@@ -62,17 +61,25 @@ export const publishWakaruArtifacts = async (
bytes: file.bytesWritten,
};
};
const snapshot = await readRecoveryFile(
source.snapshot_path,
RECOVERY_LIMITS.inputBytes,
signal,
);
if (recoveryDigest(snapshot) !== source.sha256)
throw new AnalysisOutputError(
"recover_javascript_sources",
"The input snapshot changed during recovery",
const publishedSources: JavaScriptRecoveryResult["source"][] = [];
for (const selected of sources) {
const { relative_path, ...source } = selected;
const snapshot = await readRecoveryFile(
source.snapshot_path,
source.bytes,
signal,
);
const publishedCopy = await write("inputs/bundle.js", snapshot);
if (recoveryDigest(snapshot) !== source.sha256)
throw new AnalysisOutputError(
"recover_javascript_sources",
`The input snapshot changed during recovery: ${source.path}`,
);
publishedSources.push({
...source,
published_copy: await write(`inputs/${relative_path}`, snapshot),
});
}
const [source, ...additionalSources] = publishedSources;
const modules: JavaScriptRecoveryResult["modules"] = [];
for (const module of parsed.report.modules) {
modules.push(await publishModule(context, module, write));
@@ -90,7 +97,10 @@ export const publishWakaruArtifacts = async (
),
);
const details = {
source: { ...source, published_copy: publishedCopy },
source,
...(additionalSources.length === 0
? {}
: { additional_sources: additionalSources }),
engine: {
id: "wakaru",
version: context.admitted.version,
@@ -151,7 +161,7 @@ const publishModule = async (
module: PublicationContext["parsed"]["report"]["modules"][number],
write: RecoveryWriter,
): Promise<JavaScriptRecoveryResult["modules"][number]> => {
const { staging, source, parsed, signal } = context;
const { staging, sources, parsed, signal } = context;
const artifact = await write(
`modules/${module.filename}`,
await readRecoveryFile(
@@ -182,6 +192,14 @@ const publishModule = async (
"recover_javascript_sources",
`Missing module provenance: ${module.filename}`,
);
const origin = sources.find(
(source) => source.snapshot_path === provenance.input,
);
if (origin === undefined)
throw new AnalysisOutputError(
"recover_javascript_sources",
`Unselected module provenance input: ${provenance.input}`,
);
return {
reported_filename: module.filename,
reported_status: module.status,
@@ -189,8 +207,8 @@ 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 ?? []),
+1 -1
View File
@@ -36,5 +36,5 @@ export const RECOVERY_LIMITATIONS = [
"Extraction ranges refer to half-open UTF-8 byte offsets in the original input, not rewritten line positions or whole-bundle coverage.",
"Emitted source maps are preserved as reported by the engine; their mapping accuracy is not independently established.",
"Inspection mode may produce non-executable regions. No input or recovered application code is executed.",
"Linux adapter: one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB input, 128 MiB output, 10000 output entries and 8 MiB per diagnostic/report stream. Resource limits are per operation, not aggregate host containment.",
"Linux adapter: one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB total input, 128 MiB output, 10000 output entries and 8 MiB per diagnostic/report stream. Resource limits are per operation, not aggregate host containment.",
] as const;
+5 -4
View File
@@ -54,8 +54,7 @@ const OPERATION = "recover_javascript_sources";
type ReportContext = {
execution: Awaited<ReturnType<typeof runWakaruCommand>>;
provenancePath: string;
snapshotPath: string;
sourceBytes: Buffer;
snapshots: ReadonlyMap<string, Buffer>;
files: readonly { relativePath: string }[];
};
@@ -121,7 +120,7 @@ const validateModuleSet = (
provenance: z.output<typeof provenanceSchema>,
context: ReportContext,
): void => {
const { files, snapshotPath, sourceBytes } = context;
const { files, snapshots } = context;
const registry = new ArtifactPathRegistry();
const expected = new Set(["provenance.json"]);
const observed = new Set(files.map((file) => file.relativePath));
@@ -135,7 +134,9 @@ const validateModuleSet = (
expected.add(path);
if (observed.has(`${path}.map`)) expected.add(`${path}.map`);
const entry = provenance.modules[path];
if (entry === undefined || entry.input !== snapshotPath)
const sourceBytes =
entry === undefined ? undefined : snapshots.get(entry.input);
if (entry === undefined || sourceBytes === undefined)
throw new TypeError(
`Module provenance does not bind the selected input snapshot: ${path}`,
);