crates/gunnar-cli/tests/verify_chain.rs
#![cfg(all(feature = "server", feature = "control"))]
//! **The chain, end to end** (roadmap phase 2).
//!
//! A line of source is pushed by stock git over real ssh; then `verify` walks
//! backwards from that line and says which commit wrote it, which push landed
//! it, and where that push sits in the transparency log.
//!
//! # Why the arms are shaped like this
//!
//! Every one of them has a **control**. A verifier that reported `Proven` for
//! everything would pass an "it verifies" test and be worth nothing, so each
//! arm also drives the case the link is supposed to catch:
//!
//! * the blame is checked against a line whose author is known *because the
//! test wrote it in a specific commit*, not against whatever the verifier
//! says;
//! * the log link is checked before **and** after a tree head exists, because
//! an inclusion proof with nothing signed over it is arithmetic the server
//! did about itself;
//! * a rewritten log turns the link `BAD`, and `BAD` is asserted to be a
//! different answer from the `??` an unsigned commit gets.
mod common;
use common::Harness;
use gunnar_cli::server::verify::{verify, Chain, LinkState};
use gunnar_core::HashKind;
use gunnar_proto::RepoPath;
fn tools_present() -> bool {
["git", "ssh"].iter().all(|tool| {
std::process::Command::new(tool)
.arg("--version")
.output()
.is_ok()
})
}
/// Verify through the harness's **own** stores.
///
/// Not `verify_at`, which opens a second `ServerStores` — redb takes an
/// exclusive lock on `control.redb`, so a second handle while the server is up
/// is `Database already open`. That refusal is correct and is why the CLI verb
/// is a separate process from the server; a test that is *in* the server's
/// process has to reuse its handle.
fn chain_of(harness: &Harness, repo: &RepoPath, rev: &str, line: Option<&str>) -> Chain {
let plane = gunnar_cli::server::CoreControlPlane::new(std::sync::Arc::clone(&harness.stores));
verify(&harness.stores, &plane, repo, rev, line).expect("verify")
}
/// Three commits over one file, each adding one line, each its own push.
///
/// Returns the commit oids in order, so an arm can assert the blame against the
/// commit it *knows* wrote a line rather than against the verifier's own
/// answer.
fn history(harness: &Harness, work: &std::path::Path, url: &str) -> Vec<String> {
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
let mut oids = Vec::new();
let mut body = String::new();
for (i, line) in ["first line", "second line", "third line"]
.iter()
.enumerate()
{
body.push_str(line);
body.push('\n');
std::fs::write(work.join("src.txt"), &body).expect("write");
harness.git(work, &["add", "src.txt"]).expect_ok("add");
harness
.git(work, &["commit", "--quiet", "-m", &format!("commit {i}")])
.expect_ok("commit");
let head = harness.git(work, &["rev-parse", "HEAD"]);
head.expect_ok("rev-parse");
oids.push(head.stdout.trim().to_owned());
harness
.git(work, &["push", "--quiet", url, "main"])
.expect_ok("push");
}
oids
}
/// **Line 2 was written by the second commit**, and the verifier says so.
///
/// The oid is the one `git rev-parse` reported at the time, so this is the
/// verifier being checked against git and not against itself.
#[test]
fn the_blame_names_the_commit_that_wrote_the_line() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "chain", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "chain");
let oids = history(&harness, &work, &url);
let repo = RepoPath::new("team", "chain");
for (line, expected) in [(1, &oids[0]), (2, &oids[1]), (3, &oids[2])] {
let chain = chain_of(
&harness,
&repo,
"refs/heads/main",
Some(&format!("src.txt:{line}")),
);
match &chain.line {
LinkState::Proven(detail) => assert!(
detail.contains(expected.as_str()),
"line {line} was written by {expected} and the blame said: {detail}"
),
other => panic!("line {line} did not resolve to a commit: {other:?}"),
}
}
harness.stop();
}
/// **The log link is unproven until a head is signed, and proven after.**
///
/// This is the arm that says an inclusion proof alone is not evidence: before
/// `attest head` the record is in the log and nothing has sworn to the tree it
/// is in, so the honest answer is `??` and not `ok`.
#[test]
fn the_log_link_needs_a_signed_head_and_says_so_until_it_has_one() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "headed", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "headed");
history(&harness, &work, &url);
let repo = RepoPath::new("team", "headed");
let target = Some("src.txt:3");
let before = chain_of(&harness, &repo, "refs/heads/main", target);
assert!(
before.push.is_proven(),
"the push must be attested before the log link means anything: {:?}",
before.push
);
assert!(
matches!(before.log, LinkState::Unproven(_)),
"with no signed head the log link must be UNPROVEN, not proven: {:?}",
before.log
);
assert!(
!before.is_unbroken(),
"a chain with an unproven link reported as unbroken"
);
// Now sign one.
let key = harness.stores.transparency_key().expect("key");
harness
.stores
.transparency()
.issue_head(&key, 1_700_000_000_000)
.expect("head");
let after = chain_of(&harness, &repo, "refs/heads/main", target);
assert!(
after.log.is_proven(),
"with a signed head covering it, the log link must be proven: {:?}",
after.log
);
harness.stop();
}
/// **A rewritten log turns the link BAD**, which is a different answer from the
/// `??` an unsigned commit gets.
///
/// Both are "not proven"; only one is an accusation. A verifier that returned
/// the same thing for both would make an unsigned repository indistinguishable
/// from a tampered one.
#[test]
fn a_rewritten_log_breaks_the_chain_and_an_unsigned_commit_does_not() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "tampered", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "tampered");
history(&harness, &work, &url);
let repo = RepoPath::new("team", "tampered");
let target = Some("src.txt:1");
let key = harness.stores.transparency_key().expect("key");
let log = harness.stores.transparency();
log.issue_head(&key, 1_700_000_000_000).expect("head");
let clean = chain_of(&harness, &repo, "refs/heads/main", target);
assert!(clean.log.is_proven(), "control: {:?}", clean.log);
// The commits stock git made here are unsigned, and that must read as
// UNPROVEN — nothing is wrong with them.
assert!(
matches!(clean.signature, LinkState::Unproven(_)),
"an unsigned commit must be unproven, never broken: {:?}",
clean.signature
);
assert!(
!clean.is_broken(),
"an unsigned commit made the chain read as BROKEN, which accuses it of something"
);
// Rewrite a record under the signed head.
let path = log.records().path().to_path_buf();
let text = std::fs::read_to_string(&path).expect("read");
let tampered = text.replacen("\"transport\":\"ssh\"", "\"transport\":\"smoke\"", 1);
assert_ne!(tampered, text, "the rewrite changed nothing");
std::fs::write(&path, tampered).expect("rewrite");
let after = chain_of(&harness, &repo, "refs/heads/main", target);
assert!(
after.log.is_broken(),
"the log was rewritten under a signed head and the chain did not say BROKEN: {:?}",
after.log
);
assert!(after.is_broken(), "the chain must report as broken");
harness.stop();
}
/// A line that is not there, and a repository that is not there, are refusals
/// with reasons — not an empty chain that reads as a pass.
#[test]
fn the_refusals_name_what_was_wrong() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "refuse", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "refuse");
history(&harness, &work, &url);
let repo = RepoPath::new("team", "refuse");
let past_the_end = chain_of(&harness, &repo, "refs/heads/main", Some("src.txt:99"));
assert!(
past_the_end.line.is_broken(),
"a line past the end of the file must break link 1, not pass it: {:?}",
past_the_end.line
);
let no_such_file = chain_of(&harness, &repo, "refs/heads/main", Some("nope.txt:1"));
assert!(
no_such_file.line.is_broken(),
"a path that is not in the tree must break link 1: {:?}",
no_such_file.line
);
let plane = gunnar_cli::server::CoreControlPlane::new(std::sync::Arc::clone(&harness.stores));
assert!(
verify(
&harness.stores,
&plane,
&RepoPath::new("team", "absent"),
"HEAD",
None
)
.is_err(),
"a repository that does not exist must be an error, not an empty chain"
);
// Without `--line`, link 1 is UNPROVEN and the chain is incomplete — never
// green just because nothing was asked of it.
let no_line = chain_of(&harness, &repo, "refs/heads/main", None);
assert!(matches!(no_line.line, LinkState::Unproven(_)));
assert!(
!no_line.is_unbroken(),
"a chain that was never asked about a line reported as unbroken"
);
harness.stop();
}
// ─────────────────────────────────────────────────────────────────────────────
// Link 2 — the commit's OWN signature, at chain level.
//
// `gunnar-policy/tests/signature.rs` proves the cryptography red-first; these
// arms prove the CHAIN keeps the four verdicts apart, because collapsing any
// two of them is the failure the roadmap names:
//
// signed-and-verified → Proven, naming the key AND its principal
// unsigned → Unproven (covered by
// `a_rewritten_log_breaks_the_chain_…` above)
// signed-by-unknown-key → Broken, saying "unknown", never "verified"
// signature-invalid → Broken, saying "invalid", never "unsigned"
// PGP (present, uncheckable) → Unproven, "not verifiable by this server"
// ─────────────────────────────────────────────────────────────────────────────
/// `git commit -S` with `gpg.format=ssh` shells out to `ssh-keygen -Y sign`,
/// so these arms need it on PATH as well.
fn signing_tools_present() -> bool {
tools_present()
&& std::process::Command::new("ssh-keygen")
.arg("-Y")
.arg("sign")
.output()
.is_ok()
}
/// Configure `work` so `git commit -S` signs with `key` the way a developer's
/// machine would: stock git, `gpg.format=ssh`, no gunnar code involved in the
/// signing half.
fn config_ssh_signing(harness: &Harness, work: &std::path::Path, key: &std::path::Path) {
harness
.git(work, &["config", "gpg.format", "ssh"])
.expect_ok("config gpg.format");
harness
.git(work, &["config", "user.signingkey", key.to_str().unwrap()])
.expect_ok("config user.signingkey");
}
/// One commit over `src.txt`, signed (`-S`) or not, pushed to `url`. Returns
/// the oid `git rev-parse` reported.
fn one_commit(
harness: &Harness,
work: &std::path::Path,
url: &str,
content: &str,
message: &str,
sign: bool,
) -> String {
std::fs::write(work.join("src.txt"), content).expect("write");
harness.git(work, &["add", "src.txt"]).expect_ok("add");
let mut args = vec!["commit", "--quiet", "-m", message];
if sign {
args.push("-S");
}
harness.git(work, &args).expect_ok("commit");
let head = harness.git(work, &["rev-parse", "HEAD"]);
head.expect_ok("rev-parse");
harness
.git(work, &["push", "--quiet", "--force", url, "main"])
.expect_ok("push");
head.stdout.trim().to_owned()
}
/// Write `raw` into the odb as a commit, point `main` at it, and force-push.
/// This is how every doctored commit in these arms reaches the server: through
/// stock git, exactly as an attacker outside gunnar would send it.
fn push_raw_commit(harness: &Harness, work: &std::path::Path, url: &str, raw: &str) -> String {
let path = work.join("doctored_commit");
std::fs::write(&path, raw).expect("write the doctored commit");
let hashed = harness.git(
work,
&[
"hash-object",
"-t",
"commit",
"-w",
"--literally",
"doctored_commit",
],
);
hashed.expect_ok("hash-object");
let oid = hashed.stdout.trim().to_owned();
harness
.git(work, &["update-ref", "refs/heads/main", &oid])
.expect_ok("update-ref");
harness
.git(work, &["push", "--quiet", "--force", url, "main"])
.expect_ok("push the doctored commit");
oid
}
/// **The happy path is proven with real `git commit -S`**, and the verdict
/// names both the key and the identity-table principal it belongs to — "signed" without
/// "by whom" is not an answer an auditor can act on.
#[test]
fn a_commit_signed_by_a_registered_key_is_proven_and_names_the_signer() {
assert!(
signing_tools_present(),
"this test requires `git`, `ssh` and `ssh-keygen` on PATH"
);
let mut harness = Harness::start("team", "signed", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "signed");
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
config_ssh_signing(&harness, &work, &harness.key_path);
one_commit(
&harness,
&work,
&url,
"signed line\n",
"a signed commit",
true,
);
let repo = RepoPath::new("team", "signed");
let chain = chain_of(&harness, &repo, "refs/heads/main", None);
match &chain.signature {
LinkState::Proven(detail) => {
assert!(
detail.contains(&harness.key_fingerprint),
"the verdict must name the key that signed ({}): {detail}",
harness.key_fingerprint
);
assert!(
detail.contains("tester"),
"the verdict must name the principal the key belongs to: {detail}"
);
}
other => panic!("a commit signed by the registered key must prove link 2: {other:?}"),
}
harness.stop();
}
/// **Tampered is "invalid", an unknown key is "unknown", PGP is "present, not
/// verifiable" — and none of them is any of the others.**
#[test]
fn tampered_unknown_and_unverifiable_signatures_are_kept_apart() {
assert!(
signing_tools_present(),
"this test requires `git`, `ssh` and `ssh-keygen` on PATH"
);
let mut harness = Harness::start("team", "verdicts", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "verdicts");
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
config_ssh_signing(&harness, &work, &harness.key_path);
let repo = RepoPath::new("team", "verdicts");
// ── arm 1: a REAL signature over a commit whose content was then altered.
// The signature is genuine and the key is registered; only the payload
// lies. The answer is "invalid" — never "unsigned", never proven.
one_commit(&harness, &work, &url, "line one\n", "good message", true);
let raw = harness.git(&work.to_path_buf(), &["cat-file", "commit", "HEAD"]);
raw.expect_ok("cat-file");
let tampered = raw.stdout.replace("good message", "evil message");
assert_ne!(tampered, raw.stdout, "the tamper changed nothing");
let oid = push_raw_commit(&harness, &work, &url, &tampered);
let chain = chain_of(&harness, &repo, &oid, None);
match &chain.signature {
LinkState::Broken(detail) => {
assert!(
detail.contains("invalid"),
"a tampered signature must read as invalid: {detail}"
);
assert!(
!detail.contains("not signed"),
"a tampered signature must never read as unsigned: {detail}"
);
}
other => panic!("a tampered signature must break link 2, got {other:?}"),
}
// ── arm 2: a genuine signature by a key NOBODY registered. The maths may
// be perfect; the answer is "unknown", never "verified".
let unknown_private = gunnar_core::mint_ed25519().expect("mint an unregistered key");
let unknown_path = work.join("unknown_key");
std::fs::write(&unknown_path, &unknown_private).expect("write the key");
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&unknown_path, std::fs::Permissions::from_mode(0o600))
.expect("chmod 600");
}
config_ssh_signing(&harness, &work, &unknown_path);
let oid = one_commit(
&harness,
&work,
&url,
"line one\nline two\n",
"signed by a stranger",
true,
);
let chain = chain_of(&harness, &repo, &oid, None);
match &chain.signature {
LinkState::Broken(detail) => {
assert!(
detail.contains("does not know"),
"an unknown key must read as unknown: {detail}"
);
}
other => panic!("an unknown key must break link 2, got {other:?}"),
}
assert!(
!chain.signature.is_proven(),
"a signature by an unregistered key read as verified"
);
// ── arm 3: a PGP signature. gunnar cannot check it, and the honest answer
// is "present, not verifiable by this server" — a hole, not an accusation.
config_ssh_signing(&harness, &work, &harness.key_path);
one_commit(
&harness,
&work,
&url,
"line one\nline two\nline three\n",
"unsigned base",
false,
);
let raw = harness.git(&work.to_path_buf(), &["cat-file", "commit", "HEAD"]);
raw.expect_ok("cat-file");
let idx = raw
.stdout
.find("\n\n")
.expect("a commit has a header/message boundary");
let doctored = format!(
"{}\ngpgsig -----BEGIN PGP SIGNATURE-----\n iQIzBAABCAAdFiEE\n =dead\n -----END PGP SIGNATURE-----\n\n{}",
&raw.stdout[..idx],
&raw.stdout[idx + 2..]
);
let oid = push_raw_commit(&harness, &work, &url, &doctored);
let chain = chain_of(&harness, &repo, &oid, None);
match &chain.signature {
LinkState::Unproven(detail) => {
assert!(
detail.contains("not verifiable by this server"),
"a PGP signature must read as present-but-not-verifiable: {detail}"
);
}
other => {
panic!("a PGP signature is a hole, not a broken link and not a pass, got {other:?}")
}
}
harness.stop();
}
/// **The wire chain is the walk's chain** — `Admin.VerifyChain` through the
/// seam answers exactly what `gunnar verify` prints, and an unsigned commit
/// crosses the wire as a NAMED hole, never as a pass.
///
/// This is the arm that guards the console: a provenance view is exactly the
/// kind of page that renders green over a broken chain, so the wire shape is
/// pinned to carry all three verdicts. The commits stock git makes here are
/// unsigned, so the signature link must be UNPROVEN with the commit named in
/// the detail, and the summary must be INCOMPLETE — a chain with a hole must
/// not serialize as UNBROKEN.
#[test]
fn the_wire_chain_names_the_unsigned_commit_as_a_hole() {
use gunnar_grpc::ControlPlane as _;
use gunnar_proto::{ChainLinkVerdict, ChainSummary, VerifyChainRequest};
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "wire", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "wire");
let oids = history(&harness, &work, &url);
// A head, so links 3 and 4 are proven and the ONE hole left is the
// signature — otherwise "INCOMPLETE" would be true for the wrong reason
// and the signature assertion below could pass against a chain that never
// reached link 2.
let key = harness.stores.transparency_key().expect("key");
harness
.stores
.transparency()
.issue_head(&key, 1_700_000_000_000)
.expect("head");
let plane = gunnar_cli::server::CoreControlPlane::new(std::sync::Arc::clone(&harness.stores));
let response = plane
.verify_chain(&VerifyChainRequest {
repo: Some(RepoPath::new("team", "wire")),
rev: "refs/heads/main".into(),
target: "src.txt:2".into(),
})
.expect("the control plane serves Admin.VerifyChain");
assert_eq!(response.repo, "team/wire");
assert_eq!(response.target, "src.txt:2");
let by_id = |id: &str| {
response
.links
.iter()
.find(|l| l.id == id)
.unwrap_or_else(|| panic!("the wire chain carries no {id:?} link: {response:?}"))
};
let line = by_id("line");
assert_eq!(line.verdict, ChainLinkVerdict::ChainLinkProven as i32);
assert!(
line.detail.contains(oids[1].as_str()),
"line 2 was written by {} and the wire said: {}",
oids[1],
line.detail
);
let signature = by_id("signature");
assert_eq!(
signature.verdict,
ChainLinkVerdict::ChainLinkUnproven as i32,
"an unsigned commit must cross the wire as UNPROVEN, not as {:?}",
signature.verdict
);
assert!(
signature.detail.contains("not signed"),
"the hole must be named in words: {}",
signature.detail
);
assert_eq!(
by_id("push").verdict,
ChainLinkVerdict::ChainLinkProven as i32
);
assert_eq!(
by_id("log").verdict,
ChainLinkVerdict::ChainLinkProven as i32
);
assert_eq!(
response.summary,
ChainSummary::ChainIncomplete as i32,
"a chain with an unproven link serialized as something other than INCOMPLETE"
);
harness.stop();
}
/// **A rewritten log crosses the wire as BROKEN**, and the wire refuses a walk
/// it cannot start: a repository that does not exist is an error, never an
/// empty chain that a console would render as six grey rows.
#[test]
fn the_wire_chain_breaks_and_refuses_like_the_walk_does() {
use gunnar_grpc::ControlPlane as _;
use gunnar_proto::{ChainLinkVerdict, ChainSummary, VerifyChainRequest};
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "wiretamper", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "wiretamper");
history(&harness, &work, &url);
let key = harness.stores.transparency_key().expect("key");
let log = harness.stores.transparency();
log.issue_head(&key, 1_700_000_000_000).expect("head");
// Rewrite a record under the signed head.
let path = log.records().path().to_path_buf();
let text = std::fs::read_to_string(&path).expect("read");
let tampered = text.replacen("\"transport\":\"ssh\"", "\"transport\":\"smoke\"", 1);
assert_ne!(tampered, text, "the rewrite changed nothing");
std::fs::write(&path, tampered).expect("rewrite");
let plane = gunnar_cli::server::CoreControlPlane::new(std::sync::Arc::clone(&harness.stores));
let response = plane
.verify_chain(&VerifyChainRequest {
repo: Some(RepoPath::new("team", "wiretamper")),
rev: "refs/heads/main".into(),
target: "src.txt:1".into(),
})
.expect("verify_chain");
let log_link = response
.links
.iter()
.find(|l| l.id == "log")
.expect("a log link");
assert_eq!(
log_link.verdict,
ChainLinkVerdict::ChainLinkBroken as i32,
"a tampered log must cross the wire as BROKEN: {}",
log_link.detail
);
assert_eq!(response.summary, ChainSummary::ChainBroken as i32);
// And the refusal: no such repository is an error, not an empty chain.
assert!(
plane
.verify_chain(&VerifyChainRequest {
repo: Some(RepoPath::new("team", "absent")),
rev: "HEAD".into(),
target: String::new(),
})
.is_err(),
"a repository that does not exist must refuse, not answer an empty chain"
);
harness.stop();
}