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

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

//! **What the pre-receive gates are actually shown.**
//!
//! `PushGate::check` used to receive a `&RefCommand` and a `&dyn ObjectStore`
//! and nothing else. Two facts a quota gate cannot work without were therefore
//! unavailable to it, and both are asserted here against a **real `git push`
//! over real SSH**, because both were wrong in ways that only a real client
//! produces:
//!
//! * **which objects the push introduced.** Recovered downstream by reading the
//!   written `.idx` back, which for a thin push also lists the bases
//!   `index_thin_pack` pulled out of the store to complete it. A `max_objects`
//!   gate was therefore counting objects the repository already held.
//! * **how many bytes the client sent.** Never measured at all, so
//!   `LimitPolicy::max_pack_bytes` could not be enforced — the gate correctly
//!   refused rather than pretending, which means a configured byte limit
//!   refused *every* push instead of oversized ones.
//!
//! # The two vacuity traps in this file, and what stops them
//!
//! 1. **A push that is not thin proves nothing.** If `git` sends every object
//!    whole, no base is pulled, the subtraction removes nothing, and the
//!    assertion passes against the unfixed code. So the fixture writes a large,
//!    highly compressible file and then edits one line of it, and the test
//!    **fails** — rather than skipping — if the pack that landed does not
//!    contain more objects than the push introduced. That inequality *is* the
//!    proof that a base was pulled in.
//! 2. **A limit that refuses everything looks like a limit that works.** The
//!    byte-limit test asserts the exact refusal text, so the pre-fix behaviour
//!    ("the pack size was not measured, so the limit cannot be enforced") is a
//!    different failure from the post-fix one, and it asserts an under-limit
//!    push is still **accepted** on the same server with the same policy.
//!
//! Ground truth for the object set comes from **`git` on the client**
//! (`git rev-list --objects HEAD --not HEAD^`), not from anything gunnar
//! computes. Two halves of gunnar agreeing with each other proves nothing.
//!
//! # 🔴 What this file read until 2026-08-30, and why none of it existed
//!
//! Three tests here opened `<git_dir>/objects/pack`, walked the `.idx` files
//! with `gix_pack::index::File` and summed `*.pack`. **The engine gunnar ships
//! creates none of those.** Since `e31ab8f4` put the server back on znippy-git
//! storage, a push is appended verbatim into one archive
//! (`<git_dir>/gunnar-git.znippy/objects.pack`), `IndexedPack::data_path` is
//! `None`, and there is no pack directory, no `.idx` and no loose object. Two
//! of the three died on `read … objects/pack: No such file or directory` and
//! the third on its own "no pack on disk to cross-check against" guard. **The
//! premise was wrong, not the server** — the same finding made twice
//! (`observability::the_push_line_reports_the_pack_that_landed`,
//! `push_events`' pack hash).
//!
//! The same properties, through what the engine really produces:
//!
//! * *which objects the gate was shown* — the attestation's `new_object_oids`,
//!   cross-checked against the client's `rev-list`, **and** an applied output
//!   that a wrong answer would change: a `max_objects` gate set to exactly what
//!   the push introduced, which a gate counting the repository (or counting
//!   bases pulled in to complete a thin pack) would refuse;
//! * *what a refused push left behind* — `GitOps::has` on the repository's own
//!   store, which is the ODB on this engine, plus the archive growing;
//! * *the pack's size on disk* — the archive it was appended into.
//!
//! One consequence is worth stating rather than hiding: on this engine nothing
//! **completes** a thin pack at ingest, so the specific mechanism that produced
//! the original defect (bases pulled out by `index_thin_pack` and then listed
//! in the written `.idx`) cannot occur here. What is still measured, and is
//! what a gate actually depends on, is that the set the gate is shown is the
//! set the client introduced and no larger — asserted against `git` and against
//! a limit that refuses if it is larger.

mod common;

use std::collections::BTreeSet;
use std::path::Path;

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

