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

//! `git push -o …` reaches a gate, and the gate can say no.
//!
//! # The defect this witnesses
//!
//! `push-options` was advertised at `gunnar-wire/src/caps.rs`, parsed at
//! `gunnar-wire/src/receive_pack.rs`, and then discarded. The only consumer of
//! a parsed push option anywhere in the tree was `.len()`, in one `tracing`
//! field in `gunnar-cli/src/server/transport.rs`. `PushFacts` — the struct that
//! exists precisely so a gate can see what a push carried — had no options
//! field at all, so no policy could act on a push option even in principle.
//!
//! A client running `git push -o ci.skip` was therefore told on the wire that
//! the server speaks push options, and the option went nowhere. That is a wrong
//! answer on the wire, not a missing feature.
//!
//! # What is asserted
//!
//! Both directions of both rules, plus the undecidable case — and, in
//! `crates/gunnar-wire/tests/protocol.rs`, that a real
//! `ReceivePack::serve` hands the parsed options to the gate rather than only
//! to the outcome. A gate that can refuse but never receives anything is the
//! same defect with more code in it.

mod common;

use common::{expect_allow, expect_reject, refname, MergeGraph, Scenario};
use gunnar_policy::{Gate, PolicyConfig, PolicyEngine, PushOptionPolicy, PushOptionsGate, Verdict};

fn scenario(graph: &MergeGraph) -> Scenario {
    Scenario::new(graph.repo.objects.clone())
        .pushing(gunnar_policy::RefUpdate::update(
            refname("refs/heads/main"),
            graph.b,
            graph.f2,
        ))
        .introducing(graph.all())
}

#[test]
fn an_option_outside_the_allowlist_is_refused_and_one_inside_it_is_not() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let gate = PushOptionsGate::new(
        PushOptionPolicy::new(["ci.*", "reviewer=*"], Vec::<String>::new()).unwrap(),
    );

    // ── red ────────────────────────────────────────────────────────────────
    let sneaky = scenario(&graph).with_push_options(["ci.skip", "rm-rf=/"]);
    expect_reject(&gate, &sneaky.context(), "\"rm-rf=/\" is not allowed here").unwrap();

    // ── green, and for the right reason: the allowed ones really pass ──────
    let fine = scenario(&graph).with_push_options(["ci.skip", "reviewer=alice"]);
    expect_allow(&gate, &fine.context()).unwrap();

    // An empty option list is not an allowlist violation: there is nothing to
    // violate. This is the line that keeps the gate from refusing every
    // ordinary push the moment an allowlist is configured.
    let none = scenario(&graph).with_push_options(Vec::<String>::new());
    expect_allow(&gate, &none.context()).unwrap();

    // The glob is the shared `RefPattern` matcher, so `*` does not cross `/`.
    let crossing = scenario(&graph).with_push_options(["ci.skip/and/more"]);
    expect_reject(&gate, &crossing.context(), "is not allowed here").unwrap();
}

#[test]
fn a_required_option_that_is_missing_is_refused_and_one_that_is_present_is_not() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let gate = PushOptionsGate::new(
        PushOptionPolicy::new(Vec::<String>::new(), ["ticket=NORD-*"]).unwrap(),
    );

    // ── red: no options at all ─────────────────────────────────────────────
    let bare = scenario(&graph).with_push_options(Vec::<String>::new());
    expect_reject(&gate, &bare.context(), "requires a push option matching").unwrap();

    // ── red: options, but not the one demanded ─────────────────────────────
    let wrong = scenario(&graph).with_push_options(["ci.skip"]);
    let verdict = expect_reject(&gate, &wrong.context(), "ticket=NORD-*").unwrap();
    assert!(
        verdict.reason().unwrap_or_default().contains("\"ci.skip\""),
        "the refusal must name what WAS sent, or an operator cannot tell a typo from an \
         omission: {verdict:?}"
    );

    // ── green ──────────────────────────────────────────────────────────────
    let ok = scenario(&graph).with_push_options(["ci.skip", "ticket=NORD-1"]);
    expect_allow(&gate, &ok.context()).unwrap();
}

