mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? []),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user