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