/// The undecidable case, which is the shape this whole gate was born out of.
///
/// A context whose `push_options` is `None` means nobody wired the options
/// through. If the gate treated that as "the client sent none" it would be
/// satisfied by its own plumbing being missing — which is exactly the state the
/// server shipped in — and an allowlist would find nothing to object to.
#[test]
fn a_context_that_never_saw_the_options_is_refused_rather_than_allowed() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let unwired = scenario(&graph); // no `.with_push_options(…)` at all
    assert!(
        unwired.context().push_options.is_none(),
        "this test is about the unwired context; if it is Some, the premise is gone"
    );

    for gate in [
        PushOptionsGate::new(PushOptionPolicy::new(["ci.*"], Vec::<String>::new()).unwrap()),
        PushOptionsGate::new(PushOptionPolicy::new(Vec::<String>::new(), ["ci.*"]).unwrap()),
    ] {
        expect_reject(&gate, &unwired.context(), "carried no push-option report").unwrap();
    }

    // And the contrast that makes the assertion mean something: the SAME gate
    // over an explicitly-empty list allows.
    let measured_none = scenario(&graph).with_push_options(Vec::<String>::new());
    expect_allow(
        &PushOptionsGate::new(PushOptionPolicy::new(["ci.*"], Vec::<String>::new()).unwrap()),
        &measured_none.context(),
    )
    .unwrap();
}

/// An unconfigured policy builds **no gate**, rather than a gate that says yes.
///
/// The difference is visible in `gate_names`, so "is this repository refusing
/// on push options" is answerable — and a gate that always allowed would be
/// indistinguishable from a broken one in every test above.
#[test]
fn an_unconfigured_push_option_policy_contributes_no_gate() {
    assert!(PushOptionPolicy::default().is_empty());
    let engine = PolicyEngine::from_config(&PolicyConfig::default());
    assert!(!engine.gate_names().contains(&"push_options"));

    let configured = PolicyEngine::from_config(&PolicyConfig {
        push_options: PushOptionPolicy::new(["ci.*"], Vec::<String>::new()).unwrap(),
        ..PolicyConfig::default()
    });
    // `ai-trailers` is the one gate every repository runs unconfigured.
    assert_eq!(configured.gate_names(), vec!["push_options", "ai-trailers"]);
}

/// The policy survives the JSON it is stored as.
///
/// The policy file is what an operator edits; a field that round-trips to
/// nothing is a rule that silently stops applying.
#[test]
fn the_push_option_policy_round_trips_through_json() {
    let config = PolicyConfig {
        push_options: PushOptionPolicy::new(["ci.*"], ["ticket=NORD-*"]).unwrap(),
        ..PolicyConfig::default()
    };
    let json = config.to_json().unwrap();
    assert!(
        json.contains("ticket=NORD-*"),
        "the required pattern did not reach the stored document:\n{json}"
    );
    assert_eq!(PolicyConfig::from_json(&json).unwrap(), config);

    // A typo must be an error, not a rule that quietly does not apply.
    assert!(PolicyConfig::from_json(r#"{"push_options":{"allowedd":["ci.*"]}}"#).is_err());
}

/// The engine runs it end to end, and names it as the refusing gate.
#[test]
fn the_engine_reports_which_gate_refused_the_push_option() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let engine = PolicyEngine::from_config(&PolicyConfig {
        push_options: PushOptionPolicy::new(["ci.*"], Vec::<String>::new()).unwrap(),
        ..PolicyConfig::default()
    });

    let bad = scenario(&graph).with_push_options(["definitely-not-allowed"]);
    let decision = engine.evaluate(&bad.context());
    assert!(!decision.is_allowed());
    let first = decision.rejections.first().expect("a rejection");
    assert_eq!(first.gate, "push_options", "{decision:?}");

    let good = scenario(&graph).with_push_options(["ci.skip"]);
    assert!(
        engine.evaluate(&good.context()).is_allowed(),
        "the engine must still let a legal option through, or the red above proves nothing"
    );
}

/// The gate must be able to allow. A gate wired to reject everything satisfies
/// every red above and is worth nothing.
#[test]
fn the_gate_is_not_a_universal_no() {
    let graph = MergeGraph::build(gix_hash::Kind::Sha1);
    let gate =
        PushOptionsGate::new(PushOptionPolicy::new(["ci.*", "reviewer=*"], ["ci.*"]).unwrap());
    let ok = scenario(&graph).with_push_options(["ci.skip", "reviewer=x"]);
    assert_eq!(gate.check(&ok.context()).unwrap(), Verdict::Allow);
    assert_eq!(gate.name(), "push_options");
}