Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/appliance-pid1/src/durable.rs

//! **A file PID 1 keeps, written so a power cut leaves the old one or the new one.**
//!
//! R10 (test3, 2026-10-20): holger's `metadata_seed::write_secret` wrote the seed to a
//! temporary file and renamed it over the marker without an fsync. A hard power-off on
//! XFS then left `/data/pairing/seed.toml` EMPTY on the next (keep) boot: the rename was
//! on disk, the data was not. Gunnar never had this, because its secrets went through
//! `durable::write_atomic_unframed`: a staged file, fsync, rename, fsync of the
//! directory. This is that writer, once, for every product's PID 1 and pairing state.
//!
//! * [`write_atomic`]: replace a file whole. A reader (or the next boot) sees the old
//!   bytes or the new bytes, never an empty or a half file.
//! * [`write_new`]: create a file that must not exist yet (an election, a first mint),
//!   whole: the staged file is hard-linked into place, so the name appears with all its
//!   bytes already synced, and a second writer is refused with `AlreadyExists`.
//!
//! std and libc only, like the reaper.

use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

/// Each staging file has its own name (pid and a counter), so two writers of the same
/// file never write into one staging file.
static STAGED: AtomicU64 = AtomicU64::new(0);

/// The directory a path lives in (`.` for a bare name).
fn dir_of(path: &Path) -> &Path {
    match path.parent() {
        Some(p) if !p.as_os_str().is_empty() => p,
        _ => Path::new("."),
    }
}

/// Stage `bytes` beside `path`: written, given `mode`, and synced. Returns the staging
/// file; on any error it is removed again.
fn stage(path: &Path, bytes: &[u8], mode: Option<u32>) -> io::Result<PathBuf> {
    let dir = dir_of(path);
    std::fs::create_dir_all(dir)?;
    let name = path.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: no file name", path.display())))?;
    let n = STAGED.fetch_add(1, Ordering::Relaxed);
    let tmp = dir.join(format!(".{}.{}.{n}.staging", name.to_string_lossy(), std::process::id()));
    let mut open = std::fs::OpenOptions::new();
    open.write(true).create_new(true);
    #[cfg(unix)]
    if let Some(m) = mode {
        use std::os::unix::fs::OpenOptionsExt as _;
        open.mode(m);
    }
    let mut f = open.open(&tmp)?;
    let written = (|| {
        #[cfg(unix)]
        if let Some(m) = mode {
            // The umask may have narrowed the create mode; a secret is exactly `m`.
            use std::os::unix::fs::PermissionsExt as _;
            f.set_permissions(std::fs::Permissions::from_mode(m))?;
        }
        f.write_all(bytes)?;
        f.sync_all()
    })();
    drop(f);
    if let Err(e) = written {
        let _ = std::fs::remove_file(&tmp);
        return Err(e);
    }
    Ok(tmp)
}

/// **fsync a directory**, so a rename or a link in it survives a power cut.
pub fn sync_dir(dir: &Path) -> io::Result<()> {
    std::fs::File::open(dir)?.sync_all()
}

/// **Replace `path` whole**: the bytes staged beside it (mode `mode` when given: `0o600`
/// for a secret), fsync, rename over `path`, fsync of the directory. After a power cut at
/// any point the file holds its old bytes or its new bytes, never none of them (R10).
pub fn write_atomic(path: &Path, bytes: &[u8], mode: Option<u32>) -> io::Result<()> {
    let tmp = stage(path, bytes, mode)?;
    if let Err(e) = std::fs::rename(&tmp, path) {
        let _ = std::fs::remove_file(&tmp);
        return Err(e);
    }
    sync_dir(dir_of(path))
}

/// **Create `path` whole, only if it does not exist**: staged and synced, then hard-linked
/// into place (`link(2)` refuses an existing name, which is the `create_new` an election
/// needs), the staging name removed and the directory synced. The name never appears
/// empty. A second writer gets `AlreadyExists` and the first writer's bytes stay.
pub fn write_new(path: &Path, bytes: &[u8], mode: Option<u32>) -> io::Result<()> {
    let tmp = stage(path, bytes, mode)?;
    let linked = std::fs::hard_link(&tmp, path);
    let _ = std::fs::remove_file(&tmp);
    linked?;
    sync_dir(dir_of(path))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::os::unix::fs::PermissionsExt as _;

    fn names(dir: &Path) -> Vec<String> {
        let mut v: Vec<String> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
        v.sort();
        v
    }

    /// ★ **R10: a secret is written whole, owner-only, and no staging file is left.**
    /// (test3 2026-10-20: the seed was renamed into place unsynced, and a hard power-off on
    /// XFS left `/data/pairing/seed.toml` empty on the keep boot.)
    #[test]
    fn a_secret_is_whole_owner_only_and_leaves_no_staging_file() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("pairing/seed.toml");
        write_atomic(&p, b"estate = \"acme\"\n", Some(0o600)).unwrap();
        assert_eq!(std::fs::read_to_string(&p).unwrap(), "estate = \"acme\"\n");
        assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
        write_atomic(&p, b"estate = \"beta\"\n", Some(0o600)).unwrap();
        assert_eq!(std::fs::read_to_string(&p).unwrap(), "estate = \"beta\"\n", "replaced whole");
        assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600, "still owner-only");
        assert_eq!(names(p.parent().unwrap()), vec!["seed.toml".to_string()], "no staging file is left beside it");
    }

    /// A write that cannot be staged changes nothing: the old file stays whole.
    #[test]
    fn a_write_that_fails_keeps_the_old_file_whole() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("seed.toml");
        write_atomic(&p, b"old\n", None).unwrap();
        // The file's place is fine, but its directory is a FILE for a path below it.
        let below = p.join("x");
        assert!(write_atomic(&below, b"new\n", None).is_err());
        assert_eq!(std::fs::read_to_string(&p).unwrap(), "old\n");
    }

    /// ★ **A first write (an election, a first mint) appears whole or not at all**, and a
    /// second one is refused by `AlreadyExists` with the first kept.
    #[test]
    fn a_new_file_appears_whole_and_a_second_writer_is_refused() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("role.ron");
        write_new(&p, b"(role: Primary)\n", Some(0o600)).unwrap();
        assert_eq!(std::fs::read_to_string(&p).unwrap(), "(role: Primary)\n");
        let again = write_new(&p, b"(role: Twin)\n", Some(0o600)).unwrap_err();
        assert_eq!(again.kind(), io::ErrorKind::AlreadyExists);
        assert_eq!(std::fs::read_to_string(&p).unwrap(), "(role: Primary)\n", "the first writer's bytes are kept");
        assert_eq!(names(d.path()), vec!["role.ron".to_string()], "no staging file is left");
    }

    /// The default mode is the process's ordinary one (not forced to 0600).
    #[test]
    fn with_no_mode_the_file_is_an_ordinary_one() {
        let d = tempfile::tempdir().unwrap();
        let p = d.path().join("state.toml");
        write_atomic(&p, b"x", None).unwrap();
        assert_ne!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0, "readable by its owner at least");
        assert!(sync_dir(d.path()).is_ok());
    }
}