refactor(node): generalize managed hook transactions

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 03:45:37 -04:00
1 parent 942e5f5ac4
commit 4998d79c0b
5 files changed
+574 -307

No files matched your search

+8 -7
View File
@@ -33,8 +33,7 @@ export async function preflightCodexInstallation(
): Promise<CodexRuntimePaths> { ): Promise<CodexRuntimePaths> {
return preflightManagedInstallation({ return preflightManagedInstallation({
agentKey: AGENT_KEY, agentKey: AGENT_KEY,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, hookLocations: [{ directoryLabel: HOOKS_DIRECTORY_LABEL, filePath: hooksPath }],
hooksPath,
config, config,
}, GUARD_SCRIPT, AUDIT_SCRIPT); }, GUARD_SCRIPT, AUDIT_SCRIPT);
} }
@@ -51,11 +50,13 @@ export async function prepareCodexInstallation(
return prepareManagedInstallation({ return prepareManagedInstallation({
agentKey: AGENT_KEY, agentKey: AGENT_KEY,
displayName: DISPLAY_NAME, displayName: DISPLAY_NAME,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, hookSources: [{
hooksLabel: HOOKS_LABEL, directoryLabel: HOOKS_DIRECTORY_LABEL,
hooksPath: rendered.document.filePath, label: HOOKS_LABEL,
expectedHooksSource: rendered.document.raw, filePath: rendered.document.filePath,
hooksSource, expectedSource: rendered.document.raw,
source: hooksSource,
}],
config, config,
auditOptions: { nativePayload: true }, auditOptions: { nativePayload: true },
}, GUARD_SCRIPT, AUDIT_SCRIPT); }, GUARD_SCRIPT, AUDIT_SCRIPT);
+11 -7
View File
@@ -33,8 +33,10 @@ export async function preflightCursorInstallation(
): Promise<CursorRuntimePaths> { ): Promise<CursorRuntimePaths> {
return preflightManagedInstallation({ return preflightManagedInstallation({
agentKey: AGENT_KEY, agentKey: AGENT_KEY,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, hookLocations: [{
hooksPath: cursorConfigPath(), directoryLabel: HOOKS_DIRECTORY_LABEL,
filePath: cursorConfigPath(),
}],
config, config,
}, GUARD_SCRIPT, AUDIT_SCRIPT); }, GUARD_SCRIPT, AUDIT_SCRIPT);
} }
@@ -51,11 +53,13 @@ export async function prepareCursorInstallation(
return prepareManagedInstallation({ return prepareManagedInstallation({
agentKey: AGENT_KEY, agentKey: AGENT_KEY,
displayName: DISPLAY_NAME, displayName: DISPLAY_NAME,
hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, hookSources: [{
hooksLabel: HOOKS_LABEL, directoryLabel: HOOKS_DIRECTORY_LABEL,
hooksPath: rendered.document.filePath, label: HOOKS_LABEL,
expectedHooksSource: rendered.document.raw, filePath: rendered.document.filePath,
hooksSource, expectedSource: rendered.document.raw,
source: hooksSource,
}],
config, config,
guardOptions: { guardOptions: {
failClosed: true, failClosed: true,
+83 -293
View File
@@ -1,38 +1,23 @@
import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js'; import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js';
import type { InstallConfig } from './base.js'; import type { InstallConfig } from './base.js';
import { generateGuardScript, type GuardScriptOptions } from './guard-template.js'; import { generateGuardScript, type GuardScriptOptions } from './guard-template.js';
import { generateHookScript, type HookScriptOptions } from './hook-template.js'; import { generateHookScript, type HookScriptOptions } from './hook-template.js';
import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js';
import { import {
inspectPhysicalDirectory, commitManagedTransaction,
readPhysicalFile, prepareManagedFileChange,
type FileSnapshot, type ManagedDirectory,
} from './managed-files.js'; type ManagedFileChange,
type PreparedManagedTransaction,
type RenameFile,
} from './managed-transaction.js';
import { parseStrictJsonObject } from './strict-json.js'; import { parseStrictJsonObject } from './strict-json.js';
const MAX_SECRET_BYTES = 64 * 1024; const MAX_SECRET_BYTES = 64 * 1024;
const MAX_CONFIG_BYTES = 512 * 1024; const MAX_CONFIG_BYTES = 512 * 1024;
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
interface FileChange {
readonly filePath: string;
readonly label: string;
readonly next: string;
readonly mode: number;
readonly maximumBytes: number;
readonly original?: FileSnapshot;
}
interface StagedChange {
readonly change: FileChange;
readonly temporaryPath: string;
rollbackPath?: string;
committedSnapshot?: FileSnapshot;
committed: boolean;
}
export interface ManagedRuntimePaths { export interface ManagedRuntimePaths {
readonly runtimeRoot: string; readonly runtimeRoot: string;
readonly agentDirectory: string; readonly agentDirectory: string;
@@ -41,41 +26,39 @@ export interface ManagedRuntimePaths {
readonly guardPath: string; readonly guardPath: string;
readonly auditPath: string; readonly auditPath: string;
} }
export interface ManagedHookLocation {
readonly directoryLabel: string;
readonly filePath: string;
}
export interface ManagedHookSource extends ManagedHookLocation {
readonly label: string;
readonly expectedSource?: string;
readonly source: string;
}
export interface ManagedInstallationSpec { export interface ManagedInstallationSpec {
readonly agentKey: string; readonly agentKey: string;
readonly displayName: string; readonly displayName: string;
readonly hooksDirectoryLabel: string; readonly hookSources: readonly ManagedHookSource[];
readonly hooksLabel: string;
readonly hooksPath: string;
readonly expectedHooksSource?: string;
readonly hooksSource: string;
readonly config: InstallConfig; readonly config: InstallConfig;
readonly guardOptions?: GuardScriptOptions; readonly guardOptions?: GuardScriptOptions;
readonly auditOptions?: HookScriptOptions; readonly auditOptions?: HookScriptOptions;
} }
export interface ManagedPreflightSpec { export interface ManagedPreflightSpec {
readonly agentKey: string; readonly agentKey: string;
readonly hooksDirectoryLabel: string; readonly hookLocations: readonly ManagedHookLocation[];
readonly hooksPath: string;
readonly config: InstallConfig; readonly config: InstallConfig;
} }
export interface PreparedManagedInstallation { export interface PreparedManagedInstallation {
readonly displayName: string;
readonly hooksDirectoryLabel: string;
readonly paths: ManagedRuntimePaths; readonly paths: ManagedRuntimePaths;
readonly changes: readonly FileChange[]; readonly transaction: PreparedManagedTransaction;
}
export type RenameFile = (source: string, destination: string) => Promise<void>;
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
} }
function errorMessage(error: unknown): string { export type { RenameFile };
return error instanceof Error ? error.message : String(error);
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
export function samePath(left: string, right: string): boolean { export function samePath(left: string, right: string): boolean {
const normalizedLeft = path.resolve(left); const normalizedLeft = path.resolve(left);
@@ -84,6 +67,7 @@ export function samePath(left: string, right: string): boolean {
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight; : normalizedLeft === normalizedRight;
} }
function validateInstallConfig(config: InstallConfig, agentKey: string): void { function validateInstallConfig(config: InstallConfig, agentKey: string): void {
for (const [field, value] of [ for (const [field, value] of [
['agentName', config.agentName], ['agentName', config.agentName],
@@ -108,7 +92,7 @@ function validateInstallConfig(config: InstallConfig, agentKey: string): void {
throw new Error('privateKey must be a canonical 32-byte base64url value'); throw new Error('privateKey must be a canonical 32-byte base64url value');
} }
const baseUrl = new URL(config.baseUrl); const baseUrl = new URL(config.baseUrl);
if (!['http:', 'https:'].includes(baseUrl.protocol)) { if (!['http:', 'https:'].includes(baseUrl.protocol) || !baseUrl.hostname) {
throw new Error('baseUrl must use HTTP or HTTPS'); throw new Error('baseUrl must use HTTP or HTTPS');
} }
if (baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) { if (baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) {
@@ -188,7 +172,9 @@ export async function preflightManagedInstallation(
auditScript: string, auditScript: string,
): Promise<ManagedRuntimePaths> { ): Promise<ManagedRuntimePaths> {
const paths = managedRuntimePaths(spec.config, spec.agentKey, guardScript, auditScript); const paths = managedRuntimePaths(spec.config, spec.agentKey, guardScript, auditScript);
await inspectPhysicalDirectory(path.dirname(spec.hooksPath), spec.hooksDirectoryLabel); for (const location of spec.hookLocations) {
await inspectPhysicalDirectory(path.dirname(location.filePath), location.directoryLabel);
}
await validateRuntimeIdentity(paths, spec.agentKey); await validateRuntimeIdentity(paths, spec.agentKey);
return paths; return paths;
} }
@@ -209,21 +195,11 @@ function runtimeConfig(config: InstallConfig, agentKey: string): string {
return encoded; return encoded;
} }
async function prepareChange( function hookDirectories(sources: readonly ManagedHookSource[]): ManagedDirectory[] {
filePath: string, return sources.map((source) => ({
label: string, path: path.dirname(source.filePath),
next: string, label: source.directoryLabel,
mode: number, }));
maximumBytes = MAX_SOURCE_BYTES,
): Promise<FileChange> {
return {
filePath,
label,
next,
mode,
maximumBytes,
original: await readPhysicalFile(filePath, label, maximumBytes),
};
} }
export async function prepareManagedInstallation( export async function prepareManagedInstallation(
@@ -231,250 +207,64 @@ export async function prepareManagedInstallation(
guardScript: string, guardScript: string,
auditScript: string, auditScript: string,
): Promise<PreparedManagedInstallation> { ): Promise<PreparedManagedInstallation> {
const paths = await preflightManagedInstallation(spec, guardScript, auditScript); if (spec.hookSources.length === 0) throw new Error('At least one hook source is required');
const paths = await preflightManagedInstallation({
agentKey: spec.agentKey,
hookLocations: spec.hookSources,
config: spec.config,
}, guardScript, auditScript);
const changes = await Promise.all([ const changes = await Promise.all([
prepareChange( prepareManagedFileChange({
paths.guardPath, filePath: paths.guardPath,
'Elydora guard runtime', label: 'Elydora guard runtime',
generateGuardScript(spec.agentKey, spec.config.agentId, spec.guardOptions), next: generateGuardScript(spec.agentKey, spec.config.agentId, spec.guardOptions),
0o700, mode: 0o700,
), }),
prepareChange( prepareManagedFileChange({
paths.configPath, filePath: paths.configPath,
'Elydora runtime config', label: 'Elydora runtime config',
runtimeConfig(spec.config, spec.agentKey), next: runtimeConfig(spec.config, spec.agentKey),
0o600, mode: 0o600,
MAX_CONFIG_BYTES, maximumBytes: MAX_CONFIG_BYTES,
), }),
prepareChange( prepareManagedFileChange({
paths.keyPath, filePath: paths.keyPath,
'Elydora private key', label: 'Elydora private key',
spec.config.privateKey, next: spec.config.privateKey,
0o600, mode: 0o600,
MAX_SECRET_BYTES, maximumBytes: MAX_SECRET_BYTES,
), }),
prepareChange( prepareManagedFileChange({
paths.auditPath, filePath: paths.auditPath,
'Elydora audit runtime', label: 'Elydora audit runtime',
generateHookScript(spec.agentKey, spec.config.agentId, spec.auditOptions), next: generateHookScript(spec.agentKey, spec.config.agentId, spec.auditOptions),
0o700, mode: 0o700,
), }),
prepareChange(spec.hooksPath, spec.hooksLabel, spec.hooksSource, 0o600), ...spec.hookSources.map((source) => prepareManagedFileChange({
filePath: source.filePath,
label: source.label,
next: source.source,
mode: 0o600,
expectedSource: source.expectedSource,
verifyExpectedSource: true,
})),
]); ]);
const hooksSource = changes.at(-1)!.original?.contents;
if (hooksSource !== spec.expectedHooksSource) {
throw new Error(`${spec.hooksLabel} changed before installation: ${spec.hooksPath}`);
}
await validateRuntimeIdentity(paths, spec.agentKey); await validateRuntimeIdentity(paths, spec.agentKey);
return { return {
displayName: spec.displayName,
hooksDirectoryLabel: spec.hooksDirectoryLabel,
paths, paths,
changes, transaction: {
displayName: spec.displayName,
directories: hookDirectories(spec.hookSources),
changes: changes.filter((change): change is ManagedFileChange => change !== undefined),
},
}; };
} }
async function unlinkOptional(filePath: string): Promise<void> {
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasCode(error, 'ENOENT')) throw error;
}
}
async function assertUnchanged(change: FileChange, displayName: string): Promise<void> {
const current = await readPhysicalFile(change.filePath, change.label, change.maximumBytes);
if ((!current && change.original)
|| (current && !change.original)
|| (current && change.original && (
current.contents !== change.original.contents
|| current.device !== change.original.device
|| current.inode !== change.original.inode
))) {
throw new Error(`${change.label} changed during ${displayName} installation: ${change.filePath}`);
}
}
async function writeExclusive(
filePath: string,
contents: string,
mode: number,
label: string,
): Promise<void> {
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, 'wx', mode);
await handle.writeFile(contents, 'utf-8');
await handle.sync();
await handle.close();
handle = undefined;
if (process.platform !== 'win32') await fsp.chmod(filePath, mode);
} catch (error) {
const failures = [asError(error)];
if (handle) {
try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); }
}
try { await unlinkOptional(filePath); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`);
throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] });
}
}
async function stage(change: FileChange, displayName: string): Promise<StagedChange> {
await assertUnchanged(change, displayName);
const token = randomUUID();
const directory = path.dirname(change.filePath);
const temporaryPath = path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
const rollbackPath = change.original
? path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`)
: undefined;
try {
await writeExclusive(temporaryPath, change.next, change.mode, change.label);
if (rollbackPath && change.original) {
await writeExclusive(
rollbackPath,
change.original.contents,
change.original.mode,
`${change.label} rollback`,
);
}
return { change, temporaryPath, rollbackPath, committed: false };
} catch (error) {
const failures = [asError(error)];
for (const item of [temporaryPath, rollbackPath]) {
if (!item) continue;
try { await unlinkOptional(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`);
throw error;
}
}
async function commit(
staged: StagedChange,
displayName: string,
renameFile: RenameFile,
): Promise<void> {
await assertUnchanged(staged.change, displayName);
await renameFile(staged.temporaryPath, staged.change.filePath);
staged.committed = true;
const current = await readPhysicalFile(
staged.change.filePath,
staged.change.label,
staged.change.maximumBytes,
);
if (!current || current.contents !== staged.change.next) {
throw new Error(
`${staged.change.label} changed immediately after commit: ${staged.change.filePath}`,
);
}
staged.committedSnapshot = current;
}
async function assertCommittedUnchanged(staged: StagedChange): Promise<void> {
const current = await readPhysicalFile(
staged.change.filePath,
staged.change.label,
staged.change.maximumBytes,
);
const committed = staged.committedSnapshot;
if (!current || !committed
|| current.contents !== committed.contents
|| current.device !== committed.device
|| current.inode !== committed.inode) {
throw new Error(
`${staged.change.label} changed during transaction recovery: ${staged.change.filePath}`,
);
}
}
function preserveRollback(staged: StagedChange, cause: unknown): Error {
if (!staged.rollbackPath) return asError(cause);
const rollbackPath = staged.rollbackPath;
staged.rollbackPath = undefined;
return new Error(
`${errorMessage(cause)}; original content preserved at ${rollbackPath}`,
{ cause: asError(cause) },
);
}
async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
if (!staged.committed) return;
try {
await assertCommittedUnchanged(staged);
} catch (error) {
throw preserveRollback(staged, error);
}
if (!staged.change.original) {
await unlinkOptional(staged.change.filePath);
return;
}
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
try {
await renameFile(staged.rollbackPath, staged.change.filePath);
} catch (error) {
throw preserveRollback(staged, error);
}
}
async function cleanup(staged: StagedChange): Promise<void> {
await Promise.all(
[staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)),
);
}
async function ensureManagedDirectory(directory: string, label: string): Promise<void> {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
if (!await inspectPhysicalDirectory(directory, label)) {
throw new Error(`${label} is missing: ${directory}`);
}
}
export async function commitManagedInstallation( export async function commitManagedInstallation(
prepared: PreparedManagedInstallation, prepared: PreparedManagedInstallation,
renameFile: RenameFile = fsp.rename, renameFile?: RenameFile,
): Promise<void> { ): Promise<void> {
await ensurePrivateDirectory(prepared.paths.runtimeRoot); await ensurePrivateDirectory(prepared.paths.runtimeRoot);
await ensurePrivateDirectory(prepared.paths.agentDirectory); await ensurePrivateDirectory(prepared.paths.agentDirectory);
await ensureManagedDirectory( await commitManagedTransaction(prepared.transaction, renameFile);
path.dirname(prepared.changes.at(-1)!.filePath),
prepared.hooksDirectoryLabel,
);
const staged: StagedChange[] = [];
try {
for (const change of prepared.changes) {
staged.push(await stage(change, prepared.displayName));
}
for (const item of staged) await commit(item, prepared.displayName, renameFile);
} catch (error) {
const failures = [asError(error)];
for (const item of [...staged].reverse()) {
try { await rollback(item, renameFile); } catch (rollbackError) {
failures.push(asError(rollbackError));
}
}
for (const item of staged) {
try { await cleanup(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
throw new AggregateError(
failures,
`Install ${prepared.displayName} hooks: ${errorMessage(error)}`,
);
}
const failures: Error[] = [];
for (const item of staged) {
try { await cleanup(item); } catch (error) { failures.push(asError(error)); }
}
if (failures.length > 0) {
throw new AggregateError(
failures,
`Clean ${prepared.displayName} installation transaction files`,
);
}
} }
@@ -0,0 +1,366 @@
import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import path from 'node:path';
import {
inspectPhysicalDirectory,
readPhysicalFile,
type FileSnapshot,
} from './managed-files.js';
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
export interface ManagedChangeSpec {
readonly filePath: string;
readonly label: string;
readonly next?: string;
readonly mode: number;
readonly maximumBytes?: number;
readonly expectedSource?: string;
readonly verifyExpectedSource?: boolean;
}
export interface ManagedFileChange {
readonly filePath: string;
readonly label: string;
readonly next?: string;
readonly mode: number;
readonly maximumBytes: number;
readonly original?: FileSnapshot;
}
export interface ManagedDirectory {
readonly path: string;
readonly label: string;
}
export interface PreparedManagedTransaction {
readonly displayName: string;
readonly directories: readonly ManagedDirectory[];
readonly changes: readonly ManagedFileChange[];
}
interface StagedChange {
readonly change: ManagedFileChange;
temporaryPath?: string;
rollbackPath?: string;
committedSnapshot?: FileSnapshot;
committed: boolean;
}
export type RenameFile = (source: string, destination: string) => Promise<void>;
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hasCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
function targetKey(filePath: string): string {
const resolved = path.resolve(filePath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
async function unlinkOptional(filePath?: string): Promise<void> {
if (!filePath) return;
try {
await fsp.unlink(filePath);
} catch (error) {
if (!hasCode(error, 'ENOENT')) throw error;
}
}
export async function prepareManagedFileChange(
spec: ManagedChangeSpec,
): Promise<ManagedFileChange | undefined> {
const maximumBytes = spec.maximumBytes ?? MAX_SOURCE_BYTES;
if (spec.next !== undefined && Buffer.byteLength(spec.next) > maximumBytes) {
throw new Error(`${spec.label} exceeds ${maximumBytes} bytes: ${spec.filePath}`);
}
const original = await readPhysicalFile(spec.filePath, spec.label, maximumBytes);
if (spec.verifyExpectedSource && original?.contents !== spec.expectedSource) {
throw new Error(`${spec.label} changed before installation: ${spec.filePath}`);
}
if (!original && spec.next === undefined) return undefined;
return {
filePath: spec.filePath,
label: spec.label,
next: spec.next,
mode: spec.mode,
maximumBytes,
original,
};
}
async function assertUnchanged(change: ManagedFileChange, displayName: string): Promise<void> {
const current = await readPhysicalFile(change.filePath, change.label, change.maximumBytes);
if ((!current && change.original)
|| (current && !change.original)
|| (current && change.original && (
current.contents !== change.original.contents
|| current.device !== change.original.device
|| current.inode !== change.original.inode
))) {
throw new Error(`${change.label} changed during ${displayName} installation: ${change.filePath}`);
}
}
async function writeExclusive(
filePath: string,
contents: string,
mode: number,
label: string,
): Promise<void> {
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, 'wx', mode);
await handle.writeFile(contents, 'utf-8');
await handle.sync();
await handle.close();
handle = undefined;
if (process.platform !== 'win32') await fsp.chmod(filePath, mode);
} catch (error) {
const failures = [asError(error)];
if (handle) {
try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); }
}
try { await unlinkOptional(filePath); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${label}`);
throw new Error(`Stage ${label}: ${errorMessage(error)}`, { cause: failures[0] });
}
}
async function reservePath(filePath: string, label: string): Promise<void> {
let handle: FileHandle | undefined;
try {
handle = await fsp.open(filePath, 'wx', 0o600);
await handle.close();
handle = undefined;
await fsp.unlink(filePath);
} catch (error) {
const failures = [asError(error)];
if (handle) {
try { await handle.close(); } catch (closeError) { failures.push(asError(closeError)); }
}
try { await unlinkOptional(filePath); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
if (failures.length > 1) throw new AggregateError(failures, `Reserve ${label}`);
throw new Error(`Reserve ${label}: ${errorMessage(error)}`, { cause: failures[0] });
}
}
async function stage(change: ManagedFileChange, displayName: string): Promise<StagedChange> {
await assertUnchanged(change, displayName);
const token = randomUUID();
const directory = path.dirname(change.filePath);
const temporaryPath = change.next === undefined
? undefined
: path.join(directory, `.${path.basename(change.filePath)}.${token}.tmp`);
const rollbackPath = change.original
? path.join(directory, `.${path.basename(change.filePath)}.${token}.rollback`)
: undefined;
const staged: StagedChange = {
change,
temporaryPath,
rollbackPath,
committed: false,
};
try {
if (temporaryPath && change.next !== undefined) {
await writeExclusive(temporaryPath, change.next, change.mode, change.label);
}
if (rollbackPath && change.original) {
if (change.next === undefined) {
await reservePath(rollbackPath, `${change.label} rollback`);
} else {
await writeExclusive(
rollbackPath,
change.original.contents,
change.original.mode,
`${change.label} rollback`,
);
}
}
return staged;
} catch (error) {
const failures = [asError(error)];
for (const item of [temporaryPath, rollbackPath]) {
try { await unlinkOptional(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
if (failures.length > 1) throw new AggregateError(failures, `Stage ${change.label}`);
throw error;
}
}
async function commit(
staged: StagedChange,
displayName: string,
renameFile: RenameFile,
): Promise<void> {
await assertUnchanged(staged.change, displayName);
if (staged.change.next === undefined) {
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
await renameFile(staged.change.filePath, staged.rollbackPath);
} else {
if (!staged.temporaryPath) throw new Error(`Missing staged file for ${staged.change.label}`);
await renameFile(staged.temporaryPath, staged.change.filePath);
}
staged.committed = true;
const current = await readPhysicalFile(
staged.change.filePath,
staged.change.label,
staged.change.maximumBytes,
);
if (staged.change.next === undefined) {
if (current) throw new Error(`${staged.change.label} remained after removal`);
return;
}
if (!current || current.contents !== staged.change.next) {
throw new Error(
`${staged.change.label} changed immediately after commit: ${staged.change.filePath}`,
);
}
staged.committedSnapshot = current;
}
async function assertCommittedUnchanged(staged: StagedChange): Promise<void> {
const current = await readPhysicalFile(
staged.change.filePath,
staged.change.label,
staged.change.maximumBytes,
);
if (staged.change.next === undefined) {
if (current) {
throw new Error(
`${staged.change.label} changed during transaction recovery: ${staged.change.filePath}`,
);
}
return;
}
const committed = staged.committedSnapshot;
if (!current || !committed
|| current.contents !== committed.contents
|| current.device !== committed.device
|| current.inode !== committed.inode) {
throw new Error(
`${staged.change.label} changed during transaction recovery: ${staged.change.filePath}`,
);
}
}
function preserveRollback(staged: StagedChange, cause: unknown): Error {
if (!staged.rollbackPath) return asError(cause);
const rollbackPath = staged.rollbackPath;
staged.rollbackPath = undefined;
return new Error(
`${errorMessage(cause)}; original content preserved at ${rollbackPath}`,
{ cause: asError(cause) },
);
}
async function rollback(staged: StagedChange, renameFile: RenameFile): Promise<void> {
if (!staged.committed) return;
try {
await assertCommittedUnchanged(staged);
} catch (error) {
throw preserveRollback(staged, error);
}
try {
if (staged.change.next === undefined || staged.change.original) {
if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`);
await renameFile(staged.rollbackPath, staged.change.filePath);
} else {
await unlinkOptional(staged.change.filePath);
}
} catch (error) {
throw preserveRollback(staged, error);
}
}
async function cleanup(staged: StagedChange): Promise<void> {
await Promise.all([
unlinkOptional(staged.temporaryPath),
unlinkOptional(staged.rollbackPath),
]);
}
async function ensureManagedDirectory(directory: string, label: string): Promise<void> {
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
if (!await inspectPhysicalDirectory(directory, label)) {
throw new Error(`${label} is missing: ${directory}`);
}
}
function transactionDirectories(transaction: PreparedManagedTransaction): ManagedDirectory[] {
const directories = new Map<string, ManagedDirectory>();
for (const item of transaction.directories) directories.set(targetKey(item.path), item);
for (const change of transaction.changes) {
const directory = path.dirname(change.filePath);
const key = targetKey(directory);
if (!directories.has(key)) {
directories.set(key, { path: directory, label: `Directory for ${change.label}` });
}
}
return [...directories.values()];
}
export async function commitManagedTransaction(
transaction: PreparedManagedTransaction,
renameFile: RenameFile = fsp.rename,
): Promise<void> {
const targets = new Set<string>();
for (const change of transaction.changes) {
const key = targetKey(change.filePath);
if (targets.has(key)) {
throw new Error(
`${transaction.displayName} installation contains duplicate file target ${change.filePath}`,
);
}
targets.add(key);
}
for (const directory of transactionDirectories(transaction)) {
await ensureManagedDirectory(directory.path, directory.label);
}
const staged: StagedChange[] = [];
try {
for (const change of transaction.changes) staged.push(await stage(change, transaction.displayName));
for (const item of staged) await commit(item, transaction.displayName, renameFile);
} catch (error) {
const failures = [asError(error)];
for (const item of [...staged].reverse()) {
try { await rollback(item, renameFile); } catch (rollbackError) {
failures.push(asError(rollbackError));
}
}
for (const item of staged) {
try { await cleanup(item); } catch (cleanupError) {
failures.push(asError(cleanupError));
}
}
throw new AggregateError(
failures,
`Install ${transaction.displayName} hooks: ${errorMessage(error)}`,
);
}
const failures: Error[] = [];
for (const item of staged) {
try { await cleanup(item); } catch (error) { failures.push(asError(error)); }
}
if (failures.length > 0) {
throw new AggregateError(
failures,
`Clean ${transaction.displayName} installation transaction files`,
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import {
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
const transactionModuleUrl = pathToFileURL(
path.resolve('dist/plugins/managed-transaction.js'),
).href;
async function fixture() {
const root = await mkdtemp(path.join(os.tmpdir(), 'elydora-managed-transaction-'));
const directory = path.join(root, 'managed');
await mkdir(directory);
const updatePath = path.join(directory, 'update.txt');
const removePath = path.join(directory, 'remove.txt');
const createPath = path.join(directory, 'create.txt');
await writeFile(updatePath, 'old update\n');
await writeFile(removePath, 'old remove\n');
return {
root,
directory,
updatePath,
removePath,
createPath,
close: () => rm(root, { recursive: true, force: true }),
};
}
async function prepareTransaction(state) {
const { prepareManagedFileChange } = await import(transactionModuleUrl);
const changes = await Promise.all([
prepareManagedFileChange({
filePath: state.updatePath,
label: 'updated source',
next: 'new update\n',
mode: 0o600,
}),
prepareManagedFileChange({
filePath: state.removePath,
label: 'removed source',
mode: 0o600,
}),
prepareManagedFileChange({
filePath: state.createPath,
label: 'created source',
next: 'new create\n',
mode: 0o600,
}),
]);
return {
displayName: 'test adapter',
directories: [{ path: state.directory, label: 'test directory' }],
changes: changes.filter(Boolean),
};
}
async function assertNoTransactionFiles(directory) {
const names = await readdir(directory);
assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false, names.join(', '));
}
test('managed transaction commits update, removal, and creation atomically', async () => {
const state = await fixture();
try {
const { commitManagedTransaction } = await import(transactionModuleUrl);
await commitManagedTransaction(await prepareTransaction(state));
assert.equal(await readFile(state.updatePath, 'utf-8'), 'new update\n');
await assert.rejects(readFile(state.removePath), { code: 'ENOENT' });
assert.equal(await readFile(state.createPath, 'utf-8'), 'new create\n');
await assertNoTransactionFiles(state.directory);
} finally {
await state.close();
}
});
test('managed transaction restores update and removal after a later commit fails', async () => {
const state = await fixture();
try {
const { commitManagedTransaction } = await import(transactionModuleUrl);
let calls = 0;
await assert.rejects(
commitManagedTransaction(await prepareTransaction(state), async (source, destination) => {
calls += 1;
if (calls === 3) throw new Error('injected create failure');
await rename(source, destination);
}),
/injected create failure/,
);
assert.equal(await readFile(state.updatePath, 'utf-8'), 'old update\n');
assert.equal(await readFile(state.removePath, 'utf-8'), 'old remove\n');
await assert.rejects(readFile(state.createPath), { code: 'ENOENT' });
await assertNoTransactionFiles(state.directory);
} finally {
await state.close();
}
});