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

nordisk/gunnar

crates/gunnar-policy/tests/common/mod.rs

//! Fixtures and the assertion harness.
//!
//! # The harness returns `Result`, it does not `assert!`
//!
//! [`expect_reject`] and [`expect_allow`] hand back an `Err(String)` instead of
//! panicking. Every ordinary test unwraps them, so a failure reads the same as a
//! failed assertion, but `tests/blind.rs` can then *call* them against a
//! deliberately blind gate and assert they come back `Err`.
//!
//! That is the difference between "somebody once watched this test fail" and
//! "this test is checked, on every run, to be capable of failing". Measuring
//! rather than asserting asks for the second.

#![allow(dead_code)] // each test binary uses a different slice of this module

use gix_hash::ObjectId;
use gix_object::Kind;
use gunnar_policy::{Gate, Principal, PushContext, RefUpdate, RepoId, StaticKeyDirectory, Verdict};
use gunnar_store::git_storage_trait::{GitOps, GitServe};
use gunnar_store::mem::MemGitServe;
use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey};

/// A reference name, or a panic; test input is under our control.
///
/// It handed back a `gunnar_store::FullName` until 2026-08-10 — a newtype that
/// could not exist without passing the rules. That type went with `RefStore`
/// and `gunnar_policy::RefUpdate::name` is a `String` now, so the *validation*
/// is what this keeps: [`gunnar_store::refname::validate`], the same single
/// authority `receive_pack::parse_command` uses as its path-traversal
/// boundary. A fixture that could quietly build `refs/heads/../../etc/passwd`
/// would be a fixture testing a name the server can never see.
pub fn refname(name: &str) -> String {
    gunnar_store::refname::validate(name).expect("test reference names are valid");
    name.to_owned()
}

// ───────────────────────────── object fixtures ──────────────────────────────

/// An object store with helpers for building real git objects in it.
///
/// The commits it writes are byte-for-byte what git writes: a header block of
/// `tree` / `parent` / `author` / `committer` / optional `gpgsig`, a blank line,
/// then the message. They are read back through `gix-object`'s parser, so a
/// fixture that is not a real commit fails loudly rather than quietly exercising
/// nothing.
pub struct Repo {
    /// Objects and references in one store. `ObjectStore` and `RefStore`
    /// collapsed into `GitOps`/`GitServe` on 2026-08-10; nothing in this crate
    /// wanted the reference half, so the field keeps its name and only its type
    /// moved.
    pub objects: MemGitServe,
    pub hash_kind: gix_hash::Kind,
}

impl Repo {
    pub fn new(hash_kind: gix_hash::Kind) -> Self {
        Repo {
            objects: MemGitServe::new(hash_kind),
            hash_kind,
        }
    }

    pub fn sha1() -> Self {
        Repo::new(gix_hash::Kind::Sha1)
    }

    pub fn sha256() -> Self {
        Repo::new(gix_hash::Kind::Sha256)
    }

    pub fn blob(&self, bytes: &[u8]) -> ObjectId {
        self.write(Kind::Blob, bytes)
    }

    /// The empty tree, whose id differs between sha1 and sha256 repositories.
    pub fn empty_tree(&self) -> ObjectId {
        self.write(Kind::Tree, b"")
    }

    /// One object, stored.
    ///
    /// # Why this is a one-object packfile
    ///
    /// `MemObjectStore::write(kind, bytes)` is gone with the trait it was on.
    /// `GitOps` takes **packs and nothing else** — deliberately, because an
    /// engine that stores pack entries verbatim has nowhere to put a loose
    /// object — so the only way in is [`GitOps::put_pack`].
    ///
    /// The writer is `gunnar_pack`'s, the same one the shipping send-pack path
    /// uses, rather than git's entry format spelled a second time in a test
    /// module (one implementation). That is also why `gunnar-pack` is a
    /// dev-dependency of this crate as of 2026-08-10: it is test-only and
    /// nothing under `src/` names it.
    ///
    /// Re-storing an object already held is a no-op in the store, so the
    /// fixtures that write the empty tree five times cost five packs and one
    /// object, exactly as they cost five writes and one object before.
    pub fn write(&self, kind: Kind, bytes: &[u8]) -> ObjectId {
        let mut pack = Vec::new();
        let mut writer = gunnar_pack::PackWriter::new(
            &mut pack,
            1,
            &gunnar_pack::WriteOptions::new(self.hash_kind),
        )
        .expect("start a pack");
        writer.add_object(kind, bytes).expect("add the object");
        writer.finish().expect("finish the pack");
        self.objects.put_pack(&pack).expect("store the pack");
        gix_object::compute_hash(self.hash_kind, kind, bytes).expect("hash the object")
    }

