crates/gunnar-policy/tests/engine.rs
//! The engine, the declarative configuration, and the remaining gate detail.
mod common;
use common::{expect_allow, expect_reject, mint_key, refname, MergeGraph, Repo, Scenario};
use gunnar_policy::{
AiTrailerPolicy, Gate, LimitPolicy, LimitsGate, MergePolicy, PolicyConfig, PolicyEngine,
ProtectionRule, PushOptionPolicy, RefNameGate, RefNamePolicy, RefPattern, RefUpdate,
SignaturePolicy,
};
// ─────────────────────────────── configuration ──────────────────────────────
/// **The merge policy (GUN-MERGE-ENGINE, 2026-11-01)**: a document that never names
/// `merge` gets a fast-forward and a merge commit and no squash, and a document that names
/// one field keeps the other two at their defaults.
#[test]
fn the_merge_policy_defaults_to_fast_forward_and_merge_commit() {
let none = PolicyConfig::from_json("{}").unwrap();
assert_eq!(
none.merge,
MergePolicy {
allow_fast_forward: true,
allow_merge_commit: true,
allow_squash: false,
}
);
let linear = PolicyConfig::from_json(r#"{"merge": {"allow_merge_commit": false}}"#).unwrap();
assert!(linear.merge.allow_fast_forward && !linear.merge.allow_merge_commit);
assert!(!linear.merge.allow_squash);
assert!(
PolicyConfig::from_json(r#"{"merge": {"allow_rebase": true}}"#).is_err(),
"an unknown merge field is refused, like every other policy field"
);
}
#[test]
fn a_policy_survives_a_json_round_trip() {
let config = PolicyConfig {
ref_names: RefNamePolicy::new(["refs/heads/**"], ["refs/heads/tmp-*"]).unwrap(),
protection: vec![ProtectionRule::protect("refs/heads/main")
.unwrap()
.requiring_signature()
.by(["release-bot"])],
signatures: SignaturePolicy::required_from_pusher(),
limits: LimitPolicy {
max_object_bytes: Some(4 << 20),
max_objects: Some(100_000),
..LimitPolicy::default()
},
push_options: PushOptionPolicy::new(["ci.*", "reviewer=*"], ["ticket=NORD-*"]).unwrap(),
ai_trailers: AiTrailerPolicy {
seed_principal: None,
..AiTrailerPolicy::off()
},
merge: MergePolicy {
allow_squash: true,
..MergePolicy::default()
},
};
let json = config.to_json().unwrap();
let back = PolicyConfig::from_json(&json).unwrap();
assert_eq!(config, back, "round trip changed the policy:\n{json}");
}
#[test]
fn an_unknown_field_in_a_policy_is_an_error_not_a_silently_dropped_rule() {
// A typo in a protection rule must not produce a policy that quietly protects
// less than it says. `deny_unknown_fields` is what makes that true, so it is
// asserted rather than assumed.
let err = PolicyConfig::from_json(
r#"{"protection":[{"pattern":"refs/heads/main","allow_force_pushh":true}]}"#,
)
.unwrap_err();
assert!(
err.to_string().contains("allow_force_pushh"),
"expected the unknown field to be named, got: {err}"
);
}
#[test]
fn a_protection_rule_that_names_only_a_pattern_is_the_strict_one() {
// The under-specified case must be the safe one. If these defaults ever flip,
// every policy written as `{"pattern": "..."}` silently stops protecting.
let rule: ProtectionRule = serde_json::from_str(r#"{"pattern":"refs/heads/main"}"#).unwrap();
assert!(!rule.allow_force_push);
assert!(!rule.allow_delete);
assert!(!rule.require_signature);
assert!(
rule.allow_create,
"a protection rule must not forbid new branches by default"
);
assert_eq!(rule.allowed_principals, None);
}
#[test]
fn a_malformed_pattern_is_refused_at_parse_time() {
assert!(PolicyConfig::from_json(r#"{"protection":[{"pattern":""}]}"#).is_err());
assert!(RefPattern::new("").is_err());
}
#[test]
fn only_configured_gates_are_live() {
// `Policy.ListGates` must reflect what actually runs. An empty policy builds
// no gates at all rather than four gates that always say yes. The difference
// is that the second is indistinguishable from four broken ones.
//
// The one exception is deliberate and is the only gate an unconfigured
// repository runs: the attribution-trailer gate is ON by default (owner
// ruling 2026-09-28), and turning it off is what an empty engine now is.
let unconfigured = PolicyEngine::from_config(&PolicyConfig::default());
assert_eq!(unconfigured.gate_names(), vec!["ai-trailers"]);
let empty = PolicyEngine::from_config(&PolicyConfig {
ai_trailers: AiTrailerPolicy::off(),
..PolicyConfig::default()
});
assert!(empty.is_empty(), "an empty policy must build no gates");
assert_eq!(empty.gate_names(), Vec::<&str>::new());
let full = PolicyEngine::from_config(&PolicyConfig {
ref_names: RefNamePolicy::new(["refs/heads/**"], Vec::<String>::new()).unwrap(),
protection: vec![ProtectionRule::protect("refs/heads/main").unwrap()],
signatures: SignaturePolicy::required(),
limits: LimitPolicy::objects(10),
push_options: PushOptionPolicy::new(["ci.*"], Vec::<String>::new()).unwrap(),
ai_trailers: AiTrailerPolicy::default(),
merge: MergePolicy::default(),
});
assert_eq!(
full.gate_names(),
vec![
"ref-name",
"branch-protection",
"signed-commits",
"limits",
"push_options",
"ai-trailers"
]
);
}
// ───────────────────────────────── the engine ───────────────────────────────
#[test]
fn an_empty_engine_allows_and_a_configured_one_can_refuse() {
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let scenario = Scenario::new(graph.repo.objects.clone())
.pushing(RefUpdate::update(
refname("refs/heads/main"),
graph.b,
graph.f2,
))
.introducing(graph.all());
let allow_all = PolicyEngine::from_config(&PolicyConfig::default());
assert!(allow_all.evaluate(&scenario.context()).is_allowed());
let protecting = PolicyEngine::from_config(&PolicyConfig {
protection: vec![ProtectionRule::protect("refs/heads/main").unwrap()],
..PolicyConfig::default()
});
let decision = protecting.evaluate(&scenario.context());
assert!(!decision.is_allowed());
assert_eq!(decision.rejections.len(), 1);
assert_eq!(decision.rejections[0].gate, "branch-protection");
assert!(decision.summary().contains("non-fast-forward"));
}
#[test]
fn every_refused_reference_gets_its_own_reason() {
// `report-status-v2` sends one `ng <ref> <reason>` line per refused
// reference. A gate returns one verdict, so the engine re-runs it per
// reference on the refusal path; this asserts both refs come back, not just
// the first one the gate happened to notice.
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let scenario = Scenario::new(graph.repo.objects.clone())
.pushing(RefUpdate::update(
refname("refs/heads/main"),
graph.b,
graph.f2,
))
.pushing(RefUpdate::update(
refname("refs/heads/release"),
graph.n,
graph.m,
))
.pushing(RefUpdate::update(
refname("refs/heads/ok"),
graph.a,
graph.b,
))
.introducing(graph.all());
let engine = PolicyEngine::from_config(&PolicyConfig {
protection: vec![ProtectionRule::protect("refs/heads/**").unwrap()],
..PolicyConfig::default()
});
let decision = engine.evaluate(&scenario.context());
let refused = decision.rejected_refs();
assert_eq!(
refused.len(),
2,
"expected both bad refs to be named, got {:?}",
decision.summary()
);
assert!(refused.contains_key(refname("refs/heads/main").as_str()));
assert!(refused.contains_key(refname("refs/heads/release").as_str()));
assert!(
!refused.contains_key(refname("refs/heads/ok").as_str()),
"the legitimate fast-forward must not be blamed"
);
}
#[test]
fn a_gate_that_cannot_decide_refuses_the_push_and_says_so() {
// The error path is the one that would most naturally be written as an
// allow. Here the objects are simply not in the store, so the ancestry walk
// cannot run at all.
let graph = MergeGraph::build(gix_hash::Kind::Sha1);
let empty = Repo::sha1();
let scenario = Scenario::new(empty.objects.clone())
.pushing(RefUpdate::update(
refname("refs/heads/main"),
graph.b,
graph.n,
))
.introducing(graph.all());
let engine = PolicyEngine::from_config(&PolicyConfig {
protection: vec![ProtectionRule::protect("refs/heads/main").unwrap()],
..PolicyConfig::default()
});
let decision = engine.evaluate(&scenario.context());
assert!(
!decision.is_allowed(),
"a push whose ancestry cannot be established must not be allowed"
);
assert!(decision.summary().contains("could not be"));
}
#[test]
fn all_gates_run_so_one_round_trip_reports_everything_wrong() {
let repo = Repo::sha1();
let tip = repo.commit("c", &[]);
let extra: Vec<_> = (0..5)
.map(|i| repo.blob(format!("{i}").as_bytes()))
.collect();
let mut introduced = vec![tip];
introduced.extend(extra);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/tmp-x"), tip))
.introducing(introduced);
let engine = PolicyEngine::from_config(&PolicyConfig {
ref_names: RefNamePolicy::new(Vec::<String>::new(), ["refs/heads/tmp-*"]).unwrap(),
limits: LimitPolicy::objects(2),
..PolicyConfig::default()
});
let decision = engine.evaluate(&scenario.context());
let gates: Vec<&str> = decision
.rejections
.iter()
.map(|r| r.gate.as_str())
.collect();
assert_eq!(
gates,
vec!["ref-name", "limits"],
"both gates must report, not just the first: {}",
decision.summary()
);
}
// ─────────────────────────────── ref-name gate ──────────────────────────────
#[test]
fn an_empty_ref_name_policy_allows_everything_and_a_populated_one_does_not() {
// The identity-value trap for this gate: "no allowlist" must mean "no
// opinion", never "allow nothing" and never "allow everything regardless of
// the denylist". All three readings are asserted here.
let repo = Repo::sha1();
let tip = repo.commit("c", &[]);
let push = |name: &str| {
Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname(name), tip))
.introducing([tip])
};
let inert = RefNameGate::new(RefNamePolicy::default());
expect_allow(&inert, &push("refs/heads/anything").context()).unwrap();
expect_allow(&inert, &push("refs/pull/1/head").context()).unwrap();
let deny_only =
RefNameGate::new(RefNamePolicy::new(Vec::<String>::new(), ["refs/heads/tmp-*"]).unwrap());
expect_allow(&deny_only, &push("refs/pull/1/head").context()).unwrap();
expect_reject(
&deny_only,
&push("refs/heads/tmp-x").context(),
"is denied by pattern",
)
.unwrap();
let allow_only =
RefNameGate::new(RefNamePolicy::new(["refs/heads/**"], Vec::<String>::new()).unwrap());
expect_allow(&allow_only, &push("refs/heads/deep/name").context()).unwrap();
expect_reject(
&allow_only,
&push("refs/pull/1/head").context(),
"matches no allowed pattern",
)
.unwrap();
}
#[test]
fn deny_beats_allow() {
let repo = Repo::sha1();
let tip = repo.commit("c", &[]);
let gate =
RefNameGate::new(RefNamePolicy::new(["refs/heads/**"], ["refs/heads/tmp-*"]).unwrap());
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/tmp-x"), tip))
.introducing([tip]);
expect_reject(&gate, &scenario.context(), "is denied by pattern").unwrap();
}
#[test]
fn a_denied_name_cannot_be_deleted_either() {
let repo = Repo::sha1();
let tip = repo.commit("c", &[]);
let gate =
RefNameGate::new(RefNamePolicy::new(Vec::<String>::new(), ["refs/heads/keep-*"]).unwrap());
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::delete(refname("refs/heads/keep-me"), tip));
expect_reject(&gate, &scenario.context(), "is denied by pattern").unwrap();
}
// ──────────────────────────────── limits gate ───────────────────────────────
#[test]
fn a_limit_that_is_not_configured_is_not_a_limit_of_zero() {
// `None` and `Some(0)` must never behave the same. A push of one object
// passes an unconfigured count limit and fails a zero one.
let repo = Repo::sha1();
let tip = repo.commit("c", &[]);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/main"), tip))
.introducing([tip]);
let unconfigured = LimitsGate::new(LimitPolicy::default());
expect_allow(&unconfigured, &scenario.context()).unwrap();
let zero = LimitsGate::new(LimitPolicy::objects(0));
expect_reject(&zero, &scenario.context(), "over the 0-object limit").unwrap();
}
#[test]
fn a_blob_limit_and_an_object_limit_are_different_limits() {
let repo = Repo::sha1();
// A tree big enough to exceed a tight blob limit, with a small blob in it.
let blob = repo.blob(b"tiny");
let commit = repo.commit_with_blob("c", &[], b"tiny");
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/main"), commit))
.introducing([blob, commit]);
let blob_only = LimitsGate::new(LimitPolicy {
max_blob_bytes: Some(2),
..LimitPolicy::default()
});
expect_reject(&blob_only, &scenario.context(), "byte blob limit").unwrap();
let generous_blob = LimitsGate::new(LimitPolicy {
max_blob_bytes: Some(1024),
..LimitPolicy::default()
});
expect_allow(&generous_blob, &scenario.context()).unwrap();
}
#[test]
fn the_total_size_limit_sums_across_objects() {
let repo = Repo::sha1();
let blobs: Vec<_> = (0..4).map(|i| repo.blob(&[b'a' + i; 100])).collect();
let tip = repo.commit("c", &[]);
let mut introduced = blobs.clone();
introduced.push(tip);
let scenario = Scenario::new(repo.objects.clone())
.pushing(RefUpdate::create(refname("refs/heads/main"), tip))
.introducing(introduced);
// No single object is over 200 bytes, but together they are over 300.
let per_object_ok = LimitsGate::new(LimitPolicy {
max_object_bytes: Some(200),
..LimitPolicy::default()
});
expect_allow(&per_object_ok, &scenario.context()).unwrap();
let total_too_small = LimitsGate::new(LimitPolicy {
max_object_bytes: Some(200),
max_total_bytes: Some(300),
..LimitPolicy::default()
});
expect_reject(
&total_too_small,
&scenario.context(),
"over the 300-byte limit",
)
.unwrap();
}
#[test]
fn the_gate_reads_headers_only_so_a_huge_object_need_not_be_decompressed() {
// Not a performance claim but a behavioural one. The store below refuses to
// serve payloads at all; if the gate ever starts reading them, this goes red.
use anyhow::Result as StoreResult;
use gix_hash::ObjectId;
use gunnar_store::git_storage_trait::{
Caps, Extent, GcReport, GitOps, GitServe, ObjType, Oid, PackStats, ReachSet, RefCas,
RefRow, RefUpdate as ContractRefUpdate, Stored, TxId,
};
struct HeadersOnly {
id: ObjectId,
size: u64,
}
/// Everything a `GitServe` owes that this fixture is not about.
///
/// `unimplemented!` rather than a plausible answer, and rather than a
/// default on the trait: a default would be a silent opt-out, and a
/// plausible answer would let the gate wander off this fixture without
/// anyone noticing. Anything the gate touches other than `header` and
/// `has` fails the test by name, which is the same property the old
/// `panic!` on `read` had — now extended to the whole surface, because
/// `GitServe` is nineteen methods where `ObjectStore` was five.
macro_rules! forbidden {
($what:literal) => {
unimplemented!(concat!("the limits gate must not call ", $what))
};
}
impl GitOps for HeadersOnly {
fn put(&self, _pack: &[u8], _refs: &[ContractRefUpdate]) -> StoreResult<TxId> {
forbidden!("put")
}
fn put_pack(&self, _bytes: &[u8]) -> StoreResult<TxId> {
forbidden!("put_pack")
}
fn put_refs(&self, _updates: &[ContractRefUpdate]) -> StoreResult<TxId> {
forbidden!("put_refs")
}
/// **The assertion.** `Stored.bytes` is a payload; handing one out is
/// exactly what this test exists to catch.
fn get(&self, _oid: Oid<'_>) -> StoreResult<Option<Stored>> {
panic!("the limits gate must not read object payloads");
}
fn has(&self, oid: Oid<'_>) -> StoreResult<bool> {
Ok(oid == self.id.as_bytes())
}
fn size(&self, oid: Oid<'_>) -> StoreResult<Option<u64>> {
Ok((oid == self.id.as_bytes()).then_some(self.size))
}
fn extents(&self, _oids: &[Oid<'_>]) -> StoreResult<Vec<Option<Extent>>> {
forbidden!("extents")
}
fn refs(&self) -> StoreResult<Vec<RefRow>> {
Ok(Vec::new())
}
fn update_ref(
&self,
_name: &str,
_old: Option<Oid<'_>>,
_new: Option<Oid<'_>>,
) -> StoreResult<TxId> {
forbidden!("update_ref")
}
fn put_refs_cas(&self, _edits: &[RefCas<'_>]) -> StoreResult<TxId> {
forbidden!("put_refs_cas")
}
fn reachable(&self, _want: &[Oid<'_>], _have: &[Oid<'_>]) -> StoreResult<Vec<Vec<u8>>> {
forbidden!("reachable")
}
fn gc(&self) -> StoreResult<GcReport> {
forbidden!("gc")
}
}
impl GitServe for HeadersOnly {
/// **The assertion, in its new home.** `GitServe::read` is what
/// `ObjectStore::read` became: inflated, delta-resolved content.
fn read(&self, _oid: Oid<'_>) -> StoreResult<Option<(ObjType, Vec<u8>)>> {
panic!("the limits gate must not read object payloads");
}
/// The one honest answer, and a 4 GiB one that no fixture could
/// actually hold — which is the whole point of asking for the header.
fn header(&self, oid: Oid<'_>) -> StoreResult<Option<(ObjType, u64)>> {
Ok((oid == self.id.as_bytes()).then_some((ObjType::Blob, self.size)))
}
fn sizes(&self, oids: &[Oid<'_>]) -> StoreResult<Vec<Option<u64>>> {
oids.iter().map(|oid| self.size(oid)).collect()
}
fn head(&self) -> StoreResult<Option<RefRow>> {
Ok(None)
}
fn set_head(&self, _target: &str) -> StoreResult<TxId> {
forbidden!("set_head")
}
fn emit_pack(
&self,
_want: &[Oid<'_>],
_have: &[Oid<'_>],
_caps: &Caps,
_out: &mut dyn std::io::Write,
) -> StoreResult<PackStats> {
forbidden!("emit_pack")
}
fn select(&self, _want: &[Oid<'_>], _have: &[Oid<'_>]) -> StoreResult<Option<ReachSet>> {
forbidden!("select")
}
}
let id = gix_hash::Kind::Sha1.null();
let store = HeadersOnly {
id,
size: 4 << 30, // 4 GiB
};
let scenario = Scenario::new(store)
.pushing(RefUpdate::create(refname("refs/heads/main"), id))
.introducing([id]);
let gate = LimitsGate::new(LimitPolicy::object_bytes(1 << 20));
expect_reject(
&gate,
&scenario.context(),
"over the 1048576-byte object limit",
)
.unwrap();
}
#[test]
fn gate_names_are_the_ones_events_and_reports_will_carry() {
assert_eq!(
RefNameGate::new(RefNamePolicy::default()).name(),
"ref-name"
);
assert_eq!(LimitsGate::new(LimitPolicy::default()).name(), "limits");
let _ = mint_key(); // keep the key helper exercised in this binary too
}