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/protection.rs

//! Branch protection in detail, and the ancestry walk underneath it.
//!
//! `tests/gates.rs` proves the gate refuses and allows. This file proves the
//! *reason* it does: that the force-push check is a real graph walk and not one
//! of the four plausible impostors listed in `crate::gates::protection`.

mod common;

use common::{expect_allow, expect_reject, mint_key, refname, MergeGraph, Repo, Scenario};
use gunnar_policy::ancestry::{is_descendant_of, DEFAULT_ANCESTRY_BUDGET};
use gunnar_policy::{
    BranchProtectionGate, Error, Gate, ProtectionRule, PushContext, RefUpdate, Verdict,
};

const BUDGET: usize = DEFAULT_ANCESTRY_BUDGET;

#[test]
fn ancestry_follows_every_parent_edge_not_just_the_first() {
    // THE discriminating case. `m` is a merge of `b` and `f2`; `f2` is reachable
    // from `m` only through the SECOND parent. A first-parent walk, which is
    // indistinguishable from a correct one on any linear fixture, answers
    // `false` here and turns a legitimate fast-forward into a force-push.
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let objects = &graph.repo.objects;

    assert!(
        is_descendant_of(objects, &graph.m, &graph.f2, BUDGET).unwrap(),
        "the old tip sits on the merge's second parent and must still be found"
    );
    assert!(
        is_descendant_of(objects, &graph.m, &graph.f1, BUDGET).unwrap(),
        "and so must its parent"
    );
    assert!(is_descendant_of(objects, &graph.m, &graph.b, BUDGET).unwrap());
    assert!(is_descendant_of(objects, &graph.n, &graph.f2, BUDGET).unwrap());
}

#[test]
fn ancestry_is_directional_and_says_no_when_it_means_no() {
    // The identity-value guard: a walk that returned `true` unconditionally would
    // pass the test above. These are the cases that must come back `false`.
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let objects = &graph.repo.objects;

    assert!(!is_descendant_of(objects, &graph.f2, &graph.b, BUDGET).unwrap());
    assert!(!is_descendant_of(objects, &graph.b, &graph.f2, BUDGET).unwrap());
    assert!(!is_descendant_of(objects, &graph.m, &graph.n, BUDGET).unwrap());
    assert!(!is_descendant_of(objects, &graph.a, &graph.b, BUDGET).unwrap());
}

#[test]
fn a_commit_is_its_own_ancestor_so_a_ref_that_does_not_move_is_not_a_force_push() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    assert!(is_descendant_of(&graph.repo.objects, &graph.m, &graph.m, BUDGET).unwrap());
}

#[test]
fn ancestry_works_the_same_on_a_sha256_repository() {
    // The verification rule: the sha256 path is exercised by a sha256 fixture, never inferred
    // from a green sha1 run. The oids are 32 bytes here and the commit headers
    // are 64 hex characters wide, which is exactly the kind of thing a
    // length-assuming parser gets wrong.
    let graph = MergeGraph::build(gix_hash::Kind::Sha256);
    assert_eq!(graph.m.kind(), gix_hash::Kind::Sha256);
    assert_eq!(graph.m.as_bytes().len(), 32);

    let objects = &graph.repo.objects;
    assert!(is_descendant_of(objects, &graph.m, &graph.f2, BUDGET).unwrap());
    assert!(!is_descendant_of(objects, &graph.f2, &graph.m, BUDGET).unwrap());

    let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main").unwrap()]);
    let good = Scenario::new(objects.clone())
        .pushing(RefUpdate::update(
            refname("refs/heads/main"),
            graph.f2,
            graph.m,
        ))
        .introducing(graph.all());
    expect_allow(&gate, &good.context()).unwrap();

    let bad = Scenario::new(objects.clone())
        .pushing(RefUpdate::update(
            refname("refs/heads/main"),
            graph.b,
            graph.f2,
        ))
        .introducing(graph.all());
    expect_reject(&gate, &bad.context(), "non-fast-forward").unwrap();
}

#[test]
fn a_missing_commit_makes_the_answer_undecided_not_false() {
    // A store that has forgotten a commit must not silently answer "not a
    // descendant"; that would turn a storage fault into a policy verdict.
    let repo = Repo::sha1();
    let root = repo.commit("root", &[]);
    let orphan_parent = repo.commit("gone", &[]);
    let tip = repo.commit("tip", &[orphan_parent]);

    // A second, empty store: the commits exist in `repo`, not here.
    let empty = Repo::sha1();
    let err = is_descendant_of(&empty.objects, &tip, &root, BUDGET).unwrap_err();
    assert!(
        matches!(err, Error::MissingObject { .. }),
        "expected MissingObject, got {err}"
    );
}

#[test]
fn an_exhausted_budget_is_an_error_not_a_verdict() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let err = is_descendant_of(&graph.repo.objects, &graph.n, &graph.root, 2).unwrap_err();
    assert!(
        matches!(err, Error::AncestryBudget { budget: 2, .. }),
        "expected AncestryBudget, got {err}"
    );
}

#[test]
fn the_first_matching_rule_wins() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let gate = BranchProtectionGate::new(vec![
        ProtectionRule {
            allow_force_push: true,
            ..ProtectionRule::protect("refs/heads/scratch").unwrap()
        },
        ProtectionRule::protect("refs/heads/**").unwrap(),
    ]);

    // `refs/heads/scratch` hits the permissive rule first…
    let permitted = Scenario::new(graph.repo.objects.clone())
        .pushing(RefUpdate::update(
            refname("refs/heads/scratch"),
            graph.b,
            graph.f2,
        ))
        .introducing(graph.all());
    expect_allow(&gate, &permitted.context()).unwrap();

    // …while everything else falls through to the strict one.
    let refused = Scenario::new(graph.repo.objects.clone())
        .pushing(RefUpdate::update(
            refname("refs/heads/main"),
            graph.b,
            graph.f2,
        ))
        .introducing(graph.all());
    expect_reject(&gate, &refused.context(), "non-fast-forward").unwrap();
}

