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

nordisk/gunnar

crates/gunnar-server/tests/slow_disk_placement.rs

//! **The placement table, proved against a real `ServerStores`.**
//!
//! `gunnar_server::slow`'s unit tests prove the rules in isolation; this file
//! proves that a server opened on a data root actually PUTS things where the
//! layout config says, and that what it then reports about them is honest in
//! each of the three states.
//!
//! # The config drives everything, and the config is a file
//!
//! Each test writes a layout config of its own into a temp directory, sealed
//! exactly as `korp_installer::layout::seal` seals it with no signing key — an
//! empty `auth.signature` and the note saying why — and points
//! `gunnar_server::layout_config::CONFIG_ENV` at it. That is the same channel
//! the installer uses to tell gunnar anything: a boot parameter.
//!
//! # Why the environment variable is taken under a lock
//!
//! `GUNNAR_LAYOUT_CONFIG` is process-global and these tests run on threads of
//! one binary. Every test holds `ENV` for as long as it needs the value to be
//! true, so two of them can never be inside `ServerStores::open` with each
//! other's config. Without it this file would be the flakiest thing in the
//! repository.

use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};

use gunnar_server::layout_config::{self, Authenticity};
use gunnar_server::slow::{self, Claim};
use gunnar_server::stores::{ServerStores, IDENTITY_MIRROR};

/// Serialises every test that touches `GUNNAR_LAYOUT_CONFIG`.
static ENV: Mutex<()> = Mutex::new(());

/// A machine: a data root, a `/serialdat` stand-in, and a layout config that
/// points at both.
struct Machine {
    _guard: MutexGuard<'static, ()>,
    dir: tempfile::TempDir,
    data: PathBuf,
    slow: PathBuf,
    config: PathBuf,
}

impl Machine {
    /// The installer's own shape: `meta` is a second copy on `/serialdat` with
    /// no `/data` twin, and the other kinds keep a `/data` path that is a
    /// symlink onto the other spindle. A temp directory has no second device to
    /// point at, so the "symlink" is not laid — which is exactly the honest
    /// thing for a test to assert: gunnar writes the CONFIGURED path, and what
    /// that path is on is the installer's business and the config's claim.
    fn dir_of(&self, name: &str) -> PathBuf {
        match name {
            slow::META_DIR => self.slow.join(name),
            _ => self.data.join(name),
        }
    }
}

impl Drop for Machine {
    fn drop(&mut self) {
        std::env::remove_var(layout_config::CONFIG_ENV);
    }
}

/// Seal a body with no signing key, exactly as the installer does today.
fn seal_unsigned(body: &str) -> String {
    format!(
        "{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature=\n\
         auth.pubkey=\nauth.note=NOT SIGNED: no ed25519 root key was enrolled at build time \
         (KORP_ROOT_SIGNING_KEY unset), so this config carries INTEGRITY (blake3) and NO \
         AUTHENTICITY.\n",
        blake3::hash(body.as_bytes()).to_hex()
    )
}

