crates/gunnar-policy/tests/git_interop.rs
//! Checked against stock `git`, because agreeing with yourself is not evidence.
//!
//! The verification rule: *interoperability is asserted against the real `git` binary as a
//! client. gunnar never runs git; its tests do.*
//!
//! Two claims in this crate cannot be honestly proved any other way:
//!
//! 1. **The signed payload is reconstructed the way git constructs it.** A
//! signature this crate mints and this crate verifies proves only that the two
//! halves of one function agree. Here `git 2.5x` with `gpg.format=ssh` and
//! `ssh-keygen` produces the signature, and gunnar's pure-Rust path (no
//! subprocess, no `gpg`, no `ssh-keygen`) verifies it.
//! 2. **The ancestry walk answers what git answers.** Not on a fixture picked to
//! suit it, but on every ordered pair of commits in a real repository with a
//! real merge, cross-checked against `git merge-base --is-ancestor`.
//!
//! This test **requires** `git` and `ssh-keygen` and fails loudly without them.
//! A test that quietly skips itself is a green nobody has seen red.
mod common;
use std::path::Path;
use std::process::Command;
use gix_hash::ObjectId;
use gunnar_policy::ancestry::is_descendant_of;
use gunnar_policy::signature::{self, SignatureCheck, SignatureProblem, GIT_NAMESPACE};
use gunnar_store::git_storage_trait::GitOps;
use ssh_key::{LineEnding, PrivateKey};
const BUDGET: usize = 10_000;
fn tool(name: &str) -> String {
let out = Command::new(name)
.arg("--version")
.output()
.unwrap_or_else(|err| {
panic!(
"`{name}` is required by this test and could not be run ({err}). \
gunnar itself never shells out; its interop tests must."
)
});
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
/// Run `git` in `dir` with the ambient configuration neutralised.
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "A U Thor")
.env("GIT_AUTHOR_EMAIL", "a@example.com")
.env("GIT_COMMITTER_NAME", "A U Thor")
.env("GIT_COMMITTER_EMAIL", "a@example.com")
.args(args)
.output()
.expect("git must be runnable");
assert!(
out.status.success(),
"git {args:?} failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
/// `git merge-base --is-ancestor`, as a boolean rather than an assertion.
fn git_says_ancestor(dir: &Path, ancestor: &ObjectId, tip: &ObjectId) -> bool {
let out = Command::new("git")
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.args([
"merge-base",
"--is-ancestor",
&ancestor.to_string(),
&tip.to_string(),
])
.output()
.expect("git must be runnable");
match out.status.code() {
Some(0) => true,
Some(1) => false,
other => panic!("git merge-base --is-ancestor returned {other:?}"),
}
}
/// Write `key` where `git` and `ssh-keygen` will accept it.
fn install_key(dir: &Path, key: &PrivateKey) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("signing_key");
let pem = key
.to_openssh(LineEnding::LF)
.expect("encode the private key");
std::fs::write(&path, pem.as_bytes()).expect("write the private key");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.expect("ssh-keygen refuses a world-readable private key");
let public = key
.public_key()
.to_openssh()
.expect("encode the public key");
std::fs::write(path.with_extension("pub"), format!("{public}\n"))
.expect("write the public key");
path
}
/// Read one object out of a git repository using `gix`, not by parsing `git`
/// output. A second, independent reader.
fn read_object(repo: &gix::Repository, id: &ObjectId) -> Vec<u8> {
repo.find_object(*id)
.expect("object must exist in the repository")
.data
.clone()
}
#[test]
fn a_signature_made_by_real_git_verifies_in_pure_rust() {
eprintln!("interop against: {}", tool("git"));
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
let key = common::mint_key();
let key_path = install_key(path, &key);
// `ssh-keygen` must be present: it is the binary git delegates ssh signing
// to. gunnar has no such dependency, and that asymmetry is the whole point.
let keygen = Command::new("ssh-keygen")
.args(["-l", "-f"])
.arg(key_path.with_extension("pub"))
.output()
.expect("ssh-keygen is required by this test — git signs with it");
assert!(
keygen.status.success(),
"ssh-keygen could not read the key this test minted with `ssh-key`: {}",
String::from_utf8_lossy(&keygen.stderr)
);
// An allowed-signers file, so `git` itself will verify the signature and
// this test's sanity gate is git's own verdict rather than a guess.
let public = key
.public_key()
.to_openssh()
.expect("encode the public key");
let allowed = path.join("allowed_signers");
std::fs::write(&allowed, format!("a@example.com {public}\n")).expect("write allowed signers");
git(path, &["init", "-q", "--initial-branch=main"]);
git(path, &["config", "user.name", "A U Thor"]);
git(path, &["config", "user.email", "a@example.com"]);
git(path, &["config", "gpg.format", "ssh"]);
git(
path,
&["config", "user.signingkey", key_path.to_str().unwrap()],
);
git(
path,
&[
"config",
"gpg.ssh.allowedSignersFile",
allowed.to_str().unwrap(),
],
);
git(
path,
&["commit", "-q", "--allow-empty", "-m", "unsigned root"],
);
git(
path,
&[
"commit",
"-q",
"--allow-empty",
"-S",
"-m",
"signed by stock git",
],
);
let signed_hex = git(path, &["rev-parse", "HEAD"]);
let signed = ObjectId::from_hex(signed_hex.as_bytes()).expect("a valid oid");
// Sanity, from git rather than from us: `%G?` is `G` only when git has
// verified the signature against the allowed-signers file. If this is not
// `G`, the fixture is not a validly signed commit and everything below would
// be vacuous.
let verdict = git(path, &["show", "-s", "--format=%G?", "HEAD"]);
assert_eq!(
verdict, "G",
"git does not consider the fixture commit validly signed (%G? = {verdict:?}); \
the interop assertions below would prove nothing"
);
// …and git must say the same about the key, so the two implementations are
// demonstrably talking about the same key.
let signer = git(path, &["show", "-s", "--format=%GF", "HEAD"]);
assert!(
!signer.is_empty(),
"git reported no signing key fingerprint for a commit it just verified"
);
let gix_repo = gix::open(path).expect("gix must be able to open the repository");
let raw = read_object(&gix_repo, &signed);
assert!(
raw.windows(7).any(|w| w == b"gpgsig "),
"the raw commit git wrote has no gpgsig header"
);
assert!(
String::from_utf8_lossy(&raw).contains("BEGIN SSH SIGNATURE"),
"git did not write an SSHSIG blob; is gpg.format=ssh in effect?"
);
// The claim: gunnar reconstructs the payload the way git does.
let check =
signature::check_commit_bytes(signed, &raw, &[key.public_key().clone()], GIT_NAMESPACE);
match &check {
SignatureCheck::Verified(verified) => assert_eq!(
verified.fingerprint,
key.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string()
),
SignatureCheck::Problem(problem) => panic!(
"a signature produced by stock git did not verify: {problem}\n\
raw commit:\n{}",
String::from_utf8_lossy(&raw)
),
}
// …and the same bytes with one character changed must NOT verify. Without
// this half, a verifier that ignores the payload entirely passes the half
// above.
let tampered = String::from_utf8_lossy(&raw)
.replace("signed by stock git", "signed by stock GIT")
.into_bytes();
assert_ne!(raw, tampered, "the tamper must actually change the bytes");
let tampered_check = signature::check_commit_bytes(
signed,
&tampered,
&[key.public_key().clone()],
GIT_NAMESPACE,
);
assert!(
matches!(
tampered_check.problem(),
Some(SignatureProblem::BadSignature { .. })
),
"a tampered git-signed commit must fail, got {tampered_check:?}"
);
// …and a stranger's key must not verify it either.
let stranger = common::mint_key();
let stranger_check = signature::check_commit_bytes(
signed,
&raw,
&[stranger.public_key().clone()],
GIT_NAMESPACE,
);
assert!(matches!(
stranger_check.problem(),
Some(SignatureProblem::KeyNotRegistered { .. })
));
}
#[test]
fn the_ancestry_walk_agrees_with_git_on_every_pair_in_a_real_merge_history() {
tool("git");
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
git(path, &["init", "-q", "--initial-branch=main"]);
git(path, &["config", "user.name", "A U Thor"]);
git(path, &["config", "user.email", "a@example.com"]);
// A history git built, not one gunnar built: root → a → b on main, a → f1 →
// f2 on a feature branch, then a real `--no-ff` merge, then one more commit.
git(path, &["commit", "-q", "--allow-empty", "-m", "root"]);
git(path, &["commit", "-q", "--allow-empty", "-m", "a"]);
let a = ObjectId::from_hex(git(path, &["rev-parse", "HEAD"]).as_bytes()).unwrap();
git(path, &["commit", "-q", "--allow-empty", "-m", "b"]);
git(path, &["checkout", "-q", "-b", "feature", &a.to_string()]);
git(path, &["commit", "-q", "--allow-empty", "-m", "f1"]);
git(path, &["commit", "-q", "--allow-empty", "-m", "f2"]);
let f2 = ObjectId::from_hex(git(path, &["rev-parse", "HEAD"]).as_bytes()).unwrap();
git(path, &["checkout", "-q", "main"]);
git(
path,
&["merge", "-q", "--no-ff", "-m", "merge feature", "feature"],
);
let merge = ObjectId::from_hex(git(path, &["rev-parse", "HEAD"]).as_bytes()).unwrap();
git(path, &["commit", "-q", "--allow-empty", "-m", "n"]);
// The fixture must genuinely contain a merge, or the second-parent case is
// not being exercised at all.
let parents = git(
path,
&["rev-list", "--parents", "-n", "1", &merge.to_string()],
);
assert_eq!(
parents.split_whitespace().count(),
3,
"the merge commit must have two parents: {parents}"
);
// Copy every commit into a gunnar store. gix reads them; gunnar walks them.
let gix_repo = gix::open(path).expect("gix must be able to open the repository");
let repo = common::Repo::sha1();
let store = &repo.objects;
let commits: Vec<ObjectId> = git(path, &["rev-list", "--all"])
.lines()
.map(|line| ObjectId::from_hex(line.trim().as_bytes()).expect("a valid oid"))
.collect();
assert_eq!(
commits.len(),
7,
"expected 7 commits, got {}",
commits.len()
);
for id in &commits {
let raw = read_object(&gix_repo, id);
let written = repo.write(gix_object::Kind::Commit, &raw);
assert_eq!(
&written, id,
"gunnar hashed a commit git wrote to a different id"
);
// And the STORE agrees, which is a second and stronger claim: the id
// above is what `gix_object::compute_hash` made of the bytes, while
// this one is what the store derived for itself while absorbing the
// pack. Two independent hashers, one answer. It could not be asked
// before — `ObjectStore::write` took the id from the same call that
// stored it — and it can be now.
assert!(
store
.has(id.as_bytes())
.expect("ask the store whether it holds the commit"),
"the store absorbed the pack but does not hold {id} under that id"
);
}
// The whole pair matrix. 49 comparisons, and both `true` and `false` answers
// have to appear or the cross-check is vacuous.
let mut agreed = 0usize;
let mut trues = 0usize;
let mut falses = 0usize;
for tip in &commits {
for ancestor in &commits {
let ours = is_descendant_of(store, tip, ancestor, BUDGET)
.unwrap_or_else(|err| panic!("gunnar could not decide {tip} ⊒ {ancestor}: {err}"));
let theirs = git_says_ancestor(path, ancestor, tip);
assert_eq!(
ours, theirs,
"gunnar and git disagree: is {ancestor} an ancestor of {tip}? \
gunnar says {ours}, git says {theirs}"
);
agreed += 1;
if ours {
trues += 1;
} else {
falses += 1;
}
}
}
assert_eq!(agreed, 49);
assert!(
trues > 0 && falses > 0,
"the cross-check saw only one answer ({trues} true, {falses} false), \
so it could not have caught a constant implementation"
);
// And the specific case a first-parent walk gets wrong.
assert!(
is_descendant_of(store, &merge, &f2, BUDGET).unwrap(),
"the feature tip is reachable from the merge only through its second parent"
);
assert!(git_says_ancestor(path, &f2, &merge));
}