Skip to main content

ryra_core/system/
atomic_write.rs

1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::Path;
4
5use crate::error::{Error, Result};
6
7/// Write `contents` to `path` atomically with the given permission mode.
8///
9/// Guarantees:
10/// - The file is created with `mode` from byte zero (no world-readable window
11///   even for secret-bearing files written with 0o600).
12/// - The file never appears half-written on disk — either the old contents or
13///   the new contents exist after this call, never a torn state. Achieved by
14///   writing to a sibling `.<name>.tmp.<pid>` file and `rename`-ing over the
15///   target (atomic on a single POSIX filesystem).
16///
17/// The temporary file is written to the same directory as `path` so the
18/// rename stays within one filesystem. `sync_all` is called before the
19/// rename so the new content hits disk before the rename metadata op.
20pub fn atomic_write(path: &Path, contents: &[u8], mode: u32) -> Result<()> {
21    let parent = path.parent().ok_or_else(|| Error::FileWrite {
22        path: path.to_path_buf(),
23        source: std::io::Error::new(
24            std::io::ErrorKind::InvalidInput,
25            "path has no parent directory",
26        ),
27    })?;
28
29    // Ensure parent exists before we try to create the tempfile inside it.
30    if !parent.as_os_str().is_empty() {
31        std::fs::create_dir_all(parent).map_err(|source| Error::DirCreate {
32            path: parent.to_path_buf(),
33            source,
34        })?;
35    }
36
37    let name = path.file_name().ok_or_else(|| Error::FileWrite {
38        path: path.to_path_buf(),
39        source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no file name"),
40    })?;
41
42    // Refuse to clobber a symlink at `path`. The final `rename` would replace
43    // the link itself with our tempfile, silently destroying the user's link
44    // target. We only manage regular files — if a user has intentionally
45    // symlinked a ryra-managed config elsewhere, surface the conflict.
46    if let Ok(meta) = std::fs::symlink_metadata(path)
47        && meta.file_type().is_symlink()
48    {
49        return Err(Error::FileWrite {
50            path: path.to_path_buf(),
51            source: std::io::Error::new(
52                std::io::ErrorKind::InvalidInput,
53                "refusing to overwrite a symlink — resolve the symlink or remove it",
54            ),
55        });
56    }
57
58    // .<name>.tmp.<pid> — dot-prefixed so it's not mistaken for user content
59    // if something interrupts us mid-write.
60    let mut tmp_name = std::ffi::OsString::from(".");
61    tmp_name.push(name);
62    tmp_name.push(".tmp.");
63    tmp_name.push(std::process::id().to_string());
64    let tmp_path = parent.join(tmp_name);
65
66    let write_result = (|| -> Result<()> {
67        let mut opts = OpenOptions::new();
68        opts.write(true).create(true).truncate(true);
69        #[cfg(unix)]
70        {
71            use std::os::unix::fs::OpenOptionsExt;
72            opts.mode(mode);
73        }
74        let mut f = opts.open(&tmp_path).map_err(|source| Error::FileWrite {
75            path: tmp_path.clone(),
76            source,
77        })?;
78
79        f.write_all(contents).map_err(|source| Error::FileWrite {
80            path: tmp_path.clone(),
81            source,
82        })?;
83        f.sync_all().map_err(|source| Error::FileWrite {
84            path: tmp_path.clone(),
85            source,
86        })?;
87
88        // On non-unix (none of our targets, but for completeness) the mode
89        // argument has no effect at creation. Apply it after the fact so the
90        // behavior is at least consistent.
91        #[cfg(not(unix))]
92        {
93            let _ = mode;
94        }
95
96        std::fs::rename(&tmp_path, path).map_err(|source| Error::FileWrite {
97            path: path.to_path_buf(),
98            source,
99        })?;
100
101        Ok(())
102    })();
103
104    // Best-effort cleanup if anything failed before the rename. After a
105    // successful rename the tmp path no longer exists, so this is a no-op.
106    if write_result.is_err() {
107        let _ = std::fs::remove_file(&tmp_path);
108    }
109
110    write_result
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn writes_file_with_mode() -> std::result::Result<(), Box<dyn std::error::Error>> {
119        let dir = tempfile::tempdir()?;
120        let path = dir.path().join("secret.toml");
121        atomic_write(&path, b"hello=world\n", 0o600)?;
122
123        let contents = std::fs::read_to_string(&path)?;
124        assert_eq!(contents, "hello=world\n");
125
126        #[cfg(unix)]
127        {
128            use std::os::unix::fs::PermissionsExt;
129            let mode = std::fs::metadata(&path)?.permissions().mode() & 0o777;
130            assert_eq!(mode, 0o600);
131        }
132        Ok(())
133    }
134
135    #[test]
136    fn overwrites_existing() -> std::result::Result<(), Box<dyn std::error::Error>> {
137        let dir = tempfile::tempdir()?;
138        let path = dir.path().join("config.toml");
139        atomic_write(&path, b"first\n", 0o644)?;
140        atomic_write(&path, b"second\n", 0o644)?;
141
142        let contents = std::fs::read_to_string(&path)?;
143        assert_eq!(contents, "second\n");
144        Ok(())
145    }
146
147    #[test]
148    #[cfg(unix)]
149    fn refuses_to_clobber_symlink() -> std::result::Result<(), Box<dyn std::error::Error>> {
150        let dir = tempfile::tempdir()?;
151        let target = dir.path().join("real.toml");
152        std::fs::write(&target, b"original")?;
153        let link = dir.path().join("config.toml");
154        std::os::unix::fs::symlink(&target, &link)?;
155
156        let result = atomic_write(&link, b"new", 0o644);
157        assert!(result.is_err(), "expected error, got {result:?}");
158
159        // Target must be untouched — the whole point of the check.
160        assert_eq!(std::fs::read_to_string(&target)?, "original");
161        Ok(())
162    }
163
164    #[test]
165    fn tightens_permissions_on_overwrite() -> std::result::Result<(), Box<dyn std::error::Error>> {
166        let dir = tempfile::tempdir()?;
167        let path = dir.path().join("preferences.toml");
168        atomic_write(&path, b"v1", 0o644)?;
169        atomic_write(&path, b"v2", 0o600)?;
170
171        #[cfg(unix)]
172        {
173            use std::os::unix::fs::PermissionsExt;
174            let mode = std::fs::metadata(&path)?.permissions().mode() & 0o777;
175            assert_eq!(mode, 0o600, "rename-over should install the new mode");
176        }
177        Ok(())
178    }
179}