/// A file big enough that `git` deltas the second version against the first.
///
/// Small edits to small files are sent whole, and a push with no delta pulls no
/// base out of the store, which would make the thin-pack assertion vacuous.
fn bulky(marker: &str) -> String {
    let mut text = String::with_capacity(96 * 1024);
    for line in 0..3000 {
        text.push_str(&format!("line {line} of a file that deltas well\n"));
    }
    text.push_str(marker);
    text
}

/// Everything reachable from `HEAD` and not from `HEAD^`, as `git` sees it.
///
/// This is the definition of "what this push introduced", taken from the
/// client, which is the only party that is not gunnar.
fn introduced_by_head(harness: &Harness, work: &Path) -> BTreeSet<String> {
    let out = harness.git(work, &["rev-list", "--objects", "HEAD", "--not", "HEAD^"]);
    out.expect_ok("git rev-list --objects");
    out.stdout
        .lines()
        .filter_map(|line| line.split_whitespace().next())
        .map(str::to_owned)
        .collect()
}

/// Does the repository's own store hold every one of `oids`?
///
/// `GitOps::has` is the ODB question on this engine — the same call
/// negotiation makes a thousand at a time — and it absorbs a pending pack
/// before answering, so a push that has landed but not yet been indexed
/// answers truthfully rather than "not here yet".
///
/// Returns the ids it could NOT find, so a failure names them.
fn missing_from_store(
    harness: &Harness,
    namespace: &str,
    repo: &str,
    oids: &BTreeSet<String>,
) -> Vec<String> {
    use gunnar_store::git_storage_trait::GitOps;

    let path = gunnar_core::RepoRef::new(namespace, repo).expect("a legal repository path");
    let row = harness
        .stores
        .catalog()
        .get(&path)
        .expect("read the catalogue")
        .expect("the repository is catalogued");
    let store = harness
        .stores
        .repo_store(&row)
        .expect("open the repository's store");
    oids.iter()
        .filter(|hex| {
            let id = gix_hash::ObjectId::from_hex(hex.as_bytes())
                .unwrap_or_else(|err| panic!("{hex} is not an object id: {err}"));
            !GitOps::has(store.serve(), id.as_bytes())
                .expect("ask the store whether it has an object")
        })
        .cloned()
        .collect()
}

/// The `new_object_oids` of the most recent attestation.
///
/// The attestation carries the same value the gates were handed, so asserting
/// on it asserts on what a gate saw — and it is an applied output on disk
/// rather than a field read back out of the code under test.
fn last_attestation_objects(harness: &Harness) -> BTreeSet<String> {
    let records = harness
        .attestation_log()
        .read()
        .expect("read attestations.jsonl");
    let last = records
        .last()
        .expect("a push should have written an attestation");
    last.new_object_oids.iter().cloned().collect()
}

// ───────────────────────────────────────────────────────────────────────────
// (a) the object set
// ───────────────────────────────────────────────────────────────────────────

/// Every object id the client can reach from any reference — the whole
/// repository, as `git` sees it.
fn everything_the_client_holds(harness: &Harness, work: &Path) -> BTreeSet<String> {
    let out = harness.git(work, &["rev-list", "--objects", "--all"]);
    out.expect_ok("git rev-list --objects --all");
    out.stdout
        .lines()
        .filter_map(|line| line.split_whitespace().next())
        .map(str::to_owned)
        .collect()
}

/// Write a policy document with a ceiling on how many objects one push may
/// introduce.
///
/// This is the gate the header names: a `max_objects` that was counting objects
/// the repository already held. It is also what makes the ingest **enumerate**
/// the object set at all — `PushIngest` asks the store for the ids only when
/// `!gate.engine.is_empty()`, because enumerating waits for the indexer to
/// absorb the pack and a repository with no gates must not pay for it.
fn set_max_objects(harness: &Harness, namespace: &str, repo: &str, max: u64) {
    let config = gunnar_policy::PolicyConfig {
        limits: gunnar_policy::LimitPolicy::objects(max),
        ..Default::default()
    };
    let path = harness
        .git_dir(namespace, repo)
        .join(gunnar_cli::server::transport::POLICY_FILE);
    std::fs::write(&path, config.to_json().expect("render the policy"))
        .unwrap_or_else(|err| panic!("write {}: {err}", path.display()));
}