    /// Raw commit bytes, unsigned.
    pub fn commit_bytes(&self, message: &str, parents: &[ObjectId], tree: ObjectId) -> Vec<u8> {
        let mut raw = format!("tree {tree}\n");
        for parent in parents {
            raw.push_str(&format!("parent {parent}\n"));
        }
        raw.push_str("author A U Thor <a@example.com> 1700000000 +0000\n");
        raw.push_str("committer A U Thor <a@example.com> 1700000000 +0000\n");
        raw.push('\n');
        raw.push_str(message);
        raw.push('\n');
        raw.into_bytes()
    }

    /// Write an unsigned commit whose author and committer time is `seconds`,
    /// for walks that order by date.
    pub fn commit_at(&self, message: &str, parents: &[ObjectId], seconds: i64) -> ObjectId {
        let tree = self.empty_tree();
        let raw = String::from_utf8(self.commit_bytes(message, parents, tree))
            .expect("fixture commits are utf-8")
            .replace(" 1700000000 +0000", &format!(" {seconds} +0000"));
        self.write_commit(raw.as_bytes())
    }

    /// Point `name` at `id`, as an existing reference of the repository.
    pub fn set_ref(&self, name: &str, id: ObjectId) {
        self.objects
            .update_ref(&refname(name), None, Some(id.as_bytes()))
            .expect("set a fixture reference");
    }

    /// Write an unsigned commit.
    pub fn commit(&self, message: &str, parents: &[ObjectId]) -> ObjectId {
        let tree = self.empty_tree();
        let raw = self.commit_bytes(message, parents, tree);
        self.write_commit(&raw)
    }

    /// Write a commit whose content is unique per `message` and which carries a
    /// real tree, so a size-limit fixture has something to measure.
    pub fn commit_with_blob(&self, message: &str, parents: &[ObjectId], blob: &[u8]) -> ObjectId {
        let blob_id = self.blob(blob);
        let mut tree = Vec::new();
        tree.extend_from_slice(b"100644 file\0");
        tree.extend_from_slice(blob_id.as_bytes());
        let tree_id = self.write(Kind::Tree, &tree);
        let raw = self.commit_bytes(message, parents, tree_id);
        self.write_commit(&raw)
    }

    /// Write a commit signed with `key`, exactly the way `git commit -S` does
    /// with `gpg.format=ssh`.
    pub fn signed_commit(&self, message: &str, parents: &[ObjectId], key: &PrivateKey) -> ObjectId {
        let tree = self.empty_tree();
        let payload = self.commit_bytes(message, parents, tree);
        let raw = sign_commit_bytes(&payload, key, "git");
        self.write_commit(&raw)
    }

    /// A commit whose signature was minted under a different SSHSIG namespace.
    pub fn commit_signed_in_namespace(
        &self,
        message: &str,
        parents: &[ObjectId],
        key: &PrivateKey,
        namespace: &str,
    ) -> ObjectId {
        let tree = self.empty_tree();
        let payload = self.commit_bytes(message, parents, tree);
        let raw = sign_commit_bytes(&payload, key, namespace);
        self.write_commit(&raw)
    }

    /// A commit carrying a signature made over *different* content: the
    /// tampered-payload case.
    pub fn commit_with_transplanted_signature(
        &self,
        message: &str,
        parents: &[ObjectId],
        key: &PrivateKey,
        signed_over: &str,
    ) -> ObjectId {
        let tree = self.empty_tree();
        let other = self.commit_bytes(signed_over, parents, tree);
        let signed_other = sign_commit_bytes(&other, key, "git");
        let armor = extract_armor(&signed_other);

        let payload = self.commit_bytes(message, parents, tree);
        let raw = splice_signature(&payload, &armor);
        self.write_commit(&raw)
    }

    /// Raw annotated tag bytes, unsigned, exactly as `git tag -a` writes them.
    pub fn tag_bytes(
        &self,
        name: &str,
        target: ObjectId,
        target_kind: Kind,
        message: &str,
    ) -> Vec<u8> {
        let mut raw = format!("object {target}\n");
        raw.push_str(&format!("type {target_kind}\n"));
        raw.push_str(&format!("tag {name}\n"));
        raw.push_str("tagger A U Thor <a@example.com> 1700000000 +0000\n");
        raw.push('\n');
        raw.push_str(message);
        raw.push('\n');
        raw.into_bytes()
    }

