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=/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(); 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, }; }