* feat(javascript): recover readable modules with Wakaru Integrate pinned upstream source behind a typed optional recovery port, preserve source identity and producer provenance, and publish verified artifacts for existing JavaScript analysis through CLI and MCP. Add focused real-tool CI with genuine bundler fixtures and bounded resource, cancellation, cleanup, schema and packaged-artifact checks. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(javascript): align recovery catalog and browser verification Share canonical Wakaru provider identity, describe CLI mode options, and format the new workflow test. Disconnect the real page transport after its observation readiness progress instead of a fixed delay. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * refactor(javascript): consume the investigation evidence writer Verify that the SDK recovery handler records complete Evidence through the newly shared investigation owner. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) * chore(test): account for isolated recovery fixture compilers * fix(javascript): validate recovery paths before acquiring the engine Return field-specific invalid_format diagnostics for relative paths and verify compiled CLI/MCP parity even when the optional engine is unavailable. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
202 lines
6.5 KiB
TypeScript
202 lines
6.5 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import { lstat, open, readdir, writeFile } from "node:fs/promises";
|
|
import { isAbsolute, join } from "node:path";
|
|
import { TextDecoder } from "node:util";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
AnalysisTimeoutError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import { RECOVERY_LIMITS } from "./WakaruRelease.js";
|
|
|
|
const OPERATION = "recover_javascript_sources";
|
|
|
|
/** Fingerprint exact selected or derived bytes. */
|
|
export const recoveryDigest = (bytes: Uint8Array): string =>
|
|
createHash("sha256").update(bytes).digest("hex");
|
|
|
|
/** Read a stable regular file without retaining bytes during engine fingerprinting. */
|
|
const scanRecoveryFile = async (
|
|
path: string,
|
|
maximum: number,
|
|
consume: (chunk: Buffer) => void,
|
|
signal?: AbortSignal,
|
|
) => {
|
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
const hash = createHash("sha256");
|
|
try {
|
|
const before = await handle.stat();
|
|
if (!before.isFile() || before.size > maximum)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
`Expected a regular file no larger than ${String(maximum)} bytes: ${path}`,
|
|
);
|
|
let bytes = 0;
|
|
for await (const raw of handle.createReadStream({ autoClose: false })) {
|
|
checkRecoveryCancellation(signal);
|
|
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
bytes += chunk.length;
|
|
if (bytes > maximum)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
`File exceeds ${String(maximum)} bytes: ${path}`,
|
|
);
|
|
hash.update(chunk);
|
|
consume(chunk);
|
|
}
|
|
const after = await handle.stat();
|
|
const current = await lstat(path);
|
|
if (
|
|
before.dev !== current.dev ||
|
|
before.ino !== current.ino ||
|
|
before.size !== bytes ||
|
|
before.mtimeMs !== after.mtimeMs ||
|
|
before.ctimeMs !== after.ctimeMs ||
|
|
before.size !== after.size
|
|
)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
`File identity changed while reading: ${path}`,
|
|
);
|
|
return { sha256: hash.digest("hex"), bytes };
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
};
|
|
|
|
/** Fingerprint a regular file incrementally with bounded memory. */
|
|
export const fingerprintRecoveryFile = async (
|
|
path: string,
|
|
maximum: number,
|
|
signal?: AbortSignal,
|
|
) => scanRecoveryFile(path, maximum, () => {}, signal);
|
|
|
|
/** Read only a regular file through one handle with a bounded, stable identity. */
|
|
export const readRecoveryFile = async (
|
|
path: string,
|
|
maximum: number,
|
|
signal?: AbortSignal,
|
|
): Promise<Buffer> => {
|
|
const chunks: Buffer[] = [];
|
|
const result = await scanRecoveryFile(
|
|
path,
|
|
maximum,
|
|
(chunk) => chunks.push(chunk),
|
|
signal,
|
|
);
|
|
return Buffer.concat(chunks, result.bytes);
|
|
};
|
|
|
|
/** Snapshot one caller-selected UTF-8 script before invoking the engine. */
|
|
export const snapshotRecoveryInput = async (
|
|
path: string,
|
|
snapshotPath: string,
|
|
signal?: AbortSignal,
|
|
) => {
|
|
if (!isAbsolute(path))
|
|
throw recoveryInputFailure(
|
|
"path",
|
|
"Select an absolute JavaScript file path",
|
|
);
|
|
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,
|
|
);
|
|
}
|
|
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
|
|
return {
|
|
path,
|
|
snapshot_path: snapshotPath,
|
|
sha256: recoveryDigest(bytes),
|
|
bytes: bytes.length,
|
|
};
|
|
};
|
|
|
|
/** Inventory staging without following symlinks or publishing special files. */
|
|
export const inventoryRecoveryFiles = async (
|
|
root: string,
|
|
signal?: AbortSignal,
|
|
): Promise<
|
|
readonly { relativePath: string; path: string; bytes: number }[]
|
|
> => {
|
|
const files: { relativePath: string; path: string; bytes: number }[] = [];
|
|
let bytes = 0;
|
|
let entries = 0;
|
|
const visit = async (directory: string, prefix: string): Promise<void> => {
|
|
const metadata = await lstat(directory);
|
|
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
`Unsafe staging directory: ${directory}`,
|
|
);
|
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
checkRecoveryCancellation(signal);
|
|
entries++;
|
|
const relativePath =
|
|
prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
const path = join(directory, entry.name);
|
|
const info = await lstat(path);
|
|
if (entries > RECOVERY_LIMITS.outputEntries)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"Recovery staging exceeds the 10000-entry resource limit",
|
|
);
|
|
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile()))
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
`Recovery produced a symlink or special file: ${relativePath}`,
|
|
);
|
|
if (info.isDirectory()) await visit(path, relativePath);
|
|
else {
|
|
bytes += info.size;
|
|
if (bytes > RECOVERY_LIMITS.outputBytes)
|
|
throw new AnalysisOutputError(
|
|
OPERATION,
|
|
"Recovery staging exceeds the 128 MiB resource limit",
|
|
);
|
|
files.push({ relativePath, path, bytes: info.size });
|
|
}
|
|
}
|
|
};
|
|
await visit(root, "");
|
|
return files;
|
|
};
|
|
|
|
/** Preserve actionable caller constraints in the ordinary error algebra. */
|
|
export const recoveryInputFailure = (
|
|
field: string,
|
|
message: string,
|
|
cause?: unknown,
|
|
): AnalysisInputError =>
|
|
new AnalysisInputError(OPERATION, { cause }, [
|
|
{ path: [field], reason: "invalid_value", message },
|
|
]);
|
|
|
|
/** Convert one caught boundary cause into diagnostic text without masking its type. */
|
|
export const recoveryFailureMessage = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|
|
|
|
/** Cancellation remains distinct from malformed source or provider output. */
|
|
export const checkRecoveryCancellation = (signal?: AbortSignal): void => {
|
|
if (signal?.aborted === true) throw new AnalysisCancelledError(OPERATION);
|
|
};
|
|
|
|
/** Keep both tool invocations within one operation's execution budget. */
|
|
export const checkRecoveryDeadline = (
|
|
deadline: number,
|
|
signal?: AbortSignal,
|
|
): void => {
|
|
checkRecoveryCancellation(signal);
|
|
if (Date.now() >= deadline)
|
|
throw new AnalysisTimeoutError(OPERATION, RECOVERY_LIMITS.timeoutMs);
|
|
};
|