blob: f6a6320fb8826385b029e4abf53db87bb5ae64fa [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { randomUUID } from 'node:crypto';
import { open, rename, rm } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { syncDirectory } from './stable-storage.js';
/**
* Atomic writer shared by the legacy settings, MCP, and credentials JSON
* stores. It publishes an exclusive hidden temp file with rename and removes
* that temp on failures before publication.
*
* Durability support depends on the platform and storage stack:
* - Linux and POSIX systems other than macOS: sync the temp file before
* rename and fsync the parent directory afterwards. Persistence still
* depends on the filesystem, mount, device, and hardware honoring them.
* - macOS: use Node's ordinary fsync operations; Node does not expose
* F_FULLFSYNC here, so this is not a uniform sudden-power-loss guarantee.
* - Windows: sync the temp file before rename, but parent-directory sync is a
* no-op because Node does not provide an equivalent directory fence.
*
* The caller owns the final file-mode policy. The writer applies that mode
* through the open handle before synchronization, fail-loud on POSIX and
* skipped on Windows (no POSIX mode). Callers may supply an exact private mode
* or one already derived from the process umask.
* Directory creation and permission policy belong to each caller.
*/
export interface AtomicFileWriteOptions {
/** Effective mode to apply to the temporary file before it is synchronized
* and published. */
fileMode: number;
}
/** The fs surface `writeAtomicFile` needs; injectable for fault-injection
* tests (same pattern as marker-file.ts). */
export interface AtomicFileWriteHandle {
writeFile(data: string, encoding: 'utf8'): Promise<void>;
chmod(mode: number): Promise<void>;
sync(): Promise<void>;
close(): Promise<void>;
}
export interface AtomicFileWriteDependencies {
open(path: string, flags: string, mode?: number): Promise<AtomicFileWriteHandle>;
randomUUID(): string;
syncDirectory(path: string): Promise<void>;
}
const defaultDependencies: AtomicFileWriteDependencies = {
open,
randomUUID,
syncDirectory,
};
export class AtomicFileWriteCommitUnknownError extends Error {
readonly published = true;
constructor(options: { cause: unknown }) {
super('Atomic file commit outcome is unknown; reload before retrying', options);
this.name = 'AtomicFileWriteCommitUnknownError';
}
}
export async function writeAtomicFile(
path: string,
contents: string,
options: AtomicFileWriteOptions,
dependencies: Partial<AtomicFileWriteDependencies> = {},
): Promise<void> {
const deps = { ...defaultDependencies, ...dependencies };
const { fileMode } = options;
const tempPath = join(dirname(path), `.${basename(path)}.${deps.randomUUID()}.tmp`);
// Only the entry this call created — and hasn't renamed away — may be
// cleaned up. A pre-existing file or planted symlink the 'wx' open refused
// must not be deleted as "our" temp.
let tempCreated = false;
let published = false;
try {
const handle = await deps.open(tempPath, 'wx', fileMode);
tempCreated = true;
try {
await handle.writeFile(contents, 'utf8');
if (process.platform !== 'win32') await handle.chmod(fileMode);
await handle.sync();
await handle.close();
} catch (error) {
// Release the descriptor best-effort; never let a close failure mask
// the error that actually aborted the write.
await handle.close().catch(() => {});
throw error;
}
await rename(tempPath, path);
published = true;
tempCreated = false;
await deps.syncDirectory(dirname(path));
} catch (error) {
if (tempCreated) {
// Cleanup must never mask the original failure.
await rm(tempPath, { force: true }).catch(() => {});
}
if (published) throw new AtomicFileWriteCommitUnknownError({ cause: error });
throw error;
}
}