    /// An unsigned annotated tag object. **The GUN-003 payload**: this is what
    /// `git tag -a v1 <commit>` writes, and pushing it as a ref tip used to end
    /// the signature walk before it began.
    pub fn tag(&self, name: &str, target: ObjectId, target_kind: Kind) -> ObjectId {
        let raw = self.tag_bytes(name, target, target_kind, "the tag message");
        self.write_tag(&raw)
    }

    /// A tag signed the way `git tag -s` does with `gpg.format=ssh`: the armor
    /// appended after the message, NOT folded into a header.
    pub fn signed_tag(
        &self,
        name: &str,
        target: ObjectId,
        target_kind: Kind,
        key: &PrivateKey,
    ) -> ObjectId {
        let payload = self.tag_bytes(name, target, target_kind, "the tag message");
        self.write_tag(&append_tag_signature(&payload, key, "git"))
    }

    /// A tag whose *message* carries a signature block over other bytes, with
    /// the real armor after it. The splitter must take the LAST block.
    pub fn tag_with_decoy_signature(
        &self,
        name: &str,
        target: ObjectId,
        target_kind: Kind,
        key: &PrivateKey,
    ) -> ObjectId {
        let short = self.tag_bytes(name, target, target_kind, "the tag message");
        let decoy = append_tag_signature(&short, key, "git");
        // …and then more text after the block that was genuinely signed, so
        // the payload a first-block splitter would verify is a strict prefix
        // of the object.
        let mut raw = decoy;
        raw.extend_from_slice(b"and something the tagger added afterwards\n");
        self.write_tag(&raw)
    }

    /// Store raw tag bytes, asserting they parse as a tag first.
    pub fn write_tag(&self, raw: &[u8]) -> ObjectId {
        gix_object::TagRef::from_bytes(raw, self.hash_kind)
            .expect("fixture must be a real, parseable tag");
        self.write(Kind::Tag, raw)
    }

    /// Store raw commit bytes, asserting they parse as a commit first.
    pub fn write_commit(&self, raw: &[u8]) -> ObjectId {
        gix_object::CommitRef::from_bytes(raw, self.hash_kind)
            .expect("fixture must be a real, parseable commit");
        self.write(Kind::Commit, raw)
    }

    pub fn raw(&self, id: &ObjectId) -> Vec<u8> {
        self.objects
            .read(id.as_bytes())
            .expect("read")
            .expect("object is present")
            .1
    }
}

/// Sign `payload` and append the armored blob, the way a signed TAG carries it.
pub fn append_tag_signature(payload: &[u8], key: &PrivateKey, namespace: &str) -> Vec<u8> {
    let sig = key
        .sign(namespace, HashAlg::Sha512, payload)
        .expect("sign the tag payload");
    let armor = sig.to_pem(LineEnding::LF).expect("armor the signature");
    let mut out = payload.to_vec();
    out.extend_from_slice(armor.trim_end().as_bytes());
    out.push(b'\n');
    out
}

/// Sign `payload` and splice the armored blob back in as a `gpgsig` header.
pub fn sign_commit_bytes(payload: &[u8], key: &PrivateKey, namespace: &str) -> Vec<u8> {
    let sig = key
        .sign(namespace, HashAlg::Sha512, payload)
        .expect("sign the commit payload");
    let armor = sig.to_pem(LineEnding::LF).expect("armor the signature");
    splice_signature(payload, armor.trim_end())
}

/// Fold `armor` into a `gpgsig` header and insert it after `committer`.
pub fn splice_signature(payload: &[u8], armor: &str) -> Vec<u8> {
    let mut header = String::from("gpgsig ");
    for (i, line) in armor.trim_end().split('\n').enumerate() {
        if i > 0 {
            header.push_str("\n ");
        }
        header.push_str(line);
    }
    header.push('\n');

    // git puts the signature immediately after the committer line.
    let text = String::from_utf8(payload.to_vec()).expect("fixtures are utf-8");
    let committer_end = text
        .find("\ncommitter ")
        .map(|start| text[start + 1..].find('\n').expect("committer line ends") + start + 2)
        .expect("fixtures have a committer line");
    let mut out = String::with_capacity(text.len() + header.len());
    out.push_str(&text[..committer_end]);
    out.push_str(&header);
    out.push_str(&text[committer_end..]);
    out.into_bytes()
}