#[test]
fn a_thin_push_shows_the_gate_only_the_objects_it_introduced() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "project", HashKind::Sha256);
    let work = harness.dir.path().join("work");
    local_repo(&harness, &work, HashKind::Sha256, &bulky("first\n"));

    let url = harness.url("team", "project");
    harness
        .git(&work, &["push", "--quiet", &url, "main"])
        .expect_ok("the first push");

    // The second commit edits one line of a large file, so `git` sends a
    // ref-delta whose base is the blob the server already has.
    std::fs::write(work.join("hello.txt"), bulky("second\n")).expect("rewrite the file");
    harness
        .git(
            &work,
            &["commit", "--quiet", "-a", "-m", "the second commit"],
        )
        .expect_ok("the second commit");

    // ── the gate, sized from the CLIENT's ground truth ─────────────────────
    //
    // Exactly what this push introduces and not one more. The number is `git`'s,
    // not gunnar's, and it is what turns the equality below into an applied
    // output: a gate shown the repository's objects (six), or shown the
    // introduced objects plus a base pulled in to complete a thin pack (four),
    // exceeds it and the push is REFUSED. So the acceptance is itself an
    // assertion about the set, not decoration around one.
    let introduced = introduced_by_head(&harness, &work);
    assert!(
        introduced.len() >= 3,
        "the fixture should introduce at least a commit, a tree and a blob, got {introduced:?}"
    );

    // ── the vacuity check, and it is the whole point ────────────────────────
    //
    // If the repository held no more than this push introduced, a gate counting
    // the whole repository would sit under the ceiling below too, the acceptance
    // would prove nothing, and this test would pass against the code it exists
    // to catch. A hard failure, not a skip.
    let everything = everything_the_client_holds(&harness, &work);
    assert!(
        everything.len() > introduced.len(),
        "the repository holds {} objects and the push introduces {}. They must differ, or a gate \
         counting the whole repository would pass the ceiling below and this test could not tell \
         the two apart",
        everything.len(),
        introduced.len()
    );
    set_max_objects(&harness, "team", "project", introduced.len() as u64);

    harness
        .git(&work, &["push", "--quiet", &url, "main"])
        .expect_ok("the second push, under a ceiling of exactly what it introduces");

    let seen = last_attestation_objects(&harness);
    assert!(
        !seen.is_empty(),
        "the attestation carries no object ids at all, so the assertion below is vacuous. A gate \
         IS configured here, so the ingest was asked to enumerate the set"
    );
    assert_eq!(
        seen,
        introduced,
        "the gate was shown a different object set than the push introduced. Extra (objects the \
         repository already held, attributed to this push): {:?}. Missing: {:?}",
        seen.difference(&introduced).collect::<Vec<_>>(),
        introduced.difference(&seen).collect::<Vec<_>>(),
    );

    // ── and the ceiling is REAL, or the acceptance above proved nothing ────
    //
    // One below what the push introduced, on the same server with the same
    // policy shape. A `max_objects` nobody enforces passes both arms.
    let third = bulky("third\n");
    std::fs::write(work.join("hello.txt"), third).expect("rewrite the file");
    harness
        .git(
            &work,
            &["commit", "--quiet", "-a", "-m", "the third commit"],
        )
        .expect_ok("the third commit");
    set_max_objects(&harness, "team", "project", introduced.len() as u64 - 1);
    let refused = harness.git(&work, &["push", &url, "main"]);
    refused.expect_failure("a push over the object ceiling");

    harness.stop();
}

// ───────────────────────────────────────────────────────────────────────────
// (b) the byte count
// ───────────────────────────────────────────────────────────────────────────

