Skip to content

fix(apps): consolidate duplicated atomic-JSON write into one shared helper#245

Merged
stuffbucket merged 1 commit into
mainfrom
fix/consolidate-atomic-json-write
Jul 6, 2026
Merged

fix(apps): consolidate duplicated atomic-JSON write into one shared helper#245
stuffbucket merged 1 commit into
mainfrom
fix/consolidate-atomic-json-write

Conversation

@stuffbucket

Copy link
Copy Markdown
Owner

Problem

Two near-identical atomic-JSON writers had drifted:

  • atomicWriteJson in src/apps/claude-desktop/config.ts
  • writeClaudeCodeSettings in src/apps/claude-code/config.ts

Same mechanics (mkdir, O_WRONLY|O_CREAT|O_EXCL @0o600, write+fsync+rename), but only the claude-code copy carried a friendly EEXIST error. Two copies of security-sensitive file I/O is exactly the drift #231 flags.

Fix

One shared src/lib/atomic-json.tsatomicWriteJson(filePath, value, opts?: { label? }) — that both app writers now call as thin wrappers (public signatures + all call sites unchanged; label customizes the error text).

Preserved the correct semantics (not a naive rewrite)

An initial pass removed the pre-write unlinkSync(tmp), reasoning it defeated the symlink guard. That was wrong and was reverted after adversarial review of the syscall semantics:

  • Both originals were already symlink-safe. O_EXCL refuses to open through a symlink at the final path component (EEXIST), and unlink removes the link itself without following it. Neither app was vulnerable.
  • The unlink's real job is crash recovery. A .tmp left by a crashed prior write is cleared on the next attempt so writes self-heal. Removing it would hard-fail every subsequent write with a misleading "symlink attack" error until manual cleanup — a real availability regression for zero security gain.

So the shared helper consolidates onto the original unlink-then-O_EXCL pattern: mkdir -punlink stale temp (ENOENT-swallowed, symlink-safe) → O_EXCL create @0o600 → write+fsync+rename. The EEXIST→friendly-error branch is retained as a concurrency/attack backstop.

Tests — tests/atomic-json-write.test.ts (9)

Assert both properties for the shared helper and both app writers:

  • Crash recovery: a stale regular <file>.tmp is cleared and the write succeeds.
  • Symlink safety: a planted symlink at <file>.tmp is not followed — the real write lands at the intended path, and the symlink's target keeps its original content (proven with a victim file).
  • Happy path (0o600, exact content, no .tmp leak) + parent-dir creation.

Verification

  • bun run typecheck
  • targeted (atomic-json-write + both app config tests) → 66 pass / 0 fail ✅
  • bun run lint:all

Closes #231

Two byte-for-byte-identical atomic-JSON writers had drifted between
src/apps/claude-code/config.ts (writeClaudeCodeSettings) and
src/apps/claude-desktop/config.ts (atomicWriteJson). Consolidate both onto a
single shared helper, atomicWriteJson(filePath, value, opts?), in
src/lib/atomic-json.ts, with both app functions kept as thin wrappers (public
signatures unchanged, all call sites untouched).

The shared helper preserves the ORIGINAL, correct pattern exactly:
  unlink stale <file>.tmp (ignore ENOENT) → open O_WRONLY|O_CREAT|O_EXCL @0o600
  → write → fsync → rename.

Both properties matter and are preserved:
  - O_EXCL is the real symlink guard: the kernel refuses to open THROUGH a
    symlink at the final path component, so neither writer was ever vulnerable
    to a pre-planted symlink temp.
  - The unlink is crash recovery: it clears a stale regular <file>.tmp left by
    a write that died between open and rename (and removes a symlink entry
    without following it), so the next write self-heals instead of failing
    forever. Dropping it would have been a net regression — a self-inflicted
    denial-of-writes on the hot enable/disable/boot-reconcile path.

The EEXIST branch is kept as a concurrency/attack backstop with a clear
message. Adds tests/atomic-json-write.test.ts asserting, for the shared helper
and both app writers: happy-path 0o600/content/no-.tmp-leak, self-heal over a
stale regular temp, and clearing a planted symlink temp without following or
clobbering its target.

Closes #231
@stuffbucket
stuffbucket merged commit d3401d4 into main Jul 6, 2026
4 checks passed
@stuffbucket
stuffbucket deleted the fix/consolidate-atomic-json-write branch July 13, 2026 05:29
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.

fix(apps): consolidate duplicated atomic-JSON write; symlink guard missing in one copy

1 participant