/// Pull the armored blob back out of a signed commit, unfolded.
pub fn extract_armor(raw: &[u8]) -> String {
    let text = String::from_utf8(raw.to_vec()).expect("fixtures are utf-8");
    let start = text.find("gpgsig ").expect("a gpgsig header") + "gpgsig ".len();
    let mut out = String::new();
    for line in text[start..].split('\n') {
        if out.is_empty() {
            out.push_str(line);
        } else if let Some(rest) = line.strip_prefix(' ') {
            out.push('\n');
            out.push_str(rest);
        } else {
            break;
        }
    }
    out
}

/// A fresh ed25519 key pair, minted in-process. No `ssh-keygen`.
pub fn mint_key() -> PrivateKey {
    PrivateKey::random(
        &mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
        Algorithm::Ed25519,
    )
    .expect("mint an ed25519 key")
}

// ───────────────────────────── the merge graph ──────────────────────────────

/// A commit graph with a real merge in it.
///
/// ```text
///                 b ──────────────╮
///                ╱                 ╲
///   root ── a ──┤                   m ── n        (m has parents [b, f2])
///                ╲                 ╱
///                 f1 ── f2 ───────╯
/// ```
///
/// The point of the shape is `f2`: it is reachable from `m` **only through the
/// second parent**, so a first-parent-only ancestry walk (the plausible wrong
/// implementation) reports the legitimate fast-forward `f2 → m` as a
/// force-push. A linear fixture cannot tell the two implementations apart.
pub struct MergeGraph {
    pub repo: Repo,
    pub root: ObjectId,
    pub a: ObjectId,
    pub b: ObjectId,
    pub f1: ObjectId,
    pub f2: ObjectId,
    pub m: ObjectId,
    pub n: ObjectId,
}

impl MergeGraph {
    pub fn build(hash_kind: gix_hash::Kind) -> Self {
        let repo = Repo::new(hash_kind);
        let root = repo.commit("root", &[]);
        let a = repo.commit("a", &[root]);
        let b = repo.commit("b", &[a]);
        let f1 = repo.commit("f1", &[a]);
        let f2 = repo.commit("f2", &[f1]);
        let m = repo.commit("m: merge feature into main", &[b, f2]);
        let n = repo.commit("n", &[m]);
        MergeGraph {
            repo,
            root,
            a,
            b,
            f1,
            f2,
            m,
            n,
        }
    }

    pub fn all(&self) -> Vec<ObjectId> {
        vec![self.root, self.a, self.b, self.f1, self.f2, self.m, self.n]
    }
}

// ───────────────────────────────── scenarios ────────────────────────────────

/// Everything a [`PushContext`] borrows, owned in one place.
pub struct Scenario {
    pub principal: Principal,
    pub repo_id: RepoId,
    pub updates: Vec<RefUpdate>,
    pub new_objects: Vec<ObjectId>,
    pub keys: StaticKeyDirectory,
    pub objects: Box<dyn GitServe>,
    pub pack_bytes: Option<u64>,
    /// `None` is "receive-pack never reported any", which a configured
    /// push-option gate must refuse rather than treat as "the client sent
    /// none". `Some(vec![])` is the second thing.
    pub push_options: Option<Vec<String>>,
}

impl Scenario {
    pub fn new(objects: impl GitServe + 'static) -> Self {
        Scenario {
            principal: Principal::user("alice"),
            repo_id: RepoId::new("nordisk", "gunnar"),
            updates: Vec::new(),
            new_objects: Vec::new(),
            keys: StaticKeyDirectory::new(),
            objects: Box::new(objects),
            pack_bytes: None,
            push_options: None,
        }
    }

    pub fn pushed_by(mut self, id: &str) -> Self {
        self.principal = Principal::user(id);
        self
    }

    pub fn pushing(mut self, update: RefUpdate) -> Self {
        self.updates.push(update);
        self
    }

    pub fn introducing(mut self, objects: impl IntoIterator<Item = ObjectId>) -> Self {
        self.new_objects.extend(objects);
        self
    }

    pub fn with_key(mut self, principal: &str, key: &PrivateKey) -> Self {
        self.keys.add(principal, key.public_key().clone());
        self
    }

