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)
379 .with_context(|| format!("{} is not an object id", head.oid))?;
380
381 let mut chain = Chain {
382 repo: repo_ref.to_string(),
383 from: rev.to_owned(),
384 target: target.map(str::to_owned),
385 line: LinkState::Unproven(
386 "no line was named; pass `--line <path>:<n>` to close link 1".to_owned(),
387 ),
388 signature: LinkState::Unproven("not reached".to_owned()),
389 push: LinkState::Unproven("not reached".to_owned()),
390 log: LinkState::Unproven("not reached".to_owned()),
391 build: None,
392 build_log: None,
393 };
394
395 let subject = match target {
397 None => tip,
398 Some(spec) => {
399 let (path, number) = spec.rsplit_once(':').with_context(|| {
400 format!("{spec:?} is not `<path>:<line>`; the line number is what makes it link 1")
401 })?;
402 let number: usize = number
403 .parse()
404 .with_context(|| format!("{number:?} is not a line number"))?;
405 anyhow::ensure!(number >= 1, "line numbers start at 1");
406 match blame(git, hash, tip, path, number - 1) {
407 Ok(Some(origin)) => {
408 chain.line = LinkState::Proven(format!(
409 "{path}:{number} was written by {} ({} commit(s) walked)",
410 origin.commit, origin.visited
411 ));
412 origin.commit
413 }
414 Ok(None) => {
415 chain.line = LinkState::Broken(format!("{path} has no line {number} at {rev}"));
416 return Ok(chain);
417 }
418 Err(err) => {
419 chain.line = LinkState::Unproven(format!("{err:#}"));
420 return Ok(chain);
421 }
422 }
423 }
424 };
425
426 let registered = stores.auth().signing_keys()?;
440 let keys: Vec<gunnar_core::ssh_key::PublicKey> = registered
441 .iter()
442 .filter_map(|key| gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh).ok())
443 .collect();
444 chain.signature = if keys.is_empty() {
445 LinkState::Unproven(
448 "this server holds no signing keys, so no commit signature can be checked".to_owned(),
449 )
450 } else {
451 use gunnar_policy::{signature::SignatureCheck, SignatureProblem};
452 match gunnar_policy::signature::check_commit(git, &subject, &keys, "git") {
453 Ok(SignatureCheck::Verified(sig)) => {
454 let owner = registered
460 .iter()
461 .find(|key| key.fingerprint_sha256 == sig.fingerprint)
462 .and_then(|key| {
463 stores
464 .auth()
465 .principal(&key.principal_id)
466 .ok()
467 .flatten()
468 .map(|p| p.name)
469 });
470 LinkState::Proven(match owner {
471 Some(name) => format!(
472 "{subject} is signed by {}, registered to {name}; the signature verifies",
473 sig.fingerprint
474 ),
475 None => format!(
476 "{subject} is signed by {}; the signature verifies",
477 sig.fingerprint
478 ),
479 })
480 }
481 Ok(SignatureCheck::Problem(problem)) => match problem {
482 SignatureProblem::Unsigned => LinkState::Unproven(format!(
483 "{subject} is not signed. That is not a failed signature — this repository \
484 does not require one"
485 )),
486 SignatureProblem::NotAnSshSignature { armor } => LinkState::Unproven(format!(
487 "{subject} carries a signature that is not verifiable by this server \
488 ({armor}); gunnar verifies ssh signatures (gpg.format=ssh), and a \
489 signature it cannot check is a hole, not a verdict"
490 )),
491 SignatureProblem::KeyNotRegistered { fingerprint } => LinkState::Broken(format!(
492 "{subject} is signed by {fingerprint}, a key this server does not know; \
493 cryptography by a stranger proves nothing about identity"
494 )),
495 SignatureProblem::BadSignature {
496 fingerprint,
497 reason,
498 } => LinkState::Broken(format!(
499 "{subject}'s signature by {fingerprint} is invalid: {reason}"
500 )),
501 problem => {
507 LinkState::Broken(format!("{subject}'s signature is invalid: {problem}"))
508 }
509 },
510 Err(err) => LinkState::Unproven(format!("reading {subject}: {err}")),
511 }
512 };
513
514 let log = stores.transparency();
516 let indexed = log.records().read_indexed()?;
520 let records: Vec<&gunnar_proto::PushAttestation> = indexed.iter().map(|(_, r)| r).collect();
521 let wanted = subject.to_string();
522 let attested = indexed.iter().find(|(_, record)| {
523 record
524 .repo
525 .as_ref()
526 .is_some_and(|r| r.namespace == repo.namespace && r.repo == repo.repo)
527 && record.new_object_oids.contains(&wanted)
528 });
529 let index = match attested {
530 Some((index, record)) => {
531 let index = *index;
532 chain.push = LinkState::Proven(format!(
533 "pushed by {} with key {} at {} (pack {})",
534 record.principal_name,
535 record.key_fingerprint,
536 record.received_at_unix_ms,
537 record.pack_hash
538 ));
539 Some(index)
540 }
541 None => {
542 let blind = records
545 .iter()
546 .any(|record| !record.new_object_oids_unknown.is_empty());
547 chain.push = LinkState::Unproven(if blind {
548 "this log holds attestations whose object set was never enumerated, so it \
549 cannot say which push introduced this commit. Start the server with object \
550 attestation on."
551 .to_owned()
552 } else {
553 format!("no attestation in this log names {subject} as an object it introduced")
554 });
555 None
556 }
557 };
558
559 chain.log = match index {
561 None => LinkState::Unproven("no attestation to prove".to_owned()),
562 Some(index) => {
563 let key = stores.transparency_key()?;
564 let heads = log.heads()?;
565 let inclusion = log
566 .prove(index)?
567 .context("the record is in the log and has no proof, which cannot happen")?;
568 let covering = heads.iter().rev().find(|head| {
571 head.head.size as usize >= inclusion.size
572 && gunnar_core::transparency::verify_tree_head(head, key.public_key()).is_ok()
573 });
574 match covering {
575 Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
576 "record {} of {} under the head signed at {} (root {})",
577 inclusion.index, inclusion.size, head.head.at_unix_ms, head.head.root
578 )),
579 Some(head) => LinkState::Broken(format!(
580 "the log's records hash to {} and the head signed at {} swore to {}",
581 inclusion.root, head.head.at_unix_ms, head.head.root
582 )),
583 None => LinkState::Unproven(format!(
584 "record {} of {} is in the log and no signed tree head covers it yet; run \
585 `gunnar attest head`",
586 inclusion.index, inclusion.size
587 )),
588 }
589 }
590 };
591
592 Ok(chain)
593}
594
595pub fn verify_at(
597 data_root: &Path,
598 repo: &gunnar_proto::RepoPath,
599 rev: &str,
600 target: Option<&str>,
601) -> Result<Chain> {
602 let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
603 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
604 verify(&stores, &plane, repo, rev, target)
605}
606
607pub fn verify_artifact(
620 stores: &ServerStores,
621 plane: &crate::control_plane::CoreControlPlane,
622 artifact_sha256: &str,
623 line: Option<&str>,
624) -> Result<Chain> {
625 let builds = stores.build_log();
626 let key = stores.provenance_key()?;
627 let signed: Vec<gunnar_core::SignedBuild> = builds
628 .lines_of()?
629 .iter()
630 .map(|line| serde_json::from_str(line).context("decoding a build record"))
631 .collect::<Result<_>>()?;
632
633 let index = signed
634 .iter()
635 .position(|build| build.record.produced(artifact_sha256));
636 let Some(index) = index else {
637 anyhow::bail!(
638 "no build record in this log claims to have produced {artifact_sha256}. That is \
639 not a failed verification — nothing here has ever attested to that artifact."
640 )
641 };
642 let build = &signed[index];
643
644 let build_state = match gunnar_core::provenance::verify_build(build, key.public_key()) {
646 Ok(()) => LinkState::Proven(format!(
647 "{} built {} from {}@{} ({} network)",
648 build.record.builder,
649 artifact_sha256,
650 build.record.source_repo,
651 build.record.source_commit,
652 if build.record.network { "with" } else { "no" },
653 )),
654 Err(problem) => LinkState::Broken(problem.to_string()),
655 };
656
657 let log_key = stores.transparency_key()?;
659 let build_log_state = match builds.prove(index)? {
660 None => LinkState::Unproven("the record is not in the log it was read from".to_owned()),
661 Some(inclusion) => {
662 let heads = builds.heads()?;
663 let covering = heads.iter().rev().find(|head| {
664 head.head.size as usize >= inclusion.size
665 && gunnar_core::transparency::verify_tree_head(head, log_key.public_key())
666 .is_ok()
667 });
668 match covering {
669 Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
670 "record {} of {} under the head signed at {}",
671 inclusion.index, inclusion.size, head.head.at_unix_ms
672 )),
673 Some(head) => LinkState::Broken(format!(
674 "the build log's records hash to {} and the head signed at {} swore to {}",
675 inclusion.root, head.head.at_unix_ms, head.head.root
676 )),
677 None => LinkState::Unproven(format!(
678 "record {} of {} is in the build log and no signed head covers it yet; run \
679 `gunnar attest head --builds`",
680 inclusion.index, inclusion.size
681 )),
682 }
683 }
684 };
685
686 if build_state.is_broken() {
697 return Ok(Chain {
698 repo: build.record.source_repo.clone(),
699 from: format!("artifact {artifact_sha256}"),
700 target: line.map(str::to_owned),
701 line: LinkState::Unproven(
702 "the build record does not verify, so nothing it says about the source is \
703 followed"
704 .to_owned(),
705 ),
706 signature: LinkState::Unproven("not reached: the build record is broken".to_owned()),
707 push: LinkState::Unproven("not reached: the build record is broken".to_owned()),
708 log: LinkState::Unproven("not reached: the build record is broken".to_owned()),
709 build: Some(build_state),
710 build_log: Some(build_log_state),
711 });
712 }
713
714 let (namespace, name) = build.record.source_repo.split_once('/').with_context(|| {
716 format!(
717 "the build record's source repo {:?} is not `<namespace>/<repo>`",
718 build.record.source_repo
719 )
720 })?;
721 let mut chain = verify(
722 stores,
723 plane,
724 &gunnar_proto::RepoPath::new(namespace, name),
725 &build.record.source_commit,
726 line,
727 )?;
728 chain.from = format!("artifact {artifact_sha256}");
729 chain.build = Some(build_state);
730 chain.build_log = Some(build_log_state);
731 Ok(chain)
732}