/// A machine whose config says the off-stripe-requiring directories are (or
/// are not) satisfied. `None` writes no config at all — the appliance
/// installed before the config existed.
fn machine(satisfied: Option<bool>) -> Machine {
    let guard = ENV.lock().unwrap_or_else(|e| e.into_inner());
    let dir = tempfile::tempdir().expect("a temp dir");
    let data = dir.path().join("data");
    let slow = dir.path().join("serialdat");
    let config = dir.path().join("layout.conf");
    std::fs::create_dir_all(&data).expect("mkdir data");
    std::fs::create_dir_all(&slow).expect("mkdir serialdat");

    if let Some(satisfied) = satisfied {
        let yn = if satisfied { "yes" } else { "no" };
        let class = if satisfied { "p3" } else { "fallback" };
        let device = if satisfied {
            "/dev/vda3".to_owned()
        } else {
            data.display().to_string()
        };
        let mut body = format!(
            "korp-layout-config 1\n\
             install.unix=1789000000\n\
             install.product=gunnar\n\
             install.mode=fresh\n\
             boot.dev=/dev/vda\n\
             boot.why=largest-group-remainder\n\
             data.mountpoint={data}\n\
             data.shape=raid0\n\
             data.striped=yes\n\
             data.count=4\n\
             data.0.dev=/dev/vdb\n\
             unused.count=1\n\
             unused.0.name=vdf\n\
             unused.0.why=unequal, not used\n\
             serialdat.mountpoint={slowdir}\n\
             serialdat.class={class}\n\
             serialdat.dev={device}\n\
             serialdat.off_stripe={yn}\n\
             dir.users_dir=repos\n\
             threshold.rootfs_measured_bytes=12163481\n",
            data = data.display(),
            slowdir = slow.display(),
        );
        for p in slow::PLACEMENT {
            let path = match p.dir {
                slow::META_DIR => slow.join(p.dir),
                _ => data.join(p.dir),
            };
            let required = if p.requires_off_stripe { "yes" } else { "no" };
            let sat = if p.requires_off_stripe { yn } else { "yes" };
            body.push_str(&format!(
                "dir.{d}.path={path}\ndir.{d}.device=serialdat\n\
                 dir.{d}.off_stripe_required={required}\n\
                 dir.{d}.off_stripe_satisfied={sat}\n\
                 dir.{d}.what={what}\n",
                d = p.dir,
                path = path.display(),
                what = p.what,
            ));
        }
        std::fs::write(&config, seal_unsigned(&body)).expect("write the config");
    }
    // Set either way: with no file at that path, `load` answers ABSENT and
    // names it — which is the case the third state is about.
    std::env::set_var(layout_config::CONFIG_ENV, &config);
    Machine {
        _guard: guard,
        dir,
        data,
        slow,
        config,
    }
}

fn open(m: &Machine) -> ServerStores {
    ServerStores::open(&m.data).expect("the stores open")
}

/// **Every path gunnar uses is the one the config gave it** — no path is
/// computed, and no second placement mechanism sits on top of the installer's
/// symlinks.
#[test]
fn every_path_is_the_configured_one() {
    let m = machine(Some(true));
    let stores = open(&m);
    let reality = stores.storage_reality();
    assert_eq!(reality.placement.len(), slow::PLACEMENT.len());
    for row in &reality.placement {
        let p = slow::PLACEMENT
            .iter()
            .find(|p| p.what == row.what)
            .expect("every row comes from the table");
        assert_eq!(
            Path::new(&row.path),
            m.dir_of(p.dir),
            "{} was not placed where the config said",
            p.what
        );
    }
}

/// **The metadata mirror is written at open**, into `dir.meta.path`.
///
/// Not a directory that exists: a file with the principals and the enrolled
/// keys in it, before anybody has typed anything.
#[test]
fn the_identity_mirror_lands_where_the_config_says() {
    let m = machine(Some(true));
    let _stores = open(&m);
    let mirror = m.slow.join(slow::META_DIR).join(IDENTITY_MIRROR);
    let text = std::fs::read_to_string(&mirror)
        .unwrap_or_else(|err| panic!("no {}: {err}", mirror.display()));
    let doc: serde_json::Value = serde_json::from_str(&text).expect("the mirror is JSON");
    assert!(doc.get("principals").is_some(), "{text}");
    assert!(doc.get("keys").is_some(), "{text}");
    // Public material only. A mirror that carried secrets would make the other
    // spindle a second place to steal them from.
    assert!(
        !text.contains("PRIVATE KEY"),
        "the identity mirror must never carry a private key:\n{text}"
    );
}