    pub fn with_pack_bytes(mut self, bytes: u64) -> Self {
        self.pack_bytes = Some(bytes);
        self
    }

    /// The push options receive-pack read off the wire. An empty slice is
    /// legitimate and means the client sent none.
    pub fn with_push_options<I>(mut self, options: I) -> Self
    where
        I: IntoIterator,
        I::Item: Into<String>,
    {
        self.push_options = Some(options.into_iter().map(Into::into).collect());
        self
    }

    pub fn context(&self) -> PushContext<'_> {
        let ctx = PushContext::new(
            &self.principal,
            &self.repo_id,
            &self.updates,
            &self.new_objects,
            self.objects.as_ref(),
            &self.keys,
        );
        let ctx = match self.pack_bytes {
            Some(bytes) => ctx.with_pack_bytes(bytes),
            None => ctx,
        };
        match &self.push_options {
            Some(options) => ctx.with_push_options(options),
            None => ctx,
        }
    }
}

/// `Box<dyn ObjectStore>` needs these to satisfy `PushContext`'s bound.
impl std::fmt::Debug for Scenario {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Scenario")
            .field("principal", &self.principal.id)
            .field("updates", &self.updates)
            .finish_non_exhaustive()
    }
}

// ────────────────────────────────── harness ─────────────────────────────────

/// Assert that `gate` refuses `ctx`, with `needle` in the reason.
///
/// Returns `Err` rather than panicking so that `tests/blind.rs` can prove this
/// function is capable of failing.
pub fn expect_reject(
    gate: &dyn Gate,
    ctx: &PushContext<'_>,
    needle: &str,
) -> Result<Verdict, String> {
    match gate.check(ctx) {
        Ok(Verdict::Allow) => Err(format!(
            "gate `{}` ALLOWED a push that must be refused ({needle})",
            gate.name()
        )),
        Ok(verdict @ Verdict::Reject(_)) => {
            let reason = verdict.reason().unwrap_or_default();
            if reason.contains(needle) {
                Ok(verdict)
            } else {
                Err(format!(
                    "gate `{}` refused, but for the wrong reason:\n  expected to contain: {needle}\n  actual: {reason}",
                    gate.name()
                ))
            }
        }
        Err(err) => Err(format!(
            "gate `{}` errored instead of refusing: {err}",
            gate.name()
        )),
    }
}

/// Assert that `gate` allows `ctx`.
pub fn expect_allow(gate: &dyn Gate, ctx: &PushContext<'_>) -> Result<(), String> {
    match gate.check(ctx) {
        Ok(Verdict::Allow) => Ok(()),
        Ok(verdict) => Err(format!(
            "gate `{}` REFUSED a push that must be allowed: {}",
            gate.name(),
            verdict.reason().unwrap_or_default()
        )),
        Err(err) => Err(format!(
            "gate `{}` errored on a push that must be allowed: {err}",
            gate.name()
        )),
    }
}

// ──────────────────────── the shared crafted-bad-push set ───────────────────

/// One push that a working gate must refuse.
///
/// `run` builds its own fixture and applies whatever gate it is handed, which is
/// what lets the identical case be replayed through a blind gate in
/// `tests/blind.rs`.
pub type RejectRun = Box<dyn Fn(&dyn Gate) -> Result<Verdict, String>>;
pub type AllowRun = Box<dyn Fn(&dyn Gate) -> Result<(), String>>;

pub struct BadPush {
    pub name: &'static str,
    pub needle: &'static str,
    pub gate: Box<dyn Gate>,
    pub run: RejectRun,
}

/// One push that a working gate must allow.
pub struct GoodPush {
    pub name: &'static str,
    pub gate: Box<dyn Gate>,
    pub run: AllowRun,
}

use gunnar_policy::{AiTrailerGate, AiTrailerPolicy};
use gunnar_policy::{BranchProtectionGate, LimitsGate, RefNameGate, SignedCommitsGate};
use gunnar_policy::{LimitPolicy, ProtectionRule, RefNamePolicy, SignaturePolicy, SignerScope};

fn protection_gate() -> BranchProtectionGate {
    BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main").unwrap()])
}

fn refname_gate() -> RefNameGate {
    RefNameGate::new(
        RefNamePolicy::new(["refs/heads/**", "refs/tags/**"], ["refs/heads/tmp-*"]).unwrap(),
    )
}

fn signed_gate() -> SignedCommitsGate {
    SignedCommitsGate::new(SignaturePolicy::required())
}

