import { lstat, readdir, rmdir, unlink } from "node:fs/promises"; import type { Stats } from "node:fs"; import { OwnedFileHandle } from "./OwnedFileHandle.js"; interface Identity { readonly dev: number; readonly ino: number; } const identityOf = (stats: Stats): Identity => ({ dev: stats.dev, ino: stats.ino, }); const sameIdentity = (stats: Stats, identity: Identity): boolean => stats.dev === identity.dev && stats.ino === identity.ino; /** One atomically staged file and directory retained through verified cleanup. */ export class OwnedStagedOutput { #file: OwnedFileHandle | undefined; #fileIdentity: Identity | undefined; #fileClosed = false; #fileRemovedByOwner = false; #publishedRename = false; #closeAttempt: Promise | undefined; #closed = false; #identityFailure: unknown; private constructor( readonly directory: string, private readonly directoryIdentity: Identity | undefined, ) {} static async create(directory: string): Promise { try { const stats = await lstat(directory); if (!stats.isDirectory() || stats.isSymbolicLink()) return new OwnedStagedOutput(directory, undefined); return new OwnedStagedOutput(directory, identityOf(stats)); } catch (cause: unknown) { // Keep an owner that refuses deletion when creation identity is unknown. const owner = new OwnedStagedOutput(directory, undefined); owner.#identityFailure = cause; return owner; } } setFile(file: OwnedFileHandle): void { if (this.#file !== undefined) throw new Error("Staged output already owns a file handle"); this.#file = file; } /** Verify the staged name against its descriptor after the last write. */ async assertReadyForPublication(content: string): Promise { await this.#verifiedDirectory(); if (this.#file !== undefined && !this.#fileClosed) await this.#observeFileIdentity(); if (this.#fileIdentity === undefined) throw new Error("Cannot verify staged file identity", { cause: this.#identityFailure, }); const stagedFile = await lstat(content); if ( !stagedFile.isFile() || stagedFile.isSymbolicLink() || !sameIdentity(stagedFile, this.#fileIdentity) ) throw new Error(`Staged file identity changed: ${content}`); } /** Observe the descriptor's identity after its last write, then close it. */ async closeFile(): Promise { const file = this.#file; if (file === undefined || this.#fileClosed) return; await this.#observeFileIdentity(); await file.close(); this.#fileClosed = true; } async #observeFileIdentity(): Promise { const file = this.#file; if (file === undefined) return; // FAT and exFAT assign a file ID when the first write allocates storage, // so only the descriptor's final identity can prove the staged pathname. try { this.#fileIdentity = identityOf(await file.handle.stat()); } catch (cause: unknown) { // The handle remains owned; cleanup will close it and refuse pathname removal. this.#fileIdentity = undefined; this.#identityFailure = cause; } } /** Record that the staged file moved to the published destination by rename. */ recordPublishedRename(): void { this.#publishedRename = true; } /** Close the owned descriptor and remove only the exact staged entries. */ close(): Promise { if (this.#closed) return Promise.resolve(); if (this.#closeAttempt !== undefined) return this.#closeAttempt; const attempt = Promise.resolve().then(() => this.#cleanup()); this.#closeAttempt = attempt; void attempt.then( () => { this.#closed = true; this.#closeAttempt = undefined; }, () => { this.#closeAttempt = undefined; }, ); return attempt; } async #cleanup(): Promise { await this.closeFile(); await this.#verifiedDirectory(); // No descriptor was acquired when creating /content failed. Enumerate // the owned directory instead of probing a possibly overlong child path. if (this.#file !== undefined) { const content = `${this.directory}/content`; let stagedFile: Stats | undefined; try { stagedFile = await lstat(content); } catch (cause: unknown) { if ( errorCode(cause) === "ENOENT" && (this.#fileRemovedByOwner || this.#publishedRename) ) { stagedFile = undefined; } else if (errorCode(cause) === "ENOENT") { throw new Error(`Owned staged file disappeared: ${content}`, { cause, }); } else { throw cause; } } if (stagedFile !== undefined) { if ( !stagedFile.isFile() || stagedFile.isSymbolicLink() || this.#fileIdentity === undefined || !sameIdentity(stagedFile, this.#fileIdentity) ) throw new Error(`Staged file identity changed: ${content}`); await unlink(content); this.#fileRemovedByOwner = true; } } const remaining = await readdir(this.directory); if (remaining.length > 0) throw new Error( `Unexpected staging entries remain in ${this.directory}: ${remaining.join(", ")}`, ); await this.#verifiedDirectory(); await rmdir(this.directory); } async #verifiedDirectory(): Promise { if (this.directoryIdentity === undefined) throw new Error( `Cannot verify staging directory ownership: ${this.directory}`, { cause: this.#identityFailure, }, ); const directory = await lstat(this.directory); if ( !directory.isDirectory() || directory.isSymbolicLink() || !sameIdentity(directory, this.directoryIdentity) ) throw new Error(`Staging directory identity changed: ${this.directory}`); } } const errorCode = (cause: unknown): unknown => typeof cause === "object" && cause !== null && "code" in cause ? cause.code : undefined;