mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
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<SafeOutputTree["write"]>
|
|
) {
|
|
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<SafeOutputTree["write"]>
|
|
) {
|
|
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<SafeOutputTree["write"]>
|
|
) {
|
|
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();
|
|
}
|
|
},
|
|
);
|