Files
rea/src/application/SkillFilePublication.ts
T

269 lines
8.5 KiB
TypeScript

import { constants, type Stats } from "node:fs";
import { link, lstat, mkdtemp, open, realpath, rename } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js";
import {
RegularFileChangedError,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
/** The read owner's byte-exact original and selected identity. */
export interface SkillFileSnapshot {
readonly bytes: Buffer;
readonly mode: number;
readonly state: Stats;
}
export interface SkillPublicationCleanupOwner {
readonly path: string;
readonly kind: "lease";
readonly resource: string;
readonly lease: AnalysisCleanupLease;
close(): Promise<void>;
}
/** Staging cleanup failed before a publication could be returned to its caller. */
export class SkillFilePreparationFailure extends Error {
readonly _tag = "SkillFilePreparationFailure";
constructor(
readonly publication: SkillPublicationCleanupOwner,
readonly primary: unknown,
readonly cleanupCause: unknown,
) {
super(
`Skill staging cleanup failed for ${publication.path}: ${primary instanceof Error ? primary.message : String(primary)}; cleanup: ${cleanupCause instanceof Error ? cleanupCause.message : String(cleanupCause)}`,
{
cause: new AggregateError([primary, cleanupCause]),
},
);
}
}
/** One byte-oriented publication, retained before entering a fallible publish. */
export class SkillFilePublication {
#attempted = false;
#restored = false;
#publishedState: Stats | undefined;
readonly kind = "lease";
readonly resource: string;
readonly lease: AnalysisCleanupLease;
private constructor(
readonly path: string,
private readonly bytes: Buffer,
private readonly original: SkillFileSnapshot | undefined,
private readonly staging: OwnedStagedOutput,
private readonly owner: OwnedFileHandle,
private readonly stagedPath: string,
) {
this.resource = staging.directory;
this.lease = new AnalysisCleanupLease(
() => staging.close(),
[staging.directory],
);
}
static async prepare(
path: string,
bytes: Buffer,
original: SkillFileSnapshot | undefined,
): Promise<SkillFilePublication> {
const parent = await realpath(dirname(path));
const destination = join(parent, basename(path));
const directory = await mkdtemp(join(parent, ".rea-skill-"));
const staging = await OwnedStagedOutput.create(directory);
let publication: SkillFilePublication | undefined;
try {
const stagedPath = join(directory, "content");
const owner = new OwnedFileHandle(
await open(
stagedPath,
constants.O_RDWR |
constants.O_CREAT |
constants.O_EXCL |
(constants.O_NOFOLLOW ?? 0),
0o600,
),
);
staging.setFile(owner);
publication = new SkillFilePublication(
destination,
Buffer.from(bytes),
original,
staging,
owner,
stagedPath,
);
await owner.handle.writeFile(bytes);
const created = await owner.handle.stat();
if (
original !== undefined &&
(created.uid !== original.state.uid ||
created.gid !== original.state.gid)
)
await owner.handle.chown(original.state.uid, original.state.gid);
await owner.handle.chmod(original?.mode ?? 0o600);
await owner.handle.sync();
await staging.assertReadyForPublication(stagedPath);
return publication;
} catch (primary: unknown) {
try {
await staging.close();
} catch (cleanupCause: unknown) {
// Even an open failure leaves the staging directory owned and retryable.
if (publication === undefined) {
const lease = new AnalysisCleanupLease(
() => staging.close(),
[directory],
);
throw new SkillFilePreparationFailure(
{
path: destination,
kind: "lease",
resource: directory,
lease,
close: () => lease.release(),
},
primary,
cleanupCause,
);
}
throw new SkillFilePreparationFailure(
publication,
primary,
cleanupCause,
);
}
throw primary;
}
}
async publish(): Promise<void> {
await this.staging.assertReadyForPublication(this.stagedPath);
await this.#assertSelected();
this.#attempted = true;
if (this.original === undefined) {
// Exclusive creation preserves a file created after the absence check.
await link(this.stagedPath, this.path);
} else {
await rename(this.stagedPath, this.path);
this.staging.recordPublishedRename();
}
this.#publishedState = await this.owner.handle.stat();
}
async assertCurrent(): Promise<void> {
const before = this.#publishedState;
if (
before === undefined ||
!sameRegularFileState(before, await this.owner.handle.stat())
)
throw new RegularFileChangedError(this.path);
await this.#assertNamed(before);
if (!(await this.#matches(this.bytes)))
throw new RegularFileChangedError(this.path);
if (!sameRegularFileState(before, await this.owner.handle.stat()))
throw new RegularFileChangedError(this.path);
await this.#assertNamed(before);
}
/** Restore through the owned descriptor; a changed name never grants write authority. */
async restore(): Promise<void> {
if (!this.#attempted || this.#restored) return;
// A rejected exclusive link or rename may leave the original selection intact.
const named = await optionalState(this.path);
if (
this.original !== undefined &&
named !== undefined &&
sameRegularFileState(this.original.state, named)
)
return;
if (this.original === undefined && named === undefined) return;
await this.assertCurrent();
if (this.original === undefined)
throw new Error(
"First-install skill recovery requires manual removal; pathname deletion cannot prove ownership",
);
const original = this.original;
for (let position = 0; position < original.bytes.length;) {
const { bytesWritten } = await this.owner.handle.write(
original.bytes,
position,
original.bytes.length - position,
position,
);
if (bytesWritten === 0)
throw new Error("Skill restoration made no write progress");
position += bytesWritten;
}
await this.owner.handle.truncate(original.bytes.length);
await this.owner.handle.chmod(original.mode);
await this.owner.handle.sync();
await this.#assertNamed(await this.owner.handle.stat());
if (!(await this.#matches(original.bytes)))
throw new RegularFileChangedError(this.path);
this.#restored = true;
}
close(): Promise<void> {
return this.lease.release();
}
async #assertNamed(expected: Stats): Promise<void> {
const named = await lstat(this.path);
if (
expected.ino === 0 ||
named.isSymbolicLink() ||
!sameRegularFileState(expected, named)
)
throw new RegularFileChangedError(this.path);
}
async #matches(expected: Buffer): Promise<boolean> {
if ((await this.owner.handle.stat()).size !== expected.length) return false;
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, expected.length));
for (let position = 0; position < expected.length;) {
const { bytesRead } = await this.owner.handle.read(
chunk,
0,
Math.min(chunk.length, expected.length - position),
position,
);
if (
bytesRead === 0 ||
!chunk
.subarray(0, bytesRead)
.equals(expected.subarray(position, position + bytesRead))
)
return false;
position += bytesRead;
}
return true;
}
async #assertSelected(): Promise<void> {
const current = await optionalState(this.path);
if (
this.original === undefined
? current !== undefined
: current === undefined ||
current.isSymbolicLink() ||
!sameRegularFileState(this.original.state, current)
)
throw new RegularFileChangedError(this.path);
}
}
const optionalState = async (path: string): Promise<Stats | undefined> => {
try {
return await lstat(path);
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return undefined;
throw cause;
}
};