diff --git a/src/application/environment/environmentBundleWrite.test.ts b/src/application/environment/environmentBundleWrite.test.ts index 76d73c0398..1e1ec5dd5e 100644 --- a/src/application/environment/environmentBundleWrite.test.ts +++ b/src/application/environment/environmentBundleWrite.test.ts @@ -9,7 +9,6 @@ import { writeFile, } from "node:fs/promises"; import { join } from "node:path"; -import { Readable } from "node:stream"; import { expect, it, vi } from "vitest"; @@ -19,7 +18,6 @@ import { writeEnvironmentServeFiles, } from "../../../tests/fixtures/environmentServe.js"; import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; -import * as fileChunks from "../../filesystem/readFileHandleChunks.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { AnalysisAccessDeniedError, @@ -30,61 +28,6 @@ import { readEnvironmentExportInputs, } from "./environmentExport.js"; -vi.mock("../../filesystem/readFileHandleChunks.js", { spy: true }); - -it("attributes a pathless tarball read failure during the copy to the tarball", async () => { - const root = await createTestTempDirectory("rea-tarball-read-failure-"); - const files = await writeEnvironmentServeFiles(root); - const inputs = await readEnvironmentExportInputs( - files.manifestPath, - files.capturePath, - ); - if (!inputs.ok) throw inputs.error; - const tarball = join(root, "package.tgz"); - await writeFile(tarball, "admitted package"); - const directory = join(root, "bundle"); - const actual = await vi.importActual( - "../../filesystem/readFileHandleChunks.js", - ); - // Only the tarball descriptor is read twice: once to hash, once to copy. - const reads = new Map(); - const reader = vi - .mocked(fileChunks.readFileHandleChunks) - .mockImplementation((handle, options) => { - const count = (reads.get(handle) ?? 0) + 1; - reads.set(handle, count); - if (count < 2) return actual.readFileHandleChunks(handle, options); - const failure = Object.assign(new Error("EIO: i/o error, read"), { - code: "EIO", - syscall: "read", - }); - return Object.assign( - new Readable({ - read() { - this.destroy(failure); - }, - }), - { readCoverage: () => ({ status: "unknown" as const }) }, - ) as unknown as ReturnType; - }); - try { - const result = await exportEnvironmentBundle({ - ...inputs.value, - source: environmentServeSource(), - directory, - packageTarball: tarball, - }); - if (result.ok || !(result.error instanceof AnalysisOutputError)) - throw new Error("expected an output failure"); - expect(result.error.reason).toContain(`reading ${tarball}`); - expect(result.error.remediationAction).toContain(tarball); - expect(result.error.remediationAction).not.toContain("disk space"); - expect(result.error.diagnostics).toEqual({ output_directory: directory }); - } finally { - reader.mockRestore(); - } -}); - it("retains the admitted tarball descriptor and refuses a pathname replacement during output", async () => { const root = await createTestTempDirectory("rea-tarball-instance-"); const files = await writeEnvironmentServeFiles(root); diff --git a/src/application/environment/environmentExport.test.ts b/src/application/environment/environmentExport.test.ts index bb053ef326..a6701e3bbb 100644 --- a/src/application/environment/environmentExport.test.ts +++ b/src/application/environment/environmentExport.test.ts @@ -1,8 +1,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { join, sep } from "node:path"; -import * as v8 from "node:v8"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { environmentServeFixture, @@ -12,17 +11,11 @@ import { import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js"; -import { RegularFileChangedError } from "../../filesystem/RegularFile.js"; -import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js"; -import * as regularFileRead from "../RegularFileRead.js"; import { exportEnvironmentBundle, readEnvironmentExportInputs, } from "./environmentExport.js"; -vi.mock("node:v8", { spy: true }); -vi.mock("../RegularFileRead.js", { spy: true }); - const listFiles = async (root: string): Promise => (await readdir(root, { recursive: true, withFileTypes: true })) .filter((entry) => entry.isFile()) @@ -52,41 +45,6 @@ const prepare = async (prefix: string) => { }; describe("readEnvironmentExportInputs", () => { - it.each(["manifest", "evidence"] as const)( - "returns a tagged input error when the %s snapshot changes", - async (role) => { - const root = await createTestTempDirectory( - "rea-environment-input-changed-", - ); - const files = await writeEnvironmentServeFiles(root); - const changedPath = - role === "manifest" ? files.manifestPath : files.capturePath; - const actual = await vi.importActual( - "../RegularFileRead.js", - ); - const reader = vi.mocked(regularFileRead.readRegularFile); - reader.mockImplementation(async (path, options) => { - if (path === changedPath) throw new RegularFileChangedError(path); - return actual.readRegularFile(path, options); - }); - try { - const result = await readEnvironmentExportInputs( - files.manifestPath, - files.capturePath, - ); - if (result.ok) throw new Error("Expected changed input refusal"); - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "invalid_request", - details: { - operation: "environment-export", - issues: [{ path: [role], reason: "invalid_value" }], - }, - }); - } finally { - reader.mockImplementation(actual.readRegularFile); - } - }, - ); it("reads each file once as the bytes on disk and the value they parse to", async () => { const root = await createTestTempDirectory("rea-environment-inputs-"); const fixture = environmentServeFixture(); @@ -107,32 +65,6 @@ describe("readEnvironmentExportInputs", () => { expect(inputs.value.evidence.value).toEqual(fixture.capture); }); - it("refuses an input whose whole read and parse would not fit live heap headroom, before reading it", async () => { - const root = await createTestTempDirectory("rea-environment-export-heap-"); - const files = await writeEnvironmentServeFiles( - root, - environmentServeFixture(), - ); - vi.mocked(v8.getHeapStatistics).mockReturnValueOnce({ - ...v8.getHeapStatistics(), - total_available_size: 1024, - }); - const refused = await readEnvironmentExportInputs( - files.manifestPath, - files.capturePath, - ); - expect(refused.ok).toBe(false); - if (refused.ok) return; - expect(refused.error).toMatchObject({ - _tag: "AnalysisResourceConstraintError", - resource: "memory", - reportedLimits: { - input_path: files.manifestPath, - available_heap_bytes: 1024, - }, - }); - }); - it("refuses inline JSON, a directory and a file that is not JSON by role", async () => { const root = await createTestTempDirectory("rea-environment-inputs-bad-"); const files = await writeEnvironmentServeFiles(root); @@ -314,34 +246,3 @@ describe("exportEnvironmentBundle file modes", () => { }, ); }); - -describe("readEnvironmentExportInputs heap admission", () => { - it("reserves headroom for snapshot sealing after the native parse", async () => { - const root = await createTestTempDirectory( - "rea-environment-export-copies-", - ); - const files = await writeEnvironmentServeFiles( - root, - environmentServeFixture(), - ); - const { size } = await stat(files.manifestPath); - // Room for parsing, but not for the owned snapshot traversal that follows it. - vi.mocked(v8.getHeapStatistics).mockReturnValueOnce({ - ...v8.getHeapStatistics(), - total_available_size: nativeJsonParseRequiredBytes(size) * 1.5, - }); - const refused = await readEnvironmentExportInputs( - files.manifestPath, - files.capturePath, - ); - expect(refused.ok).toBe(false); - if (refused.ok) return; - expect(refused.error).toMatchObject({ - _tag: "AnalysisResourceConstraintError", - reportedLimits: { - input_path: files.manifestPath, - required_heap_bytes: nativeJsonParseRequiredBytes(size, true), - }, - }); - }); -}); diff --git a/tests/boundary/cli/environmentExportInputsCli.test.ts b/tests/boundary/cli/environmentExportInputsCli.test.ts new file mode 100644 index 0000000000..17ebf3cc53 --- /dev/null +++ b/tests/boundary/cli/environmentExportInputsCli.test.ts @@ -0,0 +1,193 @@ +import { + appendFile, + readFile, + stat, + truncate, + writeFile, +} from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { nativeJsonParseRequiredBytes } from "../../../src/application/JsonInputFile.js"; +import { writeEnvironmentServeFiles } from "../../fixtures/environmentServe.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const probe = pathToFileURL( + resolve("tests/fixtures/environmentExportInputProbe.mjs"), +).href; + +for (const role of ["manifest", "evidence"] as const) { + cliTest( + `refuses a real ${role} file changed during its read`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-export-input-race-cli-"); + const files = await writeEnvironmentServeFiles(root); + const input = + role === "manifest" ? files.manifestPath : files.capturePath; + const before = await readFile(input); + const marker = join(root, "read-marker"); + const directory = join(root, "bundle"); + const result = await cli.run({ + arguments: [ + "environment-export", + files.manifestPath, + "--evidence", + files.capturePath, + directory, + "--json", + ], + environment: { + NODE_OPTIONS: `--import=${probe} --max-old-space-size=256`, + REA_TEST_EXPORT_INPUT: input, + REA_TEST_EXPORT_READ_MARKER: marker, + REA_TEST_EXPORT_READ_ACTION: "append", + }, + timeoutMs: 30_000, + }); + expect(await readFile(marker, "utf8")).toBe("read"); + expect(await readFile(input)).toEqual( + Buffer.concat([before, Buffer.from(" ")]), + ); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "environment-export", + issues: [{ path: [role], reason: "invalid_value" }], + }, + }); + await expect(stat(directory)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); +} + +const heapRefusalSchema = z.object({ + code: z.literal("resource_constraint"), + details: z.object({ + operation: z.literal("environment-export"), + resource: z.literal("memory"), + reported_limits: z.object({ + input_path: z.string(), + input_file_bytes: z.number(), + required_heap_bytes: z.number(), + available_heap_bytes: z.number(), + }), + }), +}); + +cliTest( + "admits neither the whole read nor an unbudgeted snapshot under a real heap limit", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-export-input-heap-cli-"); + const files = await writeEnvironmentServeFiles(root); + const originalBytes = (await stat(files.manifestPath)).size; + const marker = join(root, "read-marker"); + const directory = join(root, "bundle"); + // Whitespace preserves a valid manifest while making the actual file large. + await appendFile(files.manifestPath, Buffer.alloc(8 * 1024 * 1024, " ")); + let available: number | undefined; + for (const phase of ["whole-read", "snapshot"] as const) { + if (available !== undefined) { + // Fit the native parse in two thirds of the previous child's real + // headroom, while the additional snapshot reservation cannot fit. + const overhead = nativeJsonParseRequiredBytes(0); + const perByte = nativeJsonParseRequiredBytes(1) - overhead; + await truncate( + files.manifestPath, + Math.max( + originalBytes, + Math.floor(((available * 2) / 3 - overhead) / perByte), + ), + ); + } + const result = await cli.run({ + arguments: [ + "environment-export", + files.manifestPath, + "--evidence", + files.capturePath, + directory, + "--json", + ], + environment: { + NODE_OPTIONS: `--import=${probe} --max-old-space-size=256`, + REA_TEST_EXPORT_INPUT: files.manifestPath, + REA_TEST_EXPORT_READ_MARKER: marker, + REA_TEST_EXPORT_READ_ACTION: "record", + }, + timeoutMs: 30_000, + }); + expect(result.exitCode, result.stderr).toBe(1); + const limits = heapRefusalSchema.parse(result.json).details + .reported_limits; + expect(limits.input_path).toBe(files.manifestPath); + expect(limits.input_file_bytes).toBe( + (await stat(files.manifestPath)).size, + ); + expect(limits.required_heap_bytes).toBe( + nativeJsonParseRequiredBytes(limits.input_file_bytes, true), + ); + expect(limits.required_heap_bytes).toBeGreaterThan( + limits.available_heap_bytes, + ); + if (phase === "snapshot") + expect( + nativeJsonParseRequiredBytes(limits.input_file_bytes), + ).toBeLessThan(limits.available_heap_bytes); + else + expect( + nativeJsonParseRequiredBytes(limits.input_file_bytes), + ).toBeGreaterThan(limits.available_heap_bytes); + available = limits.available_heap_bytes; + await expect(stat(marker)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(stat(directory)).rejects.toMatchObject({ code: "ENOENT" }); + } + }, +); + +cliTest( + "attributes a pathless descriptor failure during the real tarball copy", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-export-copy-read-cli-"); + const files = await writeEnvironmentServeFiles(root); + const tarball = join(root, "package.tgz"); + await writeFile(tarball, "admitted package"); + const marker = join(root, "read-marker"); + const directory = join(root, "bundle"); + const result = await cli.run({ + arguments: [ + "environment-export", + files.manifestPath, + "--evidence", + files.capturePath, + directory, + "--package-tarball", + tarball, + "--json", + ], + environment: { + NODE_OPTIONS: `--import=${probe} --max-old-space-size=256`, + REA_TEST_EXPORT_INPUT: tarball, + REA_TEST_EXPORT_READ_MARKER: marker, + REA_TEST_EXPORT_READ_ACTION: "fail-copy", + }, + timeoutMs: 30_000, + }); + expect(await readFile(marker, "utf8")).toBe("copy"); + expect(await readFile(tarball, "utf8")).toBe("admitted package"); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "unreadable_output", + details: { + reason: expect.stringContaining(`reading ${tarball}`), + diagnostics: { output_directory: directory }, + }, + remediation: { action: expect.stringContaining(tarball) }, + }); + expect(JSON.stringify(result.json)).not.toContain("disk space"); + }, +); diff --git a/tests/fixtures/environmentExportInputProbe.mjs b/tests/fixtures/environmentExportInputProbe.mjs new file mode 100644 index 0000000000..09d19cd302 --- /dev/null +++ b/tests/fixtures/environmentExportInputProbe.mjs @@ -0,0 +1,41 @@ +import { promises as fs } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; + +const path = process.env.REA_TEST_EXPORT_INPUT; +const marker = process.env.REA_TEST_EXPORT_READ_MARKER; +const action = process.env.REA_TEST_EXPORT_READ_ACTION; +if (!path || !marker || !["append", "record", "fail-copy"].includes(action)) + throw new Error("Expected an explicit export input, read marker and action"); + +// Instrument only the selected real descriptor in this disposable CLI process. +// Reads and metadata checks remain the production filesystem operations. +const open = fs.open; +fs.open = async (...args) => { + const handle = await open(...args); + if (args[0] !== path) return handle; + const read = handle.read.bind(handle); + let recorded = false; + let hashed = false; + handle.read = async (...readArgs) => { + if (action === "fail-copy" && hashed) { + await fs.writeFile(marker, "copy"); + throw Object.assign(new Error("controlled pathless descriptor failure"), { + code: "EIO", + syscall: "read", + }); + } + const result = await read(...readArgs); + if (action === "fail-copy") { + hashed = result.bytesRead === 0; + return result; + } + if (!recorded) { + recorded = true; + await fs.writeFile(marker, "read"); + if (action === "append") await fs.appendFile(path, " "); + } + return result; + }; + return handle; +}; +syncBuiltinESMExports();