Files
reverse-engineering-anything/src/filesystem/OwnedStagedOutput.ts
T

188 lines
6.0 KiB
TypeScript

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<void> | undefined;
#closed = false;
#identityFailure: unknown;
private constructor(
readonly directory: string,
private readonly directoryIdentity: Identity | undefined,
) {}
static async create(directory: string): Promise<OwnedStagedOutput> {
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<void> {
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<void> {
const file = this.#file;
if (file === undefined || this.#fileClosed) return;
await this.#observeFileIdentity();
await file.close();
this.#fileClosed = true;
}
async #observeFileIdentity(): Promise<void> {
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<void> {
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<void> {
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<void> {
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;