From 3d5806793111163d8ccbaaa7c71697ad9734320b Mon Sep 17 00:00:00 2001 From: Galmanus Date: Tue, 25 Aug 2026 07:13:27 -0300 Subject: [PATCH] feat(core): atomic file writes for corruption-sensitive paths (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the temp-file + rename pattern from writeArtifacts into a shared fs/atomic-write-file.ts helper and route the writes where a torn file breaks things through it: caatinga.artifacts.json, the frontend package.json (ensureBufferDependency), the frontend env file (syncFrontendEnv), the bindings freshness marker (writeBindingMarker), and template variable substitution. The helper creates parent dirs and unlinks the temp file on failure. Deliberately NOT converted: the scaffold writes in create-zk-project / create-minimal-project use flag 'wx' (exclusive create — refuse to overwrite unless --force), a different, intentional semantic that atomic overwrite-by- rename would break. (The patch-generated-binding-package path in the issue no longer exists.) Tests: atomic write leaves no temp file, creates missing parents, and cleans up the temp file on a failed commit. --- .../core/src/artifacts/write-artifacts.ts | 17 +------ packages/core/src/bindings/binding-marker.ts | 5 +- .../src/frontend/ensure-buffer-dependency.ts | 5 +- .../core/src/frontend/sync-frontend-env.ts | 7 ++- .../core/src/fs/atomic-write-file.test.ts | 48 +++++++++++++++++++ packages/core/src/fs/atomic-write-file.ts | 25 ++++++++++ .../templates/create-project-from-template.ts | 5 +- 7 files changed, 87 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/fs/atomic-write-file.test.ts create mode 100644 packages/core/src/fs/atomic-write-file.ts diff --git a/packages/core/src/artifacts/write-artifacts.ts b/packages/core/src/artifacts/write-artifacts.ts index c7149903..61f4c563 100644 --- a/packages/core/src/artifacts/write-artifacts.ts +++ b/packages/core/src/artifacts/write-artifacts.ts @@ -1,6 +1,5 @@ -import { randomBytes } from "node:crypto"; -import { mkdir, rename, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; import type { CaatingaArtifacts } from "./artifact.schema.js"; export async function writeArtifacts( @@ -8,19 +7,7 @@ export async function writeArtifacts( cwd = process.cwd() ): Promise { const artifactsPath = path.resolve(cwd, "caatinga.artifacts.json"); - await mkdir(path.dirname(artifactsPath), { recursive: true }); - - const tmpPath = `${artifactsPath}.${randomBytes(4).toString("hex")}.tmp`; - const payload = `${JSON.stringify(artifacts, null, 2)}\n`; - - try { - await writeFile(tmpPath, payload, "utf8"); - await rename(tmpPath, artifactsPath); - } catch (error) { - await unlink(tmpPath).catch(() => undefined); - throw error; - } - + await atomicWriteFile(artifactsPath, `${JSON.stringify(artifacts, null, 2)}\n`); return artifactsPath; } diff --git a/packages/core/src/bindings/binding-marker.ts b/packages/core/src/bindings/binding-marker.ts index 3ad67587..f4ee6edc 100644 --- a/packages/core/src/bindings/binding-marker.ts +++ b/packages/core/src/bindings/binding-marker.ts @@ -1,6 +1,7 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; export const BINDING_MARKER_FILENAME = ".caatinga-bindings.json"; @@ -16,7 +17,7 @@ export type BindingMarker = z.infer; export async function writeBindingMarker(outputDir: string, marker: BindingMarker): Promise { const markerPath = path.join(outputDir, BINDING_MARKER_FILENAME); - await writeFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf8"); + await atomicWriteFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`); } /** Returns null when the marker is absent or unreadable — freshness degrades, never throws. */ diff --git a/packages/core/src/frontend/ensure-buffer-dependency.ts b/packages/core/src/frontend/ensure-buffer-dependency.ts index e8ef2a82..4240afdb 100644 --- a/packages/core/src/frontend/ensure-buffer-dependency.ts +++ b/packages/core/src/frontend/ensure-buffer-dependency.ts @@ -1,5 +1,6 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; // Backs the Buffer polyfill that every generated binding imports. Pinned to the // same major the templates ship so behaviour matches across init and adoption. @@ -80,7 +81,7 @@ export async function ensureBufferDependency( } pkg.dependencies = { ...(pkg.dependencies ?? {}), buffer: BUFFER_DEPENDENCY_RANGE }; - await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8"); + await atomicWriteFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); return { packageJsonPath, added: true }; } diff --git a/packages/core/src/frontend/sync-frontend-env.ts b/packages/core/src/frontend/sync-frontend-env.ts index d4a78610..54919337 100644 --- a/packages/core/src/frontend/sync-frontend-env.ts +++ b/packages/core/src/frontend/sync-frontend-env.ts @@ -1,5 +1,6 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; import { readArtifacts } from "../artifacts/read-artifacts.js"; import type { CaatingaConfig } from "../config/config.schema.js"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; @@ -141,10 +142,8 @@ export async function syncFrontendEnv( } const envFile = path.resolve(cwd, frontend.envFile); - await mkdir(path.dirname(envFile), { recursive: true }); - const body = mergeEnvContents(await readExistingEnv(envFile), entries); - await writeFile(envFile, body, "utf8"); + await atomicWriteFile(envFile, body); return { envFile, entries }; } diff --git a/packages/core/src/fs/atomic-write-file.test.ts b/packages/core/src/fs/atomic-write-file.test.ts new file mode 100644 index 00000000..e07e7540 --- /dev/null +++ b/packages/core/src/fs/atomic-write-file.test.ts @@ -0,0 +1,48 @@ +import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { atomicWriteFile } from "./atomic-write-file.js"; + +describe("atomicWriteFile", () => { + let dir: string; + + afterEach(async () => { + if (dir) { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("should_write_the_contents_and_leave_no_temp_file", async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "caatinga-atomic-")); + const target = path.join(dir, "out.json"); + + await atomicWriteFile(target, "hello\n"); + + expect(await readFile(target, "utf8")).toBe("hello\n"); + const leftovers = (await readdir(dir)).filter((name) => name.includes(".tmp")); + expect(leftovers).toEqual([]); + }); + + it("should_create_missing_parent_directories", async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "caatinga-atomic-")); + const target = path.join(dir, "nested", "deep", "out.txt"); + + await atomicWriteFile(target, "ok"); + + expect(await readFile(target, "utf8")).toBe("ok"); + }); + + it("should_clean_up_the_temp_file_when_the_write_cannot_complete", async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "caatinga-atomic-")); + const target = path.join(dir, "blocked"); + // Make the target a directory: the final rename cannot replace it, simulating + // an interrupted/failed commit. The temp file must not be left behind. + await mkdir(target); + + await expect(atomicWriteFile(target, "data")).rejects.toBeDefined(); + + const leftovers = (await readdir(dir)).filter((name) => name.includes(".tmp")); + expect(leftovers).toEqual([]); + }); +}); diff --git a/packages/core/src/fs/atomic-write-file.ts b/packages/core/src/fs/atomic-write-file.ts new file mode 100644 index 00000000..9c8b091e --- /dev/null +++ b/packages/core/src/fs/atomic-write-file.ts @@ -0,0 +1,25 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, rename, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * Write `contents` to `filePath` atomically: write to a unique temp file in the + * same directory, then `rename` it into place. A rename on the same filesystem + * is atomic, so a reader never observes a half-written file and a crash mid-write + * can't corrupt the target — it leaves the old file intact (#84). + * + * Creates the parent directory if needed and removes the temp file on failure. + */ +export async function atomicWriteFile(filePath: string, contents: string): Promise { + const resolved = path.resolve(filePath); + await mkdir(path.dirname(resolved), { recursive: true }); + + const tmpPath = `${resolved}.${randomBytes(4).toString("hex")}.tmp`; + try { + await writeFile(tmpPath, contents, "utf8"); + await rename(tmpPath, resolved); + } catch (error) { + await unlink(tmpPath).catch(() => undefined); + throw error; + } +} diff --git a/packages/core/src/templates/create-project-from-template.ts b/packages/core/src/templates/create-project-from-template.ts index 9143254c..7f76ef6f 100644 --- a/packages/core/src/templates/create-project-from-template.ts +++ b/packages/core/src/templates/create-project-from-template.ts @@ -1,4 +1,5 @@ -import { cp, lstat, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { cp, lstat, mkdir, readFile, readdir, stat } from "node:fs/promises"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; import path from "node:path"; import { z } from "zod"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; @@ -145,7 +146,7 @@ async function replaceTemplateVariables(dir: string, projectName: string): Promi } const content = await readFile(entryPath, "utf8"); - await writeFile(entryPath, content.replaceAll("__PROJECT_NAME__", projectName), "utf8"); + await atomicWriteFile(entryPath, content.replaceAll("__PROJECT_NAME__", projectName)); }) ); }