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"],
);
}