Skip to main content

agent_mesh_protocol/
atomic.rs

1//! Crash-safe file publication: write to a fresh sibling, sync it, rename it
2//! over the destination, sync the directory.
3//!
4//! The temp file is created with `create_new` under an unpredictable name and
5//! mode 0600, so a symlink or file planted at a guessable path is never
6//! followed or truncated. One helper for the revocation log (`amesh`) and the
7//! `Seen` record ([`crate::RevocationGuard`]), so there is a single copy of the dance.
8
9use std::fs::{self, File, OpenOptions};
10use std::io::{self, Write};
11use std::path::{Path, PathBuf};
12
13/// A fresh temp file beside `dest`: `create_new` (never follows or truncates an
14/// existing path) under an unpredictable name, mode 0600 on Unix.
15///
16/// # Errors
17/// An I/O error creating the file, or `AlreadyExists` after repeated name clashes.
18pub fn create_tmp(dir: &Path, dest: &Path) -> io::Result<(PathBuf, File)> {
19    let name = dest
20        .file_name()
21        .map_or_else(String::new, |n| n.to_string_lossy().into_owned());
22    for _ in 0..8 {
23        let tmp = dir.join(format!(".{name}.{:032x}.tmp", rand::random::<u128>()));
24        let mut o = OpenOptions::new();
25        o.write(true).create_new(true);
26        #[cfg(unix)]
27        std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
28        match o.open(&tmp) {
29            Ok(f) => return Ok((tmp, f)),
30            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
31            Err(e) => return Err(e),
32        }
33    }
34    Err(io::Error::new(
35        io::ErrorKind::AlreadyExists,
36        format!("could not create a temp file in {}", dir.display()),
37    ))
38}
39
40/// Replace `dest` with `bytes` atomically and durably: temp file, `sync_all`,
41/// rename, then `sync_all` on the directory (Unix). On failure the temp file
42/// is removed and `dest` is untouched.
43///
44/// # Errors
45/// Any I/O error along the way.
46pub fn atomic_write(dest: &Path, bytes: &[u8]) -> io::Result<()> {
47    let dir = match dest.parent() {
48        Some(d) if !d.as_os_str().is_empty() => d,
49        _ => Path::new("."),
50    };
51    let (tmp, mut f) = create_tmp(dir, dest)?;
52    let written = f
53        .write_all(bytes)
54        .and_then(|()| f.sync_all())
55        .and_then(|()| fs::rename(&tmp, dest));
56    if let Err(e) = written {
57        let _ = fs::remove_file(&tmp);
58        return Err(e);
59    }
60    #[cfg(unix)]
61    File::open(dir)?.sync_all()?;
62    Ok(())
63}
64
65#[cfg(all(test, unix))]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn replaces_the_destination_and_leaves_no_temp_file() {
71        let dir = tempfile::tempdir().unwrap();
72        let dest = dir.path().join("f.json");
73        atomic_write(&dest, b"one").unwrap();
74        atomic_write(&dest, b"two").unwrap();
75        assert_eq!(fs::read(&dest).unwrap(), b"two");
76        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
77    }
78
79    #[test]
80    fn a_symlink_at_a_guessed_temp_name_is_not_followed() {
81        let dir = tempfile::tempdir().unwrap();
82        let victim = dir.path().join("victim");
83        fs::write(&victim, "precious").unwrap();
84        let dest = dir.path().join("f.json");
85        for guess in ["f.tmp", "f.json.tmp", "f.json.tmp.1"] {
86            let _ = std::os::unix::fs::symlink(&victim, dir.path().join(guess));
87        }
88        atomic_write(&dest, b"data").unwrap();
89        assert_eq!(fs::read_to_string(&victim).unwrap(), "precious");
90    }
91
92    #[test]
93    fn a_failed_rename_removes_the_temp_file() {
94        let dir = tempfile::tempdir().unwrap();
95        let dest = dir.path().join("is-a-dir");
96        fs::create_dir(&dest).unwrap();
97        fs::write(dest.join("x"), "x").unwrap();
98        assert!(atomic_write(&dest, b"data").is_err());
99        let leftovers: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
100        assert_eq!(leftovers.len(), 1, "only the directory remains");
101    }
102}