/// **The roster is mirrored too** — the cheapest thing on the list, and the
/// only one that changes what happens when something breaks.
///
/// Gated with the roster itself: `gunnar_server::mirror` is the `replication`
/// feature, and naming a type a default build does not compile would take the
/// whole file down with it.
#[cfg(feature = "replication")]
#[test]
fn the_replication_roster_is_mirrored() {
    let m = machine(Some(true));
    let stores = open(&m);
    gunnar_server::ReplicationConfig::default()
        .save_mirrored(stores.root(), stores.slow())
        .expect("the roster saves");

    let primary = m.data.join(gunnar_server::mirror::REPLICATION_CONFIG);
    let mirror = m
        .slow
        .join(slow::META_DIR)
        .join(gunnar_server::mirror::REPLICATION_CONFIG);
    assert!(
        primary.is_file(),
        "the primary copy is still on the data root"
    );
    assert_eq!(
        std::fs::read(&primary).expect("primary"),
        std::fs::read(&mirror)
            .unwrap_or_else(|err| panic!("no mirror at {}: {err}", mirror.display())),
        "the mirror must be the same bytes, not a summary of them"
    );
}

/// **The audit trail is adopted into `dir.log.path`, once.**
#[test]
fn the_audit_trail_moves_into_the_configured_log_directory() {
    let m = machine(Some(true));
    let stores = open(&m);
    // A chain from a build that predates the layout config.
    let old = stores.control_dir().join(gunnar_core::AUDIT_FILE);
    std::fs::write(&old, b"{\"seq\":0}\n").expect("write the old chain");

    let used = stores.audit_path();
    assert_eq!(
        used,
        m.data.join(slow::LOG_DIR).join(gunnar_core::AUDIT_FILE)
    );
    assert_eq!(std::fs::read(&used).expect("adopted"), b"{\"seq\":0}\n");
    assert!(!old.exists(), "the old copy is gone");
    // Idempotent: a second call does not re-adopt and does not clobber.
    assert_eq!(stores.audit_path(), used);
}

/// ★ **The assertion this whole lane exists for.**
///
/// On a box the installer could not give a second device, the mirror, the
/// crash records and the audit trail are on the same device as the thing they
/// protect. They are STILL WRITTEN — one code path, never a branch on whether
/// the slow area is real — and they are never reported as satisfied.
///
/// A health check that says "mirror OK" for a mirror that dies with its
/// original is worse than no mirror, because somebody will plan a recovery
/// around it.
#[test]
fn a_same_device_layout_is_written_and_never_reported_as_satisfied() {
    let m = machine(Some(false));
    let stores = open(&m);

    // Written. The placement does not change.
    assert!(
        m.slow.join(slow::META_DIR).join(IDENTITY_MIRROR).is_file(),
        "the mirror is written anyway: that is the one code path"
    );

    // And not claimed.
    assert_eq!(stores.slow().claim(), Claim::SameDevice);
    assert!(!stores.slow().claim().satisfied());

    let reality = stores.storage_reality();
    assert_eq!(reality.off_stripe, "same-device");
    assert_eq!(reality.backing_class, "fallback");

    assert_eq!(reality.off_stripe_health.len(), 3);
    for line in &reality.off_stripe_health {
        let lower = line.to_lowercase();
        assert!(lower.contains("present"), "never `missing`: {line}");
        assert!(lower.contains("not off-stripe"), "{line}");
        assert!(!lower.contains(" ok"), "never `OK`: {line}");
    }
    // Every row that requires another failure domain carries the verdict, and
    // no row anywhere says it is satisfied.
    for row in reality.placement.iter().filter(|r| r.requires_off_stripe) {
        assert_eq!(row.off_stripe, "same-device", "{}", row.what);
    }
    // A row that does not require it carries no verdict at all — painting
    // "off-stripe" on git's own objects would suggest a guarantee nobody made.
    for row in reality.placement.iter().filter(|r| !r.requires_off_stripe) {
        assert_eq!(row.off_stripe, "", "{}", row.what);
    }
}

