Skip to main content

atomcode_core/setup/
fs_atomic.rs

1//! Cross-platform atomic file write: tempfile in same dir → fsync → persist
2//! → parent dir fsync. POSIX durability + Windows MoveFileEx semantics.
3
4use anyhow::{Context, Result};
5use std::fs::File;
6use std::io::Write;
7use std::path::Path;
8
9/// Atomically write `content` to `path`. Uses a temp file in the **same parent
10/// directory** (avoids EXDEV on iCloud/Dropbox symlinks), fsyncs the file
11/// before persist, then fsyncs the parent directory for POSIX durability.
12///
13/// On Windows, `tempfile::NamedTempFile::persist` uses `MoveFileExW` with
14/// `MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH` so the rename is
15/// atomic even when the target exists.
16///
17/// `mode` is the Unix file mode. Use `0o644` for normal config files and
18/// `0o600` for secrets (tokens, OAuth credentials). Ignored on Windows
19/// for now; ACL handling is a P8 follow-up.
20pub fn atomic_write(path: &Path, content: &[u8], mode: u32) -> Result<()> {
21    let parent = path.parent().context("atomic_write: path has no parent")?;
22    std::fs::create_dir_all(parent)
23        .with_context(|| format!("atomic_write: create_dir_all({})", parent.display()))?;
24
25    let mut tmp = tempfile::NamedTempFile::new_in(parent)
26        .with_context(|| format!("atomic_write: NamedTempFile::new_in({})", parent.display()))?;
27    tmp.write_all(content)
28        .with_context(|| format!("atomic_write: write({})", tmp.path().display()))?;
29    tmp.as_file_mut()
30        .sync_all()
31        .with_context(|| format!("atomic_write: fsync({})", tmp.path().display()))?;
32
33    #[cfg(unix)]
34    {
35        use std::os::unix::fs::PermissionsExt;
36        std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(mode))
37            .with_context(|| format!("atomic_write: chmod({:o})", mode))?;
38    }
39    #[cfg(not(unix))]
40    {
41        let _ = mode;
42        // TODO(P8-v2): Windows ACL for mode 0o600 files (mcp.json with tokens).
43        // Use `windows-acl` crate or `icacls` command to restrict to owner-only.
44        // Currently Windows files inherit parent directory ACL, which is acceptable
45        // for most dev environments but not for shared/enterprise machines.
46        // Tracked in spec Section 5 / "Windows ACL" follow-up.
47    }
48
49    tmp.persist(path)
50        .map_err(|e| anyhow::anyhow!("atomic_write: persist({}): {}", path.display(), e.error))?;
51
52    // POSIX durability: fsync the directory entry so dirent survives crash.
53    let dir = File::open(parent)
54        .with_context(|| format!("atomic_write: open parent dir {}", parent.display()))?;
55    dir.sync_all()
56        .with_context(|| format!("atomic_write: fsync parent {}", parent.display()))?;
57
58    Ok(())
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn atomic_write_creates_file_with_content() {
67        let dir = tempfile::tempdir().unwrap();
68        let path = dir.path().join("hello.txt");
69        atomic_write(&path, b"hello world", 0o644).unwrap();
70        let read = std::fs::read(&path).unwrap();
71        assert_eq!(read, b"hello world");
72    }
73
74    #[test]
75    fn atomic_write_overwrites_existing_file() {
76        let dir = tempfile::tempdir().unwrap();
77        let path = dir.path().join("file.txt");
78        std::fs::write(&path, b"original").unwrap();
79        atomic_write(&path, b"updated", 0o644).unwrap();
80        assert_eq!(std::fs::read(&path).unwrap(), b"updated");
81    }
82
83    #[cfg(unix)]
84    #[test]
85    fn atomic_write_sets_mode_0600_for_secrets() {
86        use std::os::unix::fs::PermissionsExt;
87        let dir = tempfile::tempdir().unwrap();
88        let path = dir.path().join("secret.json");
89        atomic_write(&path, b"{}", 0o600).unwrap();
90        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
91        assert_eq!(mode, 0o600);
92    }
93
94    #[test]
95    fn atomic_write_creates_parent_dirs() {
96        let dir = tempfile::tempdir().unwrap();
97        let path = dir.path().join("a/b/c/file.txt");
98        atomic_write(&path, b"deep", 0o644).unwrap();
99        assert_eq!(std::fs::read(&path).unwrap(), b"deep");
100    }
101}