Skip to content

Fix TOCTOU race condition in write-back#1

Open
rileycx wants to merge 2 commits intohunterassembly:mainfrom
rileycx:fix/toctou-writeback-race
Open

Fix TOCTOU race condition in write-back#1
rileycx wants to merge 2 commits intohunterassembly:mainfrom
rileycx:fix/toctou-writeback-race

Conversation

@rileycx
Copy link
Copy Markdown

@rileycx rileycx commented Mar 4, 2026

Problem

The write-back feature has a TOCTOU (time-of-check-time-of-use) race condition that can destroy source files — replacing the entire file contents with just the replacement text.

How to reproduce

  1. Open a site with Hemingway running alongside a dev server (Next.js, Vite, etc.)
  2. Use the overlay to change some text, e.g. "See Work" → "Browse Work" — this succeeds
  3. Change it back: "Browse Work" → "See Work"
  4. The source file (e.g. Hero.tsx) is now overwritten with just See Work — all other content is gone

Root cause

In writeText(), the scan phase reads all source files and stores character indices for text matches. Then, in a separate read, it re-reads the best-match file to perform the replacement. If the file changed between those two reads — which easily happens because the first write triggers a dev server hot-reload or file watcher — the stored indices are stale. The substring replacement then slices at the wrong positions and produces garbage output.

Scan: reads Hero.tsx (v1) → finds "Browse Work" at index 184
       ↓ first write lands, dev server hot-reloads, file changes
Re-read: reads Hero.tsx (v2) → index 184 no longer points to "Browse Work"
Replace: source.substring(0, 184) + "See Work" + source.substring(195) → 💥 corrupted file

Fix

After re-reading the file, we now verify the stored span still exists at the expected index. If it doesn't:

  1. Re-find the original text in the current file content
  2. Pick the match closest to the original position (handles inserted/removed lines)
  3. Return an error if the text is no longer present (instead of silently corrupting)

Also moves normalizeReplacement to use the actual current span rather than the stale one from the scan phase.

Single file change: src/server/write.ts — no changes needed to overlay, index, or write-adapters since all write paths flow through writeText().

Test plan

  • Added src/server/__tests__/write.test.ts with vitest (4 tests, all passing):
    • Stable file write (happy path)
    • Rapid double write (change text then revert — the exact bug scenario)
    • File content shifts between scan and replace (simulated via mock)
    • Text removed between scan and replace (simulated via mock)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Tests

    • Introduced comprehensive test suite validating text write operations across multiple scenarios: successful in-place replacements, sequential write sequences, and edge cases with file content modifications.
  • Chores

    • Integrated vitest testing framework as a development dependency and added corresponding test execution script to package.json.

When the file changed between the scan-phase read and the replace-phase
re-read (e.g. dev server hot-reload, file watchers, a preceding write),
the stored character indices became stale and the substring replacement
could overwrite the entire file with just the replacement text.

After re-reading the file, we now verify the stored span still exists at
the expected index. If the file changed, we re-find the closest match in
the current content before replacing — or return an error if the text is
no longer present. Also moves normalizeReplacement to use the actual
current span rather than the stale one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 4, 2026

@rileycx is attempting to deploy a commit to the hunterassembly's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 4, 2026

📝 Walkthrough
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing a TOCTOU race condition in the write-back feature. It is specific, relevant to the changeset, and directly addresses the primary problem being solved.
Description check ✅ Passed The description is comprehensive and well-structured. It includes a clear problem statement with reproduction steps, root cause analysis with code flow diagrams, the fix explanation, scope clarification, and a detailed test plan with four passing tests. All major template sections are covered with substantial content.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/write.ts (1)

563-613: ⚠️ Potential issue | 🟡 Minor

Returned line number can be stale after re-location

When finalIndex changes, line should be recomputed from current content. Returning best.line can point to the wrong location.

Proposed fix
   const before = source.substring(0, finalIndex);
   const after = source.substring(finalIndex + finalLength);
   const modified = before + replacement + after;
