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

nordisk/gunnar

crates/gunnar-core/tests/arrow_log_conformance.rs

//! **The Arrow log runs `gunnar-store`'s conformance suite**, both hash kinds,
//! green and red.
//!
//! These two harnesses lived in `gunnar_store::conformance` until 2026-08-10,
//! when [`gunnar_core::arrow_log`] moved here and took its Arrow dependencies
//! with it — `gunnar-store` is the contract crate both `GitOps` backends
//! dev-depend on from other repositories, and it must not make either of them
//! resolve Arrow. The harnesses came here rather than being deleted, because
//! what they prove is not about where the code lives:
//!
//! * the Arrow log is **the only durable secret store in this repository**.
//!   `MemSecretStore`'s "restart" reopens `Arc`-shared state; reopening an
//!   `ArrowLogStore` closes the files, releases the exclusive lock and replays
//!   the IPC streams from scratch, so `secret_get_or_create_survives_restart`
//!   means here what it says.
//! * it is also the **legacy** store the host-key migration reads out of
//!   ([`gunnar_core::secrets`]), so "it still answers what it stored" is a live
//!   requirement, not history.
//!
//! # The git half is the control, deliberately
//!
//! `arrow_log` implements `SecretStore` and **not** `GitOps` — objects and refs
//! are the storage backends' job and there are two of those, neither of them
//! here. So the git checks in these runs are evidence about `MemGitServe` and the
//! **secret** checks are evidence about the log. That was true before the move
//! too; it is restated because it is the one thing a reader could get wrong
//! about a 46-outcome green.
//!
//! # The red half, and why it is reconstructed rather than imported
//!
//! `gunnar_store::conformance::red`'s five decorators are private to that
//! crate, and widening them is a decision about the conformance suite's public
//! surface rather than about where the Arrow log lives — so it was not taken
//! here. [`Regenerate`] below is the one break these harnesses ever used, in
//! twenty lines: a `get_or_create` that always creates, which is an SSH host
//! key that changes on every boot.
//!
//! Without it, the green above is worth nothing: a harness that quietly did
//! nothing would pass every check. That matters more for this backend than it
//! would for a database, because the log's atomicity comes from the IPC framing
//! rather than from a commit, so *"the frame makes it atomic"* is exactly the
//! kind of claim that goes untested.

use std::collections::HashMap;
use std::sync::Mutex;

use gunnar_core::arrow_log::{ArrowLogSecretStore, ArrowLogStore, MemoryLog};
use gunnar_store::conformance::{self, Backend, Harness, HASH_KINDS};
use gunnar_store::mem::MemGitServe;
use gunnar_store::{Result as StoreResult, SecretStore};

/// 62 = 31 checks x 2 hash kinds. Asserted rather than counted, so that losing
/// a check is a failure and not a quieter suite. It was 46 until 2026-08-10,
/// when the suite was retargeted from `GitOps` at `GitServe`, and 58 until
/// 2026-08-31, when `reachable` gained the annotated-tag edge, and 60 until
/// 2026-09-08, when `put_pack_observed` gained its moving-count check.
const EXPECTED: usize = 62;

// ── the two harnesses ────────────────────────────────────────────────────────

/// The suite against an Arrow IPC secret log, one directory per slot.
///
/// Reopening genuinely closes the files, releases the exclusive lock and
/// replays the streams from scratch.
struct ArrowLogHarness {
    dir: tempfile::TempDir,
    git: Mutex<HashMap<(String, gix_hash::Kind), MemGitServe>>,
}

impl ArrowLogHarness {
    fn new() -> anyhow::Result<Self> {
        Ok(ArrowLogHarness {
            dir: tempfile::tempdir()?,
            git: Mutex::new(HashMap::new()),
        })
    }
}

impl Harness for ArrowLogHarness {
    type Git = MemGitServe;
    type Secrets = ArrowLogSecretStore;

    fn name(&self) -> &str {
        "arrow-log"
    }

    fn open(
        &self,
        slot: &str,
        hash_kind: gix_hash::Kind,
    ) -> anyhow::Result<Backend<Self::Git, Self::Secrets>> {
        let path = self.dir.path().join(format!("{slot}-{hash_kind}"));
        let store = ArrowLogStore::open(path, hash_kind)?;
        let git = git_for(&self.git, slot, hash_kind);
        Ok(Backend {
            git,
            secrets: store.secrets(),
        })
    }
}

/// The same with **no file behind it** — the no-OS shape. A "restart" here
/// rebuilds the store by replaying the same bytes, so it is a real replay
/// rather than a shared handle.
#[derive(Default)]
struct ArrowLogInMemoryHarness {
    slots: Mutex<HashMap<(String, gix_hash::Kind), MemoryLog>>,
    git: Mutex<HashMap<(String, gix_hash::Kind), MemGitServe>>,
}

impl Harness for ArrowLogInMemoryHarness {
    type Git = MemGitServe;
    type Secrets = ArrowLogSecretStore;

    fn name(&self) -> &str {
        "arrow-log-in-memory"
    }

