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/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
}