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();
}