mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +00:00
fix(stack-files): atomic write via tmp+rename with optional exclusive mode (#1205)
* fix(stack-files): atomic write via tmp+rename with optional exclusive mode writeStackFile and writeStackFileBuffer previously called fs.writeFile directly, which truncates the target then streams the new bytes. A crash, disk-full event, or process kill between the truncate and the write left the target with partial content and no easy way to detect the half-write at read time. A private writeStackFileAtomic helper stages every write into a sibling .sencho-tmp-<suffix> file in the same directory, fsyncs, then promotes via fs.rename. A crash now leaves either the original target intact or a leftover .sencho-tmp file (cleaned up on the next failure path); a torn target file is no longer reachable through this path. The helper accepts an optional `exclusive: true` flag that swaps the final promote step from rename to link+unlink. link is atomic against EEXIST so a caller that needs "create only if not present" gets a race-free FILE_EXISTS error instead of a clobber. The upload route's overwrite-confirm flow (PR #1204) will wire this through in a follow-up so the existence check becomes authoritative. Behaviour for current callers (writeStackFile, writeStackFileBuffer, BlueprintService deploy) is unchanged: the non-exclusive default matches the prior fs.writeFile semantics from the caller's perspective. * fix(stack-files): tighten atomic write entropy + concurrent / failure tests Tmp suffix now uses crypto.randomBytes(6) so the per-process collision window is a true 48-bit space (Math.random().toString(36).slice(2,6) could drop leading zeros and narrow entropy unpredictably). Adds a short comment on the Windows link path noting NTFS / same-FS POSIX is required, both guaranteed by tmp+target being siblings. Two new tests close coverage gaps the first round missed: - a write step that throws (writeFile rejected) leaves no tmp leak and no partial target; - concurrent non-exclusive writers settle with at least one success and the final file is exactly one of the inputs (POSIX silently overwrites; Windows EPERMs the loser, both consistent).
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import { promises as fsPromises, createReadStream } from 'fs';
|
||||
import type { Readable } from 'stream';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
@@ -699,16 +700,80 @@ export class FileSystemService {
|
||||
};
|
||||
}
|
||||
|
||||
async writeStackFile(stackName: string, relPath: string, content: string): Promise<void> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
/**
|
||||
* Atomic write: stages the content in a sibling .tmp file in the same
|
||||
* directory, fsyncs, then promotes it to the final path. A crash between
|
||||
* the open and the rename leaves either the original target intact or a
|
||||
* leftover .tmp file (cleaned up on next failure path), never a truncated
|
||||
* target.
|
||||
*
|
||||
* `exclusive: true` uses link+unlink instead of rename so the create is
|
||||
* race-free atomic: link fails with EEXIST if the target already exists,
|
||||
* giving the caller a definitive "did not exist when we wrote it" signal.
|
||||
* The non-exclusive default uses rename, which is atomic against partial
|
||||
* reads but clobbers any existing target.
|
||||
*/
|
||||
private async writeStackFileAtomic(
|
||||
safePath: string,
|
||||
data: string | Buffer,
|
||||
opts: { exclusive?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
|
||||
await fsPromises.writeFile(safePath, content, 'utf-8');
|
||||
// crypto.randomBytes gives a guaranteed-length high-entropy suffix; Math.random
|
||||
// can drop leading zeros which narrows entropy unpredictably.
|
||||
const suffix = `${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
|
||||
const tmpPath = `${safePath}.sencho-tmp-${suffix}`;
|
||||
let stagedTmp = false;
|
||||
try {
|
||||
const fh = await fsPromises.open(tmpPath, 'wx');
|
||||
stagedTmp = true;
|
||||
try {
|
||||
await fh.writeFile(data);
|
||||
await fh.sync();
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
if (opts.exclusive) {
|
||||
// link() is atomic against EEXIST. Tmp and target are guaranteed to live
|
||||
// in the same directory (same filesystem); link works on NTFS and any
|
||||
// POSIX FS without elevated privileges.
|
||||
try {
|
||||
await fsPromises.link(tmpPath, safePath);
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
throw Object.assign(new Error('File already exists'), { code: 'FILE_EXISTS' as const });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
await fsPromises.rename(tmpPath, safePath);
|
||||
stagedTmp = false;
|
||||
}
|
||||
} finally {
|
||||
if (stagedTmp) {
|
||||
await fsPromises.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async writeStackFileBuffer(stackName: string, relPath: string, buffer: Buffer): Promise<void> {
|
||||
async writeStackFile(
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
content: string,
|
||||
opts?: { exclusive?: boolean },
|
||||
): Promise<void> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
|
||||
await fsPromises.writeFile(safePath, buffer);
|
||||
await this.writeStackFileAtomic(safePath, content, opts);
|
||||
}
|
||||
|
||||
async writeStackFileBuffer(
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
buffer: Buffer,
|
||||
opts?: { exclusive?: boolean },
|
||||
): Promise<void> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
await this.writeStackFileAtomic(safePath, buffer, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user