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());
}
}