refactor: generalize managed hook transactions
This commit is contained in:
1 parent
d8bff04380
commit
9290c1c6f1
5 files changed
+574
-307
No files matched your search
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user