Skip to content

fix(fs): crash-safe atomic writes for auth vault and shell profile files - #175

Merged
getappz merged 2 commits into
masterfrom
feat/atomic-fs-crash-safe-writes
Jul 13, 2026
Merged

fix(fs): crash-safe atomic writes for auth vault and shell profile files#175
getappz merged 2 commits into
masterfrom
feat/atomic-fs-crash-safe-writes

Conversation

@getappz

@getappz getappz commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

auth.rs's activate_with/activate_into write the agent's live credential file (e.g. ~/.claude/.credentials.json) via plain fs::write, and alias.rs's write_managed_block writes directly into the user's real shell rc file. Neither is crash-atomic: a process killed mid-write (OOM kill, SIGKILL, power loss) leaves a truncated or zero-byte file in place of the original.

  • auth.rs: a truncated write corrupts the credential file the agent is actively using — breaks login immediately.
  • alias.rs: a truncated write is data loss in the user's own .bashrc/.zshrc.

Fix

Adds src/atomic_fs.rs (ported from lean-ctx's core/atomic_fs.rs): a same-directory temp file + rename for crash-atomicity, with an in-place-overwrite fallback for the read-only-directory/writable-inode case. Wires it into both write sites (activate_with, activate_into, write_managed_block).

libc added as a [target.'cfg(unix)'.dependencies] entry for the O_NOFOLLOW/errno pieces of the fallback path — both already conditionally compiled per-platform in the ported module, inert on Windows.

Testing

  • cargo test --workspace — 368 passed, 0 failed (includes atomic_fs's own unit tests: atomic replace, in-place-overwrite, read-only-dir fallback, no-leftover-temp-file)
  • cargo clippy --workspace --all-targets -- -D warnings -A unsafe_code -A clippy::pedantic — clean
  • cargo fmt --check — clean

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability when saving and restoring profile, alias, and vault files by switching to crash-atomic, safer write behavior with a read-only-directory fallback.
    • Prevented data loss during interrupted file writes by using atomic replacement (with cleanup) instead of direct overwrite.
    • Ensured updates follow real file targets through symlinks and preserve existing permissions when not explicitly specified.

auth.rs's activate_with/activate_into write the agent's LIVE credential
file (~/.claude/.credentials.json etc.) via plain fs::write, and
alias.rs's write_managed_block writes directly into the user's real
shell rc file. Neither is crash-atomic: a process killed mid-write
(OOM kill, SIGKILL, power loss) leaves a truncated or zero-byte file
in place of the original — for auth.rs that's a corrupted credential
file breaking the agent's login; for alias.rs it's data loss in the
user's own .bashrc/.zshrc.

Adds src/atomic_fs.rs (ported from lean-ctx's core/atomic_fs.rs):
same-directory temp file + rename for crash-atomicity, with an
in-place-overwrite fallback for read-only-directory/writable-inode
cases. Wires it into both write sites. libc added as a unix-only
dependency for the O_NOFOLLOW/errno pieces of the fallback path (both
already conditionally compiled per-platform; inert on Windows).
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds crash-atomic file writing with an in-place fallback for read-only parent directories, then uses it for managed alias updates and encrypted or plaintext vault restoration.

Changes

Atomic write integration

Layer / File(s) Summary
Atomic write implementation and validation
Cargo.toml, src/atomic_fs.rs, src/main.rs
Adds atomic temp-file replacement, symlink resolution, permission preservation, read-only-directory fallback writes, Unix support, module wiring, and unit tests.
Managed alias profile writes
src/alias.rs
Replaces direct profile writes in managed-block replacement and append paths with the fallback writer.
Vault restore writes
src/auth.rs
Uses the fallback writer for decrypted and plaintext restoration in both activation flows.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Activation
  participant write_bytes_with_fallback
  participant try_atomic_write
  participant in_place_overwrite
  Activation->>write_bytes_with_fallback: restore file bytes
  write_bytes_with_fallback->>try_atomic_write: write temp file and rename
  try_atomic_write-->>write_bytes_with_fallback: read-only directory error
  write_bytes_with_fallback->>in_place_overwrite: overwrite existing target
  in_place_overwrite-->>Activation: return write result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed It clearly describes the main change: adding crash-safe atomic writes for auth and shell profile files.
Description check ✅ Passed It covers the summary and testing results; only the template's exact test-plan checkbox format and reviewer notes are missing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/atomic-fs-crash-safe-writes

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/atomic_fs.rs (2)

205-219: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Read-only-directory fallback test may silently pass without exercising the fallback when CI runs as root.

std::fs::set_permissions(dir.path(), 0o500) relies on the OS enforcing DAC permission checks. Root (common for containerized CI runners) bypasses this, so try_atomic_write would still succeed via the primary path, write_bytes_with_fallback would never take the fallback branch, and res.expect(...) would still pass — the test would give a false sense of coverage.

Consider skipping (or asserting the fallback was actually exercised) when running as root, e.g. via unsafe { libc::geteuid() == 0 }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/atomic_fs.rs` around lines 205 - 219, Update
fallback_overwrites_when_parent_dir_is_readonly to avoid false coverage when
running as root: detect a zero effective UID and skip the test before changing
permissions, or otherwise assert that the fallback path was exercised. Preserve
the existing permission restoration and content assertions for non-root runs.

52-55: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

"Durable" guarantee isn't fully delivered: sync errors are swallowed and the parent directory is never fsynced.

f.sync_all() errors are discarded at Line 54 and Line 100, so a failed fsync (e.g. disk full) is treated as success and the code proceeds to rename anyway. Separately, even a successful sync_all() on the temp file only durably persists file content/metadata — it does not make the rename's directory-entry update durable. This is the well-known ext4 pattern; as one source puts it, "You should fsync the containing directory in addition to the file itself whenever you need the directory-level change (creation, removal, renaming, linking) to be durable across crashes or power loss." Right now a crash right after a "successful" rename() can still roll back to the previous file on some filesystems/configurations.

🛡️ Proposed fix: fsync the parent directory after rename, propagate sync errors
     if let Err(e) = std::fs::rename(&tmp, path) {
         let _ = std::fs::remove_file(&tmp);
         return Err(e);
     }
+    if let Ok(dir) = std::fs::File::open(parent) {
+        let _ = dir.sync_all();
+    }
     Ok(())

Also applies to: 68-73, 99-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/atomic_fs.rs` around lines 52 - 55, Update both atomic write paths around
the file sync and rename operations to propagate errors from f.sync_all()
instead of discarding them, preventing rename after an unsuccessful file sync.
After each successful rename, open the destination’s parent directory and fsync
it, propagating that directory sync error as well so the durable guarantee
includes the directory entry update.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/atomic_fs.rs`:
- Around line 61-66: Remove the Windows-only pre-removal block that checks
path.exists() and calls std::fs::remove_file(path) before the atomic rename. Let
the existing std::fs::rename flow replace the destination directly, preserving
the file throughout the operation.
- Around line 61-75: Update the atomic-write flow surrounding the final
std::fs::rename call to detect when path is a symlink before removing or
replacing it. Preserve symlink-managed files by resolving the link target and
performing the atomic replacement at that target, or reject symlink paths with a
consistent error; do not allow rename to replace the symlink itself with a
regular file.
- Around line 47-59: Update try_atomic_write to capture the existing target file
permissions before creating the temporary inode, and use those permissions when
the permissions argument is None. Preserve explicitly supplied permissions,
while ensuring atomic replacement of an existing file retains its current mode
bits instead of relying on default creation permissions.

---

Nitpick comments:
In `@src/atomic_fs.rs`:
- Around line 205-219: Update fallback_overwrites_when_parent_dir_is_readonly to
avoid false coverage when running as root: detect a zero effective UID and skip
the test before changing permissions, or otherwise assert that the fallback path
was exercised. Preserve the existing permission restoration and content
assertions for non-root runs.
- Around line 52-55: Update both atomic write paths around the file sync and
rename operations to propagate errors from f.sync_all() instead of discarding
them, preventing rename after an unsuccessful file sync. After each successful
rename, open the destination’s parent directory and fsync it, propagating that
directory sync error as well so the durable guarantee includes the directory
entry update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a082013-474a-4bea-9d6d-e39cb4e861a1

📥 Commits

Reviewing files that changed from the base of the PR and between 82b2908 and 4b2362d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • Cargo.toml
  • src/alias.rs
  • src/atomic_fs.rs
  • src/auth.rs
  • src/main.rs

Comment thread src/atomic_fs.rs
Comment thread src/atomic_fs.rs Outdated
Comment thread src/atomic_fs.rs Outdated
…ndows rename)

Three issues from review of the crash-safe write module:

- try_atomic_write created the replacement inode with default (umask-masked)
  permissions when permissions=None, which every call site passes — a 0600
  credential file being rewritten would silently become world-readable.
  Now preserves the existing file's permissions when none are explicitly
  given.

- rename() replaces a symlink *path* with a regular file rather than writing
  through it, unlike the plain fs::write this module replaced. A symlinked
  .bashrc or credential file (common for dotfiles setups) would silently
  lose its symlink. Now resolves one level of symlink indirection first and
  performs the atomic write at the resolved target, matching the original
  write-through behavior while keeping the symlink itself intact.

- Removed the Windows pre-removal-before-rename step: std::fs::rename
  already replaces an existing destination on Windows (MoveFileExW with
  MOVEFILE_REPLACE_EXISTING), so the explicit remove_file only opened a
  window where neither the old nor new file existed.

New tests: permission preservation on replace, write-through-a-symlink.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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/atomic_fs.rs (1)

90-98: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Propagate sync_all() failure instead of discarding it.

let _ = f.sync_all(); swallows fsync errors (e.g. ENOSPC, EIO). flush() on File is a documented no-op so ignoring it is harmless, but ignoring sync_all()'s result means the function can return Ok(()) (after a subsequent successful rename) even though the new content was never durably written — defeating the crash-safety guarantee this module explicitly documents.

🔒 Proposed fix
         f.write_all(bytes)?;
-        let _ = f.flush();
-        let _ = f.sync_all();
+        f.sync_all()?;

Note: like the existing write_all failure path, an error here still leaves tmp behind (no cleanup happens before the ? returns); consider unifying cleanup across all failure points inside this block if you want a stronger guarantee, though that's a separate, lower-priority concern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/atomic_fs.rs` around lines 90 - 98, In the temporary-file write block,
update the sync_all call on the OpenOptions-created file to propagate failures
with the existing Result flow instead of discarding them. Preserve the current
flush handling and ensure a sync_all error returns before rename, leaving the
temporary file behavior consistent with write_all failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/atomic_fs.rs`:
- Around line 15-45: Update write_bytes_with_fallback to resolve the input path
through resolve_symlink_target before calling in_place_overwrite. Pass the
resolved target to the fallback write while preserving the existing behavior for
non-symlinks and other write paths.

---

Outside diff comments:
In `@src/atomic_fs.rs`:
- Around line 90-98: In the temporary-file write block, update the sync_all call
on the OpenOptions-created file to propagate failures with the existing Result
flow instead of discarding them. Preserve the current flush handling and ensure
a sync_all error returns before rename, leaving the temporary file behavior
consistent with write_all failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d8f0ab24-0329-41c5-a84c-06378053ecca

📥 Commits

Reviewing files that changed from the base of the PR and between 4b2362d and 6e246e9.

📒 Files selected for processing (1)
  • src/atomic_fs.rs

Comment thread src/atomic_fs.rs
Comment on lines +15 to +45
use std::borrow::Cow;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

fn invalid_input(msg: &'static str) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
}

/// Resolves one level of symlink indirection so an atomic write lands on the
/// real file `path` points at, not on a new plain file replacing the symlink
/// itself — `rename()` does not follow a symlink destination, it unlinks it
/// and puts the new file in its place. A relative link target is resolved
/// against the symlink's own parent directory. Non-symlinks (and symlinks
/// this can't stat, e.g. a dangling one) pass through unchanged.
fn resolve_symlink_target(path: &Path) -> Cow<'_, Path> {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return Cow::Borrowed(path);
};
if !meta.file_type().is_symlink() {
return Cow::Borrowed(path);
}
let Ok(target) = std::fs::read_link(path) else {
return Cow::Borrowed(path);
};
if target.is_absolute() {
Cow::Owned(target)
} else {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
Cow::Owned(parent.join(target))
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect in_place_overwrite / write_bytes_with_fallback for symlink handling.
sed -n '117,192p' src/atomic_fs.rs

Repository: getappz/agentflare

Length of output: 2662


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '47,116p' src/atomic_fs.rs

Repository: getappz/agentflare

Length of output: 2651


Resolve the symlink before the fallback write

write_bytes_with_fallback still passes the original path into in_place_overwrite, and O_NOFOLLOW will reject symlinks on Unix. Because path.is_file() follows symlinks, writable targets behind a symlink can still reach this branch and fail; resolve the link here too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/atomic_fs.rs` around lines 15 - 45, Update write_bytes_with_fallback to
resolve the input path through resolve_symlink_target before calling
in_place_overwrite. Pass the resolved target to the fallback write while
preserving the existing behavior for non-symlinks and other write paths.

@getappz
getappz merged commit 19df19f into master Jul 13, 2026
15 checks passed
@getappz
getappz deleted the feat/atomic-fs-crash-safe-writes branch July 13, 2026 22:18
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