#[test]
fn creating_a_branch_under_a_protection_pattern_is_allowed_by_default_but_can_be_refused() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let update = RefUpdate::create(refname("refs/heads/feature"), graph.n);

    let default =
        BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/**").unwrap()]);
    let scenario = Scenario::new(graph.repo.objects.clone())
        .pushing(update.clone())
        .introducing(graph.all());
    expect_allow(&default, &scenario.context()).unwrap();

    let strict = BranchProtectionGate::new(vec![ProtectionRule {
        allow_create: false,
        ..ProtectionRule::protect("refs/heads/**").unwrap()
    }]);
    expect_reject(&strict, &scenario.context(), "may not be created").unwrap();
}

#[test]
fn deleting_is_allowed_only_when_the_rule_says_so() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let scenario = Scenario::new(graph.repo.objects.clone())
        .pushing(RefUpdate::delete(refname("refs/heads/main"), graph.n));

    let strict =
        BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main").unwrap()]);
    expect_reject(&strict, &scenario.context(), "may not be deleted").unwrap();

    let permissive = BranchProtectionGate::new(vec![ProtectionRule {
        allow_delete: true,
        ..ProtectionRule::protect("refs/heads/main").unwrap()
    }]);
    expect_allow(&permissive, &scenario.context()).unwrap();
}

#[test]
fn the_allowed_principal_list_lets_the_right_principal_through() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main")
        .unwrap()
        .by(["release-bot"])]);

    let allowed = Scenario::new(graph.repo.objects.clone())
        .pushed_by("release-bot")
        .pushing(RefUpdate::update(
            refname("refs/heads/main"),
            graph.m,
            graph.n,
        ))
        .introducing(graph.all());
    expect_allow(&gate, &allowed.context()).unwrap();

    let refused = Scenario::new(graph.repo.objects.clone())
        .pushed_by("alice")
        .pushing(RefUpdate::update(
            refname("refs/heads/main"),
            graph.m,
            graph.n,
        ))
        .introducing(graph.all());
    expect_reject(
        &gate,
        &refused.context(),
        "not among its allowed principals",
    )
    .unwrap();
}

#[test]
fn require_signature_on_a_protected_ref_checks_only_the_commits_this_push_adds() {
    // The pre-existing history is unsigned. Only what the push introduces has to
    // be signed. Otherwise turning the rule on would make the next push
    // impossible, and the obvious "fix" is to check nothing at all.
    let key = mint_key();
    let repo = Repo::sha1();
    let old_root = repo.commit("pre-existing, unsigned", &[]);
    let added = repo.signed_commit("added, signed", &[old_root], &key);

    let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main")
        .unwrap()
        .requiring_signature()]);

    let good = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::update(
            refname("refs/heads/main"),
            old_root,
            added,
        ))
        .introducing([added]) // only `added` came in this pack
        .with_key("alice", &key);
    expect_allow(&gate, &good.context()).unwrap();

    // …and an unsigned addition on the same base is refused.
    let unsigned = repo.commit("added, unsigned", &[old_root]);
    let bad = Scenario::new(repo.objects.clone())
        .pushing(RefUpdate::update(
            refname("refs/heads/main"),
            old_root,
            unsigned,
        ))
        .introducing([unsigned])
        .with_key("alice", &key);
    expect_reject(&gate, &bad.context(), "requires signed commits").unwrap();
}

#[test]
fn a_reference_with_no_matching_rule_is_not_the_gates_business() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main").unwrap()]);
    let scenario = Scenario::new(graph.repo.objects.clone())
        .pushing(RefUpdate::delete(refname("refs/heads/wip"), graph.n));
    expect_allow(&gate, &scenario.context()).unwrap();
}

#[test]
fn a_command_with_neither_an_old_nor_a_new_value_is_refused() {
    // The wire spells both as the null oid, so this is what a malformed command
    // line decodes to. It must not fall through any branch as a no-op.
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let null = gix_hash::Kind::Sha1.null();
    let update = RefUpdate::from_wire(refname("refs/heads/main"), null, null);
    assert_eq!(update.kind(), gunnar_policy::UpdateKind::Noop);

    let gate = BranchProtectionGate::new(vec![ProtectionRule::protect("refs/heads/main").unwrap()]);
    let scenario = Scenario::new(graph.repo.objects.clone()).pushing(update);
    expect_reject(&gate, &scenario.context(), "neither an old nor a new value").unwrap();
}

#[test]
fn the_null_oid_is_decoded_once_and_never_leaks_into_a_walk() {
    let null = gix_hash::Kind::Sha1.null();
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);

    let create = RefUpdate::from_wire(refname("refs/heads/x"), null, graph.n);
    assert_eq!(create.old, None);
    assert_eq!(create.new, Some(graph.n));
    assert_eq!(create.kind(), gunnar_policy::UpdateKind::Create);

    let delete = RefUpdate::from_wire(refname("refs/heads/x"), graph.n, null);
    assert_eq!(delete.new, None);
    assert!(delete.is_delete());
}

#[test]
fn the_gate_name_is_stable_because_events_and_reports_carry_it() {
    let gate = BranchProtectionGate::new(Vec::new());
    assert_eq!(gate.name(), "branch-protection");
    let empty = Scenario::new(Repo::sha1().objects);
    let ctx: PushContext<'_> = empty.context();
    assert_eq!(gate.check(&ctx).unwrap(), Verdict::Allow);
}