/// The three states change the CLAIM and not one byte of the LAYOUT.
#[test]
fn the_three_states_change_only_the_claim() {
    let mut layouts = Vec::new();
    for (satisfied, expected) in [
        (Some(true), "off-stripe"),
        (Some(false), "same-device"),
        // No config: nothing has said either way, and that is its own answer.
        (None, "absent"),
    ] {
        let m = machine(satisfied);
        let stores = open(&m);
        let reality = stores.storage_reality();
        assert_eq!(reality.off_stripe, expected, "satisfied={satisfied:?}");
        layouts.push(
            reality
                .placement
                .iter()
                .map(|r| {
                    Path::new(&r.path)
                        .strip_prefix(m.dir.path())
                        .expect("under the machine")
                        .display()
                        .to_string()
                })
                .collect::<Vec<_>>(),
        );
    }
    assert_eq!(layouts[0], layouts[1], "the verdict must not move a path");
    // With no config every directory falls back under the data root — the
    // documented default, and NOT a `/serialdat` gunnar went looking for.
    assert!(
        layouts[2].iter().all(|p| p.starts_with("data/")),
        "the fallback is the data root and nothing else: {:?}",
        layouts[2]
    );
}

/// **An appliance installed before the layout config existed must start**, and
/// must SAY the config is missing rather than have its installation described
/// from the mount table.
#[test]
fn an_absent_config_is_reported_as_absent_and_nothing_is_inferred() {
    let m = machine(None);
    let stores = open(&m);
    let reality = stores.storage_reality();

    assert!(!reality.record_present);
    assert!(
        reality.record_fields.is_empty(),
        "not one field may be invented: {:?}",
        reality.record_fields
    );
    assert!(!reality.record_signature_verified);
    assert_eq!(reality.record_version, 0);
    assert_eq!(
        reality.backing_class, "",
        "a class that was not read is not reported"
    );
    assert_eq!(
        reality.slow_root, "",
        "a mountpoint that was not read is not reported"
    );
    assert_eq!(reality.off_stripe, "absent");

    let why = &reality.record_absent_reason;
    assert!(why.contains("no layout config at"), "{why}");
    assert!(
        why.contains(&m.config.display().to_string()),
        "the reason names where gunnar looked: {why}"
    );
}

/// ★ **`INTEGRITY ONLY`, in the installer's own two words.**
///
/// No `KORP_ROOT_SIGNING_KEY` is enrolled on this estate, so `auth.signature`
/// is empty and the config says so in its own bytes. gunnar says the same
/// thing, and never presents it as verified.
#[test]
fn an_unsigned_config_is_read_and_reported_as_integrity_only() {
    let m = machine(Some(true));
    let stores = open(&m);
    let reality = stores.storage_reality();

    assert!(reality.record_present);
    assert_eq!(reality.record_version, 1);
    assert_eq!(reality.record_signature, Authenticity::IntegrityOnly.word());
    assert_eq!(reality.record_signature, "INTEGRITY ONLY");
    assert!(
        !reality.record_signature_verified,
        "an unsigned config must NEVER be reported as verified"
    );
    assert!(
        reality.record_signature_note.contains("NOT SIGNED"),
        "the installer's own sentence travels with it: {}",
        reality.record_signature_note
    );

    let field = |key: &str| {
        reality
            .record_fields
            .iter()
            .find(|f| f.key == key)
            .map(|f| f.value.as_str())
    };
    // The only place the REASONING survives.
    assert_eq!(field("boot.why"), Some("largest-group-remainder"));
    assert_eq!(field("data.shape"), Some("raid0"));
    assert_eq!(field("unused.0.why"), Some("unequal, not used"));
    // The measured rootfs, read rather than guessed: 11.6 MiB, not gigabytes.
    assert_eq!(field("threshold.rootfs_measured_bytes"), Some("12163481"));
    // And nothing that was not written is answered.
    assert_eq!(field("data.bytes"), None);
}

/// **A config that does not hold together is ABSENT, not trusted** — it says
/// where the forge's data lives, so a forged one redirects its storage.
#[test]
fn a_tampered_config_is_treated_as_absent() {
    let m = machine(Some(true));
    let sealed = std::fs::read_to_string(&m.config).expect("read");
    std::fs::write(&m.config, sealed.replace("raid0", "plain")).expect("tamper");

    let stores = open(&m);
    let reality = stores.storage_reality();
    assert!(
        !reality.record_present,
        "not one value may be read out of it"
    );
    let why = &reality.record_absent_reason;
    assert!(why.contains("does not verify"), "{why}");
    assert!(why.contains("altered"), "{why}");
    assert!(why.contains("rather than trusted"), "{why}");
    // And the server still came up.
    assert_eq!(reality.off_stripe, "absent");
}

