import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { lstat, mkdir, open, realpath, rm } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import type { Readable } from "node:stream"; import { streamChunkToBuffer } from "./StreamBytes.js"; import { ArtifactPathRegistry, normalizeArtifactPath, } from "./ArtifactPaths.js"; import { ArtifactReaderFailure, type ArtifactLimits, } from "./ArtifactReader.js"; /** One file durably written to an operation-owned output tree. */ export interface SafeOutputFile { readonly relativePath: string; readonly sha256: string; readonly bytesWritten: number; } /** Cleanup facts established while rolling back an uncommitted tree. */ export interface SafeOutputCleanup { readonly attempted: boolean; readonly verified: boolean; readonly residualPaths: readonly string[]; } /** Symlink-resistant materialization in an exclusively owned, initially absent tree. */ export class SafeOutputTree { readonly #registry = new ArtifactPathRegistry(); readonly #limits: ArtifactLimits; readonly #outputRoot: string; readonly #stagingRoot: string; #totalBytes = 0; #published = false; #cleanup: SafeOutputCleanup = { attempted: false, verified: false, residualPaths: [], }; private constructor( outputRoot: string, stagingRoot: string, limits: ArtifactLimits, ) { this.#outputRoot = outputRoot; this.#stagingRoot = stagingRoot; this.#limits = limits; } /** Exclusively create the absent destination as this operation's owned tree. */ static async create( outputRoot: string, limits: ArtifactLimits, ): Promise { if (!isAbsolute(outputRoot)) throw new ArtifactReaderFailure( "path", "Extraction output root must be absolute", ); const requested = resolve(outputRoot); const name = basename(requested); if (name === "." || name === "..") throw new ArtifactReaderFailure("path", "Invalid extraction output root"); const parent = await realpath(dirname(requested)).catch( (cause: unknown) => { throw new ArtifactReaderFailure( "unavailable", "Extraction output parent is unavailable", { cause }, ); }, ); const canonicalOutput = join(parent, name); await mkdir(canonicalOutput, { mode: 0o700 }).catch((cause: unknown) => { if (isAlreadyExists(cause)) throw new ArtifactReaderFailure( "path", "Extraction output root already exists", { cause }, ); throw cause; }); try { const stagingHandle = await open( canonicalOutput, constants.O_RDONLY | constants.O_DIRECTORY, ); try { await stagingHandle.chmod(0o700); } finally { await stagingHandle.close(); } return new SafeOutputTree(canonicalOutput, canonicalOutput, limits); } catch (cause: unknown) { await rm(canonicalOutput, { recursive: true, force: true }); throw cause; } } get outputRoot(): string { return this.#outputRoot; } get cleanup(): SafeOutputCleanup { return structuredClone(this.#cleanup); } /** Stream one selected regular file with exact byte and digest verification. */ async write( relativePath: string, source: Readable, expectedSha256: string, signal?: AbortSignal, ): Promise { this.#assertWritable(); const path = normalizeArtifactPath(relativePath, this.#limits); this.#registry.add(path, "file"); const destination = await this.#prepareParent(path); const handle = await open( destination, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600, ).catch((cause: unknown) => { throw new ArtifactReaderFailure( "path", `Could not exclusively create extraction path: ${path}`, { cause }, ); }); const hash = createHash("sha256"); let bytes = 0; try { for await (const raw of source) { abortIfNeeded(signal); const chunk = streamChunkToBuffer(raw); bytes += chunk.length; if (bytes > this.#limits.maxEntryBytes) throw new ArtifactReaderFailure( "limit", `Observed entry bytes exceed limit: ${path}`, ); if (this.#totalBytes + bytes > this.#limits.maxTotalBytes) throw new ArtifactReaderFailure( "limit", "Observed extraction bytes exceed cumulative limit", ); hash.update(chunk); await writeAll(handle, chunk); } const sha256 = hash.digest("hex"); if (sha256 !== expectedSha256) throw new ArtifactReaderFailure( "integrity", `Extracted content disagrees with inventory: ${path}`, ); await handle.sync(); await handle.close(); const readback = await hashFile(destination, bytes, signal); if (readback.sha256 !== sha256 || readback.bytes !== bytes) throw new ArtifactReaderFailure( "integrity", `Durable readback verification failed: ${path}`, ); this.#totalBytes += bytes; return { relativePath: path, sha256, bytesWritten: bytes }; } catch (cause: unknown) { await handle.close().catch(() => undefined); throw cause; } } /** Sync the owned output tree and prevent further writes through this instance. */ async commit(): Promise { this.#assertWritable(); const parent = await open( dirname(this.#outputRoot), constants.O_RDONLY | constants.O_DIRECTORY, ); const staging = await open( this.#stagingRoot, constants.O_RDONLY | constants.O_DIRECTORY, ); try { await staging.sync(); await parent.sync(); this.#published = true; } catch (cause: unknown) { throw new ArtifactReaderFailure( "path", "Could not durably sync extraction output", { cause }, ); } finally { await Promise.allSettled([parent.close(), staging.close()]); } } /** Remove only this operation's unsealed tree and verify absence. */ async rollback(): Promise { if (this.#published) return structuredClone(this.#cleanup); await rm(this.#stagingRoot, { recursive: true, force: true }); const absent = await isAbsent(this.#stagingRoot); this.#cleanup = { attempted: true, verified: absent, residualPaths: absent ? [] : [basename(this.#stagingRoot)], }; if (!absent) throw new ArtifactReaderFailure( "integrity", "Extraction output cleanup could not be verified", ); return structuredClone(this.#cleanup); } async #prepareParent(relativePath: string): Promise { const parts = relativePath.split("/"); const fileName = parts.pop(); if (fileName === undefined) throw new ArtifactReaderFailure("path", "Invalid extraction path"); let current = this.#stagingRoot; for (const part of parts) { current = join(current, part); await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => { if (!isAlreadyExists(cause)) throw cause; }); const metadata = await lstat(current); if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new ArtifactReaderFailure( "path", `Unsafe extraction parent: ${relativePath}`, ); } return join(current, fileName); } #assertWritable(): void { if (this.#published) throw new ArtifactReaderFailure( "integrity", "Extraction tree is already committed", ); } } const hashFile = async ( path: string, maximum: number, signal?: AbortSignal, ): Promise<{ readonly sha256: string; readonly bytes: number }> => { const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); const hash = createHash("sha256"); let bytes = 0; try { for await (const raw of handle.createReadStream({ autoClose: false })) { abortIfNeeded(signal); const chunk = streamChunkToBuffer(raw); bytes += chunk.length; if (bytes > maximum) throw new ArtifactReaderFailure( "integrity", "Readback exceeded the bytes written", ); hash.update(chunk); } } finally { await handle.close(); } return { sha256: hash.digest("hex"), bytes }; }; const writeAll = async ( handle: Awaited>, chunk: Buffer, ): Promise => { let offset = 0; while (offset < chunk.length) { const { bytesWritten } = await handle.write( chunk, offset, chunk.length - offset, ); if (bytesWritten === 0) throw new ArtifactReaderFailure( "unavailable", "Extraction output stopped accepting bytes", ); offset += bytesWritten; } }; 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"; const abortIfNeeded = (signal?: AbortSignal): void => { if (signal?.aborted === true) throw new ArtifactReaderFailure( "cancelled", "Artifact extraction cancelled", ); };