    fn open(
        &self,
        slot: &str,
        hash_kind: gix_hash::Kind,
    ) -> anyhow::Result<Backend<Self::Git, Self::Secrets>> {
        let log = {
            let mut slots = self.slots.lock().expect("harness lock");
            slots
                .entry((slot.to_owned(), hash_kind))
                .or_default()
                .clone()
        };
        let store = ArrowLogStore::open_memory(&log, hash_kind)?;
        let git = git_for(&self.git, slot, hash_kind);
        Ok(Backend {
            git,
            secrets: store.secrets(),
        })
    }
}

/// One `MemGitServe` per slot, shared across reopens so the git half's restart
/// checks see the first handle's data.
fn git_for(
    map: &Mutex<HashMap<(String, gix_hash::Kind), MemGitServe>>,
    slot: &str,
    hash_kind: gix_hash::Kind,
) -> MemGitServe {
    map.lock()
        .expect("harness lock")
        .entry((slot.to_owned(), hash_kind))
        .or_insert_with(|| MemGitServe::new(hash_kind))
        .clone()
}

fn run<H: Harness>(harness: &H) -> usize {
    let outcomes = conformance::run_all(harness);
    let expected = conformance::check_names::<H>().len() * HASH_KINDS.len();
    assert_eq!(outcomes.len(), expected, "[{}]", harness.name());
    // Both hash kinds must genuinely have run: sha256 green is never inferred
    // from sha1 green.
    for hash_kind in HASH_KINDS {
        let ran = outcomes.iter().filter(|o| o.hash_kind == hash_kind).count();
        assert_eq!(
            ran,
            expected / HASH_KINDS.len(),
            "[{}] only {ran} checks ran against {hash_kind}",
            harness.name()
        );
    }
    conformance::assert_all_pass(&outcomes)
}

#[test]
fn the_arrow_log_conforms() {
    let harness = ArrowLogHarness::new().expect("temporary directory");
    assert_eq!(run(&harness), EXPECTED);
}

#[test]
fn the_arrow_log_with_no_file_conforms() {
    assert_eq!(run(&ArrowLogInMemoryHarness::default()), EXPECTED);
}

// ── the red half ─────────────────────────────────────────────────────────────

/// **`get_or_create` that always creates.** The SSH host key that changes on
/// every boot, which is the bug `SecretStore::get_or_create` exists to make
/// impossible, and the one break these harnesses were ever run under.
struct Regenerate<S>(S);

impl<S: SecretStore> SecretStore for Regenerate<S> {
    fn get(&self, key: &str) -> StoreResult<Option<Vec<u8>>> {
        self.0.get(key)
    }
    fn put(&self, key: &str, value: &[u8]) -> StoreResult<()> {
        self.0.put(key, value)
    }
    fn delete(&self, key: &str) -> StoreResult<()> {
        self.0.delete(key)
    }
    fn get_or_create(
        &self,
        key: &str,
        mk: &mut dyn FnMut() -> StoreResult<Vec<u8>>,
    ) -> StoreResult<Vec<u8>> {
        // The bug: mint unconditionally, store it, hand it back. Every other
        // method is untouched, so `secret_put_get_delete` must still pass — a
        // suite that failed everything would be as blind as one that passed
        // everything.
        let value = mk()?;
        self.0.put(key, &value)?;
        Ok(value)
    }
}

struct Broken<H>(H);

impl<H: Harness> Harness for Broken<H> {
    type Git = H::Git;
    type Secrets = Regenerate<H::Secrets>;

    fn name(&self) -> &str {
        self.0.name()
    }

    fn open(
        &self,
        slot: &str,
        hash_kind: gix_hash::Kind,
    ) -> anyhow::Result<Backend<Self::Git, Self::Secrets>> {
        let inner = self.0.open(slot, hash_kind)?;
        Ok(Backend {
            git: inner.git,
            secrets: Regenerate(inner.secrets),
        })
    }
}

#[track_caller]
fn assert_red<H: Harness>(harness: H, must_fail: &[&str], must_pass: &[&str]) {
    let name = harness.name().to_owned();
    let outcomes = conformance::run_all(&Broken(harness));
    let failed: Vec<&str> = outcomes
        .iter()
        .filter(|o| !o.passed())
        .map(|o| o.check)
        .collect();
    for check in must_fail {
        assert!(
            failed.contains(check),
            "[{name}] the suite did NOT catch a get_or_create that always creates: `{check}` \
             passed against a backend whose SSH host key changes on every boot. Failed: {failed:?}"
        );
    }
    for check in must_pass {
        assert!(
            !failed.contains(check),
            "[{name}] `{check}` failed against a backend broken only in `get_or_create`; a suite \
             that fails everything is as blind as one that passes everything"
        );
    }
}

/// The Arrow log's green run is live code, not a harness that does nothing.
#[test]
fn the_arrow_log_backend_can_be_broken() {
    assert_red(
        ArrowLogHarness::new().expect("temporary directory"),
        &[
            "secret_get_or_create_survives_restart",
            "secret_get_or_create_is_atomic",
        ],
        &["secret_put_get_delete", "pack_roundtrip_every_entry"],
    );
}

/// The same for the no-filesystem shape, which is the one the appliance runs.
#[test]
fn the_in_memory_arrow_log_backend_can_be_broken() {
    assert_red(
        ArrowLogInMemoryHarness::default(),
        &[
            "secret_get_or_create_survives_restart",
            "secret_get_or_create_is_atomic",
        ],
        &["secret_put_get_delete", "pack_roundtrip_every_entry"],
    );
}