/// Layer 2: only the configured paths are measured — and a measurement that
/// failed reports no number at all rather than a zero somebody reads as full.
#[test]
fn the_filesystem_rows_are_measured_or_say_they_were_not() {
    let m = machine(Some(true));
    let stores = open(&m);
    let reality = stores.storage_reality();
    assert!(!reality.filesystems.is_empty());
    for fs in &reality.filesystems {
        assert!(
            Path::new(&fs.path).starts_with(m.dir.path()),
            "only paths gunnar was told to use are measured: {}",
            fs.path
        );
        if fs.measured {
            assert!(fs.total_bytes > 0, "{fs:?}");
            assert!(fs.error.is_empty(), "{fs:?}");
        } else {
            assert_eq!(fs.total_bytes, 0);
            assert_eq!(fs.free_bytes, 0);
            assert!(!fs.error.is_empty(), "a failure has to say why: {fs:?}");
        }
    }
    assert_eq!(reality.data_root, m.data.display().to_string());
    assert_eq!(reality.slow_root, m.slow.display().to_string());
    assert!(
        reality.slow_root_source.contains("layout config"),
        "gunnar was TOLD, and says so: {}",
        reality.slow_root_source
    );
}

// ── the trail survives a restore (lane T11, 2026-09-21; the D6 finding) ──────

/// **The audit trail where the layout puts it IS the replicated trail.**
///
/// D6: `log/` was a per-box path, so no twin ever held the audit trail and
/// `gunnar control twin replay` brought a box back with none — silently,
/// reporting success. This asserts the two halves that make that impossible:
/// the sink names the chain `log/audit-chain.jsonl` (the one path a restore
/// resolves), and every append lands in this box's transaction log.
///
/// **Fails before the fix**: restore `"log"` to the `matches!` list in
/// `gunnar_core::txlog::is_per_box_path` — `open_framed` then still succeeds
/// (the path resolves) but the first `append` is refused as a per-box path,
/// and the `head_seq` assertion never runs.
#[test]
fn the_audit_trail_is_the_replicated_trail() {
    let m = machine(Some(true));
    let stores = open(&m);
    let path = stores.audit_path();
    assert_eq!(
        path,
        m.data.join(slow::LOG_DIR).join(gunnar_core::AUDIT_FILE)
    );

    // The sink names it by the ONE relative path a restore knows.
    let (_log, rel) =
        gunnar_core::txlog::sink::log_for(&path).expect("the trail is under the data root");
    assert_eq!(rel, gunnar_core::txlog::AUDIT_REL);

    let before = stores.txlog().head_seq();
    let chain = gunnar_core::AuditChain::open_framed(&path).expect("the trail is framable");
    assert!(chain.is_framed());
    chain
        .append(
            gunnar_core::AuditEvent {
                method: "Access.Grant",
                principal: "alice",
                fingerprint: "SHA256:abc",
                scope: "server",
                level: "admin",
                auth_mode: "required",
                decision: "allow",
                reason: "",
            },
            1,
        )
        .expect("append");
    assert_eq!(
        stores.txlog().head_seq(),
        before + 1,
        "the decision must be a transaction, or the twin never sees it"
    );
    let e = stores.txlog().read(before + 1).expect("the frame");
    assert_eq!(e.kind(), Some(gunnar_core::txlog::Kind::Append));
    match e.meta().expect("meta") {
        gunnar_core::txlog::Meta::Append { log } => assert_eq!(log, gunnar_core::txlog::AUDIT_REL),
        other => panic!("the audit decision was framed as {other:?}"),
    }
}

