crates/gunnar-cli/tests/transparency.rs
#![cfg(feature = "server")]
//! **The transparency log, over real pushes** (push attestation, roadmap phase 2).
//!
//! The attestation log has been append-only *by discipline* from the start: nothing
//! stopped the file being rewritten, and nothing in it said whether it had been.
//! These arms are what turn the discipline into evidence, and the one that
//! carries the file is `a_rewritten_record_is_caught_by_a_head_signed_before_it`
//! — because every other assertion here would pass against a log with no
//! integrity at all.
//!
//! # What each arm is guarding
//!
//! * a head signs what is there, and a client can check it with the public key
//! alone;
//! * a record can be proved to be in the log **without the log** — that is what
//! an inclusion proof is for;
//! * rewriting a record is caught; **truncating** the log is caught; and the two
//! are reported as different things, because they are.
mod common;
use common::Harness;
use gunnar_cli::server::attest::AuditVerdict;
use gunnar_core::HashKind;
fn tools_present() -> bool {
["git", "ssh"].iter().all(|tool| {
std::process::Command::new(tool)
.arg("--version")
.output()
.is_ok()
})
}
/// Push `count` distinct commits, so the log gains `count` records.
fn push_some(harness: &Harness, work: &std::path::Path, url: &str, count: usize) {
harness
.git(
harness.dir.path(),
&["init", "--quiet", "-b", "main", work.to_str().unwrap()],
)
.expect_ok("git init");
for i in 0..count {
std::fs::write(work.join("a.txt"), format!("line {i}\n")).expect("write");
harness.git(work, &["add", "a.txt"]).expect_ok("git add");
harness
.git(work, &["commit", "--quiet", "-m", &format!("commit {i}")])
.expect_ok("git commit");
harness
.git(work, &["push", "--quiet", url, "main"])
.expect_ok("push");
}
}
/// **The one that matters.** A head is signed, a record is rewritten, and the
/// head catches it.
///
/// The rewrite is a plausible one — a principal's name changed, nothing else —
/// which is exactly the edit an append-only file cannot refuse and a Merkle root
/// cannot miss.
///
/// The control comes first: the same log must audit **clean** before the
/// rewrite, or "caught after" would be satisfied by an auditor that refuses
/// everything.
#[test]
fn a_rewritten_record_is_caught_by_a_head_signed_before_it() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "logged", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "logged");
push_some(&harness, &work, &url, 3);
let log = harness.stores.transparency();
let key = harness
.stores
.transparency_key()
.expect("the log's signing key");
assert_eq!(log.len().expect("count"), 3, "three pushes, three records");
let head = log
.issue_head(&key, 1_700_000_000_000)
.expect("issuing a head");
assert_eq!(head.head.size, 3);
// ── the control ────────────────────────────────────────────────────────
assert_eq!(
log.audit(key.public_key()).expect("audit"),
AuditVerdict::Consistent {
records: 3,
heads: 1
},
"the log must audit clean before a rewrite means anything"
);
// ── the rewrite: one field, in one line, in place ──────────────────────
let path = log.records().path().to_path_buf();
let text = std::fs::read_to_string(&path).expect("read the log");
let tampered = text.replacen(
"\"principal_name\":\"tester\"",
"\"principal_name\":\"mallory\"",
1,
);
assert_ne!(
tampered, text,
"the rewrite changed nothing, so the assertion below would pass against an untouched log"
);
std::fs::write(&path, &tampered).expect("rewrite the log");
match log.audit(key.public_key()).expect("audit") {
AuditVerdict::RecordsRewritten {
size,
signed_root,
actual_root,
} => {
assert_eq!(size, 3);
assert_ne!(signed_root, actual_root);
}
other => panic!(
"a record was rewritten under a signed head and the audit said {other}. An \
append-only log that cannot say it was rewritten is a file with a promise on it"
),
}
harness.stop();
}
/// Removing lines is caught, and reported as **truncation** rather than as a
/// rewrite. The two need different people to look at them.
#[test]
fn a_truncated_log_is_caught_and_named_as_truncation() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "cut", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "cut");
push_some(&harness, &work, &url, 3);
let log = harness.stores.transparency();
let key = harness.stores.transparency_key().expect("key");
log.issue_head(&key, 1_700_000_000_000).expect("head");
let path = log.records().path().to_path_buf();
let text = std::fs::read_to_string(&path).expect("read");
let kept: Vec<&str> = text.lines().take(1).collect();
std::fs::write(&path, format!("{}\n", kept.join("\n"))).expect("truncate");
match log.audit(key.public_key()).expect("audit") {
AuditVerdict::RecordsTruncated { size, records } => {
assert_eq!((size, records), (3, 1));
}
other => panic!("two records were deleted and the audit said {other}"),
}
harness.stop();
}
/// **A record proves without the log.**
///
/// The proof is taken from the server and then checked with nothing but the
/// arithmetic — no log, no server, no trust in the thing that served it. That
/// is the whole difference between a log you are told about and one you can
/// check.
#[test]
fn a_push_proves_its_place_in_the_log_offline() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "proved", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "proved");
push_some(&harness, &work, &url, 5);
let log = harness.stores.transparency();
let key = harness.stores.transparency_key().expect("key");
let head = log.issue_head(&key, 1_700_000_000_000).expect("head");
// A verifier holds: the signed head, the public key, and the record it
// cares about. Nothing else.
gunnar_core::transparency::verify_tree_head(&head, key.public_key())
.expect("the head must verify against the key it names");
let records = log.records().read().expect("read the records");
let wanted = &records[2];
let inclusion = log
.prove_pack(&wanted.pack_hash)
.expect("prove")
.expect("the pack is in the log");
assert_eq!(inclusion.index, 2);
assert_eq!(inclusion.size, 5);
assert_eq!(
inclusion.root, head.head.root,
"the proof is against a different tree from the one that was signed"
);
let leaf = gunnar_core::transparency::unhex(&inclusion.leaf).expect("a leaf hash");
let proof: Vec<_> = inclusion
.proof
.iter()
.map(|h| gunnar_core::transparency::unhex(h).expect("a proof step"))
.collect();
let root = gunnar_core::transparency::unhex(&head.head.root).expect("the signed root");
assert!(
gunnar_core::transparency::verify_inclusion(&leaf, 2, 5, &proof, &root),
"the proof the server handed out does not reconstruct the root the server signed"
);
// And the same proof must NOT verify for a different position, or it proves
// nothing about where the record is.
assert!(
!gunnar_core::transparency::verify_inclusion(&leaf, 3, 5, &proof, &root),
"the proof verified at the wrong index"
);
// A pack nobody pushed has no proof, and that is not an empty proof.
assert!(
log.prove_pack(&"f".repeat(40)).expect("prove").is_none(),
"a proof was produced for a pack the log does not name"
);
harness.stop();
}
/// Heads accumulate, and **every** one is checked — not just the newest.
///
/// A head is a claim about a prefix, so an old head must keep verifying as the
/// log grows. An auditor that only checked the newest would miss a rewrite of a
/// record that a later head no longer covers on its own.
#[test]
fn an_older_head_still_covers_its_prefix_after_the_log_grows() {
assert!(
tools_present(),
"this test requires `git` and `ssh` on PATH"
);
let mut harness = Harness::start("team", "grown", HashKind::Sha1);
let work = harness.dir.path().join("work");
let url = harness.url("team", "grown");
push_some(&harness, &work, &url, 2);
let log = harness.stores.transparency();
let key = harness.stores.transparency_key().expect("key");
let early = log.issue_head(&key, 1_700_000_000_000).expect("head");
assert_eq!(early.head.size, 2);
// Two more pushes, then a second head.
for i in 2..4 {
std::fs::write(work.join("a.txt"), format!("more {i}\n")).expect("write");
harness.git(&work, &["add", "a.txt"]).expect_ok("add");
harness
.git(&work, &["commit", "--quiet", "-m", &format!("more {i}")])
.expect_ok("commit");
harness
.git(&work, &["push", "--quiet", &url, "main"])
.expect_ok("push");
}
let late = log
.issue_head(&key, 1_700_000_001_000)
.expect("second head");
assert_eq!(late.head.size, 4);
assert_ne!(
early.head.root, late.head.root,
"the log grew and the root did not move"
);
assert_eq!(
log.audit(key.public_key()).expect("audit"),
AuditVerdict::Consistent {
records: 4,
heads: 2
},
"both heads must still verify against the log they cover"
);
// Now rewrite a record the EARLY head covers. The newest head covers it too,
// but the point is that the early one is checked at all.
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\":\"http\"", 1);
std::fs::write(&path, tampered).expect("rewrite");
match log.audit(key.public_key()).expect("audit") {
AuditVerdict::RecordsRewritten { size, .. } => assert_eq!(
size, 2,
"the EARLIEST head that no longer matches must be the one reported, so an operator \
is told how far back the rewrite reaches"
),
other => panic!("a covered record was rewritten and the audit said {other}"),
}
harness.stop();
}