Skip to main content

choir_fs/
atomic_file.rs

1//! Atomic file replacement: write a temp file in the destination
2//! directory, sync it, rename it into place, sync the parent directory.
3//! Readers see the old contents or the new, never a torn write, and a
4//! crash between any two steps leaves the destination untouched.
5//!
6//! Ported from Oak `cli/src/atomic_file.rs` (v0.102.1, commit `8de9515`,
7//! Apache-2.0); error type changed to `std::io::Error`.
8
9use std::fs::{self, File, OpenOptions};
10use std::io::{self, Write};
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14/// Atomically replace `path` with `contents`.
15///
16/// # Examples
17///
18/// The destination either holds the old bytes or the new ones. A reader
19/// racing the write never sees a prefix of the new contents, which is the
20/// whole reason this is not `fs::write`.
21///
22/// ```
23/// # use std::fs;
24/// let dir = std::env::temp_dir().join("choir-fs-doctest-write-atomic");
25/// fs::create_dir_all(&dir)?;
26/// let path = dir.join("policy");
27///
28/// choir_fs::atomic_file::write_atomic(&path, "first\n")?;
29/// assert_eq!(fs::read_to_string(&path)?, "first\n");
30///
31/// // Replacing is one rename, not a truncate-then-write.
32/// choir_fs::atomic_file::write_atomic(&path, "second\n")?;
33/// assert_eq!(fs::read_to_string(&path)?, "second\n");
34///
35/// // Nothing is left behind in the directory the temp file was written to.
36/// let strays: Vec<_> = fs::read_dir(&dir)?
37///     .filter_map(Result::ok)
38///     .filter(|e| e.file_name() != "policy")
39///     .collect();
40/// assert!(strays.is_empty(), "temp file survived the rename");
41/// # fs::remove_dir_all(&dir)?;
42/// # Ok::<(), std::io::Error>(())
43/// ```
44///
45/// # Errors
46///
47/// Any `io::Error` from creating, writing, syncing or renaming the
48/// replacement file, and `InvalidInput` when `path` has no parent
49/// directory to write the replacement into.
50pub fn write_atomic(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
51    write_atomic_impl(path, contents, false)
52}
53
54/// Atomically replace `path` with `contents`, creating the replacement
55/// file with owner-only permissions (0600) before any contents are
56/// written on Unix. For secrets: unlike write-then-chmod there is no
57/// window where the bytes exist world-readable.
58pub fn write_atomic_private(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
59    write_atomic_impl(path, contents, true)
60}
61
62fn write_atomic_impl(path: &Path, contents: impl AsRef<[u8]>, private: bool) -> io::Result<()> {
63    let parent = path.parent().ok_or_else(|| {
64        io::Error::new(
65            io::ErrorKind::InvalidInput,
66            "atomic write path has no parent",
67        )
68    })?;
69    fs::create_dir_all(parent)?;
70
71    let file_name = path.file_name().ok_or_else(|| {
72        io::Error::new(
73            io::ErrorKind::InvalidInput,
74            "atomic write path has no file name",
75        )
76    })?;
77    let nonce = SystemTime::now()
78        .duration_since(UNIX_EPOCH)
79        .map(|d| d.as_nanos())
80        .unwrap_or_default();
81    let tmp_name = format!(
82        ".{}.tmp-{}-{nonce}",
83        file_name.to_string_lossy(),
84        std::process::id()
85    );
86    let tmp_path = path.with_file_name(tmp_name);
87
88    let write_result = (|| -> io::Result<()> {
89        let mut file = create_temp_file(&tmp_path, private)?;
90        file.write_all(contents.as_ref())?;
91        file.sync_all()?;
92        drop(file);
93        fs::rename(&tmp_path, path)?;
94        sync_parent_dir(parent)?;
95        Ok(())
96    })();
97
98    if write_result.is_err() {
99        let _ = fs::remove_file(&tmp_path);
100    }
101    write_result
102}
103
104fn create_temp_file(path: &Path, private: bool) -> io::Result<File> {
105    let mut options = OpenOptions::new();
106    options.write(true).create_new(true);
107    #[cfg(unix)]
108    if private {
109        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
110        options.mode(0o600);
111        let file = options.open(path)?;
112        // mode() is masked by the process umask; assert 0600 outright.
113        file.set_permissions(fs::Permissions::from_mode(0o600))?;
114        return Ok(file);
115    }
116    let _ = private;
117    options.open(path)
118}
119
120#[cfg(unix)]
121fn sync_parent_dir(parent: &Path) -> io::Result<()> {
122    File::open(parent)?.sync_all()
123}
124
125#[cfg(not(unix))]
126fn sync_parent_dir(_parent: &Path) -> io::Result<()> {
127    Ok(())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn scratch(name: &str) -> std::path::PathBuf {
135        let dir = std::env::temp_dir().join(format!("choir-fs-{name}-{}", std::process::id()));
136        std::fs::remove_dir_all(&dir).ok();
137        std::fs::create_dir_all(&dir).unwrap();
138        dir
139    }
140
141    #[test]
142    fn creates_replaces_and_leaves_no_temp_files() {
143        let dir = scratch("atomic");
144        let path = dir.join("state.txt");
145
146        write_atomic(&path, "first").unwrap();
147        assert_eq!(fs::read_to_string(&path).unwrap(), "first");
148
149        write_atomic(&path, "second").unwrap();
150        assert_eq!(fs::read_to_string(&path).unwrap(), "second");
151
152        let leftovers: Vec<_> = fs::read_dir(&dir)
153            .unwrap()
154            .map(|entry| entry.unwrap().file_name())
155            .filter(|name| name.to_string_lossy().contains(".tmp-"))
156            .collect();
157        assert!(
158            leftovers.is_empty(),
159            "left temp files behind: {leftovers:?}"
160        );
161        std::fs::remove_dir_all(&dir).ok();
162    }
163
164    #[cfg(unix)]
165    #[test]
166    fn private_writes_are_owner_only_even_when_replacing_a_readable_file() {
167        use std::os::unix::fs::PermissionsExt;
168
169        let dir = scratch("private");
170        let path = dir.join("secret");
171        fs::write(&path, "old").unwrap();
172        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
173
174        write_atomic_private(&path, "new").unwrap();
175
176        assert_eq!(fs::read_to_string(&path).unwrap(), "new");
177        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
178        assert_eq!(mode, 0o600);
179        std::fs::remove_dir_all(&dir).ok();
180    }
181
182    #[test]
183    fn missing_parent_directories_are_created() {
184        let dir = scratch("mkdirs");
185        let path = dir.join("a/b/state.txt");
186        write_atomic(&path, "deep").unwrap();
187        assert_eq!(fs::read_to_string(&path).unwrap(), "deep");
188        std::fs::remove_dir_all(&dir).ok();
189    }
190}