/// Write a policy document naming a packfile byte ceiling.
fn set_pack_byte_limit(harness: &Harness, namespace: &str, repo: &str, max: u64) {
    let config = gunnar_policy::PolicyConfig {
        limits: gunnar_policy::LimitPolicy {
            max_pack_bytes: Some(max),
            ..Default::default()
        },
        ..Default::default()
    };
    let path = harness
        .git_dir(namespace, repo)
        .join(gunnar_cli::server::transport::POLICY_FILE);
    std::fs::write(&path, config.to_json().expect("render the policy"))
        .unwrap_or_else(|err| panic!("write {}: {err}", path.display()));
}

#[test]
fn a_pack_over_the_configured_byte_limit_is_refused_and_says_how_big_it_was() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "project", HashKind::Sha256);
    let work = harness.dir.path().join("work");
    // A file that compresses to well over the limit set below.
    local_repo(&harness, &work, HashKind::Sha256, &bulky("first\n"));
    set_pack_byte_limit(&harness, "team", "project", 512);

    let url = harness.url("team", "project");
    let refused = harness.git(&work, &["push", &url, "main"]);
    refused.expect_failure("a push over the byte limit");

    let told = format!("{}{}", refused.stdout, refused.stderr);
    assert!(
        told.contains("over the 512-byte limit"),
        "the client was not told the pack was over the limit. It said:\n{told}"
    );

    // ── the number is CROSS-CHECKED, not just present ───────────────────────
    //
    // "packfile is N bytes" with a constant, a zero or the uncompressed file
    // size would satisfy the assertion above. `N` is compared against the pack
    // gunnar wrote to disk for the same push, which holds the same objects and
    // is therefore the same order of magnitude — a different measurement of the
    // same thing, made by different code.
    let reported: u64 = told
        .split("packfile is ")
        .nth(1)
        .and_then(|rest| rest.split_whitespace().next())
        .and_then(|n| n.parse().ok())
        .unwrap_or_else(|| panic!("the refusal did not name a byte count:\n{told}"));
    // The store, not `objects/pack/*.pack` on its own: this engine appends the
    // pack verbatim into one archive and writes no packfile, so the archive is
    // where the refused push's bytes are. The archive is the pack plus this
    // engine's own framing, so it is at least as big — never smaller.
    // `common::stored_pack_bytes` reads whichever of the two shapes the engine
    // that ran actually wrote, so this measures on either engine.
    let on_disk = common::stored_pack_bytes(&harness.git_dir("team", "project"));
    assert!(
        on_disk > 0,
        "the refused push left nothing in the store to cross-check against"
    );
    assert!(
        reported <= on_disk && reported * 2 > on_disk,
        "the gate was told the pack was {reported} bytes, and the store the same push appended \
         into holds {on_disk}. The pack is inside the archive, so the first can never exceed the \
         second, and a constant or an uncompressed size would not sit just under it."
    );
    // The pre-fix failure is a DIFFERENT refusal — "the pack size was not
    // measured, so the limit cannot be enforced" — and a test that only
    // asserted the push failed would pass on it.
    assert!(
        !told.contains("was not measured"),
        "the gate refused because it could not measure the pack, not because the pack was over \
         the limit. It said:\n{told}"
    );

    harness.stop();
}

#[test]
fn a_pack_under_the_configured_byte_limit_still_lands() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "project", HashKind::Sha256);
    let work = harness.dir.path().join("work");
    local_repo(&harness, &work, HashKind::Sha256, "small\n");
    // Generous, and deliberately not `None`: a gate that refuses whenever the
    // limit is *configured* — which is what an unmeasured pack produced — fails
    // here, so this is what stops the test above passing for the wrong reason.
    set_pack_byte_limit(&harness, "team", "project", 1 << 20);

    let url = harness.url("team", "project");
    harness
        .git(&work, &["push", "--quiet", &url, "main"])
        .expect_ok("a push under the byte limit");

    // Applied output: the reference actually moved on the server.
    let head = harness.git(&work, &["rev-parse", "HEAD"]);
    head.expect_ok("git rev-parse HEAD");
    let expected = head.stdout.trim().to_owned();
    let remote = harness.git(&work, &["ls-remote", &url, "refs/heads/main"]);
    remote.expect_ok("git ls-remote");
    assert!(
        remote.stdout.contains(&expected),
        "refs/heads/main did not move to {expected} on the server; ls-remote said:\n{}",
        remote.stdout
    );

    harness.stop();
}