+  const finalLine = getLineNumber(source, finalIndex);

   await writeFile(best.file, modified, "utf-8");

   const relativePath = relative(PROJECT_ROOT, best.file);
   return {
     success: true,
     file: relativePath,
-    line: best.line,
+    line: finalLine,
     matchCount: allMatches.length,
     scannedFileCount,
     usedFallbackScan,
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/write.ts` around lines 563 - 613, The returned best.line can be
stale if finalIndex was adjusted when re-finding the original span; recompute
the line number from the current source before returning. After finalIndex is
finalized (after the occurrences reduce block and before building the return
object), compute a new line number e.g. const computedLine = source.substring(0,
finalIndex).split(/\r?\n/).length (or equivalent newline-counting logic used
elsewhere) and return that (computedLine) instead of best.line in the success
response; update any references to line in the returned object to use
computedLine.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/server/write.ts`:
- Around line 555-606: The read-modify-write in this block (readFile(...),
computing finalIndex/finalLength, then writeFile(...)) can be clobbered by
concurrent writers; add a conditional write/locking step so the final write only
succeeds if the file version matches the one you read and otherwise retry: after
readFile(best.file) capture a version token (e.g., mtime+size from stat or a
hash of source), then before writeFile check the current version (stat or
re-hash) and if it differs, re-run the span re-find and normalizeReplacement
logic (using adapter.normalizeReplacement with currentSpan) and try again a few
times; alternatively add an adapter-level compare-and-swap API (e.g.,
adapter.compareAndSwap(filePath, expectedVersion, modified) or
adapter.acquireWriteLock(best.file) / release) and use that to guard the final
write; ensure you reference readFile, writeFile, adapter.normalizeReplacement,
finalIndex/finalLength and best.file when implementing the CAS/lock and add a
bounded retry loop to avoid livelock.

---

Outside diff comments:
In `@src/server/write.ts`:
- Around line 563-613: The returned best.line can be stale if finalIndex was
adjusted when re-finding the original span; recompute the line number from the
current source before returning. After finalIndex is finalized (after the
occurrences reduce block and before building the return object), compute a new
line number e.g. const computedLine = source.substring(0,
finalIndex).split(/\r?\n/).length (or equivalent newline-counting logic used
elsewhere) and return that (computedLine) instead of best.line in the success
response; update any references to line in the returned object to use
computedLine.

ℹ️ Review info
Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e2a4a3b-7463-48d4-b165-7d075a4eff9f

📥 Commits

Reviewing files that changed from the base of the PR and between 0454207 and 0e80134.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (3)
  • package.json
  • src/server/__tests__/write.test.ts
  • src/server/write.ts

Comment thread src/server/write.ts Outdated
Addresses CodeRabbit review comments:
- Wrap read-modify-write in a compare-and-swap retry loop (stat-based
  version check before and after) so concurrent writers can't clobber
  changes between re-read and writeFile
- Recompute returned line number from current source when finalIndex
  shifts, instead of returning the stale best.line

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/server/write.ts (1)

614-627: ⚠️ Potential issue | 🔴 Critical

CAS remains non-atomic; clobber window still exists.

Line 615–616 checks version, but Line 626 writes unconditionally afterward. A concurrent writer between those steps can still be overwritten.

Suggested fix (cooperative file lock around read-modify-write)
-import { readdir, readFile, writeFile, stat } from "node:fs/promises";
+import { readdir, readFile, writeFile, stat, open, unlink } from "node:fs/promises";

 // ...
 for (let attempt = 0; attempt < MAX_CAS_RETRIES; attempt++) {
+  const lockPath = `${best.file}.hemingway.lock`;
+  let lockHandle: Awaited<ReturnType<typeof open>> | undefined;
+  try {
+    lockHandle = await open(lockPath, "wx");
+  } catch {
+    continue; // someone else is writing; retry
+  }
+
+  try {
     const fileStat = await stat(best.file);
     const versionBefore = `${fileStat.mtimeMs}:${fileStat.size}`;
     const source = await readFile(best.file, "utf-8");
     // ... compute replacement ...
     const fileStatAfter = await stat(best.file);
     const versionAfter = `${fileStatAfter.mtimeMs}:${fileStatAfter.size}`;
     if (versionBefore !== versionAfter) continue;
     await writeFile(best.file, modified, "utf-8");
     // return success...
+  } finally {
+    await lockHandle?.close();
+    await unlink(lockPath).catch(() => {});
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/write.ts` around lines 614 - 627, The compare-and-swap is
non-atomic because after computing fileStatAfter/versionAfter you still call
writeFile(best.file, ...) unconditionally, allowing a race; change the sequence
in the write loop (symbols: fileStatAfter, versionAfter, versionBefore,
writeFile, MAX_CAS_RETRIES, attempt) to perform an atomic replace: write the new
content to a temp file in the same directory, fsync the temp file and its parent
directory, then atomically rename the temp into best.file only if the version
check still passes (re-stat before rename and compare
versionBefore/versionAfter), retry up to MAX_CAS_RETRIES on mismatch;
alternatively implement a cooperative file lock around the read-modify-write to
ensure no concurrent writer can clobber the update.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/server/write.ts`:
- Around line 564-567: The stat/readFile calls around
fileStat/versionBefore/source (stat(best.file) and readFile(best.file)) can
throw and currently reject the promise; wrap these operations in a bounded retry
loop (e.g., 3 attempts with short delays) that retries on transient errors
(EAGAIN, EBUSY, ENOENT transient cases) and otherwise catch terminal fs errors
and return the function's expected structured failure object ({ success: false,
error }) instead of letting the exception escape; implement this around the
block using the existing function's return shape so any terminal error from
stat() or readFile() yields a consistent { success: false, error } response.

---

Duplicate comments:
In `@src/server/write.ts`:
- Around line 614-627: The compare-and-swap is non-atomic because after
computing fileStatAfter/versionAfter you still call writeFile(best.file, ...)
unconditionally, allowing a race; change the sequence in the write loop
(symbols: fileStatAfter, versionAfter, versionBefore, writeFile,
MAX_CAS_RETRIES, attempt) to perform an atomic replace: write the new content to
a temp file in the same directory, fsync the temp file and its parent directory,
then atomically rename the temp into best.file only if the version check still
passes (re-stat before rename and compare versionBefore/versionAfter), retry up
to MAX_CAS_RETRIES on mismatch; alternatively implement a cooperative file lock
around the read-modify-write to ensure no concurrent writer can clobber the
update.

ℹ️ Review info
Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cbd65247-c65a-4ec0-b94c-ce61b8cda573

📥 Commits

Reviewing files that changed from the base of the PR and between 0e80134 and 1cfab1a.

📒 Files selected for processing (2)
  • src/server/__tests__/write.test.ts
  • src/server/write.ts

Comment thread src/server/write.ts
Comment on lines +564 to +567
const fileStat = await stat(best.file);
const versionBefore = `${fileStat.mtimeMs}:${fileStat.size}`;
const source = await readFile(best.file, "utf-8");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unhandled fs errors can escape the API contract.

Line 564/566/626 can throw (ENOENT, EPERM, transient contention). Right now that rejects the promise instead of returning { success: false, error } like the rest of this function.

Suggested fix (bounded retry + structured failure on terminal fs errors)
 for (let attempt = 0; attempt < MAX_CAS_RETRIES; attempt++) {
-  const fileStat = await stat(best.file);
-  const versionBefore = `${fileStat.mtimeMs}:${fileStat.size}`;
-  const source = await readFile(best.file, "utf-8");
+  try {
+    const fileStat = await stat(best.file);
+    const versionBefore = `${fileStat.mtimeMs}:${fileStat.size}`;
+    const source = await readFile(best.file, "utf-8");
 
-  // ... existing logic ...
+    // ... existing logic ...
 
-  await writeFile(best.file, modified, "utf-8");
+    await writeFile(best.file, modified, "utf-8");
+  } catch (err) {
+    if (attempt < MAX_CAS_RETRIES - 1) continue;
+    return {
+      success: false,
+      scannedFileCount,
+      usedFallbackScan,
+      matchCount: allMatches.length,
+      error: `Write failed for ${relative(PROJECT_ROOT, best.file)}: ${err instanceof Error ? err.message : String(err)}`,
+    };
+  }
 }

Also applies to: 615-627

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/write.ts` around lines 564 - 567, The stat/readFile calls around
fileStat/versionBefore/source (stat(best.file) and readFile(best.file)) can
throw and currently reject the promise; wrap these operations in a bounded retry
loop (e.g., 3 attempts with short delays) that retries on transient errors
(EAGAIN, EBUSY, ENOENT transient cases) and otherwise catch terminal fs errors
and return the function's expected structured failure object ({ success: false,
error }) instead of letting the exception escape; implement this around the
block using the existing function's return shape so any terminal error from
stat() or readFile() yields a consistent { success: false, error } response.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant