diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index d9610192eb..8d32ac5dcb 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -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(); + readonly #ownedFiles = new Map(); #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 { 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 { - 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 { + async #prepareParent( + relativePath: string, + ): Promise { 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 { + for (const { path, identity } of lineage) + await assertPathIdentity(path, identity); + } + + async #assertOwnedDirectories(): Promise { + 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 => - 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"; diff --git a/src/artifacts/SafeOutputTreeCleanup.ts b/src/artifacts/SafeOutputTreeCleanup.ts new file mode 100644 index 0000000000..91e6565d19 --- /dev/null +++ b/src/artifacts/SafeOutputTreeCleanup.ts @@ -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; + readonly files: Map; +}): Promise => { + const { outputRoot, rootIdentity, directories, files } = input; + const filesByDirectory = new Map(); + 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, +): Promise => { + 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 => { + if ((await readdir(directory)).length > 0) + throw new ArtifactReaderFailure( + "path", + `Unowned entries remain in extraction directory: ${directory}`, + ); +}; diff --git a/src/artifacts/SafeOutputTreeIdentity.ts b/src/artifacts/SafeOutputTreeIdentity.ts new file mode 100644 index 0000000000..d93cda4236 --- /dev/null +++ b/src/artifacts/SafeOutputTreeIdentity.ts @@ -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; +export type FileIdentity = DirectoryIdentity; + +export const readDirectoryIdentity = async ( + path: string, +): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => + 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"; diff --git a/tests/boundary/filesystem/safeOutputTree.test.ts b/tests/boundary/filesystem/safeOutputTree.test.ts index 87c0846406..c9b015b6ea 100644 --- a/tests/boundary/filesystem/safeOutputTree.test.ts +++ b/tests/boundary/filesystem/safeOutputTree.test.ts @@ -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 () => {