// ─────────────────────── push options reach the gate ────────────────────────
//
// `push-options` was advertised by `caps::receive_pack_capabilities`, parsed by
// `ReceivePack::serve` and then dropped: `PushFacts` had no options field, and
// the only consumer of a parsed option in the whole tree was `.len()` in a
// `tracing` field in `server/transport.rs`. So `git push -o ci.skip` was
// acknowledged on the wire and ignored, which is a wrong answer rather than a
// missing feature.
//
// Asserted here against **stock `git push -o` over real SSH**, because the
// section is only produced by a client that negotiated the capability, and
// because the plumbing being fixed runs from the wire, through `PushFacts`,
// through `PolicyGate`, into `PushContext`. Every one of those seams could be
// wired and the last one not, and the crate-level tests would still be green.

/// Write a policy document demanding a push option matching `required`.
fn set_required_push_option(harness: &Harness, namespace: &str, repo: &str, required: &str) {
    let config = gunnar_policy::PolicyConfig {
        push_options: gunnar_policy::PushOptionPolicy::new(Vec::<String>::new(), [required])
            .expect("compile the push-option pattern"),
        ..Default::default()
    };
    let path = harness
        .git_dir(namespace, repo)
        .join(gunnar_cli::server::transport::POLICY_FILE);
    std::fs::write(&path, config.to_json().expect("render the policy"))
        .unwrap_or_else(|err| panic!("write {}: {err}", path.display()));
}

#[test]
fn a_push_option_the_policy_demands_is_seen_by_the_gate_over_real_ssh() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "opts", HashKind::Sha256);
    let work = harness.dir.path().join("work");
    local_repo(&harness, &work, HashKind::Sha256, "small\n");
    set_required_push_option(&harness, "team", "opts", "ticket=NORD-*");
    let url = harness.url("team", "opts");

    // ── red: no `-o` at all ────────────────────────────────────────────────
    let bare = harness.git(&work, &["push", &url, "main"]);
    bare.expect_failure("a push with no push option against a policy that demands one");
    let told = format!("{}{}", bare.stdout, bare.stderr);
    assert!(
        told.contains("requires a push option matching"),
        "the client was not told which option is missing. It said:\n{told}"
    );
    // The pre-fix failure is a DIFFERENT refusal — "carried no push-option
    // report" — which is what an unwired `PushContext` produces. A test that
    // only asserted the push failed would pass on it.
    assert!(
        !told.contains("carried no push-option report"),
        "the gate refused because nobody wired the options through, not because the client \
         omitted one. That is the defect this test exists for. It said:\n{told}"
    );

    // ── red: an option was sent, but the wrong one ─────────────────────────
    let wrong = harness.git(&work, &["push", "-o", "ci.skip", &url, "main"]);
    wrong.expect_failure("a push whose push option does not match the requirement");
    let told = format!("{}{}", wrong.stdout, wrong.stderr);
    assert!(
        told.contains("\"ci.skip\""),
        "the gate did not see the option stock git actually sent, so the option is still not \
         reaching it. It said:\n{told}"
    );

    // ── green, and the reference MOVES ─────────────────────────────────────
    harness
        .git(
            &work,
            &["push", "--quiet", "-o", "ticket=NORD-1", &url, "main"],
        )
        .expect_ok("a push carrying the required push option");

    let head = harness.git(&work, &["rev-parse", "HEAD"]);
    head.expect_ok("git rev-parse HEAD");
    let expected = head.stdout.trim().to_owned();
    let remote = harness.git(&work, &["ls-remote", &url, "refs/heads/main"]);
    remote.expect_ok("git ls-remote");
    assert!(
        remote.stdout.contains(&expected),
        "refs/heads/main did not move to {expected} on the server; ls-remote said:\n{}",
        remote.stdout
    );

    harness.stop();
}

