Skip to main content

release_kit/
atomic.rs

1//! The temp-plus-rename writer every landing write goes through.
2//!
3//! A plain `fs::write` interrupted mid-call leaves a truncated file that
4//! is valid-looking YAML until a forge parses it. Writing beside the
5//! destination and renaming over it makes each write land whole or not at
6//! all; the rename stays in one directory, which is what keeps it atomic
7//! on POSIX filesystems.
8
9use std::fs;
10use std::io::Write as _;
11use std::path::Path;
12
13/// Write `bytes` at `path` through a same-directory temporary file and a
14/// rename, creating the parent directories it needs.
15///
16/// # Errors
17///
18/// Any I/O failure from creating, writing, or renaming; on failure the
19/// temporary file is removed and the destination holds what it held.
20pub fn write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
21    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
22    if let Some(parent) = parent {
23        fs::create_dir_all(parent)?;
24    }
25    let name = path
26        .file_name()
27        .ok_or_else(|| std::io::Error::other(format!("no file name in {}", path.display())))?;
28    let mut tmp_name = std::ffi::OsString::from(format!(".{}", std::process::id()));
29    tmp_name.push(".rk-tmp.");
30    tmp_name.push(name);
31    let tmp = path.with_file_name(tmp_name);
32    let written = fs::File::create(&tmp)
33        .and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all()))
34        .and_then(|()| fs::rename(&tmp, path));
35    if written.is_err() {
36        let _ = fs::remove_file(&tmp);
37    }
38    written
39}
40
41#[cfg(test)]
42mod tests {
43    #![allow(clippy::expect_used)]
44
45    use super::write;
46
47    #[test]
48    fn a_write_creates_parents_lands_whole_and_leaves_no_temp() {
49        let dir = tempfile::tempdir().expect("a scratch dir exists");
50        let path = dir.path().join("deep/nested/file.txt");
51        write(&path, b"first").expect("the write lands");
52        assert_eq!(std::fs::read(&path).expect("the file reads"), b"first");
53        write(&path, b"second").expect("the overwrite lands");
54        assert_eq!(std::fs::read(&path).expect("the file reads"), b"second");
55        let leftovers: Vec<_> = std::fs::read_dir(path.parent().expect("a parent"))
56            .expect("the dir reads")
57            .map(|entry| entry.expect("an entry").file_name())
58            .filter(|name| name != "file.txt")
59            .collect();
60        assert!(
61            leftovers.is_empty(),
62            "temp files left behind: {leftovers:?}"
63        );
64    }
65
66    #[test]
67    fn a_failed_write_leaves_the_destination_alone() {
68        let dir = tempfile::tempdir().expect("a scratch dir exists");
69        // A directory where the file should land: the rename fails.
70        let path = dir.path().join("blocked");
71        std::fs::create_dir(&path).expect("the blocking dir creates");
72        assert!(write(&path, b"bytes").is_err());
73        assert!(path.is_dir(), "the destination must be untouched");
74    }
75}