1use std::path::Path;
30
31use anyhow::{Context, Result};
32use gunnar_store::git_storage_trait::{GitServe, ObjType};
33
34use crate::stores::ServerStores;
35
36pub const BLAME_BUDGET: usize = 4096;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum LinkState {
48 Proven(String),
50 Broken(String),
53 Unproven(String),
55}
56
57impl LinkState {
58 pub fn is_proven(&self) -> bool {
59 matches!(self, LinkState::Proven(_))
60 }
61 pub fn is_broken(&self) -> bool {
62 matches!(self, LinkState::Broken(_))
63 }
64 fn mark(&self) -> &'static str {
65 match self {
66 LinkState::Proven(_) => "ok ",
67 LinkState::Broken(_) => "BAD ",
68 LinkState::Unproven(_) => "?? ",
69 }
70 }
71 fn detail(&self) -> &str {
72 match self {
73 LinkState::Proven(d) | LinkState::Broken(d) | LinkState::Unproven(d) => d,
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct Chain {
81 pub repo: String,
83 pub from: String,
85 pub target: Option<String>,
87 pub line: LinkState,
89 pub signature: LinkState,
91 pub push: LinkState,
93 pub log: LinkState,
95}
96
97impl Chain {
98 pub fn links(&self) -> Vec<(&'static str, &'static str, &LinkState)> {
105 vec![
106 ("line", "1 line → commit ", &self.line),
107 ("signature", "2 commit signed ", &self.signature),
108 ("push", "3 push attested ", &self.push),
109 ("log", "4 in the log ", &self.log),
110 ]
111 }
112
113 pub fn to_proto(&self) -> gunnar_proto::VerifyChainResponse {
116 use gunnar_proto::{ChainLink, ChainLinkVerdict, ChainSummary};
117 gunnar_proto::VerifyChainResponse {
118 repo: self.repo.clone(),
119 from: self.from.clone(),
120 target: self.target.clone().unwrap_or_default(),
121 links: self
122 .links()
123 .iter()
124 .map(|(id, _, state)| ChainLink {
125 id: (*id).to_owned(),
126 verdict: match state {
127 LinkState::Proven(_) => ChainLinkVerdict::ChainLinkProven,
128 LinkState::Broken(_) => ChainLinkVerdict::ChainLinkBroken,
129 LinkState::Unproven(_) => ChainLinkVerdict::ChainLinkUnproven,
130 } as i32,
131 detail: state.detail().to_owned(),
132 })
133 .collect(),
134 summary: if self.is_unbroken() {
135 ChainSummary::ChainUnbroken
136 } else if self.is_broken() {
137 ChainSummary::ChainBroken
138 } else {
139 ChainSummary::ChainIncomplete
140 } as i32,
141 }
142 }
143
144 pub fn is_unbroken(&self) -> bool {
149 self.links().iter().all(|(_, _, state)| state.is_proven())
150 }
151
152 pub fn is_broken(&self) -> bool {
154 self.links().iter().any(|(_, _, state)| state.is_broken())
155 }
156}
157
158impl std::fmt::Display for Chain {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 writeln!(f, "repo {}", self.repo)?;
161 writeln!(f, "from {}", self.from)?;
162 if let Some(target) = &self.target {
163 writeln!(f, "target {target}")?;
164 }
165 writeln!(f)?;
166 for (_, label, state) in self.links() {
167 writeln!(f, " {} {} {}", state.mark(), label, state.detail())?;
168 }
169 writeln!(f)?;
170 if self.is_unbroken() {
171 write!(f, "UNBROKEN: every link is proven.")
172 } else if self.is_broken() {
173 write!(
174 f,
175 "BROKEN: a link was checked and did not hold. See the BAD line(s) above."
176 )
177 } else {
178 write!(
179 f,
180 "INCOMPLETE: nothing is wrong and not everything is proven. An unchecked link \
181 is a hole, not a pass."
182 )
183 }
184 }
185}
186
187struct Origin {
189 commit: gix_hash::ObjectId,
190 visited: usize,
192}
193
194pub(crate) fn blob_at(
201 git: &dyn GitServe,
202 hash: gix_hash::Kind,
203 commit: gix_hash::ObjectId,
204 path: &str,
205) -> Result<Option<(gix_hash::ObjectId, Vec<u8>)>> {
206 let (kind, bytes) = git
207 .read(commit.as_bytes())?
208 .with_context(|| format!("commit {commit} is not in this repository"))?;
209 anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
210 let mut tree = gunnar_store::object::commit(&bytes, hash)
211 .with_context(|| format!("decoding commit {commit}"))?
212 .tree;
213
214 let mut components = path.split('/').peekable();
215 while let Some(component) = components.next() {
216 let (kind, bytes) = match git.read(tree.as_bytes())? {
217 Some(pair) => pair,
218 None => return Ok(None),
219 };
220 if kind != ObjType::Tree {
221 return Ok(None);
222 }
223 let mut found = None;
224 for entry in gunnar_store::object::TreeIter::from_bytes(&bytes, hash).named() {
225 let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
226 if entry.name == component.as_bytes() {
227 found = Some(entry);
228 break;
229 }
230 }
231 let Some(entry) = found else {
232 return Ok(None);
233 };
234 if components.peek().is_some() {
235 tree = entry.oid;
236 continue;
237 }
238 let Some((ObjType::Blob, blob)) = git.read(entry.oid.as_bytes())? else {
240 return Ok(None);
241 };
242 return Ok(Some((entry.oid, blob)));
243 }
244 Ok(None)
245}
246
247pub(crate) fn parents(
253 git: &dyn GitServe,
254 hash: gix_hash::Kind,
255 commit: gix_hash::ObjectId,
256) -> Result<Vec<gix_hash::ObjectId>> {
257 let (_, bytes) = git
258 .read(commit.as_bytes())?
259 .with_context(|| format!("commit {commit} is not in this repository"))?;
260 Ok(gunnar_store::object::commit(&bytes, hash)
261 .with_context(|| format!("decoding commit {commit}"))?
262 .parents)
263}
264
265fn blame(
276 git: &dyn GitServe,
277 hash: gix_hash::Kind,
278 tip: gix_hash::ObjectId,
279 path: &str,
280 line: usize,
281) -> Result<Option<Origin>> {
282 let mut commit = tip;
283 let mut line = line;
284 for visited in 1..=BLAME_BUDGET {
285 let Some((_, blob)) = blob_at(git, hash, commit, path)? else {
286 return Ok(None);
290 };
291 let current = crate::diff::split_lines(&blob);
292 if line >= current.len() {
293 return Ok(None);
294 }
295 let mut stepped = None;
296 for parent in parents(git, hash, commit)? {
297 let Some((_, parent_blob)) = blob_at(git, hash, parent, path)? else {
298 continue;
299 };
300 let older = crate::diff::split_lines(&parent_blob);
301 if parent_blob == blob {
304 stepped = Some((parent, line));
305 break;
306 }
307 if let Some(at) = crate::diff::trace_line(&older, ¤t, line) {
308 stepped = Some((parent, at));
309 break;
310 }
311 }
312 match stepped {
313 Some((parent, at)) => {
314 commit = parent;
315 line = at;
316 }
317 None => return Ok(Some(Origin { commit, visited })),
319 }
320 }
321 anyhow::bail!(
322 "the blame walked {BLAME_BUDGET} commits without finding where the line was written; \
323 this is a bound, not an answer"
324 )
325}
326
327pub fn verify(
333 stores: &ServerStores,
334 plane: &crate::control_plane::CoreControlPlane,
335 repo: &gunnar_proto::RepoPath,
336 rev: &str,
337 target: Option<&str>,
338) -> Result<Chain> {
339 use gunnar_grpc::ControlPlane as _;
340
341 let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
342 .context("the repository path is not a legal one")?;
343 let entry = stores
344 .catalog()
345 .get(&repo_ref)?
346 .with_context(|| format!("no such repository: {repo_ref}"))?;
347 let store = stores.repo_store(&entry)?;
348 let git = store.serve();
349 let hash = store.hash_kind();
350
351 let commits = plane
355 .log(repo, rev, 1, "")
356 .map_err(|err| anyhow::anyhow!("{err}"))?;
357 let head = commits
358 .first()
359 .with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
360 let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
361 .with_context(|| format!("{} is not an object id", head.oid))?;
362
363 let mut chain = Chain {
364 repo: repo_ref.to_string(),
365 from: rev.to_owned(),
366 target: target.map(str::to_owned),
367 line: LinkState::Unproven(
368 "no line was named; pass `--line <path>:<n>` to close link 1".to_owned(),
369 ),
370 signature: LinkState::Unproven("not reached".to_owned()),
371 push: LinkState::Unproven("not reached".to_owned()),
372 log: LinkState::Unproven("not reached".to_owned()),
373 };
374
375 let subject = match target {
377 None => tip,
378 Some(spec) => {
379 let (path, number) = spec.rsplit_once(':').with_context(|| {
380 format!("{spec:?} is not `<path>:<line>`; the line number is what makes it link 1")
381 })?;
382 let number: usize = number
383 .parse()
384 .with_context(|| format!("{number:?} is not a line number"))?;
385 anyhow::ensure!(number >= 1, "line numbers start at 1");
386 match blame(git, hash, tip, path, number - 1) {
387 Ok(Some(origin)) => {
388 chain.line = LinkState::Proven(format!(
389 "{path}:{number} was written by {} ({} commit(s) walked)",
390 origin.commit, origin.visited
391 ));
392 origin.commit
393 }
394 Ok(None) => {
395 chain.line = LinkState::Broken(format!("{path} has no line {number} at {rev}"));
396 return Ok(chain);
397 }
398 Err(err) => {
399 chain.line = LinkState::Unproven(format!("{err:#}"));
400 return Ok(chain);
401 }
402 }
403 }
404 };
405
406 let registered = stores.auth().signing_keys()?;
420 let keys: Vec<gunnar_core::ssh_key::PublicKey> = registered
421 .iter()
422 .filter_map(|key| gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh).ok())
423 .collect();
424 chain.signature = if keys.is_empty() {
425 LinkState::Unproven(
428 "this server holds no signing keys, so no commit signature can be checked".to_owned(),
429 )
430 } else {
431 use gunnar_policy::{signature::SignatureCheck, SignatureProblem};
432 match gunnar_policy::signature::check_commit(git, &subject, &keys, "git") {
433 Ok(SignatureCheck::Verified(sig)) => {
434 let owner = registered
440 .iter()
441 .find(|key| key.fingerprint_sha256 == sig.fingerprint)
442 .and_then(|key| {
443 stores
444 .auth()
445 .principal(&key.principal_id)
446 .ok()
447 .flatten()
448 .map(|p| p.name)
449 });
450 LinkState::Proven(match owner {
451 Some(name) => format!(
452 "{subject} is signed by {}, registered to {name}; the signature verifies",
453 sig.fingerprint
454 ),
455 None => format!(
456 "{subject} is signed by {}; the signature verifies",
457 sig.fingerprint
458 ),
459 })
460 }
461 Ok(SignatureCheck::Problem(problem)) => match problem {
462 SignatureProblem::Unsigned => LinkState::Unproven(format!(
463 "{subject} is not signed. That is not a failed signature — this repository \
464 does not require one"
465 )),
466 SignatureProblem::NotAnSshSignature { armor } => LinkState::Unproven(format!(
467 "{subject} carries a signature that is not verifiable by this server \
468 ({armor}); gunnar verifies ssh signatures (gpg.format=ssh), and a \
469 signature it cannot check is a hole, not a verdict"
470 )),
471 SignatureProblem::KeyNotRegistered { fingerprint } => LinkState::Broken(format!(
472 "{subject} is signed by {fingerprint}, a key this server does not know; \
473 cryptography by a stranger proves nothing about identity"
474 )),
475 SignatureProblem::BadSignature {
476 fingerprint,
477 reason,
478 } => LinkState::Broken(format!(
479 "{subject}'s signature by {fingerprint} is invalid: {reason}"
480 )),
481 problem => {
487 LinkState::Broken(format!("{subject}'s signature is invalid: {problem}"))
488 }
489 },
490 Err(err) => LinkState::Unproven(format!("reading {subject}: {err}")),
491 }
492 };
493
494 let log = stores.transparency();
496 let indexed = log.records().read_indexed()?;
500 let records: Vec<&gunnar_proto::PushAttestation> = indexed.iter().map(|(_, r)| r).collect();
501 let wanted = subject.to_string();
502 let attested = indexed.iter().find(|(_, record)| {
503 record
504 .repo
505 .as_ref()
506 .is_some_and(|r| r.namespace == repo.namespace && r.repo == repo.repo)
507 && record.new_object_oids.contains(&wanted)
508 });
509 let index = match attested {
510 Some((index, record)) => {
511 let index = *index;
512 chain.push = LinkState::Proven(format!(
513 "pushed by {} with key {} at {} (pack {})",
514 record.principal_name,
515 record.key_fingerprint,
516 record.received_at_unix_ms,
517 record.pack_hash
518 ));
519 Some(index)
520 }
521 None => {
522 let blind = records
525 .iter()
526 .any(|record| !record.new_object_oids_unknown.is_empty());
527 chain.push = LinkState::Unproven(if blind {
528 "this log holds attestations whose object set was never enumerated, so it \
529 cannot say which push introduced this commit. Start the server with object \
530 attestation on."
531 .to_owned()
532 } else {
533 format!("no attestation in this log names {subject} as an object it introduced")
534 });
535 None
536 }
537 };
538
539 chain.log = match index {
541 None => LinkState::Unproven("no attestation to prove".to_owned()),
542 Some(index) => {
543 let key = stores.transparency_key()?;
544 let heads = log.heads()?;
545 let inclusion = log
546 .prove(index)?
547 .context("the record is in the log and has no proof, which cannot happen")?;
548 let covering = heads.iter().rev().find(|head| {
551 head.head.size as usize >= inclusion.size
552 && gunnar_core::transparency::verify_tree_head(head, key.public_key()).is_ok()
553 });
554 match covering {
555 Some(head) if head.head.root == inclusion.root => LinkState::Proven(format!(
556 "record {} of {} under the head signed at {} (root {})",
557 inclusion.index, inclusion.size, head.head.at_unix_ms, head.head.root
558 )),
559 Some(head) => LinkState::Broken(format!(
560 "the log's records hash to {} and the head signed at {} swore to {}",
561 inclusion.root, head.head.at_unix_ms, head.head.root
562 )),
563 None => LinkState::Unproven(format!(
564 "record {} of {} is in the log and no signed tree head covers it yet; run \
565 `gunnar attest head`",
566 inclusion.index, inclusion.size
567 )),
568 }
569 }
570 };
571
572 Ok(chain)
573}
574
575pub fn verify_at(
577 data_root: &Path,
578 repo: &gunnar_proto::RepoPath,
579 rev: &str,
580 target: Option<&str>,
581) -> Result<Chain> {
582 let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
583 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
584 verify(&stores, &plane, repo, rev, target)
585}