// ───────────────── what a REFUSED push leaves on the server ─────────────────

/// **Measured, not assumed: a rejected push leaves its objects in the ODB.**
///
/// Stock `git receive-pack` unpacks a push into a temporary object directory
/// (`objects/incoming-*`, `GIT_QUARANTINE_PATH`) and migrates it into the real
/// ODB only after `receive.fsckObjects` and the hooks have passed. gunnar has
/// no quarantine and no fsck: `ReceivePack::serve` indexes the pack into the
/// store and *then* runs the gate, so a refused push has already landed its
/// bytes. Nothing in the tree said so — it was not even a known position — and
/// this test is where the consequence is now written down.
///
/// This test is the measurement. It is deliberately an assertion of the
/// CURRENT behaviour rather than of the desired one: the day somebody
/// implements quarantine, this goes red and names the position to update. A
/// position nobody can see change is not a position.
#[test]
fn a_refused_push_leaves_its_objects_in_the_object_database() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    let mut harness = Harness::start("team", "refused", HashKind::Sha256);
    let work = harness.dir.path().join("work");
    local_repo(&harness, &work, HashKind::Sha256, &bulky("refused\n"));
    // Any gate that says no will do; this one refuses without needing the
    // objects to be malformed, so what is measured is the ORDER of ingest and
    // gate, not a pack reader's error path.
    set_required_push_option(&harness, "team", "refused", "ticket=NORD-*");

    let git_dir = harness.git_dir("team", "refused");
    let before_bytes = common::stored_pack_bytes(&git_dir);

    let url = harness.url("team", "refused");
    let refused = harness.git(&work, &["push", &url, "main"]);
    refused.expect_failure("a push the policy gate refuses");

    // The reference did NOT move — the gate worked.
    let remote = harness.git(&work, &["ls-remote", &url, "refs/heads/main"]);
    remote.expect_ok("git ls-remote");
    assert!(
        remote.stdout.trim().is_empty(),
        "the refused push moved the reference anyway: {}",
        remote.stdout
    );

    // …and the objects are in the ODB regardless.
    //
    // `GitOps::has` and not a pack index: on this engine there is no
    // `objects/pack`, no `.idx` and no loose object, and the ODB question is
    // the one the store answers. It absorbs a pending pack before answering, so
    // "not indexed yet" cannot be mistaken for "not here".
    //
    // Everything reachable from the one commit this fixture made. `HEAD^` does
    // not exist here (a single-commit repository), so the whole graph IS what
    // the push introduced.
    let listed = harness.git(&work, &["rev-list", "--objects", "HEAD"]);
    listed.expect_ok("git rev-list --objects HEAD");
    let pushed: BTreeSet<String> = listed
        .stdout
        .lines()
        .filter_map(|line| line.split_whitespace().next())
        .map(str::to_owned)
        .collect();
    assert!(
        !pushed.is_empty(),
        "the fixture pushed nothing, so this measures nothing"
    );
    let missing = missing_from_store(&harness, "team", "refused", &pushed);
    assert!(
        missing.is_empty(),
        "the refused push's objects are NOT in the ODB ({} of {} missing: {missing:?}). If that \
         is because gunnar grew a quarantine, this test is out of date and the \
         position it records has changed — which is good news and still has to \
         be written down.",
        missing.len(),
        pushed.len()
    );

    // And the bytes are real: the store grew by the pack the refused push sent.
    let after_bytes = common::stored_pack_bytes(&git_dir);
    assert!(
        after_bytes > before_bytes,
        "the refused push added nothing to the store ({before_bytes} bytes before, \
         {after_bytes} after), so nothing was measured"
    );

    harness.stop();
}