/// **A twin keeps its OWN trail, in its own file.**
///
/// A twin serves a control plane of its own and every decision it makes is
/// its decision. Writing those into the chain it is HOLDING for the primary
/// would put a second writer into a hash chain whose entire value is that it
/// has one — every `prev` after the splice would break, and the restored copy
/// would be refused at open. So the two are different files and only the
/// primary's is framed.
///
/// **Fails before the fix**: remove the `if self.is_twin() || …` early return
/// from `ServerStores::audit_path` and a twin writes into the primary's trail.
#[test]
fn a_twin_writes_its_own_audit_trail_and_not_the_primarys() {
    let m = machine(Some(true));
    let stores = open(&m);
    let primary_trail = stores.audit_path();

    stores.mark_twin();
    let own = stores.audit_path();
    assert_ne!(
        own, primary_trail,
        "a twin must not append to the trail it is holding"
    );
    assert_eq!(
        own,
        m.data.join(slow::LOG_DIR).join(
            std::path::Path::new(gunnar_core::txlog::AUDIT_LOCAL_REL)
                .file_name()
                .expect("a name")
        ),
    );
    // And its own trail is never a transaction: it stays on the twin.
    let rel = gunnar_core::txlog::sink::log_for(&own).map(|(_, rel)| rel);
    assert!(
        rel.as_deref().is_none_or(gunnar_core::txlog::never_framed),
        "a twin's own decisions must never be framed into the primary's log: {rel:?}"
    );
}

/// **The site's hub declaration becomes a transaction, once per change** (lane T11).
///
/// `wg/hub.conf` is laid into the data root by `korp-installer` through the ESP data stage and
/// is never written by gunnar, so it had no sink call site: the twin did not hold it and a
/// restored main came up with no way onto the tunnel. It is framed on open instead — and the
/// digest of what was framed is kept beside it, so a box that reboots a thousand times adds
/// one frame and not a thousand.
///
/// **Fails before the fix**: delete the `adopt_hub_conf(&root, &txlog);` call from
/// `ServerStores::open` and the log holds no `File` frame naming `wg/hub.conf`.
#[test]
fn the_hub_declaration_is_framed_once_per_change() {
    let m = machine(Some(true));
    let wg = m.data.join("wg");
    std::fs::create_dir_all(&wg).expect("wg dir");
    std::fs::write(
        wg.join("hub.conf"),
        b"[Interface]\nAddress = 10.100.0.7/32\n",
    )
    .expect("hub.conf");
    // The machine's own identity sits beside it and must never travel.
    std::fs::write(wg.join("identity.key"), b"PRIVATE").expect("identity.key");

    let named = |stores: &ServerStores| -> Vec<String> {
        let log = stores.txlog();
        let (first, head) = (log.first_seq(), log.head_seq());
        if head < first {
            return Vec::new();
        }
        log.range(first, head)
            .expect("range")
            .into_iter()
            .filter_map(|e| match e.meta() {
                Ok(gunnar_core::txlog::Meta::File { path, .. }) => Some(path),
                _ => None,
            })
            .collect()
    };

    let stores = open(&m);
    let after_first = named(&stores);
    assert_eq!(
        after_first
            .iter()
            .filter(|p| *p == gunnar_core::txlog::HUB_CONF_REL)
            .count(),
        1,
        "the hub declaration must be exactly one transaction: {after_first:?}"
    );
    assert!(
        !after_first.iter().any(|p| p.contains("identity.key")),
        "the machine's own WireGuard private key must never be a transaction: {after_first:?}"
    );
    drop(stores);

    // A reboot that changes nothing frames nothing.
    let stores = open(&m);
    assert_eq!(
        named(&stores)
            .iter()
            .filter(|p| *p == gunnar_core::txlog::HUB_CONF_REL)
            .count(),
        1,
        "a boot that changed nothing must not add a frame"
    );
    drop(stores);

    // A hub declaration that CHANGED is framed again — it is the current one that matters.
    std::fs::write(
        wg.join("hub.conf"),
        b"[Interface]\nAddress = 10.100.0.8/32\n",
    )
    .expect("hub.conf");
    let stores = open(&m);
    assert_eq!(
        named(&stores)
            .iter()
            .filter(|p| *p == gunnar_core::txlog::HUB_CONF_REL)
            .count(),
        2,
        "a changed hub declaration is a new transaction"
    );
}