diff --git a/sdks/node/src/plugins/codex-installation.ts b/sdks/node/src/plugins/codex-installation.ts index e65da89..e576d7f 100644 --- a/sdks/node/src/plugins/codex-installation.ts +++ b/sdks/node/src/plugins/codex-installation.ts @@ -33,8 +33,7 @@ export async function preflightCodexInstallation( ): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, - hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, - hooksPath, + hookLocations: [{ directoryLabel: HOOKS_DIRECTORY_LABEL, filePath: hooksPath }], config, }, GUARD_SCRIPT, AUDIT_SCRIPT); } @@ -51,11 +50,13 @@ export async function prepareCodexInstallation( return prepareManagedInstallation({ agentKey: AGENT_KEY, displayName: DISPLAY_NAME, - hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, - hooksLabel: HOOKS_LABEL, - hooksPath: rendered.document.filePath, - expectedHooksSource: rendered.document.raw, - hooksSource, + hookSources: [{ + directoryLabel: HOOKS_DIRECTORY_LABEL, + label: HOOKS_LABEL, + filePath: rendered.document.filePath, + expectedSource: rendered.document.raw, + source: hooksSource, + }], config, auditOptions: { nativePayload: true }, }, GUARD_SCRIPT, AUDIT_SCRIPT); diff --git a/sdks/node/src/plugins/cursor-installation.ts b/sdks/node/src/plugins/cursor-installation.ts index e6552f0..8d30423 100644 --- a/sdks/node/src/plugins/cursor-installation.ts +++ b/sdks/node/src/plugins/cursor-installation.ts @@ -33,8 +33,10 @@ export async function preflightCursorInstallation( ): Promise { return preflightManagedInstallation({ agentKey: AGENT_KEY, - hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, - hooksPath: cursorConfigPath(), + hookLocations: [{ + directoryLabel: HOOKS_DIRECTORY_LABEL, + filePath: cursorConfigPath(), + }], config, }, GUARD_SCRIPT, AUDIT_SCRIPT); } @@ -51,11 +53,13 @@ export async function prepareCursorInstallation( return prepareManagedInstallation({ agentKey: AGENT_KEY, displayName: DISPLAY_NAME, - hooksDirectoryLabel: HOOKS_DIRECTORY_LABEL, - hooksLabel: HOOKS_LABEL, - hooksPath: rendered.document.filePath, - expectedHooksSource: rendered.document.raw, - hooksSource, + hookSources: [{ + directoryLabel: HOOKS_DIRECTORY_LABEL, + label: HOOKS_LABEL, + filePath: rendered.document.filePath, + expectedSource: rendered.document.raw, + source: hooksSource, + }], config, guardOptions: { failClosed: true, diff --git a/sdks/node/src/plugins/managed-installation.ts b/sdks/node/src/plugins/managed-installation.ts index 7eca954..e44cfdc 100644 --- a/sdks/node/src/plugins/managed-installation.ts +++ b/sdks/node/src/plugins/managed-installation.ts @@ -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 path from 'node:path'; import { ensurePrivateDirectory, resolvePrivateChildDirectory } from '../runtime-paths.js'; import type { InstallConfig } from './base.js'; import { generateGuardScript, type GuardScriptOptions } from './guard-template.js'; import { generateHookScript, type HookScriptOptions } from './hook-template.js'; +import { inspectPhysicalDirectory, readPhysicalFile } from './managed-files.js'; import { - inspectPhysicalDirectory, - readPhysicalFile, - type FileSnapshot, -} from './managed-files.js'; + commitManagedTransaction, + prepareManagedFileChange, + type ManagedDirectory, + type ManagedFileChange, + type PreparedManagedTransaction, + type RenameFile, +} from './managed-transaction.js'; import { parseStrictJsonObject } from './strict-json.js'; const MAX_SECRET_BYTES = 64 * 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 { readonly runtimeRoot: string; readonly agentDirectory: string; @@ -41,41 +26,39 @@ export interface ManagedRuntimePaths { readonly guardPath: 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 { readonly agentKey: string; readonly displayName: string; - readonly hooksDirectoryLabel: string; - readonly hooksLabel: string; - readonly hooksPath: string; - readonly expectedHooksSource?: string; - readonly hooksSource: string; + readonly hookSources: readonly ManagedHookSource[]; readonly config: InstallConfig; readonly guardOptions?: GuardScriptOptions; readonly auditOptions?: HookScriptOptions; } + export interface ManagedPreflightSpec { readonly agentKey: string; - readonly hooksDirectoryLabel: string; - readonly hooksPath: string; + readonly hookLocations: readonly ManagedHookLocation[]; readonly config: InstallConfig; } + export interface PreparedManagedInstallation { - readonly displayName: string; - readonly hooksDirectoryLabel: string; readonly paths: ManagedRuntimePaths; - readonly changes: readonly FileChange[]; -} -export type RenameFile = (source: string, destination: string) => Promise; -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); + readonly transaction: PreparedManagedTransaction; } -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; -} +export type { RenameFile }; export function samePath(left: string, right: string): boolean { const normalizedLeft = path.resolve(left); @@ -84,6 +67,7 @@ export function samePath(left: string, right: string): boolean { ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight; } + function validateInstallConfig(config: InstallConfig, agentKey: string): void { for (const [field, value] of [ ['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'); } 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'); } if (baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) { @@ -188,7 +172,9 @@ export async function preflightManagedInstallation( auditScript: string, ): Promise { 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); return paths; } @@ -209,21 +195,11 @@ function runtimeConfig(config: InstallConfig, agentKey: string): string { return encoded; } -async function prepareChange( - filePath: string, - label: string, - next: string, - mode: number, - maximumBytes = MAX_SOURCE_BYTES, -): Promise { - return { - filePath, - label, - next, - mode, - maximumBytes, - original: await readPhysicalFile(filePath, label, maximumBytes), - }; +function hookDirectories(sources: readonly ManagedHookSource[]): ManagedDirectory[] { + return sources.map((source) => ({ + path: path.dirname(source.filePath), + label: source.directoryLabel, + })); } export async function prepareManagedInstallation( @@ -231,250 +207,64 @@ export async function prepareManagedInstallation( guardScript: string, auditScript: string, ): Promise { - 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([ - prepareChange( - paths.guardPath, - 'Elydora guard runtime', - generateGuardScript(spec.agentKey, spec.config.agentId, spec.guardOptions), - 0o700, - ), - prepareChange( - paths.configPath, - 'Elydora runtime config', - runtimeConfig(spec.config, spec.agentKey), - 0o600, - MAX_CONFIG_BYTES, - ), - prepareChange( - paths.keyPath, - 'Elydora private key', - spec.config.privateKey, - 0o600, - MAX_SECRET_BYTES, - ), - prepareChange( - paths.auditPath, - 'Elydora audit runtime', - generateHookScript(spec.agentKey, spec.config.agentId, spec.auditOptions), - 0o700, - ), - prepareChange(spec.hooksPath, spec.hooksLabel, spec.hooksSource, 0o600), + prepareManagedFileChange({ + filePath: paths.guardPath, + label: 'Elydora guard runtime', + next: generateGuardScript(spec.agentKey, spec.config.agentId, spec.guardOptions), + mode: 0o700, + }), + prepareManagedFileChange({ + filePath: paths.configPath, + label: 'Elydora runtime config', + next: runtimeConfig(spec.config, spec.agentKey), + mode: 0o600, + maximumBytes: MAX_CONFIG_BYTES, + }), + prepareManagedFileChange({ + filePath: paths.keyPath, + label: 'Elydora private key', + next: spec.config.privateKey, + mode: 0o600, + maximumBytes: MAX_SECRET_BYTES, + }), + prepareManagedFileChange({ + filePath: paths.auditPath, + label: 'Elydora audit runtime', + next: generateHookScript(spec.agentKey, spec.config.agentId, spec.auditOptions), + mode: 0o700, + }), + ...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); return { - displayName: spec.displayName, - hooksDirectoryLabel: spec.hooksDirectoryLabel, 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 { - try { - await fsp.unlink(filePath); - } catch (error) { - if (!hasCode(error, 'ENOENT')) throw error; - } -} - -async function assertUnchanged(change: FileChange, displayName: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - await Promise.all( - [staged.temporaryPath, staged.rollbackPath].filter(Boolean).map((item) => unlinkOptional(item!)), - ); -} - -async function ensureManagedDirectory(directory: string, label: string): Promise { - await fsp.mkdir(directory, { recursive: true, mode: 0o700 }); - if (!await inspectPhysicalDirectory(directory, label)) { - throw new Error(`${label} is missing: ${directory}`); - } -} - export async function commitManagedInstallation( prepared: PreparedManagedInstallation, - renameFile: RenameFile = fsp.rename, + renameFile?: RenameFile, ): Promise { await ensurePrivateDirectory(prepared.paths.runtimeRoot); await ensurePrivateDirectory(prepared.paths.agentDirectory); - await ensureManagedDirectory( - 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`, - ); - } + await commitManagedTransaction(prepared.transaction, renameFile); } diff --git a/sdks/node/src/plugins/managed-transaction.ts b/sdks/node/src/plugins/managed-transaction.ts new file mode 100644 index 0000000..e86dd59 --- /dev/null +++ b/sdks/node/src/plugins/managed-transaction.ts @@ -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; + +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 { + if (!filePath) return; + try { + await fsp.unlink(filePath); + } catch (error) { + if (!hasCode(error, 'ENOENT')) throw error; + } +} + +export async function prepareManagedFileChange( + spec: ManagedChangeSpec, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await Promise.all([ + unlinkOptional(staged.temporaryPath), + unlinkOptional(staged.rollbackPath), + ]); +} + +async function ensureManagedDirectory(directory: string, label: string): Promise { + 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(); + 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 { + const targets = new Set(); + 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`, + ); + } +} diff --git a/sdks/node/test/managed-transaction.test.mjs b/sdks/node/test/managed-transaction.test.mjs new file mode 100644 index 0000000..04e9c62 --- /dev/null +++ b/sdks/node/test/managed-transaction.test.mjs @@ -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(); + } +});