import { requirePermissionDenial } from "../../../tests/fixtures/permissionDenial.js"; import { appendFile, chmod, mkdir, readFile, readdir, rename, symlink, writeFile, } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { environmentServeSource, writeEnvironmentServeFiles, } from "../../../tests/fixtures/environmentServe.js"; import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { AnalysisAccessDeniedError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import { exportEnvironmentBundle, readEnvironmentExportInputs, } from "./environmentExport.js"; 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); const inputs = await readEnvironmentExportInputs( files.manifestPath, files.capturePath, ); if (!inputs.ok) throw inputs.error; const tarball = join(root, "package.tgz"); const originalBytes = Buffer.from("original admitted package"); await writeFile(tarball, originalBytes); const directory = join(root, "bundle"); const original = SafeOutputTree.prototype.write; let replaced = false; const writer = vi .spyOn(SafeOutputTree.prototype, "write") .mockImplementation(async function ( this: SafeOutputTree, ...args: Parameters ) { const result = await original.apply(this, args); if (!replaced) { replaced = true; await rename(tarball, join(root, "admitted.tgz")); await writeFile(tarball, "replacement package"); } return result; }); try { const result = await exportEnvironmentBundle({ ...inputs.value, source: environmentServeSource(), directory, packageTarball: tarball, }); expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisArtifactChangedError", path: tarball }, }); expect( await readFile(join(directory, "envs/grafana-example/rea-agents.tgz")), ).toEqual(originalBytes); } finally { writer.mockRestore(); } }); it("keeps tarball ownership when the admitted inode grows during output", async () => { const root = await createTestTempDirectory("rea-tarball-growth-"); 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, Buffer.alloc(2 * 1024 * 1024, 0x41)); const directory = join(root, "bundle"); const original = SafeOutputTree.prototype.write; let grown = false; const writer = vi .spyOn(SafeOutputTree.prototype, "write") .mockImplementation(async function ( this: SafeOutputTree, ...args: Parameters ) { const [relative] = args; if (!grown && relative === "envs/grafana-example/rea-agents.tgz") { grown = true; await appendFile(tarball, Buffer.alloc(1024 * 1024, 0x42)); } return original.apply(this, args); }); try { const result = await exportEnvironmentBundle({ ...inputs.value, source: environmentServeSource(), directory, packageTarball: tarball, }); expect(grown).toBe(true); expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError", operation: "environment-export", reason: "Extracted content exceeds the inventoried size: envs/grafana-example/rea-agents.tgz", }, }); if (result.ok) throw new Error("Expected the changed tarball to be refused"); expect(result.error.cleanup).toBeUndefined(); expect(result.error.message).not.toContain("invalidated"); } finally { writer.mockRestore(); } }); it("uses confirmed publication after the output owner's descriptor cleanup completes", async () => { const root = await createTestTempDirectory("rea-bundle-published-"); const files = await writeEnvironmentServeFiles(root); const inputs = await readEnvironmentExportInputs( files.manifestPath, files.capturePath, ); if (!inputs.ok) throw inputs.error; const directory = join(root, "bundle"); const original = SafeOutputTree.prototype.commit; const commit = vi .spyOn(SafeOutputTree.prototype, "commit") .mockImplementation(async function (this: SafeOutputTree, ...args) { await original.apply(this, args); throw new ArtifactReaderFailure( "unavailable", "controlled post-publication close failure", { cleanup: { reason: "controlled directory close failure", resources: [directory], }, }, ); }); try { const result = await exportEnvironmentBundle({ ...inputs.value, source: environmentServeSource(), directory, }); expect(result).toMatchObject({ ok: true, value: { status: "exported" } }); expect( await readFile(join(directory, "envs/grafana-example/evidence.json")), ).toEqual(inputs.value.evidence.bytes); } finally { commit.mockRestore(); } }); it.skipIf(process.platform === "win32")( "returns a tagged permission refusal for an existing read-only empty directory", async (context) => { await requirePermissionDenial(context, "directory-write"); const root = await createTestTempDirectory("rea-bundle-readonly-"); const files = await writeEnvironmentServeFiles(root); const inputs = await readEnvironmentExportInputs( files.manifestPath, files.capturePath, ); if (!inputs.ok) throw inputs.error; const directory = join(root, "bundle"); await mkdir(directory, { mode: 0o500 }); try { const result = await exportEnvironmentBundle({ ...inputs.value, source: environmentServeSource(), directory, }); expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisAccessDeniedError", operation: "environment-export", diagnostics: { output_directory: directory }, }, }); expect(await readdir(directory)).toEqual([]); } finally { await chmod(directory, 0o700); } }, ); it.skipIf(process.platform === "win32")( "names the nested path when write permission is lost after earlier files", async (context) => { await requirePermissionDenial(context, "directory-write"); const root = await createTestTempDirectory("rea-bundle-later-denied-"); const files = await writeEnvironmentServeFiles(root); const inputs = await readEnvironmentExportInputs( files.manifestPath, files.capturePath, ); if (!inputs.ok) throw inputs.error; const directory = join(root, "bundle"); const original = SafeOutputTree.prototype.write; let writes = 0; // The real host refuses the second file once the directory is read-only. const writer = vi .spyOn(SafeOutputTree.prototype, "write") .mockImplementation(async function (this: SafeOutputTree, ...args) { writes += 1; if (writes === 2) await chmod(directory, 0o500); return original.apply(this, args); }); try { const result = await exportEnvironmentBundle({ ...inputs.value, source: environmentServeSource(), directory, }); if (result.ok) throw new Error("expected a permission refusal"); expect(result.error).toMatchObject({ _tag: "AnalysisAccessDeniedError", diagnostics: { output_directory: directory }, }); if (!(result.error instanceof AnalysisAccessDeniedError)) throw result.error; expect(result.error.path.startsWith(`${directory}/`)).toBe(true); expect(await readdir(directory)).not.toEqual([]); } finally { writer.mockRestore(); await chmod(directory, 0o700); } }, ); it.each([ { failure: "pathless descriptor failure", // A disk-full `FileHandle.write()` reports a code and syscall, no path. error: () => Object.assign(new Error("ENOSPC: no space left on device, write"), { code: "ENOSPC", syscall: "write", }), }, { failure: "integrity refusal", error: () => new ArtifactReaderFailure( "integrity", "Extracted content disagrees with inventory", ), }, ] as const)( "reports a later $failure with its own recovery", async ({ failure, error }) => { const root = await createTestTempDirectory("rea-bundle-later-failure-"); const files = await writeEnvironmentServeFiles(root); const inputs = await readEnvironmentExportInputs( files.manifestPath, files.capturePath, ); if (!inputs.ok) throw inputs.error; const directory = join(root, "bundle"); const original = SafeOutputTree.prototype.write; const targets: string[] = []; const writer = vi .spyOn(SafeOutputTree.prototype, "write") .mockImplementation(async function (this: SafeOutputTree, ...args) { targets.push(args[0]); if (targets.length === 2) throw error(); return original.apply(this, args); }); try { const result = await exportEnvironmentBundle({ ...inputs.value, source: environmentServeSource(), directory, }); if (result.ok || !(result.error instanceof AnalysisOutputError)) throw new Error("expected an output failure"); expect(result.error.diagnostics).toEqual({ output_directory: directory, }); if (failure === "pathless descriptor failure") { expect(result.error.reason).toContain( join(directory, targets[1] ?? ""), ); expect(result.error.remediationAction).toContain("free disk space"); } else expect(result.error.remediationAction).toBeUndefined(); expect(await readdir(directory)).not.toEqual([]); } finally { writer.mockRestore(); } }, ); it .skipIf(process.platform === "win32") .each(["replace-root", "foreign-entry", "symlink-parent"] as const)( "refuses publication after an actual output transition: %s", async (transition) => { const root = await createTestTempDirectory("rea-bundle-output-transition-"); const files = await writeEnvironmentServeFiles(root); const inputs = await readEnvironmentExportInputs( files.manifestPath, files.capturePath, ); if (!inputs.ok) throw inputs.error; const directory = join(root, "bundle"); const foreign = join(root, "foreign"); await mkdir(foreign); const original = SafeOutputTree.prototype.write; let changed = false; const writer = vi .spyOn(SafeOutputTree.prototype, "write") .mockImplementation(async function ( this: SafeOutputTree, ...args: Parameters ) { const result = await original.apply(this, args); if (!changed) { changed = true; if (transition === "replace-root") { await rename(directory, join(root, "moved")); await mkdir(directory); await writeFile(join(directory, "foreign.txt"), "keep"); } else if (transition === "foreign-entry") { await writeFile(join(directory, "foreign.txt"), "keep"); } else { await symlink(foreign, join(directory, "envs"), "dir"); } } return result; }); try { const result = await exportEnvironmentBundle({ ...inputs.value, source: environmentServeSource(), directory, }); expect(changed).toBe(true); expect(result).toMatchObject({ ok: false, error: { operation: "environment-export" }, }); expect(await readdir(foreign)).toEqual([]); if (transition !== "symlink-parent") expect(await readFile(join(directory, "foreign.txt"), "utf8")).toBe( "keep", ); } finally { writer.mockRestore(); } }, );