diff --git a/docs/javascript-recovery.md b/docs/javascript-recovery.md index dc183a3b87..3a10565f9a 100644 --- a/docs/javascript-recovery.md +++ b/docs/javascript-recovery.md @@ -133,8 +133,11 @@ Each operation uses one worker, a 1 GiB address-space ceiling and a 120-second deadline; it accepts up to 64 MiB of total input across all selected files and 128 MiB/10000 entries of staged output, with 8 MiB per diagnostic/report stream. Exceeding a limit is an explicit -failure with rollback. These limits apply per operation and do not claim aggregate -host containment across concurrent callers. +failure with rollback. Selected inputs beyond the total input allowance return a +non-retryable `resource_constraint` with `resource: "file-size"` and +`reported_limits.maximum_input_bytes`/`remaining_input_bytes`; input that is not +valid UTF-8 is an `invalid_format` request issue. These limits apply per +operation and do not claim aggregate host containment across concurrent callers. See [testing.md](testing.md#javascript-source-recovery) for the focused real Wakaru CLI/MCP lane and its source-owned compiler fixtures. Other host platforms diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index 545744f084..fc1a988a1d 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -8,6 +8,7 @@ import { AnalysisCancelledError, AnalysisInputError, AnalysisOutputError, + AnalysisResourceConstraintError, AnalysisTimeoutError, } from "../../domain/analysisErrorCore.js"; import { @@ -23,6 +24,16 @@ const OPERATION = "recover_javascript_sources"; export const recoveryDigest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); +/** A file exceeded the byte bound its caller selected; the caller owns what that bound means. */ +class RecoveryFileSizeError extends AnalysisOutputError { + constructor( + reason: string, + readonly maximum: number, + ) { + super(OPERATION, reason); + } +} + /** Read a stable regular file without retaining bytes during engine fingerprinting. */ const scanRecoveryFile = async ( path: string, @@ -54,9 +65,9 @@ const scanRecoveryFile = async ( try { const before = await handle.stat(); if (before.size > maximum) - throw new AnalysisOutputError( - OPERATION, + throw new RecoveryFileSizeError( `Expected a regular file no larger than ${String(maximum)} bytes: ${path}`, + maximum, ); let bytes = 0; for await (const raw of handle.createReadStream({ autoClose: false })) { @@ -64,9 +75,9 @@ const scanRecoveryFile = async ( const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); bytes += chunk.length; if (bytes > maximum) - throw new AnalysisOutputError( - OPERATION, + throw new RecoveryFileSizeError( `File exceeds ${String(maximum)} bytes: ${path}`, + maximum, ); hash.update(chunk); consume(chunk); @@ -138,15 +149,44 @@ const snapshotRecoveryInput = async ( let bytes: Buffer; try { bytes = await readRecoveryFile(path, maximum, signal); - new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch (cause: unknown) { if (cause instanceof AnalysisCancelledError) throw cause; + // The operation-wide input allowance is a deterministic byte budget, not + // a malformed argument; the same selection fails again unchanged. + if (cause instanceof RecoveryFileSizeError) + throw new AnalysisResourceConstraintError( + OPERATION, + "file-size", + `Selected JavaScript input ${path} exceeds the ${String(cause.maximum)} bytes remaining of the ${String(RECOVERY_LIMITS.inputBytes)}-byte total input allowance`, + { + maximum_input_bytes: RECOVERY_LIMITS.inputBytes, + remaining_input_bytes: cause.maximum, + }, + { + cause, + retryable: false, + remediationAction: + "Select JavaScript inputs whose combined size is within maximum_input_bytes. Do not truncate required source; retrying the unchanged selection will fail again.", + }, + ); throw recoveryInputFailure( "path", `Cannot snapshot UTF-8 JavaScript input ${path}: ${recoveryFailureMessage(cause)}`, cause, ); } + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (cause: unknown) { + throw new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + expected: "UTF-8", + message: `JavaScript input is not valid UTF-8: ${path}`, + }, + ]); + } await mkdir(dirname(snapshotPath), { recursive: true, mode: 0o700 }); await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 }); return { diff --git a/tests/boundary/javascript-recovery/wakaruInputAllowance.test.ts b/tests/boundary/javascript-recovery/wakaruInputAllowance.test.ts new file mode 100644 index 0000000000..e0cd844c70 --- /dev/null +++ b/tests/boundary/javascript-recovery/wakaruInputAllowance.test.ts @@ -0,0 +1,122 @@ +import { access, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// Input admission precedes engine launch, so the configured engine is only +// fingerprinted here and never started. +vi.mock( + "../../../src/javascript/recovery/WakaruCommand.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../src/javascript/recovery/WakaruCommand.js") + >(); + return { + ...actual, + resolveWakaruCommand: async () => ({ + command: "/unused/wakaru", + limiter: "/unused/prlimit", + sha256: "a".repeat(64), + bytes: 1, + }), + }; + }, +); + +import { JavaScriptRecoveryService } from "../../../src/application/javascript/JavaScriptRecoveryService.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { snapshotRecoveryInputs } from "../../../src/javascript/recovery/RecoveryFiles.js"; +import { WakaruProvider } from "../../../src/javascript/recovery/WakaruProvider.js"; + +const MAXIMUM = 64 * 1024 * 1024; + +const recover = async ( + root: string, + path: string, + additional?: readonly string[], +) => { + const launches: unknown[] = []; + const service = new JavaScriptRecoveryService( + new WakaruProvider({}, async (options) => { + launches.push(options); + throw new Error("input admission must refuse before engine launch"); + }), + ); + const output = join(root, "output"); + try { + const result = await service.recover({ + path, + output_directory: output, + ...(additional === undefined ? {} : { additional_paths: additional }), + }); + if (result.ok) throw new Error("Expected recovery input refusal"); + expect(launches).toEqual([]); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + return projectAnalysisError(result.error); + } finally { + await service.close(); + } +}; + +it.skipIf(process.platform === "win32")( + "reports exhausted total input bytes as a deterministic file-size limit", + async () => { + const root = await createTestTempDirectory("rea-recovery-allowance-"); + const exact = join(root, "exact.js"); + await writeFile(exact, Buffer.alloc(MAXIMUM, 32)); + const [admitted] = await snapshotRecoveryInputs( + [exact], + join(root, "snapshot"), + ); + expect(admitted.bytes).toBe(MAXIMUM); + + const over = join(root, "over.js"); + await writeFile(over, Buffer.alloc(MAXIMUM + 1, 32)); + const single = await recover(root, over); + expect(single).toMatchObject({ + code: "resource_constraint", + retryable: false, + details: { + operation: "recover_javascript_sources", + resource: "file-size", + reported_limits: { + maximum_input_bytes: MAXIMUM, + remaining_input_bytes: MAXIMUM, + }, + }, + }); + expect(single.message).not.toMatch(/Invalid analysis output/u); + expect(single.remediation.action).toMatch(/retrying the unchanged/u); + + // A selection whose total exceeds the allowance reports what remained. + const primary = join(root, "primary.js"); + await writeFile(primary, "module.exports = 1;\n"); + const multiple = await recover(root, primary, [exact]); + expect(multiple).toMatchObject({ + code: "resource_constraint", + retryable: false, + details: { + resource: "file-size", + reported_limits: { + maximum_input_bytes: MAXIMUM, + remaining_input_bytes: MAXIMUM - 20, + }, + }, + }); + }, +); + +it("keeps malformed UTF-8 an input-format refusal distinct from size", async () => { + const root = await createTestTempDirectory("rea-recovery-encoding-"); + const invalid = join(root, "invalid.js"); + await writeFile(invalid, Buffer.from([0xff])); + const projection = await recover(root, invalid); + expect(projection).toMatchObject({ + code: "invalid_request", + details: { + issues: [{ path: ["path"], reason: "invalid_format" }], + }, + }); + expect(projection.details).not.toHaveProperty("resource"); +}); diff --git a/tests/boundary/javascript-recovery/wakaruIntegration.test.ts b/tests/boundary/javascript-recovery/wakaruIntegration.test.ts index 8e34ba1973..ce85c95c2d 100644 --- a/tests/boundary/javascript-recovery/wakaruIntegration.test.ts +++ b/tests/boundary/javascript-recovery/wakaruIntegration.test.ts @@ -528,7 +528,9 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")( output_directory: fixture.output, }); if (result.ok) throw new Error("expected aggregate capacity failure"); - expect(projectAnalysisError(result.error).code).toBe("invalid_request"); + expect(projectAnalysisError(result.error).code).toBe( + "resource_constraint", + ); expect(fixture.launches).toHaveLength(0); await expect(access(fixture.output)).rejects.toMatchObject({ code: "ENOENT",