use std::path::Path;
use anyhow::{Context, Result};
use gunnar_store::git_storage_trait::{GitServe, ObjType};
use crate::stores::ServerStores;
pub const BLAME_BUDGET: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkState {
Proven(String),
Broken(String),
Unproven(String),
}
impl LinkState {
pub fn is_proven(&self) -> bool {
matches!(self, LinkState::Proven(_))
}
pub fn is_broken(&self) -> bool {
matches!(self, LinkState::Broken(_))
}
fn mark(&self) -> &'static str {
match self {
LinkState::Proven(_) => "ok ",
LinkState::Broken(_) => "BAD ",
LinkState::Unproven(_) => "?? ",
}
}
fn detail(&self) -> &str {
match self {
LinkState::Proven(d) | LinkState::Broken(d) | LinkState::Unproven(d) => d,
}
}
}
#[derive(Debug, Clone)]
pub struct Chain {
pub repo: String,
pub from: String,
pub target: Option<String>,
pub line: LinkState,
pub signature: LinkState,
pub push: LinkState,
pub log: LinkState,
pub build: Option<LinkState>,
pub build_log: Option<LinkState>,
}
impl Chain {
pub fn links(&self) -> Vec<(&'static str, &'static str, &LinkState)> {
let mut out: Vec<(&'static str, &'static str, &LinkState)> = Vec::new();
if let Some(state) = &self.build_log {
out.push(("build_log", "7 build in log ", state));
}
if let Some(state) = &self.build {
out.push(("build", "5 artifact built ", state));
}
out.push(("line", "1 line → commit ", &self.line));
out.push(("signature", "2 commit signed ", &self.signature));
out.push(("push", "3 push attested ", &self.push));
out.push(("log", "4 in the log ", &self.log));
out
}
pub fn to_proto(&self) -> gunnar_proto::VerifyChainResponse {
use gunnar_proto::{ChainLink, ChainLinkVerdict, ChainSummary};
gunnar_proto::VerifyChainResponse {
repo: self.repo.clone(),
from: self.from.clone(),
target: self.target.clone().unwrap_or_default(),
links: self
.links()
.iter()
.map(|(id, _, state)| ChainLink {
id: (*id).to_owned(),
verdict: match state {
LinkState::Proven(_) => ChainLinkVerdict::ChainLinkProven,
LinkState::Broken(_) => ChainLinkVerdict::ChainLinkBroken,
LinkState::Unproven(_) => ChainLinkVerdict::ChainLinkUnproven,
} as i32,
detail: state.detail().to_owned(),
})
.collect(),
summary: if self.is_unbroken() {
ChainSummary::ChainUnbroken
} else if self.is_broken() {
ChainSummary::ChainBroken
} else {
ChainSummary::ChainIncomplete
} as i32,
}
}
pub fn is_unbroken(&self) -> bool {
self.links().iter().all(|(_, _, state)| state.is_proven())
}
pub fn is_broken(&self) -> bool {
self.links().iter().any(|(_, _, state)| state.is_broken())
}
}
impl std::fmt::Display for Chain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "repo {}", self.repo)?;
writeln!(f, "from {}", self.from)?;
if let Some(target) = &self.target {
writeln!(f, "target {target}")?;
}
writeln!(f)?;
for (_, label, state) in self.links() {
writeln!(f, " {} {} {}", state.mark(), label, state.detail())?;
}
writeln!(f)?;
if self.is_unbroken() {
write!(f, "UNBROKEN: every link is proven.")
} else if self.is_broken() {
write!(
f,
"BROKEN: a link was checked and did not hold. See the BAD line(s) above."
)
} else {
write!(
f,
"INCOMPLETE: nothing is wrong and not everything is proven. An unchecked link \
is a hole, not a pass."
)
}
}
}
struct Origin {
commit: gix_hash::ObjectId,
visited: usize,
}
pub(crate) fn blob_at(
git: &dyn GitServe,
hash: gix_hash::Kind,
commit: gix_hash::ObjectId,
path: &str,
) -> Result<Option<(gix_hash::ObjectId, Vec<u8>)>> {
let (kind, bytes) = git
.read(commit.as_bytes())?
.with_context(|| format!("commit {commit} is not in this repository"))?;
anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
let mut tree = gunnar_store::object::commit(&bytes, hash)
.with_context(|| format!("decoding commit {commit}"))?
.tree;
let mut components = path.split('/').peekable();
while let Some(component) = components.next() {
let (kind, bytes) = match git.read(tree.as_bytes())? {
Some(pair) => pair,
None => return Ok(None),
};
if kind != ObjType::Tree {
return Ok(None);
}
let mut found = None;
for entry in gunnar_store::object::TreeIter::from_bytes(&bytes, hash).named() {
let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
if entry.name == component.as_bytes() {
found = Some(entry);
break;
}
}
let Some(entry) = found else {
return Ok(None);
};
if components.peek().is_some() {
tree = entry.oid;
continue;
}
let Some((ObjType::Blob, blob)) = git.read(entry.oid.as_bytes())? else {
return Ok(None);
};
return Ok(Some((entry.oid, blob)));
}
Ok(None)
}
pub(crate) fn parents(
git: &dyn GitServe,
hash: gix_hash::Kind,
commit: gix_hash::ObjectId,
) -> Result<Vec<gix_hash::ObjectId>> {
let (_, bytes) = git
.read(commit.as_bytes())?
.with_context(|| format!("commit {commit} is not in this repository"))?;
Ok(gunnar_store::object::commit(&bytes, hash)
.with_context(|| format!("decoding commit {commit}"))?
.parents)
}
fn blame(
git: &dyn GitServe,
hash: gix_hash::Kind,
tip: gix_hash::ObjectId,
path: &str,
line: usize,
) -> Result<Option<Origin>> {
let mut commit = tip;
let mut line = line;
for visited in 1..=BLAME_BUDGET {
let Some((_, blob)) = blob_at(git, hash, commit, path)? else {
return Ok(None);
};
let current = crate::diff::split_lines(&blob);
if line >= current.len() {
return Ok(None);
}
let mut stepped = None;
for parent in parents(git, hash, commit)? {
let Some((_, parent_blob)) = blob_at(git, hash, parent, path)? else {
continue;
};
let older = crate::diff::split_lines(&parent_blob);
if parent_blob == blob {
stepped = Some((parent, line));
break;
}
if let Some(at) = crate::diff::trace_line(&older, ¤t, line) {
stepped = Some((parent, at));
break;
}
}
match stepped {
Some((parent, at)) => {
commit = parent;
line = at;
}
None => return Ok(Some(Origin { commit, visited })),
}
}
anyhow::bail!(
"the blame walked {BLAME_BUDGET} commits without finding where the line was written; \
this is a bound, not an answer"
)
}
pub fn verify(
stores: &ServerStores,
plane: &crate::control_plane::CoreControlPlane,
repo: &gunnar_proto::RepoPath,
rev: &str,
target: Option<&str>,
) -> Result<Chain> {
use gunnar_grpc::ControlPlane as _;
let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
.context("the repository path is not a legal one")?;
let entry = stores
.catalog()
.get(&repo_ref)?
.with_context(|| format!("no such repository: {repo_ref}"))?;
let store = stores.repo_store(&entry)?;
let git = store.serve();
let hash = store.hash_kind();
let commits = plane
.log(repo, rev, 1, "")
.map_err(|err| anyhow::anyhow!("{err}"))?;
let head = commits
.first()
.with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
.map_err(gix_error::Error::from).with_context(|| format!("{} is not an object id", head.oid))?;
let mut chain = Chain {
repo: repo_ref.to_string(),
from: rev.to_owned(),
target: target.map(str::to_owned),
line: LinkState::Unproven(
"no line was named; pass `--line <path>:<n>` to close link 1".to_owned(),
),
signature: LinkState::Unproven("not reached".to_owned()),
push: LinkState::Unproven("not reached".to_owned()),
log: LinkState::Unproven("not reached".to_owned()),
build: None,
build_log: None,
};
let subject = match target {
None => tip,
Some(spec) => {
let (path, number) = spec.rsplit_once(':').with_context(|| {
format!("{spec:?} is not `<path>:<line>`; the line number is what makes it link 1")
})?;
let number: usize = number
.parse()
.with_context(|| format!("{number:?} is not a line number"))?;
anyhow::ensure!(number >= 1, "line numbers start at 1");
match blame(git, hash, tip, path, number - 1) {
Ok(Some(origin)) => {
chain.line = LinkState::Proven(format!(
"{path}:{number} was written by {} ({} commit(s) walked)",
origin.commit, origin.visited
));
origin.commit
}
Ok(None) => {
chain.line = LinkState::Broken(format!("{path} has no line {number} at {rev}"));
return Ok(chain);
}
Err(err) => {
chain.line = LinkState::Unproven(format!("{err:#}"));
return Ok(chain);
}
}
}
};
let registered = stores.auth().signing_keys()?;
let keys: Vec<gunnar_core::ssh_key::PublicKey> = registered
.iter()
.filter_map(|key| gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh).ok())
.collect();
chain.signature = if keys.is_empty() {
LinkState::Unproven(
"this server holds no signing keys, so no commit signature can be checked".to_owned(),
)
} else {
use gunnar_policy::{signature::SignatureCheck, SignatureProblem};
match gunnar_policy::signature::check_commit(git, &subject, &keys, "git") {
Ok(SignatureCheck::Verified(sig)) => {
let owner = registered
.iter()
.find(|key| key.fingerprint_sha256 == sig.fingerprint)
.and_then(|key| {
stores
.auth()
.principal(&key.principal_id)
.ok()
.flatten()
.map(|p| p.name)
});
LinkState::Proven(match owner {
Some(name) => format!(
"{subject} is signed by {}, registered to {name}; the signature verifies",
sig.fingerprint
),
None => format!(
"{subject} is signed by {}; the signature verifies",
sig.fingerprint
),
})
}
Ok(SignatureCheck::Problem(problem)) => match problem {
SignatureProblem::Unsigned => LinkState::Unproven(format!(
"{subject} is not signed. That is not a failed signature — this repository \
does not require one"
)),
SignatureProblem::NotAnSshSignature { armor } => LinkState::Unproven(format!(
"{subject} carries a signature that is not verifiable by this server \
({armor}); gunnar verifies ssh signatures (gpg.format=ssh), and a \
signature it cannot check is a hole, not a verdict"
)),
SignatureProblem::KeyNotRegistered { fingerprint } => LinkState::Broken(format!(
"{subject} is signed by {fingerprint}, a key this server does not know; \
cryptography by a stranger proves nothing about identity"
)),
SignatureProblem::BadSignature {
fingerprint,
reason,
} => LinkState::Broken(format!(
"{subject}'s signature by {fingerprint} is invalid: {reason}"
)),
problem => LinkState::Broken(format!(
"{subject}'s signature is invalid: {problem}"
)),
},
Err(err) => LinkState::Unproven(format!("reading {subject}: {err}")),
}
};
let log = stores.transparency();
let indexed = log.records().read_indexed()?;
let records: Vec<&gunnar_proto::PushAttestation> = indexed.iter().map(|(_, r)| r).collect();
let wanted = subject.to_string();
let attested = indexed.iter().find(|(_, record)| {
record
.repo
.as_ref()
.is_some_and(|r| r.namespace == repo.namespace && r.repo == repo.repo)
&& record.new_object_oids.contains(&wanted)
});
let index = match attested {
Some((index, record)) => {
let index = *index;
chain.push = LinkState::Proven(format!(
"pushed by {} with key {} at {} (pack {})",
record.principal_name,
record.key_fingerprint,
record.received_at_unix_ms,
record.pack_hash
));
Some(index)
}
None => {
let blind = records
.iter()
.any(|record| !record.new_object_oids_unknown.is_empty());
chain.push = LinkState::Unproven(if blind {
"this log holds attestations whose object set was never enumerated, so it \
cannot say which push introduced this commit. Start the server with object \
attestation on."
.to_owned()
} else {
format!("no attestation in this log names {subject} as an object it introduced")
});
None
}
};
chain.log = match index {
None => LinkState::Unproven("no attestation to prove".to_owned()),
Some(index) => {
let key = stores.transparency_key()?;
let heads = log.heads()?;
let inclusion = log
.prove(index)?
.context("the record is in the log and has no proof, which cannot happen")?;
let covering = heads.iter().rev().find(|head| {
head.head.size as usize >= inclusion.size
&& gunnar_core::transparency::verify_tree_head(head, key.public_key()).is_ok()
});
match covering {
Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
"record {} of {} under the head signed at {} (root {})",
inclusion.index, inclusion.size, head.head.at_unix_ms, head.head.root
)),
Some(head) => LinkState::Broken(format!(
"the log's records hash to {} and the head signed at {} swore to {}",
inclusion.root, head.head.at_unix_ms, head.head.root
)),
None => LinkState::Unproven(format!(
"record {} of {} is in the log and no signed tree head covers it yet; run \
`gunnar attest head`",
inclusion.index, inclusion.size
)),
}
}
};
Ok(chain)
}
pub fn verify_at(
data_root: &Path,
repo: &gunnar_proto::RepoPath,
rev: &str,
target: Option<&str>,
) -> Result<Chain> {
let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
verify(&stores, &plane, repo, rev, target)
}
pub fn verify_artifact(
stores: &ServerStores,
plane: &crate::control_plane::CoreControlPlane,
artifact_sha256: &str,
line: Option<&str>,
) -> Result<Chain> {
let builds = stores.build_log();
let key = stores.provenance_key()?;
let signed: Vec<gunnar_core::SignedBuild> = builds
.lines_of()?
.iter()
.map(|line| serde_json::from_str(line).context("decoding a build record"))
.collect::<Result<_>>()?;
let index = signed
.iter()
.position(|build| build.record.produced(artifact_sha256));
let Some(index) = index else {
anyhow::bail!(
"no build record in this log claims to have produced {artifact_sha256}. That is \
not a failed verification — nothing here has ever attested to that artifact."
)
};
let build = &signed[index];
let build_state = match gunnar_core::provenance::verify_build(build, key.public_key()) {
Ok(()) => LinkState::Proven(format!(
"{} built {} from {}@{} ({} network)",
build.record.builder,
artifact_sha256,
build.record.source_repo,
build.record.source_commit,
if build.record.network { "with" } else { "no" },
)),
Err(problem) => LinkState::Broken(problem.to_string()),
};
let log_key = stores.transparency_key()?;
let build_log_state = match builds.prove(index)? {
None => LinkState::Unproven("the record is not in the log it was read from".to_owned()),
Some(inclusion) => {
let heads = builds.heads()?;
let covering = heads.iter().rev().find(|head| {
head.head.size as usize >= inclusion.size
&& gunnar_core::transparency::verify_tree_head(head, log_key.public_key())
.is_ok()
});
match covering {
Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
"record {} of {} under the head signed at {}",
inclusion.index, inclusion.size, head.head.at_unix_ms
)),
Some(head) => LinkState::Broken(format!(
"the build log's records hash to {} and the head signed at {} swore to {}",
inclusion.root, head.head.at_unix_ms, head.head.root
)),
None => LinkState::Unproven(format!(
"record {} of {} is in the build log and no signed head covers it yet; run \
`gunnar attest head --builds`",
inclusion.index, inclusion.size
)),
}
}
};
if build_state.is_broken() {
return Ok(Chain {
repo: build.record.source_repo.clone(),
from: format!("artifact {artifact_sha256}"),
target: line.map(str::to_owned),
line: LinkState::Unproven(
"the build record does not verify, so nothing it says about the source is \
followed"
.to_owned(),
),
signature: LinkState::Unproven("not reached: the build record is broken".to_owned()),
push: LinkState::Unproven("not reached: the build record is broken".to_owned()),
log: LinkState::Unproven("not reached: the build record is broken".to_owned()),
build: Some(build_state),
build_log: Some(build_log_state),
});
}
let (namespace, name) = build.record.source_repo.split_once('/').with_context(|| {
format!(
"the build record's source repo {:?} is not `<namespace>/<repo>`",
build.record.source_repo
)
})?;
let mut chain = verify(
stores,
plane,
&gunnar_proto::RepoPath::new(namespace, name),
&build.record.source_commit,
line,
)?;
chain.from = format!("artifact {artifact_sha256}");
chain.build = Some(build_state);
chain.build_log = Some(build_log_state);
Ok(chain)
}