/// Every crafted bad push, one per distinct refusal a gate can make.
///
/// This list is the single source of truth for both `tests/gates.rs` (the real
/// gates must refuse each one) and `tests/blind.rs` (a blind gate must not, and
/// the harness must notice).
pub fn bad_pushes() -> Vec<BadPush> {
    vec![
        BadPush {
            name: "branch-protection: a genuine non-fast-forward on a protected branch",
            needle: "non-fast-forward",
            gate: Box::new(protection_gate()),
            run: Box::new(|gate| {
                let graph = MergeGraph::build(gix_hash::Kind::Sha1);
                // `f2` sits on the feature branch and does not descend from `b`.
                let update = RefUpdate::update(refname("refs/heads/main"), graph.b, graph.f2);
                let scenario = Scenario::new(graph.repo.objects.clone())
                    .pushing(update)
                    .introducing(graph.all());
                expect_reject(gate, &scenario.context(), "non-fast-forward")
            }),
        },
        BadPush {
            name: "branch-protection: rewinding a protected branch to its own ancestor",
            needle: "non-fast-forward",
            gate: Box::new(protection_gate()),
            run: Box::new(|gate| {
                let graph = MergeGraph::build(gix_hash::Kind::Sha1);
                let update = RefUpdate::update(refname("refs/heads/main"), graph.n, graph.m);
                let scenario = Scenario::new(graph.repo.objects.clone())
                    .pushing(update)
                    .introducing(graph.all());
                expect_reject(gate, &scenario.context(), "non-fast-forward")
            }),
        },
        BadPush {
            name: "branch-protection: deleting a protected branch",
            needle: "may not be deleted",
            gate: Box::new(protection_gate()),
            run: Box::new(|gate| {
                let graph = MergeGraph::build(gix_hash::Kind::Sha1);
                let update = RefUpdate::delete(refname("refs/heads/main"), graph.n);
                let scenario = Scenario::new(graph.repo.objects.clone()).pushing(update);
                expect_reject(gate, &scenario.context(), "may not be deleted")
            }),
        },
        BadPush {
            name: "branch-protection: an unlisted principal moving a restricted branch",
            needle: "not among its allowed principals",
            gate: Box::new(BranchProtectionGate::new(vec![ProtectionRule::protect(
                "refs/heads/main",
            )
            .unwrap()
            .by(["release-bot"])])),
            run: Box::new(|gate| {
                let graph = MergeGraph::build(gix_hash::Kind::Sha1);
                let update = RefUpdate::update(refname("refs/heads/main"), graph.m, graph.n);
                let scenario = Scenario::new(graph.repo.objects.clone())
                    .pushed_by("alice")
                    .pushing(update)
                    .introducing(graph.all());
                expect_reject(
                    gate,
                    &scenario.context(),
                    "not among its allowed principals",
                )
            }),
        },
        BadPush {
            name: "branch-protection: an ancestry walk that runs out of budget refuses",
            needle: "ancestry could not be established",
            gate: Box::new(BranchProtectionGate::new(vec![ProtectionRule {
                ancestry_budget: 1,
                ..ProtectionRule::protect("refs/heads/main").unwrap()
            }])),
            run: Box::new(|gate| {
                let graph = MergeGraph::build(gix_hash::Kind::Sha1);
                // root → n is a real fast-forward, but a one-commit budget cannot
                // prove it, and "undecided" must not read as "allowed".
                let update = RefUpdate::update(refname("refs/heads/main"), graph.root, graph.n);
                let scenario = Scenario::new(graph.repo.objects.clone())
                    .pushing(update)
                    .introducing(graph.all());
                expect_reject(
                    gate,
                    &scenario.context(),
                    "ancestry could not be established",
                )
            }),
        },
        BadPush {
            name: "ref-name: a denied pattern",
            needle: "is denied by pattern",
            gate: Box::new(refname_gate()),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let tip = repo.commit("c", &[]);
                let update = RefUpdate::create(refname("refs/heads/tmp-scratch"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip]);
                expect_reject(gate, &scenario.context(), "is denied by pattern")
            }),
        },
        BadPush {
            name: "ref-name: a name outside the allowlist",
            needle: "matches no allowed pattern",
            gate: Box::new(refname_gate()),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let tip = repo.commit("c", &[]);
                let update = RefUpdate::create(refname("refs/pull/7/head"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip]);
                expect_reject(gate, &scenario.context(), "matches no allowed pattern")
            }),
        },
        BadPush {
            name: "signed-commits: an unsigned commit",
            needle: "is not signed",
            gate: Box::new(signed_gate()),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let key = mint_key();
                let tip = repo.commit("unsigned", &[]);
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip])
                    .with_key("alice", &key);
                expect_reject(gate, &scenario.context(), "is not signed")
            }),
        },
        BadPush {
            name: "signed-commits: a signature by a key gunnar does not know",
            needle: "is not registered with gunnar",
            gate: Box::new(signed_gate()),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let registered = mint_key();
                let stranger = mint_key();
                let tip = repo.signed_commit("signed by a stranger", &[], &stranger);
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip])
                    .with_key("alice", &registered);
                expect_reject(gate, &scenario.context(), "is not registered with gunnar")
            }),
        },
        BadPush {
            name: "signed-commits: a valid signature over different content",
            needle: "does not verify against the object",
            gate: Box::new(signed_gate()),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let key = mint_key();
                let tip =
                    repo.commit_with_transplanted_signature("payload B", &[], &key, "payload A");
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip])
                    .with_key("alice", &key);
                expect_reject(
                    gate,
                    &scenario.context(),
                    "does not verify against the object",
                )
            }),
        },
        BadPush {
            name: "signed-commits: a signature minted under another namespace",
            needle: "namespace",
            gate: Box::new(signed_gate()),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let key = mint_key();
                let tip = repo.commit_signed_in_namespace("c", &[], &key, "file");
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip])
                    .with_key("alice", &key);
                expect_reject(gate, &scenario.context(), "namespace")
            }),
        },
        BadPush {
            name: "signed-commits: a colleague's key under PusherKeysOnly",
            needle: "is not registered with gunnar",
            gate: Box::new(SignedCommitsGate::new(SignaturePolicy {
                scope: SignerScope::PusherKeysOnly,
                ..SignaturePolicy::required()
            })),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let mine = mint_key();
                let colleague = mint_key();
                let tip = repo.signed_commit("signed by a colleague", &[], &colleague);
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushed_by("alice")
                    .pushing(update)
                    .introducing([tip])
                    .with_key("alice", &mine)
                    .with_key("colleague", &colleague);
                expect_reject(gate, &scenario.context(), "is not registered with gunnar")
            }),
        },
        BadPush {
            name: "limits: an oversized blob",
            needle: "over the",
            gate: Box::new(LimitsGate::new(LimitPolicy::object_bytes(1024))),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let big = vec![b'x'; 4096];
                let tip = repo.commit_with_blob("big", &[], &big);
                let blob = repo.blob(&big);
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip, blob]);
                expect_reject(gate, &scenario.context(), "over the")
            }),
        },
        BadPush {
            name: "limits: an absurd object count",
            needle: "over the 2-object limit",
            gate: Box::new(LimitsGate::new(LimitPolicy::objects(2))),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let ids: Vec<_> = (0..5)
                    .map(|i| repo.blob(format!("b{i}").as_bytes()))
                    .collect();
                let tip = repo.commit("c", &[]);
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing(ids);
                expect_reject(gate, &scenario.context(), "over the 2-object limit")
            }),
        },
        BadPush {
            name: "limits: a pack-size limit that cannot be enforced refuses",
            needle: "cannot be enforced",
            gate: Box::new(LimitsGate::new(LimitPolicy {
                max_pack_bytes: Some(1_000_000),
                ..LimitPolicy::default()
            })),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let tip = repo.commit("c", &[]);
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                // No `with_pack_bytes`: receive-pack did not measure it.
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip]);
                expect_reject(gate, &scenario.context(), "cannot be enforced")
            }),
        },
        BadPush {
            name: "limits: an oversized pack",
            needle: "over the 1024-byte limit",
            gate: Box::new(LimitsGate::new(LimitPolicy {
                max_pack_bytes: Some(1024),
                ..LimitPolicy::default()
            })),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let tip = repo.commit("c", &[]);
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip])
                    .with_pack_bytes(4096);
                expect_reject(gate, &scenario.context(), "over the 1024-byte limit")
            }),
        },
        BadPush {
            name: "ai-trailers: a commit new to the forge carrying Co-Authored-By: Claude",
            needle: "carries an AI attribution trailer",
            gate: Box::new(AiTrailerGate::new(AiTrailerPolicy::default())),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let base = repo.commit("base", &[]);
                repo.set_ref("refs/heads/main", base);
                let tip = repo.commit(
                    "Fix it\n\nCo-Authored-By: Claude <noreply@anthropic.com>",
                    &[base],
                );
                let update = RefUpdate::update(refname("refs/heads/main"), base, tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip]);
                expect_reject(
                    gate,
                    &scenario.context(),
                    "carries an AI attribution trailer",
                )
            }),
        },
    ]
}

