newspaper_wedding/.aidlc/tools/aidlc-transaction.ts
Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

659 lines
22 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
chmodSync,
closeSync,
cpSync,
copyFileSync,
existsSync,
fsyncSync,
lstatSync,
linkSync,
mkdirSync,
openSync,
readFileSync,
readdirSync,
readlinkSync,
realpathSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
writeSync,
} from "node:fs";
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
import { sha256Bytes } from "./aidlc-distribution.ts";
import {
isMachineOwnedPath,
machineTransactionRoot,
windowsUninstallFencePath,
} from "./aidlc-install-paths.ts";
export type TransactionOperation =
| { kind: "write"; path: string; data: string; mode?: number; expected?: string | "absent" }
| {
kind: "copy";
path: string;
source: string;
sourceHash: string;
mode?: number;
expected?: string | "absent";
}
| {
kind: "tree";
path: string;
source: string;
sourceHash: string;
expected?: string | "absent";
}
| { kind: "remove"; path: string; expected?: string }
| { kind: "symlink"; path: string; target: string; expected?: string | "absent" };
export type TransactionPlan = {
schemaVersion: 1;
root: string;
operations: TransactionOperation[];
};
export type TransactionOptions = {
failAfter?: number;
failAt?: string;
allowPendingWindowsUninstall?: boolean;
validateLocked?: () => void;
validateCandidates?: (candidateRoot: string) => void;
validateCommitted?: () => void;
};
function normalizedRelative(path: string): string {
if (!path || isAbsolute(path)) throw new Error(`transaction path must be root-relative: ${path}`);
const raw = path.replaceAll("\\", "/");
if (raw.split("/").includes("..")) throw new Error(`transaction path escapes root: ${path}`);
const normalized = posix.normalize(raw).replace(/^\.\//, "");
if (!normalized || normalized === ".") {
throw new Error(`transaction path must name an entry below the root: ${path}`);
}
if (
normalized === ".aidlc-transaction.lock" ||
normalized.startsWith(".aidlc-lock-") ||
normalized.startsWith(".aidlc-txn-") ||
normalized.startsWith(".aidlc-recovery-")
) {
throw new Error(`transaction path uses a reserved engine name: ${path}`);
}
return normalized;
}
function canonicalRoot(path: string): string {
const absolute = resolve(path);
let cursor = absolute;
const suffix: string[] = [];
while (!existsSync(cursor)) {
const parent = dirname(cursor);
if (parent === cursor) break;
suffix.unshift(basename(cursor));
cursor = parent;
}
const base = existsSync(cursor) ? realpathSync(cursor) : cursor;
return suffix.reduce((current, entry) => join(current, entry), base);
}
function withinRoot(path: string, root: string): boolean {
const rel = relative(root, path);
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
}
// A project-and-machine route commits its project and machine changes as
// separate plans, each rooted in the tree it owns. A plan rooted in the project
// therefore never gets machine authority: when an install or command root sits
// inside the project (for example AIDLC_INSTALL_ROOT=<project>/aidlc), the
// project plan cannot reach it.
function enforceRouteMutationPlan(
root: string,
operations: readonly TransactionOperation[],
): void {
const scope = process.env.AIDLC_ROUTE_MUTATION_SCOPE;
if (!scope) return;
const route = process.env.AIDLC_ROUTE_ID ?? "unknown";
if (scope === "none") {
throw new Error(`route ${route} does not permit filesystem mutation`);
}
const machineControlPaths = [windowsUninstallFencePath()].map(canonicalRoot);
const projectValue = process.env.AIDLC_ROUTE_PROJECT_DIR;
const projectRoot = projectValue ? canonicalRoot(projectValue) : null;
const homeRoot = process.env.HOME ? canonicalRoot(process.env.HOME) : null;
let machineRooted = false;
if (scope === "project-and-machine") {
const canonical = canonicalRoot(root);
machineRooted = isMachineOwnedPath(canonical);
if (!machineRooted) {
try {
machineRooted = canonical === canonicalRoot(machineTransactionRoot());
} catch {
machineRooted = false;
}
}
}
for (const operation of operations) {
const rel = normalizedRelative(operation.path);
const target = canonicalRoot(targetPath(root, rel));
const isMachine = isMachineOwnedPath(target) ||
machineControlPaths.includes(target);
const isProject = !isMachine &&
Boolean(projectRoot && withinRoot(target, projectRoot));
const isUserHome = !isMachine &&
Boolean(homeRoot && withinRoot(target, homeRoot));
const permitted =
(scope === "project" && isProject) ||
(scope === "machine" && isMachine) ||
(scope === "project-and-machine" && (machineRooted ? isMachine : isProject)) ||
(scope === "user-home" && isUserHome);
if (!permitted) {
const rootClass = isMachine
? "machine"
: isProject
? "project"
: isUserHome
? "user-home"
: "outside";
const plan = scope === "project-and-machine"
? ` from a ${machineRooted ? "machine" : "project"}-rooted plan`
: "";
throw new Error(
`route ${route} with ${scope} mutation scope cannot mutate ${rootClass} path ${target}${plan}`,
);
}
}
}
function pathExists(path: string): boolean {
try {
lstatSync(path);
return true;
} catch {
return false;
}
}
function pendingWindowsUninstallBlocks(root: string): boolean {
try {
return root === canonicalRoot(machineTransactionRoot()) &&
pathExists(windowsUninstallFencePath());
} catch {
return false;
}
}
function nearestExisting(path: string): string {
let current = path;
while (!pathExists(current)) {
const parent = dirname(current);
if (parent === current) {
throw new Error(`transaction path has no existing filesystem ancestor: ${path}`);
}
current = parent;
}
return current;
}
function targetPath(root: string, path: string): string {
const target = resolve(root, normalizedRelative(path));
const rel = relative(root, target);
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new Error(`transaction path escapes root: ${path}`);
}
let cursor = dirname(target);
while (cursor !== root && cursor.startsWith(root)) {
if (pathExists(cursor) && lstatSync(cursor).isSymbolicLink()) {
throw new Error(`transaction path traverses a symlink: ${path}`);
}
cursor = dirname(cursor);
}
return target;
}
export function transactionState(path: string): string | "absent" {
if (!pathExists(path)) return "absent";
const stat = lstatSync(path);
if (stat.isSymbolicLink()) return `symlink:${readlinkSync(path)}`;
if (stat.isDirectory()) return `tree:${transactionSourceHash(path)}`;
if (!stat.isFile()) return `type:${stat.mode}`;
return sha256Bytes(readFileSync(path));
}
function validateTreeSource(path: string): void {
const visit = (current: string): void => {
for (const entry of readdirSync(current)) {
const child = join(current, entry);
const stat = lstatSync(child);
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
throw new Error(`${child}: transaction tree contains a link or special file`);
}
if (stat.isDirectory()) visit(child);
}
};
visit(path);
}
export function transactionSourceHash(path: string): string {
const root = lstatSync(path);
if (root.isFile()) return sha256Bytes(readFileSync(path));
if (!root.isDirectory()) throw new Error(`${path}: transaction source must be a file or directory`);
const rows = [`directory . ${root.mode & 0o777}`];
const visit = (directory: string, prefix: string): void => {
for (const entry of readdirSync(directory).sort()) {
const child = join(directory, entry);
const rel = prefix ? `${prefix}/${entry}` : entry;
const stat = lstatSync(child);
if (stat.isDirectory()) {
rows.push(`directory ${rel} ${stat.mode & 0o777}`);
visit(child, rel);
} else if (stat.isFile()) {
rows.push(`file ${rel} ${stat.mode & 0o777} ${sha256Bytes(readFileSync(child))}`);
} else {
throw new Error(`${child}: transaction tree contains a link or special file`);
}
}
};
visit(path, "");
return sha256Bytes(rows.join("\n"));
}
function verifyPlan(plan: TransactionPlan, root: string): void {
if (plan.schemaVersion !== 1) throw new Error(`unsupported transaction schema ${plan.schemaVersion}`);
const seen = new Set<string>();
const rootDevice = lstatSync(nearestExisting(root)).dev;
for (const operation of plan.operations) {
const rel = normalizedRelative(operation.path);
if (
seen.has(rel) ||
[...seen].some((other) => rel.startsWith(`${other}/`) || other.startsWith(`${rel}/`))
) {
throw new Error(`transaction operations overlap at ${rel}`);
}
seen.add(rel);
const target = targetPath(root, rel);
const existing = nearestExisting(target);
if (lstatSync(existing).dev !== rootDevice) {
throw new Error(`${rel}: transaction destination crosses a filesystem boundary`);
}
if (operation.expected && transactionState(target) !== operation.expected) {
throw new Error(`${rel}: source changed after planning`);
}
if (operation.kind === "copy" || operation.kind === "tree") {
if (!isAbsolute(operation.source)) throw new Error(`${rel}: copy source must be absolute`);
const sourceStat = lstatSync(operation.source);
if (operation.kind === "copy" && !sourceStat.isFile()) {
throw new Error(`${rel}: copy source must be a regular file`);
}
if (operation.kind === "tree" && !sourceStat.isDirectory()) {
throw new Error(`${rel}: tree source must be a directory`);
}
if (operation.kind === "tree") validateTreeSource(operation.source);
if (transactionSourceHash(operation.source) !== operation.sourceHash) {
throw new Error(`${rel}: transaction source changed after planning`);
}
}
if (operation.kind === "symlink" && !isAbsolute(operation.target)) {
throw new Error(`${rel}: symlink target must be absolute`);
}
}
}
export function validateTransactionPlan(plan: TransactionPlan): void {
const root = canonicalRoot(plan.root);
if (plan.operations.length > 0) enforceRouteMutationPlan(root, plan.operations);
verifyPlan(plan, root);
}
function verifyExpectedState(
operation: TransactionOperation,
target: string,
rel: string,
): void {
if (
operation.expected !== undefined &&
transactionState(target) !== operation.expected
) {
throw new Error(`${rel}: source changed after planning`);
}
}
function failpoint(options: TransactionOptions, name: string): void {
if (options.failAt === name) {
throw new Error(`injected transaction failure at ${name}`);
}
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
function clearStaleLock(lockPath: string): void {
let raw: string;
try {
raw = readFileSync(lockPath, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw new Error(`cannot verify transaction lock ${lockPath}`);
}
let lock: { pid?: unknown } = {};
try {
lock = JSON.parse(raw) as typeof lock;
} catch {
// An empty lock can survive only if its process exited between open and write.
}
if (typeof lock.pid === "number" && processIsAlive(lock.pid)) {
throw new Error(`another AI-DLC mutation holds ${lockPath}`);
}
const moved = join(dirname(lockPath), `.aidlc-lock-dead-${randomUUID()}`);
try {
renameSync(lockPath, moved);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
}
let movedRaw: string | null = null;
try {
movedRaw = readFileSync(moved, "utf-8");
} catch {
// Restore below: ownership cannot be proved after the atomic move.
}
if (movedRaw !== raw) {
try {
renameSync(moved, lockPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
throw new Error(`another AI-DLC mutation holds ${lockPath}`);
}
rmSync(moved, { force: true });
}
type HeldLock = {
descriptor: number;
identity: string;
};
function acquireLock(root: string, lockPath: string, staging: string): HeldLock {
for (let attempt = 0; attempt < 2; attempt++) {
const candidate = join(root, `.aidlc-lock-${randomUUID()}`);
let descriptor: number | null = null;
try {
descriptor = openSync(candidate, "wx", 0o600);
const identity = `${JSON.stringify({ pid: process.pid, staging: basename(staging) })}\n`;
writeSync(descriptor, identity);
fsyncSync(descriptor);
linkSync(candidate, lockPath);
rmSync(candidate, { force: true });
return { descriptor, identity };
} catch (error) {
if (descriptor !== null) closeSync(descriptor);
rmSync(candidate, { force: true });
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || attempt > 0) throw error;
clearStaleLock(lockPath);
}
}
throw new Error(`cannot acquire ${lockPath}`);
}
function quarantineOrphanStaging(root: string, current: string): void {
for (const entry of readdirSync(root)) {
if (
entry !== basename(current) &&
/^\.aidlc-txn-[0-9a-f]{8}-[0-9a-f-]{27}$/.test(entry)
) {
renameSync(
join(root, entry),
join(root, `.aidlc-recovery-${Date.now()}-${randomUUID()}`),
);
syncPath(root);
}
}
}
function snapshot(target: string, backup: string): void {
mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
cpSync(target, backup, {
recursive: true,
preserveTimestamps: true,
verbatimSymlinks: true,
});
}
function stageCandidate(operation: TransactionOperation, candidate: string): void {
mkdirSync(dirname(candidate), { recursive: true, mode: 0o700 });
if (operation.kind === "write") {
writeFileSync(candidate, Buffer.from(operation.data, "base64"), {
mode: operation.mode ?? 0o644,
});
} else if (operation.kind === "copy") {
copyFileSync(operation.source, candidate);
if (operation.mode !== undefined) chmodSync(candidate, operation.mode);
} else if (operation.kind === "tree") {
cpSync(operation.source, candidate, { recursive: true, preserveTimestamps: true });
} else if (operation.kind === "symlink") {
symlinkSync(operation.target, candidate);
}
if (
(operation.kind === "copy" || operation.kind === "tree") &&
transactionSourceHash(candidate) !== operation.sourceHash
) {
throw new Error(`${operation.path}: transaction source changed while staging`);
}
}
function syncPath(path: string): void {
let descriptor: number | null = null;
try {
descriptor = openSync(path, "r");
fsyncSync(descriptor);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
const unsupportedWindowsSync =
process.platform === "win32" && code === "EPERM";
if (
!unsupportedWindowsSync &&
!["EINVAL", "ENOTSUP", "EISDIR", "EBADF"].includes(code ?? "")
) {
throw error;
}
} finally {
if (descriptor !== null) closeSync(descriptor);
}
}
function syncTree(path: string): void {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
syncPath(dirname(path));
return;
}
if (stat.isDirectory()) {
for (const entry of readdirSync(path)) syncTree(join(path, entry));
}
syncPath(path);
}
export function executePlan(
plan: TransactionPlan,
options: TransactionOptions = {},
): void {
const root = canonicalRoot(plan.root);
if (plan.operations.length > 0) {
enforceRouteMutationPlan(root, plan.operations);
}
// Reject invalid plans before creating the transaction root, then recheck
// under the lock to close the preflight race.
verifyPlan(plan, root);
if (plan.operations.length === 0) return;
mkdirSync(root, { recursive: true, mode: 0o700 });
const lockPath = join(root, ".aidlc-transaction.lock");
const staging = join(root, `.aidlc-txn-${randomUUID()}`);
let lock: HeldLock | null = null;
let preserveStaging = false;
const committed: Array<{
rel: string;
existed: boolean;
state: string | "absent";
displaced: boolean;
}> = [];
try {
lock = acquireLock(root, lockPath, staging);
failpoint(options, "after-lock");
if (
!options.allowPendingWindowsUninstall &&
pendingWindowsUninstallBlocks(root)
) {
throw new Error(
"a pending Windows uninstall blocks machine mutation; run aidlc doctor",
);
}
quarantineOrphanStaging(root, staging);
options.validateLocked?.();
failpoint(options, "before-plan-validation");
verifyPlan(plan, root);
failpoint(options, "after-plan-validation");
mkdirSync(staging, { recursive: true, mode: 0o700 });
const candidates = join(staging, "candidates");
mkdirSync(candidates, { recursive: true, mode: 0o700 });
for (const [index, operation] of plan.operations.entries()) {
const boundary = `${index + 1}:${operation.kind}`;
failpoint(options, `before-stage:${boundary}`);
if (operation.kind !== "remove") {
stageCandidate(operation, join(candidates, normalizedRelative(operation.path)));
}
failpoint(options, `after-stage:${boundary}`);
}
syncTree(candidates);
failpoint(options, "before-candidate-validation");
options.validateCandidates?.(candidates);
failpoint(options, "after-candidate-validation");
for (const [index, operation] of plan.operations.entries()) {
const boundary = `${index + 1}:${operation.kind}`;
failpoint(options, `before-snapshot:${boundary}`);
const rel = normalizedRelative(operation.path);
const target = targetPath(root, rel);
verifyExpectedState(operation, target, rel);
if (pathExists(target)) snapshot(target, join(staging, "backups", rel));
failpoint(options, `after-snapshot:${boundary}`);
}
let committedCount = 0;
const failAfter = options.failAfter ?? 0;
for (const [index, operation] of plan.operations.entries()) {
const boundary = `${index + 1}:${operation.kind}`;
failpoint(options, `before-commit:${boundary}`);
const rel = normalizedRelative(operation.path);
const target = targetPath(root, rel);
verifyExpectedState(operation, target, rel);
const existed = pathExists(target);
let displaced = false;
if (operation.kind === "remove") {
if (existed) {
const removed = join(staging, "removed", rel);
mkdirSync(dirname(removed), { recursive: true, mode: 0o700 });
renameSync(target, removed);
}
} else {
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
const candidate = join(candidates, rel);
if (operation.kind === "tree" && existed) {
const removed = join(staging, "removed", rel);
mkdirSync(dirname(removed), { recursive: true, mode: 0o700 });
renameSync(target, removed);
try {
renameSync(candidate, target);
displaced = true;
} catch (error) {
renameSync(removed, target);
throw error;
}
} else {
renameSync(candidate, target);
}
}
committed.push({
rel,
existed,
state: transactionState(target),
displaced,
});
// The rename is live even if the following durability sync fails.
syncPath(dirname(target));
committedCount++;
failpoint(options, `after-commit:${boundary}`);
if (failAfter > 0 && committedCount === failAfter) {
throw new Error(`injected transaction failure after operation ${committedCount}`);
}
}
failpoint(options, "before-committed-validation");
options.validateCommitted?.();
failpoint(options, "after-committed-validation");
rmSync(staging, { recursive: true, force: true });
} catch (error) {
const rollbackErrors: unknown[] = [];
for (const entry of [...committed].reverse()) {
try {
const target = targetPath(root, entry.rel);
failpoint(options, `during-rollback:${entry.rel}`);
if (transactionState(target) !== entry.state) {
throw new Error(`${entry.rel}: destination changed during rollback`);
}
if (entry.existed) {
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
if (entry.displaced) {
rmSync(target, { recursive: true, force: true });
renameSync(join(staging, "removed", entry.rel), target);
} else {
const backup = join(staging, "backups", entry.rel);
renameSync(backup, target);
}
} else {
rmSync(target, { recursive: true, force: true });
}
syncPath(dirname(target));
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (rollbackErrors.length > 0) {
preserveStaging = true;
throw new AggregateError(
[error, ...rollbackErrors],
`transaction rollback incomplete; recovery evidence preserved at ${staging}`,
);
}
throw error;
} finally {
if (!preserveStaging) rmSync(staging, { recursive: true, force: true });
if (lock !== null) {
closeSync(lock.descriptor);
try {
if (readFileSync(lockPath, "utf-8") === lock.identity) {
rmSync(lockPath, { force: true });
}
} catch {
// A safely reclaimed or already-removed lock is no longer ours.
}
}
}
}
export function writeOperation(
path: string,
value: string | Buffer,
expected?: string | "absent",
mode?: number,
): TransactionOperation {
return {
kind: "write",
path,
data: Buffer.from(value).toString("base64"),
expected,
mode,
};
}