mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
21ff4911bf
Use one bounded positional byte comparator for client configuration and skill publication readback. Keep pathname identity, rollback, mode restoration, cleanup and retry authority in the existing publication owners. Preserve short-read progress, EOF, empty-file equality and original I/O errors. Extend existing byte-exact skill recovery coverage to empty originals. Validation: client configuration transaction, skill publication recovery and process-global rollback suites passed; static, docs and discovery checks passed.
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
import { constants, type Stats } from "node:fs";
|
|
import { link, lstat, mkdtemp, open, realpath, rename } from "node:fs/promises";
|
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
|
|
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
|
import { fileHandleMatchesBytes } from "../filesystem/fileHandleMatchesBytes.js";
|
|
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
|
import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js";
|
|
import {
|
|
RegularFileChangedError,
|
|
sameRegularFileState,
|
|
} from "../filesystem/RegularFile.js";
|
|
import type { RegularFileOperationOutcome } from "../filesystem/OwnedRegularFile.js";
|
|
import { readRegularFileTextSnapshot } from "./RegularFileRead.js";
|
|
import { ClientConfigurationPublicationFailure } from "./ClientConfigurationCleanup.js";
|
|
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
|
import { ConfigurationError } from "../domain/configurationErrors.js";
|
|
|
|
/** The original read owner's selection, before merging and preparing a backup. */
|
|
export interface ClientConfigurationSelection {
|
|
readonly requestedPath: string;
|
|
readonly state: Stats | undefined;
|
|
}
|
|
|
|
/** The exact staged file retained through publication, verification and recovery. */
|
|
export class ClientConfigurationPublication {
|
|
private constructor(
|
|
readonly path: string,
|
|
private readonly contents: string,
|
|
private readonly owner: OwnedFileHandle,
|
|
private readonly staging: OwnedStagedOutput,
|
|
private readonly cleanup: AnalysisCleanupLease,
|
|
) {}
|
|
|
|
static async write(
|
|
path: string,
|
|
contents: string,
|
|
previous: string | undefined,
|
|
mode?: number,
|
|
selection?: ClientConfigurationSelection,
|
|
): Promise<ClientConfigurationPublication> {
|
|
const parent = await realpath(dirname(path));
|
|
const destination = join(parent, basename(path));
|
|
const input = await selectedConfiguration(destination, previous);
|
|
if (selection !== undefined) {
|
|
await assertRequestedConfigurationPath(path, selection.requestedPath);
|
|
if (
|
|
selection.state === undefined
|
|
? input !== undefined
|
|
: input === undefined || !sameRegularFileState(selection.state, input)
|
|
)
|
|
throw new RegularFileChangedError(destination);
|
|
}
|
|
const directory = await mkdtemp(`${parent}${sep}.rea-config-`);
|
|
const staging = await OwnedStagedOutput.create(directory);
|
|
const cleanup = new AnalysisCleanupLease(
|
|
() => staging.close(),
|
|
[directory],
|
|
);
|
|
try {
|
|
const stagedPath = `${directory}${sep}content`;
|
|
const owner = new OwnedFileHandle(
|
|
await open(
|
|
stagedPath,
|
|
constants.O_RDWR |
|
|
constants.O_CREAT |
|
|
constants.O_EXCL |
|
|
(constants.O_NONBLOCK ?? 0) |
|
|
(constants.O_NOFOLLOW ?? 0),
|
|
0o600,
|
|
),
|
|
);
|
|
staging.setFile(owner);
|
|
await owner.handle.writeFile(contents, { encoding: "utf8" });
|
|
const created = await owner.handle.stat();
|
|
if (
|
|
input !== undefined &&
|
|
(input.uid !== created.uid || input.gid !== created.gid)
|
|
)
|
|
await owner.handle.chown(input.uid, input.gid);
|
|
await owner.handle.chmod(mode ?? (input?.mode ?? 0o600) & 0o7777);
|
|
await owner.handle.sync();
|
|
if ((await owner.handle.stat()).ino === 0)
|
|
throw new RegularFileChangedError(stagedPath);
|
|
await staging.assertReadyForPublication(stagedPath);
|
|
if (selection !== undefined)
|
|
await assertRequestedConfigurationPath(path, selection.requestedPath);
|
|
await assertSelectedConfiguration(destination, input);
|
|
if (input === undefined) {
|
|
// An exclusive link cannot overwrite a file created after absence was checked.
|
|
try {
|
|
await link(stagedPath, destination);
|
|
} catch (cause: unknown) {
|
|
if (
|
|
selection !== undefined &&
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
cause.code === "EEXIST"
|
|
)
|
|
throw new RegularFileChangedError(destination, { cause });
|
|
throw cause;
|
|
}
|
|
} else {
|
|
// The destination is explicit: rename never follows its final symlink.
|
|
await rename(stagedPath, destination);
|
|
staging.recordPublishedRename();
|
|
}
|
|
return new ClientConfigurationPublication(
|
|
destination,
|
|
contents,
|
|
owner,
|
|
staging,
|
|
cleanup,
|
|
);
|
|
} catch (cause: unknown) {
|
|
await releaseStaging(path, staging, cleanup, { kind: "failed", cause });
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
/** Keep all publication resources owned through the complete operation. */
|
|
async verify<Value>(operation: () => Promise<Value>): Promise<Value> {
|
|
let outcome: RegularFileOperationOutcome<Value>;
|
|
try {
|
|
outcome = { kind: "completed", value: await operation() };
|
|
} catch (cause: unknown) {
|
|
outcome = { kind: "failed", cause };
|
|
}
|
|
await releaseStaging(this.path, this.staging, this.cleanup, outcome);
|
|
if (outcome.kind === "failed") throw outcome.cause;
|
|
return outcome.value;
|
|
}
|
|
|
|
async assertCurrent(readback?: string): Promise<void> {
|
|
const owned = await this.owner.handle.stat();
|
|
const named = await lstat(this.path);
|
|
if (
|
|
owned.ino === 0 ||
|
|
(readback !== undefined && readback !== this.contents) ||
|
|
named.isSymbolicLink() ||
|
|
!sameRegularFileState(owned, named)
|
|
)
|
|
throw new RegularFileChangedError(this.path);
|
|
}
|
|
|
|
/** State checks do not exclude a concurrent writer to the same inode. */
|
|
async restore(original: string | undefined): Promise<void> {
|
|
const handle = this.owner.handle;
|
|
const before = await handle.stat();
|
|
const named = await lstat(this.path);
|
|
if (
|
|
before.ino === 0 ||
|
|
!before.isFile() ||
|
|
named.isSymbolicLink() ||
|
|
!sameRegularFileState(before, named) ||
|
|
!(await fileHandleMatchesBytes(
|
|
handle,
|
|
Buffer.from(this.contents, "utf8"),
|
|
)) ||
|
|
!sameRegularFileState(before, await handle.stat()) ||
|
|
!sameRegularFileState(before, await lstat(this.path))
|
|
)
|
|
throw new RegularFileChangedError(this.path);
|
|
if (original === undefined)
|
|
throw new Error(
|
|
"First-run configuration recovery requires manual removal; pathname deletion cannot prove ownership",
|
|
);
|
|
const bytes = Buffer.from(original, "utf8");
|
|
for (let position = 0; position < bytes.length;) {
|
|
const { bytesWritten } = await handle.write(
|
|
bytes,
|
|
position,
|
|
bytes.length - position,
|
|
position,
|
|
);
|
|
if (bytesWritten === 0)
|
|
throw new Error("Configuration restoration made no write progress");
|
|
position += bytesWritten;
|
|
}
|
|
await handle.truncate(bytes.length);
|
|
if (!sameRegularFileState(await handle.stat(), await lstat(this.path)))
|
|
throw new RegularFileChangedError(this.path);
|
|
}
|
|
}
|
|
|
|
const assertRequestedConfigurationPath = async (
|
|
path: string,
|
|
requestedPath: string,
|
|
): Promise<void> => {
|
|
const current = await resolveClientConfigTransactionPath(requestedPath);
|
|
if (current === undefined)
|
|
throw new ConfigurationError(
|
|
`Client configuration path could not be safely verified: ${requestedPath}`,
|
|
);
|
|
if (resolve(current) === resolve(path)) return;
|
|
// `path` is the canonical file selected for this transaction. A later
|
|
// retarget of a parent-directory alias keeps that file. A configuration
|
|
// symlink that now names another target, or a path whose parent is already
|
|
// canonical, still conflicts.
|
|
if (await isParentAliasRetarget(requestedPath, path, current)) return;
|
|
throw new RegularFileChangedError(path);
|
|
};
|
|
|
|
const isParentAliasRetarget = async (
|
|
requestedPath: string,
|
|
path: string,
|
|
current: string,
|
|
): Promise<boolean> => {
|
|
if (basename(current) !== basename(path)) return false;
|
|
let requestedStats: { isSymbolicLink(): boolean };
|
|
try {
|
|
requestedStats = await lstat(requestedPath);
|
|
} catch (cause: unknown) {
|
|
if (isNodeErrorCode(cause, "ENOENT")) return false;
|
|
throw new ConfigurationError(
|
|
`Client configuration path could not be safely verified: ${requestedPath}`,
|
|
);
|
|
}
|
|
if (requestedStats.isSymbolicLink()) return false;
|
|
const requestedParent = dirname(resolve(requestedPath));
|
|
try {
|
|
const canonicalParent = await realpath(requestedParent);
|
|
return resolve(canonicalParent) !== requestedParent;
|
|
} catch (cause: unknown) {
|
|
void cause;
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const isNodeErrorCode = (cause: unknown, code: string): boolean =>
|
|
cause instanceof Error && "code" in cause && cause.code === code;
|
|
|
|
const selectedConfiguration = async (
|
|
path: string,
|
|
previous: string | undefined,
|
|
): Promise<Stats | undefined> => {
|
|
let selected:
|
|
| Awaited<ReturnType<typeof readRegularFileTextSnapshot>>
|
|
| undefined;
|
|
try {
|
|
selected = await readRegularFileTextSnapshot(path, { symlinks: "reject" });
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
|
|
throw cause;
|
|
}
|
|
if (selected?.text !== previous || selected?.state.ino === 0)
|
|
throw new RegularFileChangedError(path);
|
|
return selected?.state;
|
|
};
|
|
|
|
const assertSelectedConfiguration = async (
|
|
path: string,
|
|
selected: Stats | undefined,
|
|
): Promise<void> => {
|
|
let current: Stats | undefined;
|
|
try {
|
|
current = await lstat(path);
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
|
|
throw cause;
|
|
}
|
|
if (
|
|
selected === undefined
|
|
? current !== undefined
|
|
: current === undefined ||
|
|
current.isSymbolicLink() ||
|
|
!sameRegularFileState(selected, current)
|
|
)
|
|
throw new RegularFileChangedError(path);
|
|
};
|
|
|
|
const releaseStaging = async <Value>(
|
|
path: string,
|
|
staging: OwnedStagedOutput,
|
|
cleanup: AnalysisCleanupLease,
|
|
outcome: RegularFileOperationOutcome<Value>,
|
|
): Promise<void> => {
|
|
try {
|
|
await cleanup.release();
|
|
} catch (cause: unknown) {
|
|
throw new ClientConfigurationPublicationFailure(
|
|
path,
|
|
outcome,
|
|
cause,
|
|
{ kind: "staged-output", resource: staging.directory, output: staging },
|
|
cleanup,
|
|
);
|
|
}
|
|
};
|