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