1use std::path::Path;
31
32use anyhow::{Context, Result};
33use gunnar_store::git_storage_trait::{GitServe, ObjType};
34
35use crate::stores::ServerStores;
36
37pub const BLAME_BUDGET: usize = 4096;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum LinkState {
49 Proven(String),
51 Broken(String),
54 Unproven(String),
56}
57
58impl LinkState {
59 pub fn is_proven(&self) -> bool {
60 matches!(self, LinkState::Proven(_))
61 }
62 pub fn is_broken(&self) -> bool {
63 matches!(self, LinkState::Broken(_))
64 }
65 fn mark(&self) -> &'static str {
66 match self {
67 LinkState::Proven(_) => "ok ",
68 LinkState::Broken(_) => "BAD ",
69 LinkState::Unproven(_) => "?? ",
70 }
71 }
72 fn detail(&self) -> &str {
73 match self {
74 LinkState::Proven(d) | LinkState::Broken(d) | LinkState::Unproven(d) => d,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct Chain {
82 pub repo: String,
84 pub from: String,
86 pub target: Option<String>,
88 pub line: LinkState,
90 pub signature: LinkState,
92 pub push: LinkState,
94 pub log: LinkState,
96 pub build: Option<LinkState>,
102 pub build_log: Option<LinkState>,
104}
105
106impl Chain {
107 pub fn links(&self) -> Vec<(&'static str, &'static str, &LinkState)> {
114 let mut out: Vec<(&'static str, &'static str, &LinkState)> = Vec::new();
115 if let Some(state) = &self.build_log {
118 out.push(("build_log", "7 build in log ", state));
119 }
120 if let Some(state) = &self.build {
121 out.push(("build", "5 artifact built ", state));
122 }
123 out.push(("line", "1 line → commit ", &self.line));
124 out.push(("signature", "2 commit signed ", &self.signature));
125 out.push(("push", "3 push attested ", &self.push));
126 out.push(("log", "4 in the log ", &self.log));
127 out
128 }
129
130 pub fn to_proto(&self) -> gunnar_proto::VerifyChainResponse {
133 use gunnar_proto::{ChainLink, ChainLinkVerdict, ChainSummary};
134 gunnar_proto::VerifyChainResponse {
135 repo: self.repo.clone(),
136 from: self.from.clone(),
137 target: self.target.clone().unwrap_or_default(),
138 links: self
139 .links()
140 .iter()
141 .map(|(id, _, state)| ChainLink {
142 id: (*id).to_owned(),
143 verdict: match state {
144 LinkState::Proven(_) => ChainLinkVerdict::ChainLinkProven,
145 LinkState::Broken(_) => ChainLinkVerdict::ChainLinkBroken,
146 LinkState::Unproven(_) => ChainLinkVerdict::ChainLinkUnproven,
147 } as i32,
148 detail: state.detail().to_owned(),
149 })
150 .collect(),
151 summary: if self.is_unbroken() {
152 ChainSummary::ChainUnbroken
153 } else if self.is_broken() {
154 ChainSummary::ChainBroken
155 } else {
156 ChainSummary::ChainIncomplete
157 } as i32,
158 }
159 }
160
161 pub fn is_unbroken(&self) -> bool {
166 self.links().iter().all(|(_, _, state)| state.is_proven())
167 }
168
169 pub fn is_broken(&self) -> bool {
171 self.links().iter().any(|(_, _, state)| state.is_broken())
172 }
173}
174
175impl std::fmt::Display for Chain {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 writeln!(f, "repo {}", self.repo)?;
178 writeln!(f, "from {}", self.from)?;
179 if let Some(target) = &self.target {
180 writeln!(f, "target {target}")?;
181 }
182 writeln!(f)?;
183 for (_, label, state) in self.links() {
184 writeln!(f, " {} {} {}", state.mark(), label, state.detail())?;
185 }
186 writeln!(f)?;
187 if self.is_unbroken() {
188 write!(f, "UNBROKEN: every link is proven.")
189 } else if self.is_broken() {
190 write!(
191 f,
192 "BROKEN: a link was checked and did not hold. See the BAD line(s) above."
193 )
194 } else {
195 write!(
196 f,
197 "INCOMPLETE: nothing is wrong and not everything is proven. An unchecked link \
198 is a hole, not a pass."
199 )
200 }
201 }
202}
203
204struct Origin {
206 commit: gix_hash::ObjectId,
207 visited: usize,
209}
210
211pub(crate) fn blob_at(
218 git: &dyn GitServe,
219 hash: gix_hash::Kind,
220 commit: gix_hash::ObjectId,
221 path: &str,
222) -> Result<Option<(gix_hash::ObjectId, Vec<u8>)>> {
223 let (kind, bytes) = git
224 .read(commit.as_bytes())?
225 .with_context(|| format!("commit {commit} is not in this repository"))?;
226 anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
227 let mut tree = gunnar_store::object::commit(&bytes, hash)
228 .with_context(|| format!("decoding commit {commit}"))?
229 .tree;
230
231 let mut components = path.split('/').peekable();
232 while let Some(component) = components.next() {
233 let (kind, bytes) = match git.read(tree.as_bytes())? {
234 Some(pair) => pair,
235 None => return Ok(None),
236 };
237 if kind != ObjType::Tree {
238 return Ok(None);
239 }
240 let mut found = None;
241 for entry in gunnar_store::object::TreeIter::from_bytes(&bytes, hash).named() {
242 let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
243 if entry.name == component.as_bytes() {
244 found = Some(entry);
245 break;
246 }
247 }
248 let Some(entry) = found else {
249 return Ok(None);
250 };
251 if components.peek().is_some() {
252 tree = entry.oid;
253 continue;
254 }
255 let Some((ObjType::Blob, blob)) = git.read(entry.oid.as_bytes())? else {
257 return Ok(None);
258 };
259 return Ok(Some((entry.oid, blob)));
260 }
261 Ok(None)
262}
263
264pub(crate) fn parents(
270 git: &dyn GitServe,
271 hash: gix_hash::Kind,
272 commit: gix_hash::ObjectId,
273) -> Result<Vec<gix_hash::ObjectId>> {
274 let (_, bytes) = git
275 .read(commit.as_bytes())?
276 .with_context(|| format!("commit {commit} is not in this repository"))?;
277 Ok(gunnar_store::object::commit(&bytes, hash)
278 .with_context(|| format!("decoding commit {commit}"))?
279 .parents)
280}
281
282fn blame(
293 git: &dyn GitServe,
294 hash: gix_hash::Kind,
295 tip: gix_hash::ObjectId,
296 path: &str,
297 line: usize,
298) -> Result<Option<Origin>> {
299 let mut commit = tip;
300 let mut line = line;
301 for visited in 1..=BLAME_BUDGET {
302 let Some((_, blob)) = blob_at(git, hash, commit, path)? else {
303 return Ok(None);
307 };
308 let current = crate::diff::split_lines(&blob);
309 if line >= current.len() {
310 return Ok(None);
311 }
312 let mut stepped = None;
313 for parent in parents(git, hash, commit)? {
314 let Some((_, parent_blob)) = blob_at(git, hash, parent, path)? else {
315 continue;
316 };
317 let older = crate::diff::split_lines(&parent_blob);
318 if parent_blob == blob {
321 stepped = Some((parent, line));
322 break;
323 }
324 if let Some(at) = crate::diff::trace_line(&older, ¤t, line) {
325 stepped = Some((parent, at));
326 break;
327 }
328 }
329 match stepped {
330 Some((parent, at)) => {
331 commit = parent;
332 line = at;
333 }
334 None => return Ok(Some(Origin { commit, visited })),
336 }
337 }
338 anyhow::bail!(
339 "the blame walked {BLAME_BUDGET} commits without finding where the line was written; \
340 this is a bound, not an answer"
341 )
342}
343
344pub fn verify(
350 stores: &ServerStores,
351 plane: &crate::control_plane::CoreControlPlane,
352 repo: &gunnar_proto::RepoPath,
353 rev: &str,
354 target: Option<&str>,
355) -> Result<Chain> {
356 use gunnar_grpc::ControlPlane as _;
357
358 let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
359 .context("the repository path is not a legal one")?;
360 let entry = stores
361 .catalog()
362 .get(&repo_ref)?
363 .with_context(|| format!("no such repository: {repo_ref}"))?;
364 let store = stores.repo_store(&entry)?;
365 let git = store.serve();
366 let hash = store.hash_kind();
367
368 let commits = plane
372 .log(repo, rev, 1, "")
373 .map_err(|err| anyhow::anyhow!("{err}"))?;
374 let head = commits
375 .first()
376 .with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
377 let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
378 .map_err(gix_error::Error::from).with_context(|| format!("{} is not an object id", head.oid))?;
379
380 let mut chain = Chain {
381 repo: repo_ref.to_string(),
382 from: rev.to_owned(),
383 target: target.map(str::to_owned),
384 line: LinkState::Unproven(
385 "no line was named; pass `--line <path>:<n>` to close link 1".to_owned(),
386 ),
387 signature: LinkState::Unproven("not reached".to_owned()),
388 push: LinkState::Unproven("not reached".to_owned()),
389 log: LinkState::Unproven("not reached".to_owned()),
390 build: None,
391 build_log: None,
392 };
393
394 let subject = match target {
396 None => tip,
397 Some(spec) => {
398 let (path, number) = spec.rsplit_once(':').with_context(|| {
399 format!("{spec:?} is not `<path>:<line>`; the line number is what makes it link 1")
400 })?;
401 let number: usize = number
402 .parse()
403 .with_context(|| format!("{number:?} is not a line number"))?;
404 anyhow::ensure!(number >= 1, "line numbers start at 1");
405 match blame(git, hash, tip, path, number - 1) {
406 Ok(Some(origin)) => {
407 chain.line = LinkState::Proven(format!(
408 "{path}:{number} was written by {} ({} commit(s) walked)",
409 origin.commit, origin.visited
410 ));
411 origin.commit
412 }
413 Ok(None) => {
414 chain.line = LinkState::Broken(format!("{path} has no line {number} at {rev}"));
415 return Ok(chain);
416 }
417 Err(err) => {
418 chain.line = LinkState::Unproven(format!("{err:#}"));
419 return Ok(chain);
420 }
421 }
422 }
423 };
424
425 let registered = stores.auth().signing_keys()?;
439 let keys: Vec<gunnar_core::ssh_key::PublicKey> = registered
440 .iter()
441 .filter_map(|key| gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh).ok())
442 .collect();
443 chain.signature = if keys.is_empty() {
444 LinkState::Unproven(
447 "this server holds no signing keys, so no commit signature can be checked".to_owned(),
448 )
449 } else {
450 use gunnar_policy::{signature::SignatureCheck, SignatureProblem};
451 match gunnar_policy::signature::check_commit(git, &subject, &keys, "git") {
452 Ok(SignatureCheck::Verified(sig)) => {
453 let owner = registered
459 .iter()
460 .find(|key| key.fingerprint_sha256 == sig.fingerprint)
461 .and_then(|key| {
462 stores
463 .auth()
464 .principal(&key.principal_id)
465 .ok()
466 .flatten()
467 .map(|p| p.name)
468 });
469 LinkState::Proven(match owner {
470 Some(name) => format!(
471 "{subject} is signed by {}, registered to {name}; the signature verifies",
472 sig.fingerprint
473 ),
474 None => format!(
475 "{subject} is signed by {}; the signature verifies",
476 sig.fingerprint
477 ),
478 })
479 }
480 Ok(SignatureCheck::Problem(problem)) => match problem {
481 SignatureProblem::Unsigned => LinkState::Unproven(format!(
482 "{subject} is not signed. That is not a failed signature — this repository \
483 does not require one"
484 )),
485 SignatureProblem::NotAnSshSignature { armor } => LinkState::Unproven(format!(
486 "{subject} carries a signature that is not verifiable by this server \
487 ({armor}); gunnar verifies ssh signatures (gpg.format=ssh), and a \
488 signature it cannot check is a hole, not a verdict"
489 )),
490 SignatureProblem::KeyNotRegistered { fingerprint } => LinkState::Broken(format!(
491 "{subject} is signed by {fingerprint}, a key this server does not know; \
492 cryptography by a stranger proves nothing about identity"
493 )),
494 SignatureProblem::BadSignature {
495 fingerprint,
496 reason,
497 } => LinkState::Broken(format!(
498 "{subject}'s signature by {fingerprint} is invalid: {reason}"
499 )),
500 problem => LinkState::Broken(format!(
506 "{subject}'s signature is invalid: {problem}"
507 )),
508 },
509 Err(err) => LinkState::Unproven(format!("reading {subject}: {err}")),
510 }
511 };
512
513 let log = stores.transparency();
515 let indexed = log.records().read_indexed()?;
519 let records: Vec<&gunnar_proto::PushAttestation> = indexed.iter().map(|(_, r)| r).collect();
520 let wanted = subject.to_string();
521 let attested = indexed.iter().find(|(_, record)| {
522 record
523 .repo
524 .as_ref()
525 .is_some_and(|r| r.namespace == repo.namespace && r.repo == repo.repo)
526 && record.new_object_oids.contains(&wanted)
527 });
528 let index = match attested {
529 Some((index, record)) => {
530 let index = *index;
531 chain.push = LinkState::Proven(format!(
532 "pushed by {} with key {} at {} (pack {})",
533 record.principal_name,
534 record.key_fingerprint,
535 record.received_at_unix_ms,
536 record.pack_hash
537 ));
538 Some(index)
539 }
540 None => {
541 let blind = records
544 .iter()
545 .any(|record| !record.new_object_oids_unknown.is_empty());
546 chain.push = LinkState::Unproven(if blind {
547 "this log holds attestations whose object set was never enumerated, so it \
548 cannot say which push introduced this commit. Start the server with object \
549 attestation on."
550 .to_owned()
551 } else {
552 format!("no attestation in this log names {subject} as an object it introduced")
553 });
554 None
555 }
556 };
557
558 chain.log = match index {
560 None => LinkState::Unproven("no attestation to prove".to_owned()),
561 Some(index) => {
562 let key = stores.transparency_key()?;
563 let heads = log.heads()?;
564 let inclusion = log
565 .prove(index)?
566 .context("the record is in the log and has no proof, which cannot happen")?;
567 let covering = heads.iter().rev().find(|head| {
570 head.head.size as usize >= inclusion.size
571 && gunnar_core::transparency::verify_tree_head(head, key.public_key()).is_ok()
572 });
573 match covering {
574 Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
575 "record {} of {} under the head signed at {} (root {})",
576 inclusion.index, inclusion.size, head.head.at_unix_ms, head.head.root
577 )),
578 Some(head) => LinkState::Broken(format!(
579 "the log's records hash to {} and the head signed at {} swore to {}",
580 inclusion.root, head.head.at_unix_ms, head.head.root
581 )),
582 None => LinkState::Unproven(format!(
583 "record {} of {} is in the log and no signed tree head covers it yet; run \
584 `gunnar attest head`",
585 inclusion.index, inclusion.size
586 )),
587 }
588 }
589 };
590
591 Ok(chain)
592}
593
594pub fn verify_at(
596 data_root: &Path,
597 repo: &gunnar_proto::RepoPath,
598 rev: &str,
599 target: Option<&str>,
600) -> Result<Chain> {
601 let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
602 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
603 verify(&stores, &plane, repo, rev, target)
604}
605
606pub fn verify_artifact(
619 stores: &ServerStores,
620 plane: &crate::control_plane::CoreControlPlane,
621 artifact_sha256: &str,
622 line: Option<&str>,
623) -> Result<Chain> {
624 let builds = stores.build_log();
625 let key = stores.provenance_key()?;
626 let signed: Vec<gunnar_core::SignedBuild> = builds
627 .lines_of()?
628 .iter()
629 .map(|line| serde_json::from_str(line).context("decoding a build record"))
630 .collect::<Result<_>>()?;
631
632 let index = signed
633 .iter()
634 .position(|build| build.record.produced(artifact_sha256));
635 let Some(index) = index else {
636 anyhow::bail!(
637 "no build record in this log claims to have produced {artifact_sha256}. That is \
638 not a failed verification — nothing here has ever attested to that artifact."
639 )
640 };
641 let build = &signed[index];
642
643 let build_state = match gunnar_core::provenance::verify_build(build, key.public_key()) {
645 Ok(()) => LinkState::Proven(format!(
646 "{} built {} from {}@{} ({} network)",
647 build.record.builder,
648 artifact_sha256,
649 build.record.source_repo,
650 build.record.source_commit,
651 if build.record.network { "with" } else { "no" },
652 )),
653 Err(problem) => LinkState::Broken(problem.to_string()),
654 };
655
656 let log_key = stores.transparency_key()?;
658 let build_log_state = match builds.prove(index)? {
659 None => LinkState::Unproven("the record is not in the log it was read from".to_owned()),
660 Some(inclusion) => {
661 let heads = builds.heads()?;
662 let covering = heads.iter().rev().find(|head| {
663 head.head.size as usize >= inclusion.size
664 && gunnar_core::transparency::verify_tree_head(head, log_key.public_key())
665 .is_ok()
666 });
667 match covering {
668 Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
669 "record {} of {} under the head signed at {}",
670 inclusion.index, inclusion.size, head.head.at_unix_ms
671 )),
672 Some(head) => LinkState::Broken(format!(
673 "the build log's records hash to {} and the head signed at {} swore to {}",
674 inclusion.root, head.head.at_unix_ms, head.head.root
675 )),
676 None => LinkState::Unproven(format!(
677 "record {} of {} is in the build log and no signed head covers it yet; run \
678 `gunnar attest head --builds`",
679 inclusion.index, inclusion.size
680 )),
681 }
682 }
683 };
684
685 if build_state.is_broken() {
696 return Ok(Chain {
697 repo: build.record.source_repo.clone(),
698 from: format!("artifact {artifact_sha256}"),
699 target: line.map(str::to_owned),
700 line: LinkState::Unproven(
701 "the build record does not verify, so nothing it says about the source is \
702 followed"
703 .to_owned(),
704 ),
705 signature: LinkState::Unproven("not reached: the build record is broken".to_owned()),
706 push: LinkState::Unproven("not reached: the build record is broken".to_owned()),
707 log: LinkState::Unproven("not reached: the build record is broken".to_owned()),
708 build: Some(build_state),
709 build_log: Some(build_log_state),
710 });
711 }
712
713 let (namespace, name) = build.record.source_repo.split_once('/').with_context(|| {
715 format!(
716 "the build record's source repo {:?} is not `<namespace>/<repo>`",
717 build.record.source_repo
718 )
719 })?;
720 let mut chain = verify(
721 stores,
722 plane,
723 &gunnar_proto::RepoPath::new(namespace, name),
724 &build.record.source_commit,
725 line,
726 )?;
727 chain.from = format!("artifact {artifact_sha256}");
728 chain.build = Some(build_state);
729 chain.build_log = Some(build_log_state);
730 Ok(chain)
731}