mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
8589b7e778
* fix(evidence): verify staged output identity after its final write On macOS FAT32 and exFAT volumes an empty file reports a placeholder file ID, and the first write assigns its cluster-derived ID. The staged JSON writer sampled the descriptor identity right after exclusive creation, so publication and cleanup both rejected REA's own staged file as replaced: every export failed with cleanup_incomplete and retained a .rea-write-* directory. The staged output owner now observes the descriptor identity immediately before closing it, after the last write, and uses that identity for the publication and cleanup pathname checks. A failed observation leaves the identity unknown, so cleanup still refuses pathname removal. Fixes #4613 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(evidence): cover failed final staged identity reads Reject the staged descriptor's final stat and verify that the writer still closes the descriptor, refuses publication, and retains the unverified staged content with incomplete cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
import { readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
import { mkdir, open, rm } from "node:fs/promises";
|
|
import type { FileHandle } from "node:fs/promises";
|
|
import * as fs from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect, it, vi } from "vitest";
|
|
|
|
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
|
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
|
|
import { EvidenceFileError } from "../../../src/domain/evidenceErrors.js";
|
|
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
|
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { writeTextParts } from "../../../src/application/JsonFiles.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const allocation = vi.hoisted(() => ({
|
|
deferred: false,
|
|
statFailure: false,
|
|
staged: [] as FileHandle[],
|
|
}));
|
|
|
|
// FAT and exFAT on macOS report a placeholder file ID for an empty file and
|
|
// assign its cluster-derived ID when the first write allocates storage.
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const filesystem = await importOriginal<typeof import("node:fs/promises")>();
|
|
const { basename, dirname } = await import("node:path");
|
|
return {
|
|
...filesystem,
|
|
open: async (...args: Parameters<typeof filesystem.open>) => {
|
|
const handle = await filesystem.open(...args);
|
|
if (
|
|
!(allocation.deferred || allocation.statFailure) ||
|
|
!basename(dirname(String(args[0]))).startsWith(".rea-write-")
|
|
)
|
|
return handle;
|
|
allocation.staged.push(handle);
|
|
const stat = handle.stat.bind(handle);
|
|
handle.stat = (async () => {
|
|
if (allocation.statFailure)
|
|
throw new Error("injected staged descriptor stat failure");
|
|
const stats = await stat();
|
|
if (stats.size === 0) stats.ino = Number.MAX_SAFE_INTEGER;
|
|
return stats;
|
|
}) as typeof handle.stat;
|
|
return handle;
|
|
},
|
|
};
|
|
});
|
|
|
|
it("closes the descriptor and retains staged content when its final identity is unknown", async () => {
|
|
const root = await createTestTempDirectory("rea-json-identity-unknown-");
|
|
const resources = new ArtifactResourceScope();
|
|
const destination = join(root, "unverified.json");
|
|
allocation.statFailure = true;
|
|
allocation.staged = [];
|
|
try {
|
|
const result = await writeTextParts(
|
|
["owned bytes"],
|
|
destination,
|
|
false,
|
|
resources,
|
|
);
|
|
if (result.ok) throw new Error("Expected unverified publication failure");
|
|
expect(allocation.staged).toHaveLength(1);
|
|
expect(allocation.staged[0]!.fd).toBe(-1);
|
|
expect(result.error).toBeInstanceOf(EvidenceFileError);
|
|
expect(result.error.published).toBeUndefined();
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cause).toMatchObject({
|
|
outcome: {
|
|
ok: false,
|
|
error: { message: "Cannot verify staged file identity" },
|
|
},
|
|
});
|
|
const stage = result.error.cleanupResources[0];
|
|
if (stage === undefined) throw new Error("Expected retained staging");
|
|
expect(stage).toContain(".rea-write-");
|
|
expect(await fs.readFile(join(stage, "content"), "utf8")).toBe(
|
|
"owned bytes",
|
|
);
|
|
await expect(fs.access(destination)).rejects.toThrow();
|
|
// A closed descriptor cannot re-establish identity, so retry stays incomplete.
|
|
await expect(resources.close()).rejects.toThrow();
|
|
expect(await fs.readFile(join(stage, "content"), "utf8")).toBe(
|
|
"owned bytes",
|
|
);
|
|
} finally {
|
|
allocation.statFailure = false;
|
|
allocation.staged = [];
|
|
await resources.close().catch(() => undefined);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"publishes when the staged file ID is assigned by its first write (overwrite: %s)",
|
|
async (overwrite) => {
|
|
const root = await createTestTempDirectory("rea-json-deferred-id-");
|
|
const resources = new ArtifactResourceScope();
|
|
const destination = join(root, "bundle.json");
|
|
allocation.deferred = true;
|
|
try {
|
|
if (overwrite) writeFileSync(destination, "previous");
|
|
const result = await writeTextParts(
|
|
["canonical ", "output"],
|
|
destination,
|
|
overwrite,
|
|
resources,
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value).toEqual({ path: destination, bytes: 16 });
|
|
expect(await fs.readFile(destination, "utf8")).toBe("canonical output");
|
|
expect(requireStages(root)).toEqual([]);
|
|
} finally {
|
|
allocation.deferred = false;
|
|
await resources.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("removes cancelled staging after the first write assigned its file ID", async () => {
|
|
const root = await createTestTempDirectory("rea-json-deferred-cancel-");
|
|
const resources = new ArtifactResourceScope();
|
|
const controller = new AbortController();
|
|
const destination = join(root, "cancelled.json");
|
|
allocation.deferred = true;
|
|
try {
|
|
function* cancelledAfterFirstWrite(): Generator<string> {
|
|
yield "partial";
|
|
controller.abort();
|
|
yield "must not publish";
|
|
}
|
|
const result = await writeTextParts(
|
|
cancelledAfterFirstWrite(),
|
|
destination,
|
|
false,
|
|
resources,
|
|
{ signal: controller.signal, operation: "export_evidence_bundle" },
|
|
);
|
|
if (result.ok) throw new Error("Expected cancellation");
|
|
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
await expect(fs.access(destination)).rejects.toThrow();
|
|
expect(requireStages(root)).toEqual([]);
|
|
} finally {
|
|
allocation.deferred = false;
|
|
await resources.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("reports committed output when post-publication staging cleanup remains owned", async () => {
|
|
const root = await createTestTempDirectory("rea-json-write-");
|
|
const resources = new ArtifactResourceScope();
|
|
try {
|
|
const result = await writeTextParts(
|
|
stagingPollutingParts(root),
|
|
join(root, "bundle.json"),
|
|
false,
|
|
resources,
|
|
);
|
|
if (result.ok) throw new Error("Expected staged cleanup failure");
|
|
const cleanupResource = result.error.cleanup?.resources[0];
|
|
if (cleanupResource === undefined)
|
|
throw new Error("Expected retained staging resource");
|
|
expect(result.error).toBeInstanceOf(EvidenceFileError);
|
|
expect(result.error.published).toMatchObject({
|
|
path: join(root, "bundle.json"),
|
|
bytes: 16,
|
|
});
|
|
expect(cleanupResource).toContain(".rea-write-");
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: { published: true, published_path: join(root, "bundle.json") },
|
|
});
|
|
expect(await fs.readFile(join(root, "bundle.json"), "utf8")).toBe(
|
|
"canonical output",
|
|
);
|
|
await fs.rm(join(cleanupResource, "unexpected"));
|
|
} finally {
|
|
await resources.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("preserves cancellation when staging cleanup also fails", async () => {
|
|
const root = await createTestTempDirectory("rea-json-cancel-");
|
|
const resources = new ArtifactResourceScope();
|
|
const controller = new AbortController();
|
|
try {
|
|
function* cancelledParts(): Generator<string> {
|
|
const directories = requireStages(root);
|
|
if (directories.length !== 1)
|
|
throw new Error("Expected one staging directory");
|
|
writeFileSync(join(root, directories[0]!, "unexpected"), "retain this");
|
|
controller.abort();
|
|
yield "must not publish";
|
|
}
|
|
const result = await writeTextParts(
|
|
cancelledParts(),
|
|
join(root, "cancelled.json"),
|
|
false,
|
|
resources,
|
|
{ signal: controller.signal, operation: "export_evidence_bundle" },
|
|
);
|
|
if (result.ok) throw new Error("Expected cancellation");
|
|
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cause).toMatchObject({
|
|
cause: expect.any(AnalysisCancelledError),
|
|
owner: { directory: expect.stringContaining(".rea-write-") },
|
|
});
|
|
await expect(fs.access(join(root, "cancelled.json"))).rejects.toThrow();
|
|
const stage = requireStages(root)[0];
|
|
if (stage !== undefined) await fs.rm(join(root, stage, "unexpected"));
|
|
} finally {
|
|
await resources.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("refuses to publish a staged path replaced after its descriptor opened", async () => {
|
|
const root = await createTestTempDirectory("rea-staged-swap-");
|
|
const resources = new ArtifactResourceScope();
|
|
const destination = join(root, "swapped.json");
|
|
try {
|
|
const result = await writeTextParts(
|
|
replacingStagedParts(root),
|
|
destination,
|
|
false,
|
|
resources,
|
|
);
|
|
if (result.ok) throw new Error("Expected staged identity rejection");
|
|
expect(
|
|
await fs.readFile(join(root, requireStages(root)[0]!, "content"), "utf8"),
|
|
).toBe("foreign bytes");
|
|
await expect(fs.access(destination)).rejects.toThrow();
|
|
const stage = requireStages(root)[0];
|
|
if (stage !== undefined) {
|
|
const directory = join(root, stage);
|
|
await fs.rm(join(directory, "content"));
|
|
await fs.rename(join(directory, "original"), join(directory, "content"));
|
|
}
|
|
} finally {
|
|
await resources.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not invent a staged owner when the supplied scope is closed", async () => {
|
|
const root = await createTestTempDirectory("rea-closed-output-scope-");
|
|
const resources = new ArtifactResourceScope();
|
|
await resources.close();
|
|
const destination = join(root, "closed.json");
|
|
const result = await writeTextParts(
|
|
["unused"],
|
|
destination,
|
|
false,
|
|
resources,
|
|
);
|
|
if (result.ok) throw new Error("Expected closed-scope admission failure");
|
|
expect(result.error).toBeInstanceOf(EvidenceFileError);
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(result.error.cleanupResources).toEqual([]);
|
|
expect(await fs.readdir(root)).toEqual([]);
|
|
});
|
|
|
|
function stagingPollutingParts(root: string): Iterable<string> {
|
|
return {
|
|
*[Symbol.iterator](): Generator<string> {
|
|
const directories = readdirSync(root);
|
|
const stage = directories.find((entry) =>
|
|
entry.startsWith(".rea-write-"),
|
|
);
|
|
if (stage === undefined) throw new Error("Expected staging directory");
|
|
writeFileSync(join(root, stage, "unexpected"), "retain this");
|
|
yield "canonical output";
|
|
},
|
|
};
|
|
}
|
|
|
|
function replacingStagedParts(root: string): Iterable<string> {
|
|
return {
|
|
*[Symbol.iterator](): Generator<string> {
|
|
const stage = requireStages(root)[0];
|
|
if (stage === undefined) throw new Error("Expected staging directory");
|
|
const content = join(root, stage, "content");
|
|
renameSync(content, join(root, stage, "original"));
|
|
writeFileSync(content, "foreign bytes");
|
|
yield "owned bytes";
|
|
},
|
|
};
|
|
}
|
|
|
|
const requireStages = (root: string): string[] =>
|
|
readdirSync(root).filter((entry) => entry.startsWith(".rea-write-"));
|
|
|
|
it("retains a failed file close owner and retries before removing its stage", async () => {
|
|
const root = await createTestTempDirectory("rea-staged-close-");
|
|
const directory = join(root, ".rea-write-owned");
|
|
await mkdir(directory);
|
|
const path = join(directory, "content");
|
|
const handle = await open(path, "wx", 0o600);
|
|
const owner = await OwnedStagedOutput.create(directory);
|
|
let closeAttempts = 0;
|
|
const actualClose = handle.close.bind(handle);
|
|
vi.spyOn(handle, "close").mockImplementation(async () => {
|
|
closeAttempts += 1;
|
|
if (closeAttempts === 1) throw new Error("injected close failure");
|
|
await actualClose();
|
|
});
|
|
owner.setFile(new OwnedFileHandle(handle));
|
|
const resources = new ArtifactResourceScope();
|
|
const stagedOwner = {
|
|
kind: "staged-output" as const,
|
|
resource: directory,
|
|
output: owner,
|
|
};
|
|
try {
|
|
expect(await resources.release(stagedOwner)).toMatchObject({
|
|
kind: "failed",
|
|
});
|
|
await resources.close();
|
|
expect(closeAttempts).toBe(2);
|
|
await expect(fs.access(directory)).rejects.toThrow();
|
|
} finally {
|
|
await resources.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("refuses to remove unexpected files from an owned staging directory", async () => {
|
|
const root = await createTestTempDirectory("rea-staged-replaced-");
|
|
const directory = join(root, ".rea-write-owned");
|
|
await mkdir(directory);
|
|
const owner = await OwnedStagedOutput.create(directory);
|
|
const unexpected = join(directory, "unexpected");
|
|
writeFileSync(unexpected, "unowned");
|
|
try {
|
|
await expect(owner.close()).rejects.toThrow(/Unexpected staging entries/u);
|
|
expect(await fs.readFile(unexpected, "utf8")).toBe("unowned");
|
|
} finally {
|
|
await fs.rm(unexpected, { force: true });
|
|
await owner.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("retains cleanup when its staging directory was renamed", async () => {
|
|
const root = await createTestTempDirectory("rea-staged-renamed-dir-");
|
|
const directory = join(root, ".rea-write-owned");
|
|
const moved = join(root, ".rea-write-moved");
|
|
await mkdir(directory);
|
|
const path = join(directory, "content");
|
|
const handle = await open(path, "wx", 0o600);
|
|
const owner = await OwnedStagedOutput.create(directory);
|
|
owner.setFile(new OwnedFileHandle(handle));
|
|
const resources = new ArtifactResourceScope();
|
|
const stagedOwner = {
|
|
kind: "staged-output" as const,
|
|
resource: directory,
|
|
output: owner,
|
|
};
|
|
try {
|
|
writeFileSync(join(directory, "unexpected"), "retain this");
|
|
expect(await resources.release(stagedOwner)).toMatchObject({
|
|
kind: "failed",
|
|
});
|
|
await fs.rename(directory, moved);
|
|
await expect(resources.close()).rejects.toThrow();
|
|
await fs.rename(moved, directory);
|
|
await rm(join(directory, "unexpected"));
|
|
await expect(resources.close()).resolves.toBeUndefined();
|
|
} finally {
|
|
await resources.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("retains cleanup when owned staged content was renamed", async () => {
|
|
const root = await createTestTempDirectory("rea-staged-renamed-file-");
|
|
const directory = join(root, ".rea-write-owned");
|
|
const content = join(directory, "content");
|
|
const moved = join(directory, "moved-content");
|
|
await mkdir(directory);
|
|
const handle = await open(content, "wx", 0o600);
|
|
const owner = await OwnedStagedOutput.create(directory);
|
|
owner.setFile(new OwnedFileHandle(handle));
|
|
const resources = new ArtifactResourceScope();
|
|
const stagedOwner = {
|
|
kind: "staged-output" as const,
|
|
resource: directory,
|
|
output: owner,
|
|
};
|
|
try {
|
|
await fs.rename(content, moved);
|
|
expect(await resources.release(stagedOwner)).toMatchObject({
|
|
kind: "failed",
|
|
});
|
|
await expect(resources.close()).rejects.toThrow(
|
|
/cleanup remains incomplete/u,
|
|
);
|
|
await fs.rename(moved, content);
|
|
await expect(resources.close()).resolves.toBeUndefined();
|
|
} finally {
|
|
await resources.close();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|