mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(artifacts): retain output identity through cleanup retries
Track created directory and file identities before materializing or removing output. Refuse displaced paths and untracked entries, preserve completed cleanup work across permission failures, and prevent publication once rollback starts. Group cleanup files once per attempt instead of rescanning them for every directory. Validation: root, nested-parent, and file symlink replacement; untracked-entry retention; real POSIX permission failure and retry; extraction integrity and CLI/MCP consumers pass. Path identity revalidation cannot eliminate syscall-boundary pathname races in portable Node. Windows behavior was simulated; native Windows and Linux-only publication providers were not exercised.
This commit is contained in:
1 parent
d510f3e928
commit
78778d91a4
4 files changed
+527
-43
No files matched your search
+129
-42
@@ -1,12 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
rm,
|
||||
rmdir,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
@@ -19,6 +18,19 @@ import {
|
||||
normalizeArtifactPath,
|
||||
} from "./ArtifactPaths.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { removeOwnedTree } from "./SafeOutputTreeCleanup.js";
|
||||
import {
|
||||
assertHandleIdentity,
|
||||
assertFilePathIdentity,
|
||||
assertPathIdentity,
|
||||
isAbsent,
|
||||
pathHasIdentity,
|
||||
readDirectoryIdentity,
|
||||
readFileIdentity,
|
||||
replacedDirectory,
|
||||
type DirectoryIdentity,
|
||||
type FileIdentity,
|
||||
} from "./SafeOutputTreeIdentity.js";
|
||||
|
||||
/** One file durably written to an operation-owned output tree. */
|
||||
export interface SafeOutputFile {
|
||||
@@ -36,10 +48,18 @@ export type SafeOutputCleanup =
|
||||
readonly residualPaths: readonly [string, ...string[]];
|
||||
};
|
||||
|
||||
/** Symlink-resistant materialization in an exclusively owned, initially absent tree. */
|
||||
/**
|
||||
* Materialize files in a tree created by this operation.
|
||||
*
|
||||
* Path identities are revalidated around operations, but Node has no portable
|
||||
* descriptor-relative traversal; a syscall-boundary pathname race remains.
|
||||
*/
|
||||
export class SafeOutputTree {
|
||||
readonly #registry = new ArtifactPathRegistry();
|
||||
readonly #outputRoot: string;
|
||||
readonly #rootIdentity: DirectoryIdentity;
|
||||
readonly #nestedDirectories = new Map<string, DirectoryIdentity>();
|
||||
readonly #ownedFiles = new Map<string, FileIdentity>();
|
||||
#published = false;
|
||||
#cleanup: SafeOutputCleanup = {
|
||||
status: "not-required",
|
||||
@@ -47,9 +67,11 @@ export class SafeOutputTree {
|
||||
|
||||
private constructor(
|
||||
outputRoot: string,
|
||||
rootIdentity: DirectoryIdentity,
|
||||
private readonly platform: NodeJS.Platform,
|
||||
) {
|
||||
this.#outputRoot = outputRoot;
|
||||
this.#rootIdentity = rootIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,29 +113,45 @@ export class SafeOutputTree {
|
||||
);
|
||||
throw cause;
|
||||
});
|
||||
let rootIdentity: DirectoryIdentity | undefined;
|
||||
try {
|
||||
rootIdentity = await readDirectoryIdentity(canonicalOutput);
|
||||
if (platform !== "win32") {
|
||||
const stagingHandle = await open(
|
||||
canonicalOutput,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
);
|
||||
try {
|
||||
await assertHandleIdentity(
|
||||
stagingHandle,
|
||||
rootIdentity,
|
||||
canonicalOutput,
|
||||
);
|
||||
await stagingHandle.chmod(0o700);
|
||||
} finally {
|
||||
await stagingHandle.close();
|
||||
}
|
||||
}
|
||||
return new SafeOutputTree(canonicalOutput, platform);
|
||||
await assertPathIdentity(canonicalOutput, rootIdentity);
|
||||
return new SafeOutputTree(canonicalOutput, rootIdentity, platform);
|
||||
} catch (cause: unknown) {
|
||||
let removalFailure: unknown;
|
||||
let ownedRootAtPath = false;
|
||||
try {
|
||||
await rm(canonicalOutput, { recursive: true, force: true });
|
||||
ownedRootAtPath =
|
||||
rootIdentity !== undefined &&
|
||||
(await pathHasIdentity(canonicalOutput, rootIdentity));
|
||||
if (ownedRootAtPath) await rmdir(canonicalOutput);
|
||||
else
|
||||
removalFailure = new Error(
|
||||
"Extraction output root identity changed during setup",
|
||||
);
|
||||
} catch (cleanupCause: unknown) {
|
||||
removalFailure = cleanupCause;
|
||||
}
|
||||
let absent = false;
|
||||
try {
|
||||
absent = await isAbsent(canonicalOutput);
|
||||
absent = ownedRootAtPath && (await isAbsent(canonicalOutput));
|
||||
} catch (cleanupCause: unknown) {
|
||||
removalFailure ??= cleanupCause;
|
||||
}
|
||||
@@ -122,7 +160,7 @@ export class SafeOutputTree {
|
||||
reason:
|
||||
removalFailure instanceof Error
|
||||
? removalFailure.message
|
||||
: "Extraction output root remains after setup failure",
|
||||
: "Extraction output root ownership could not be verified after setup failure",
|
||||
resources: [canonicalOutput],
|
||||
});
|
||||
throw cause;
|
||||
@@ -153,7 +191,11 @@ export class SafeOutputTree {
|
||||
);
|
||||
const path = normalizeArtifactPath(relativePath);
|
||||
this.#registry.add(path, "file");
|
||||
const destination = await this.#prepareParent(path);
|
||||
const lineage = await this.#prepareParent(path);
|
||||
const parent = lineage.at(-1);
|
||||
if (parent === undefined) throw replacedDirectory(this.#outputRoot);
|
||||
const fileName = path.slice(path.lastIndexOf("/") + 1);
|
||||
const destination = join(parent.path, fileName);
|
||||
const handle = await open(
|
||||
destination,
|
||||
constants.O_CREAT |
|
||||
@@ -162,20 +204,24 @@ export class SafeOutputTree {
|
||||
constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
).catch(async (cause: unknown) => {
|
||||
await throwIfDestinationCaseCollision(
|
||||
dirname(destination),
|
||||
path,
|
||||
cause,
|
||||
);
|
||||
await throwIfDestinationCaseCollision(parent.path, path, cause);
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Could not exclusively create extraction path: ${path}`,
|
||||
{ cause },
|
||||
);
|
||||
});
|
||||
const fileIdentity = await readFileIdentity(handle, destination).catch(
|
||||
async (cause: unknown) => {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
this.#ownedFiles.set(destination, fileIdentity);
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
try {
|
||||
await this.#assertLineage(lineage);
|
||||
for await (const raw of source) {
|
||||
abortIfNeeded(signal);
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
@@ -199,9 +245,13 @@ export class SafeOutputTree {
|
||||
"integrity",
|
||||
`Extracted content disagrees with inventory: ${path}`,
|
||||
);
|
||||
await this.#assertLineage(lineage);
|
||||
await assertFilePathIdentity(destination, fileIdentity);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
const readback = await hashFile(destination, bytes, signal);
|
||||
await this.#assertLineage(lineage);
|
||||
await assertFilePathIdentity(destination, fileIdentity);
|
||||
if (readback.sha256 !== sha256 || readback.bytes !== bytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
@@ -226,6 +276,7 @@ export class SafeOutputTree {
|
||||
/** Sync the owned output tree and prevent further writes through this instance. */
|
||||
async commit(): Promise<void> {
|
||||
this.#assertWritable();
|
||||
await this.#assertOwnedDirectories();
|
||||
// Windows has no directory fsync; file contents are already synced in write().
|
||||
if (this.platform === "win32") {
|
||||
this.#published = true;
|
||||
@@ -241,6 +292,7 @@ export class SafeOutputTree {
|
||||
this.#outputRoot,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
);
|
||||
await assertHandleIdentity(output, this.#rootIdentity, this.#outputRoot);
|
||||
await output.sync();
|
||||
await parent.sync();
|
||||
this.#published = true;
|
||||
@@ -257,14 +309,26 @@ export class SafeOutputTree {
|
||||
|
||||
/** Remove only this operation's unsealed tree and verify absence. */
|
||||
async rollback(): Promise<SafeOutputCleanup> {
|
||||
if (this.#published) return structuredClone(this.#cleanup);
|
||||
if (this.#published || this.#cleanup.status === "complete")
|
||||
return structuredClone(this.#cleanup);
|
||||
this.#cleanup = {
|
||||
status: "incomplete",
|
||||
residualPaths: [basename(this.#outputRoot)],
|
||||
};
|
||||
let removalFailure: unknown;
|
||||
try {
|
||||
await rm(this.#outputRoot, { recursive: true, force: true });
|
||||
await this.#assertOwnedDirectories();
|
||||
await removeOwnedTree({
|
||||
outputRoot: this.#outputRoot,
|
||||
rootIdentity: this.#rootIdentity,
|
||||
directories: this.#nestedDirectories,
|
||||
files: this.#ownedFiles,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
removalFailure = cause;
|
||||
}
|
||||
const absent = await isAbsent(this.#outputRoot);
|
||||
const absent =
|
||||
removalFailure === undefined && (await isAbsent(this.#outputRoot));
|
||||
this.#cleanup = absent
|
||||
? { status: "complete", residualPaths: [] }
|
||||
: {
|
||||
@@ -289,36 +353,61 @@ export class SafeOutputTree {
|
||||
return structuredClone(this.#cleanup);
|
||||
}
|
||||
|
||||
async #prepareParent(relativePath: string): Promise<string> {
|
||||
async #prepareParent(
|
||||
relativePath: string,
|
||||
): Promise<readonly DirectoryLineageEntry[]> {
|
||||
const parts = relativePath.split("/");
|
||||
const fileName = parts.pop();
|
||||
if (fileName === undefined)
|
||||
if (parts.pop() === undefined)
|
||||
throw new ArtifactReaderFailure("path", "Invalid extraction path");
|
||||
let current = this.#outputRoot;
|
||||
let logicalParent = "";
|
||||
const lineage: DirectoryLineageEntry[] = [
|
||||
{ path: this.#outputRoot, identity: this.#rootIdentity },
|
||||
];
|
||||
if (parts.length === 0)
|
||||
await assertPathIdentity(this.#outputRoot, this.#rootIdentity);
|
||||
for (const part of parts) {
|
||||
const parentDirectory = current;
|
||||
const parent = lineage.at(-1);
|
||||
if (parent === undefined) throw replacedDirectory(this.#outputRoot);
|
||||
const logicalPath =
|
||||
logicalParent.length === 0 ? part : `${logicalParent}/${part}`;
|
||||
current = join(current, part);
|
||||
await assertPathIdentity(parent.path, parent.identity);
|
||||
let created = true;
|
||||
await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => {
|
||||
if (!isAlreadyExists(cause)) throw cause;
|
||||
created = false;
|
||||
});
|
||||
if (!created)
|
||||
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
|
||||
const metadata = await lstat(current);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Unsafe extraction parent: ${relativePath}`,
|
||||
);
|
||||
if (!created) {
|
||||
await throwIfDestinationCaseCollision(parent.path, logicalPath);
|
||||
const identity = this.#nestedDirectories.get(current);
|
||||
if (identity === undefined) throw replacedDirectory(current);
|
||||
await assertPathIdentity(current, identity);
|
||||
} else {
|
||||
const identity = await readDirectoryIdentity(current);
|
||||
this.#nestedDirectories.set(current, identity);
|
||||
}
|
||||
await assertPathIdentity(parent.path, parent.identity);
|
||||
const identity = this.#nestedDirectories.get(current);
|
||||
if (identity === undefined) throw replacedDirectory(current);
|
||||
await assertPathIdentity(current, identity);
|
||||
lineage.push({ path: current, identity });
|
||||
logicalParent = logicalPath;
|
||||
}
|
||||
return join(current, fileName);
|
||||
return lineage;
|
||||
}
|
||||
|
||||
async #assertLineage(
|
||||
lineage: readonly DirectoryLineageEntry[],
|
||||
): Promise<void> {
|
||||
for (const { path, identity } of lineage)
|
||||
await assertPathIdentity(path, identity);
|
||||
}
|
||||
|
||||
async #assertOwnedDirectories(): Promise<void> {
|
||||
await assertPathIdentity(this.#outputRoot, this.#rootIdentity);
|
||||
for (const [path, identity] of this.#nestedDirectories)
|
||||
await assertPathIdentity(path, identity);
|
||||
}
|
||||
|
||||
#assertWritable(): void {
|
||||
@@ -327,9 +416,19 @@ export class SafeOutputTree {
|
||||
"integrity",
|
||||
"Extraction tree is already committed",
|
||||
);
|
||||
if (this.#cleanup.status !== "not-required")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Extraction tree cleanup has already started",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type DirectoryLineageEntry = {
|
||||
readonly path: string;
|
||||
readonly identity: DirectoryIdentity;
|
||||
};
|
||||
|
||||
const hashFile = async (
|
||||
path: string,
|
||||
maximum: number,
|
||||
@@ -376,18 +475,6 @@ const writeAll = async (
|
||||
}
|
||||
};
|
||||
|
||||
const isAbsent = async (path: string): Promise<boolean> =>
|
||||
lstat(path).then(
|
||||
() => false,
|
||||
(cause: unknown) => {
|
||||
if (isNotFound(cause)) return true;
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
|
||||
const isNotFound = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
|
||||
const isAlreadyExists = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readdir, rmdir, unlink } from "node:fs/promises";
|
||||
import { dirname, sep } from "node:path";
|
||||
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import {
|
||||
assertFilePathIdentity,
|
||||
assertPathIdentity,
|
||||
replacedDirectory,
|
||||
type DirectoryIdentity,
|
||||
type FileIdentity,
|
||||
} from "./SafeOutputTreeIdentity.js";
|
||||
|
||||
/** Remove only tracked files and empty directories, preserving successful progress. */
|
||||
export const removeOwnedTree = async (input: {
|
||||
readonly outputRoot: string;
|
||||
readonly rootIdentity: DirectoryIdentity;
|
||||
readonly directories: Map<string, DirectoryIdentity>;
|
||||
readonly files: Map<string, FileIdentity>;
|
||||
}): Promise<void> => {
|
||||
const { outputRoot, rootIdentity, directories, files } = input;
|
||||
const filesByDirectory = new Map<string, [string, FileIdentity][]>();
|
||||
for (const entry of files) {
|
||||
const directory = dirname(entry[0]);
|
||||
const group = filesByDirectory.get(directory) ?? [];
|
||||
group.push(entry);
|
||||
filesByDirectory.set(directory, group);
|
||||
}
|
||||
const postorder = [...directories].sort(
|
||||
([left], [right]) =>
|
||||
right.slice(outputRoot.length).split(sep).length -
|
||||
left.slice(outputRoot.length).split(sep).length,
|
||||
);
|
||||
for (const [path, identity] of postorder) {
|
||||
await removeFiles(path, identity, filesByDirectory.get(path) ?? [], files);
|
||||
const parentPath = dirname(path);
|
||||
const parentIdentity =
|
||||
parentPath === outputRoot ? rootIdentity : directories.get(parentPath);
|
||||
if (parentIdentity === undefined) throw replacedDirectory(parentPath);
|
||||
await assertPathIdentity(parentPath, parentIdentity);
|
||||
await assertPathIdentity(path, identity);
|
||||
await requireEmpty(path);
|
||||
await rmdir(path);
|
||||
directories.delete(path);
|
||||
}
|
||||
await removeFiles(
|
||||
outputRoot,
|
||||
rootIdentity,
|
||||
filesByDirectory.get(outputRoot) ?? [],
|
||||
files,
|
||||
);
|
||||
await assertPathIdentity(outputRoot, rootIdentity);
|
||||
await requireEmpty(outputRoot);
|
||||
await rmdir(outputRoot);
|
||||
};
|
||||
|
||||
const removeFiles = async (
|
||||
directory: string,
|
||||
identity: DirectoryIdentity,
|
||||
filesInDirectory: readonly (readonly [string, FileIdentity])[],
|
||||
retainedFiles: Map<string, FileIdentity>,
|
||||
): Promise<void> => {
|
||||
for (const [path, fileIdentity] of filesInDirectory) {
|
||||
await assertPathIdentity(directory, identity);
|
||||
await assertFilePathIdentity(path, fileIdentity);
|
||||
await unlink(path);
|
||||
retainedFiles.delete(path);
|
||||
}
|
||||
};
|
||||
|
||||
const requireEmpty = async (directory: string): Promise<void> => {
|
||||
if ((await readdir(directory)).length > 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Unowned entries remain in extraction directory: ${directory}`,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { BigIntStats } from "node:fs";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
export type DirectoryIdentity = Pick<BigIntStats, "dev" | "ino">;
|
||||
export type FileIdentity = DirectoryIdentity;
|
||||
|
||||
export const readDirectoryIdentity = async (
|
||||
path: string,
|
||||
): Promise<DirectoryIdentity> => {
|
||||
const metadata = await lstat(path, { bigint: true });
|
||||
if (!metadata.isDirectory()) throw replacedDirectory(path);
|
||||
return { dev: metadata.dev, ino: metadata.ino };
|
||||
};
|
||||
|
||||
export const assertPathIdentity = async (
|
||||
path: string,
|
||||
expected: DirectoryIdentity,
|
||||
): Promise<void> => {
|
||||
const metadata = await lstat(path, { bigint: true });
|
||||
if (
|
||||
!metadata.isDirectory() ||
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino
|
||||
)
|
||||
throw replacedDirectory(path);
|
||||
};
|
||||
|
||||
export const assertHandleIdentity = async (
|
||||
handle: FileHandle,
|
||||
expected: DirectoryIdentity,
|
||||
path: string,
|
||||
): Promise<void> => {
|
||||
const metadata = await handle.stat({ bigint: true });
|
||||
if (
|
||||
!metadata.isDirectory() ||
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino
|
||||
)
|
||||
throw replacedDirectory(path);
|
||||
};
|
||||
|
||||
export const readFileIdentity = async (
|
||||
handle: FileHandle,
|
||||
path: string,
|
||||
): Promise<FileIdentity> => {
|
||||
const metadata = await handle.stat({ bigint: true });
|
||||
if (!metadata.isFile())
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Extraction file identity changed: ${path}`,
|
||||
);
|
||||
return { dev: metadata.dev, ino: metadata.ino };
|
||||
};
|
||||
|
||||
export const assertFilePathIdentity = async (
|
||||
path: string,
|
||||
expected: FileIdentity,
|
||||
): Promise<void> => {
|
||||
const metadata = await lstat(path, { bigint: true });
|
||||
if (
|
||||
!metadata.isFile() ||
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Extraction file identity changed: ${path}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const pathHasIdentity = async (
|
||||
path: string,
|
||||
expected: DirectoryIdentity,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await assertPathIdentity(path, expected);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
if (isNotFound(cause) || cause instanceof ArtifactReaderFailure)
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
export const isAbsent = async (path: string): Promise<boolean> =>
|
||||
lstat(path).then(
|
||||
() => false,
|
||||
(cause: unknown) => {
|
||||
if (isNotFound(cause)) return true;
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
|
||||
export const replacedDirectory = (path: string): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Extraction directory identity changed: ${path}`,
|
||||
);
|
||||
|
||||
const isNotFound = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
@@ -2,10 +2,14 @@ import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
readlink,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
@@ -17,7 +21,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
|
||||
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
|
||||
|
||||
describe("safe artifact output tree", () => {
|
||||
describe("safe artifact output cleanup", () => {
|
||||
it.skipIf(process.platform !== "linux")(
|
||||
"closes the parent descriptor when the output disappears before commit",
|
||||
async () => {
|
||||
@@ -32,12 +36,169 @@ describe("safe artifact output tree", () => {
|
||||
),
|
||||
);
|
||||
expect(targets.filter((target) => target === parent)).toEqual([]);
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(failure).toMatchObject({ cleanup: { resources: [output] } });
|
||||
expect(tree.cleanup).toEqual({
|
||||
status: "incomplete",
|
||||
residualPaths: ["published"],
|
||||
});
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("safe artifact output tree identity", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses a replaced root and retries cleanup when the owned root is restored",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-root-");
|
||||
const output = join(parent, "published");
|
||||
const displaced = join(parent, "owned-original");
|
||||
const outside = join(parent, "outside");
|
||||
await mkdir(outside);
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
await rename(output, displaced);
|
||||
await symlink(outside, output, "dir");
|
||||
|
||||
const writeFailure = await tree
|
||||
.write("escaped.txt", Readable.from(Buffer.from("outside")), {
|
||||
sha256: createHash("sha256").update("outside").digest("hex"),
|
||||
bytes: Buffer.byteLength("outside"),
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(writeFailure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
await expect(access(join(outside, "escaped.txt"))).rejects.toThrow();
|
||||
await expect(tree.commit()).rejects.toBeInstanceOf(ArtifactReaderFailure);
|
||||
const cleanupFailure = await tree
|
||||
.rollback()
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(cleanupFailure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(cleanupFailure).toMatchObject({
|
||||
cleanup: { resources: [output] },
|
||||
});
|
||||
expect(await readlink(output)).toBe(outside);
|
||||
|
||||
await rm(output);
|
||||
await rename(displaced, output);
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual(["outside"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses a replaced nested parent without touching it and retries after restoration",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-parent-");
|
||||
const output = join(parent, "published");
|
||||
const nested = join(output, "nested");
|
||||
const displaced = join(parent, "owned-nested");
|
||||
const outside = join(parent, "outside");
|
||||
await mkdir(outside);
|
||||
await writeFile(join(outside, "sentinel.txt"), "keep");
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
const bytes = Buffer.from("owned");
|
||||
await tree.write("nested/owned.txt", Readable.from(bytes), {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
await rename(nested, displaced);
|
||||
await symlink(outside, nested, "dir");
|
||||
|
||||
const writeFailure = await tree
|
||||
.write("nested/escaped.txt", Readable.from(Buffer.from("outside")), {
|
||||
sha256: createHash("sha256").update("outside").digest("hex"),
|
||||
bytes: Buffer.byteLength("outside"),
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(writeFailure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
await expect(access(join(outside, "escaped.txt"))).rejects.toThrow();
|
||||
expect(await readFile(join(outside, "sentinel.txt"), "utf8")).toBe(
|
||||
"keep",
|
||||
);
|
||||
const cleanupFailure = await tree
|
||||
.rollback()
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(cleanupFailure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(await readlink(nested)).toBe(outside);
|
||||
expect(await readFile(join(outside, "sentinel.txt"), "utf8")).toBe(
|
||||
"keep",
|
||||
);
|
||||
|
||||
await rm(nested);
|
||||
await rename(displaced, nested);
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual(["outside"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"preserves an outside file when a tracked output file is replaced by a symlink",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-file-");
|
||||
const output = join(parent, "published");
|
||||
const tracked = join(output, "nested", "file.txt");
|
||||
const displaced = join(parent, "owned-file.txt");
|
||||
const outside = join(parent, "outside.txt");
|
||||
await writeFile(outside, "outside bytes");
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
const bytes = Buffer.from("owned bytes");
|
||||
await tree.write("nested/file.txt", Readable.from(bytes), {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
await rename(tracked, displaced);
|
||||
await symlink(outside, tracked, "file");
|
||||
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(tree.cleanup).toMatchObject({ status: "incomplete" });
|
||||
expect(await readlink(tracked)).toBe(outside);
|
||||
expect(await readFile(outside, "utf8")).toBe("outside bytes");
|
||||
|
||||
await rm(tracked);
|
||||
await rename(displaced, tracked);
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readFile(outside, "utf8")).toBe("outside bytes");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("retains an untracked regular entry and retries after it is removed", async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-untracked-");
|
||||
const output = join(parent, "published");
|
||||
const untracked = join(output, "untracked.txt");
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
const bytes = Buffer.from("owned bytes");
|
||||
await tree.write("owned.txt", Readable.from(bytes), {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
await writeFile(untracked, "leave untouched");
|
||||
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(tree.cleanup).toMatchObject({ status: "incomplete" });
|
||||
expect(await readdir(output)).toEqual(["untracked.txt"]);
|
||||
expect(await readFile(untracked, "utf8")).toBe("leave untouched");
|
||||
|
||||
await rm(untracked);
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual([]);
|
||||
});
|
||||
describe("safe artifact output tree", () => {
|
||||
it("removes only its owned tree after digest failure and proves absence", async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-");
|
||||
const output = join(parent, "published");
|
||||
@@ -72,6 +233,7 @@ describe("safe artifact output tree", () => {
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(failure).toMatchObject({
|
||||
cause: { code: "EACCES" },
|
||||
cleanup: { resources: [output] },
|
||||
});
|
||||
} finally {
|
||||
@@ -81,6 +243,54 @@ describe("safe artifact output tree", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"retains cleanup progress when one owned sibling is not writable",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-siblings-");
|
||||
const output = join(parent, "published");
|
||||
const completed = join(output, "completed");
|
||||
const blocked = join(output, "blocked");
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
for (const directory of ["completed", "blocked"]) {
|
||||
const bytes = Buffer.from(join(output, directory));
|
||||
await tree.write(`${directory}/file.txt`, Readable.from(bytes), {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await chmod(blocked, 0o500);
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(failure).toMatchObject({
|
||||
cause: { code: "EACCES" },
|
||||
cleanup: { resources: [output] },
|
||||
});
|
||||
expect(await readdir(output)).toEqual(["blocked"]);
|
||||
await expect(access(completed)).rejects.toThrow();
|
||||
expect(await readFile(join(blocked, "file.txt"), "utf8")).toBe(blocked);
|
||||
await expect(
|
||||
tree.write("new/file.txt", Readable.from(Buffer.from("late")), {
|
||||
sha256: createHash("sha256").update("late").digest("hex"),
|
||||
bytes: Buffer.byteLength("late"),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ArtifactReaderFailure);
|
||||
await expect(tree.commit()).rejects.toBeInstanceOf(
|
||||
ArtifactReaderFailure,
|
||||
);
|
||||
} finally {
|
||||
await chmod(blocked, 0o700);
|
||||
}
|
||||
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("publishes files while building and preserves them after sealing", async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-");
|
||||
const output = join(parent, "published");
|
||||
@@ -115,6 +325,13 @@ describe("safe artifact output tree", () => {
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
await expect(
|
||||
tree.write("late.txt", Readable.from(Buffer.from("late")), {
|
||||
sha256: createHash("sha256").update("late").digest("hex"),
|
||||
bytes: Buffer.byteLength("late"),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ArtifactReaderFailure);
|
||||
await expect(tree.commit()).rejects.toBeInstanceOf(ArtifactReaderFailure);
|
||||
});
|
||||
|
||||
it("publishes without POSIX-only directory chmod or fsync on Windows", async () => {
|
||||
|
||||
Reference in new issue
Block a user