Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/policy_fails_closed.rs

#![cfg(feature = "server")]

//! **GUN-007: an unreadable policy refuses the push; it does not become an
//! empty one.**
//!
//! `load_policy` answered every failure with `PolicyConfig::default()` — a
//! document that did not parse, a control store that could not be read, a file
//! that could not be opened — and `PolicyGate::check` lets an empty engine
//! through, because "nothing is configured" is a real and common answer. So a
//! typo in a policy document switched branch protection and `require_signature`
//! **off** for that push, at `error!` level in a log nobody reads while a push
//! is in flight. Degraded operation was indistinguishable from full operation.
//!
//! The old argument for it was that refusing would take the repository offline.
//! It does not, and this file measures that too: the refusal is at the
//! **pre-receive** gate, so a clone of the same repository, in the same state,
//! still works.

mod common;

use std::path::{Path, PathBuf};

use common::{tools_present, Harness};
use gunnar_core::HashKind;

const NS: &str = "team";
const REPO: &str = "project";

fn policy_path(harness: &Harness) -> PathBuf {
    harness
        .git_dir(NS, REPO)
        .join(gunnar_cli::server::transport::POLICY_FILE)
}

/// A local repository with one commit, pushed once so the fixture is real.
fn seeded(harness: &Harness) -> PathBuf {
    let work = harness.dir.path().join("work");
    harness
        .git(
            harness.dir.path(),
            &[
                "init",
                "--quiet",
                "-b",
                "main",
                work.to_str().expect("utf-8"),
            ],
        )
        .expect_ok("git init");
    std::fs::write(work.join("a.txt"), "base\n").expect("write");
    harness.git(&work, &["add", "."]).expect_ok("add");
    harness
        .git(&work, &["commit", "--quiet", "-m", "base"])
        .expect_ok("commit");
    harness
        .git(&work, &["push", "--quiet", &harness.url(NS, REPO), "main"])
        .expect_ok("the first push, with no policy at all");
    work
}

fn commit(harness: &Harness, work: &Path, text: &str) {
    std::fs::write(work.join("a.txt"), text).expect("write");
    harness.git(work, &["add", "."]).expect_ok("add");
    harness
        .git(work, &["commit", "--quiet", "-m", text])
        .expect_ok("commit");
}

/// **The finding.** A policy document that does not parse refuses the push.
///
/// Mutation-proven 2026-09-11: make `load_policy` answer `Ok(default)` on a
/// parse error again and the push below SUCCEEDS — the gates the document
/// named are gone and nothing on the wire says so.
#[test]
fn a_policy_that_does_not_parse_refuses_the_push_rather_than_disabling_the_gates() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NS, REPO, HashKind::Sha1);
    let work = seeded(&harness);
    let url = harness.url(NS, REPO);

    // A document an operator meant to configure gates with, and mistyped.
    std::fs::write(policy_path(&harness), "{ this is not json").expect("write the policy");
    commit(&harness, &work, "second\n");
    let refused = harness.git(&work, &["push", "--quiet", &url, "main"]);
    assert!(
        !refused.ok,
        "a push was served while the repository's policy could not be read:\n{}",
        refused.stderr
    );
    assert!(
        refused.stderr.contains("could not read the policy"),
        "the refusal must name the cause so an operator can fix it: {}",
        refused.stderr
    );

    // **Reads are untouched.** This is the whole answer to "refusing would
    // take the repository offline": it is the pre-receive gate, so it costs
    // pushes and nothing else.
    let clone = harness.dir.path().join("clone");
    harness
        .git(
            harness.dir.path(),
            &["clone", "--quiet", &url, clone.to_str().expect("utf-8")],
        )
        .expect_ok("a clone must still work while the policy is unreadable");

    // The other direction, same repository, same push: with the document
    // removed — nothing stored, no file, so nothing is configured — the push
    // goes through. A gate that refused everything would pass the assertions
    // above.
    std::fs::remove_file(policy_path(&harness)).expect("remove the policy");
    harness
        .git(&work, &["push", "--quiet", &url, "main"])
        .expect_ok("a repository with no policy at all must still take a push");

    // …and a document that DOES parse is honoured rather than merely
    // tolerated: `max_objects: 0` refuses a push that introduces anything, so
    // the gate is demonstrably live rather than empty.
    let config = gunnar_policy::PolicyConfig {
        limits: gunnar_policy::LimitPolicy::objects(1),
        ..Default::default()
    };
    std::fs::write(
        policy_path(&harness),
        config.to_json().expect("render the policy"),
    )
    .expect("write the policy");
    commit(&harness, &work, "third\n");
    let refused = harness.git(&work, &["push", "--quiet", &url, "main"]);
    assert!(
        !refused.ok,
        "a parseable policy must be applied, not merely accepted:\n{}",
        refused.stderr
    );
    assert!(
        !refused.stderr.contains("could not read the policy"),
        "a parseable policy must be refused BY ITS GATE, not by the reader: {}",
        refused.stderr
    );

    harness.stop();
}