/// The other direction: pushes every gate must let through.
///
/// A gate that rejects everything passes every test in [`bad_pushes`] and is
/// exactly as broken as one that allows everything.
pub fn good_pushes() -> Vec<GoodPush> {
    vec![
        GoodPush {
            name: "branch-protection: a linear fast-forward",
            gate: Box::new(protection_gate()),
            run: Box::new(|gate| {
                let graph = MergeGraph::build(gix_hash::Kind::Sha1);
                let update = RefUpdate::update(refname("refs/heads/main"), graph.a, graph.b);
                let scenario = Scenario::new(graph.repo.objects.clone())
                    .pushing(update)
                    .introducing(graph.all());
                expect_allow(gate, &scenario.context())
            }),
        },
        GoodPush {
            name: "branch-protection: a fast-forward across a merge's SECOND parent",
            gate: Box::new(protection_gate()),
            run: Box::new(|gate| {
                let graph = MergeGraph::build(gix_hash::Kind::Sha1);
                // `f2 → m` is a fast-forward, but only through the merge's second
                // parent. A first-parent walk would refuse this.
                let update = RefUpdate::update(refname("refs/heads/main"), graph.f2, graph.m);
                let scenario = Scenario::new(graph.repo.objects.clone())
                    .pushing(update)
                    .introducing(graph.all());
                expect_allow(gate, &scenario.context())
            }),
        },
        GoodPush {
            name: "branch-protection: an unprotected branch may be force-pushed",
            gate: Box::new(protection_gate()),
            run: Box::new(|gate| {
                let graph = MergeGraph::build(gix_hash::Kind::Sha1);
                let update = RefUpdate::update(refname("refs/heads/scratch"), graph.b, graph.f2);
                let scenario = Scenario::new(graph.repo.objects.clone())
                    .pushing(update)
                    .introducing(graph.all());
                expect_allow(gate, &scenario.context())
            }),
        },
        GoodPush {
            name: "ref-name: an allowed name",
            gate: Box::new(refname_gate()),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let tip = repo.commit("c", &[]);
                let update = RefUpdate::create(refname("refs/heads/feat/login"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip]);
                expect_allow(gate, &scenario.context())
            }),
        },
        GoodPush {
            name: "signed-commits: a genuinely valid signature",
            gate: Box::new(signed_gate()),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let key = mint_key();
                let tip = repo.signed_commit("signed", &[], &key);
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip])
                    .with_key("alice", &key);
                expect_allow(gate, &scenario.context())
            }),
        },
        GoodPush {
            name: "limits: objects inside every ceiling",
            gate: Box::new(LimitsGate::new(LimitPolicy {
                max_object_bytes: Some(1024),
                max_objects: Some(100),
                max_total_bytes: Some(4096),
                max_pack_bytes: Some(65536),
                max_blob_bytes: Some(512),
            })),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let tip = repo.commit_with_blob("small", &[], b"hello");
                let blob = repo.blob(b"hello");
                let update = RefUpdate::create(refname("refs/heads/main"), tip);
                let scenario = Scenario::new(repo.objects.clone())
                    .pushing(update)
                    .introducing([tip, blob])
                    .with_pack_bytes(2048);
                expect_allow(gate, &scenario.context())
            }),
        },
        GoodPush {
            name: "ai-trailers: a historical trailer commit already on another branch",
            gate: Box::new(AiTrailerGate::new(AiTrailerPolicy::default())),
            run: Box::new(|gate| {
                let repo = Repo::sha1();
                let base = repo.commit("base", &[]);
                let old = repo.commit(
                    "Old work\n\nCo-Authored-By: Claude <noreply@anthropic.com>",
                    &[base],
                );
                repo.set_ref("refs/heads/main", base);
                repo.set_ref("refs/heads/history", old);
                let update = RefUpdate::create(refname("refs/heads/again"), old);
                let scenario = Scenario::new(repo.objects.clone()).pushing(update);
                expect_allow(gate, &scenario.context())
            }),
        },
    ]
}