Files
Elydora-Node-SDK/src/plugins/managed-transaction.ts
T
雷电芽衣 b7f14ba34b chore(node-sdk): audit plugins shared
Add plugins/common.ts for the helpers and size limits copied across plugin files; managed-transaction compares snapshots in one place; guard template imported directly.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-02 21:23:57 -04:00

391 lines
13 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import fsp from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import path from 'node:path';
import { asError, errorMessage, hasCode, MAX_SOURCE_BYTES } from './common.js';
import {
inspectPhysicalDirectory,
readPhysicalFile,
type FileSnapshot,
} from './managed-files.js';
export interface ManagedChangeSpec {
readonly filePath: string;
readonly label: string;
readonly next?: string;
readonly mode: number;
readonly maximumBytes?: number;
readonly expectedSource?: string;
readonly expectedSnapshot?: FileSnapshot;
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 ManagedFilePrecondition {
readonly filePath: string;
readonly label: string;
readonly maximumBytes: number;
readonly original?: FileSnapshot;
}
export interface PreparedManagedTransaction {
readonly displayName: string;
readonly operation?: 'install' | 'uninstall';
readonly directories: readonly ManagedDirectory[];
readonly changes: readonly ManagedFileChange[];
readonly preconditions?: readonly ManagedFilePrecondition[];
}
interface StagedChange {
readonly change: ManagedFileChange;
temporaryPath?: string;
rollbackPath?: string;
committedSnapshot?: FileSnapshot;
committed: boolean;
}
export type RenameFile = (source: string, destination: string) => Promise<void>;
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 (spec.expectedSnapshot && (!original
|| original.device !== spec.expectedSnapshot.device
|| original.inode !== spec.expectedSnapshot.inode)) {
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,
};
}
function sameSnapshot(current: FileSnapshot | undefined, original: FileSnapshot | undefined): boolean {
if (!current || !original) return !current && !original;
return current.contents === original.contents
&& current.device === original.device
&& current.inode === original.inode;
}
async function assertUnchanged(
target: ManagedFilePrecondition,
operationName: string,
): Promise<void> {
const current = await readPhysicalFile(target.filePath, target.label, target.maximumBytes);
if (!sameSnapshot(current, target.original)) {
throw new Error(`${target.label} changed during ${operationName}: ${target.filePath}`);
}
}
async function assertPreconditions(
transaction: PreparedManagedTransaction,
operationName: string,
): Promise<void> {
for (const condition of transaction.preconditions ?? []) {
await assertUnchanged(condition, operationName);
}
}
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, operationName: string): Promise<StagedChange> {
await assertUnchanged(change, operationName);
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,
operationName: string,
renameFile: RenameFile,
): Promise<void> {
await assertUnchanged(staged.change, operationName);
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;
}
if (!current || !sameSnapshot(current, staged.committedSnapshot)) {
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 operationName = transaction.operation === 'uninstall'
? `${transaction.displayName} uninstall`
: `${transaction.displayName} installation`;
const failureLabel = transaction.operation === 'uninstall'
? `Uninstall ${transaction.displayName} hooks`
: `Install ${transaction.displayName} hooks`;
const targets = new Set<string>();
for (const change of transaction.changes) {
const key = targetKey(change.filePath);
if (targets.has(key)) {
throw new Error(
`${operationName} contains duplicate file target ${change.filePath}`,
);
}
targets.add(key);
}
await assertPreconditions(transaction, operationName);
for (const directory of transactionDirectories(transaction)) {
await ensureManagedDirectory(directory.path, directory.label);
}
await assertPreconditions(transaction, operationName);
const staged: StagedChange[] = [];
try {
for (const change of transaction.changes) staged.push(await stage(change, operationName));
await assertPreconditions(transaction, operationName);
for (const item of staged) {
await assertPreconditions(transaction, operationName);
await commit(item, operationName, renameFile);
}
await assertPreconditions(transaction, operationName);
} 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,
`${failureLabel}: ${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 ${operationName} transaction files`,
);
}
}