1use crate::ux::format::is_json_mode;
2use anyhow::{Context, Result, anyhow};
3use auths_infra_http::HttpOobiResolver;
4use auths_keri::Event;
5use auths_keri::witness::{SignedReceipt, WitnessReceiptLookup};
6use auths_sdk::core_config::EnvironmentConfig;
7use auths_sdk::ports::RegistryBackend;
8use auths_sdk::storage::{GitRegistryBackend, GitWitnessReceiptLookup, RegistryConfig};
9use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig};
10use auths_verifier::{
11 Attestation, CommitVerdict, IdentityBundle, VerificationReport, VerifierWitnessPolicy,
12 WitnessGateStatus, verify_chain_with_witnesses, verify_commit_against_kel_witnessed,
13};
14use clap::Parser;
15use serde::Serialize;
16use std::fs;
17use std::path::PathBuf;
18
19use crate::subprocess::git_command;
20
21use super::verify_helpers::parse_witness_keys;
22
23#[derive(Parser, Debug, Clone)]
24#[command(about = "Verify Git commit signatures against Auths identity.")]
25pub struct VerifyCommitCommand {
26 #[arg(default_value = "HEAD")]
28 pub commit: String,
29
30 #[arg(long = "witness-signatures")]
32 pub witness_receipts: Option<PathBuf>,
33
34 #[arg(long = "witnesses-required", default_value = "1")]
36 pub witness_threshold: usize,
37
38 #[arg(long, num_args = 1..)]
40 pub witness_keys: Vec<String>,
41
42 #[arg(long)]
47 pub remote: Option<String>,
48
49 #[arg(long)]
54 pub oobi: Option<String>,
55
56 #[arg(long = "require-witnesses")]
59 pub require_witnesses: bool,
60
61 #[arg(long, value_parser)]
65 pub identity_bundle: Option<PathBuf>,
66}
67
68#[derive(Serialize)]
69struct VerifyCommitResult {
70 commit: String,
71 valid: bool,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 ssh_valid: Option<bool>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 chain_valid: Option<bool>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 chain_report: Option<VerificationReport>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 witness_quorum: Option<WitnessQuorum>,
80 #[serde(skip_serializing_if = "Option::is_none")]
83 witness_gate: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 signer: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 oidc_binding: Option<OidcBindingDisplay>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 error: Option<String>,
90 #[serde(skip_serializing_if = "Vec::is_empty")]
91 warnings: Vec<String>,
92}
93
94#[derive(Serialize)]
99struct OidcBindingDisplay {
100 issuer: String,
102 subject: String,
104 audience: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 platform: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 normalized_claims: Option<serde_json::Map<String, serde_json::Value>>,
112}
113
114impl VerifyCommitResult {
115 fn failure(commit: String, error: String) -> Self {
116 Self {
117 commit,
118 valid: false,
119 ssh_valid: None,
120 chain_valid: None,
121 chain_report: None,
122 witness_quorum: None,
123 witness_gate: None,
124 signer: None,
125 oidc_binding: None,
126 error: Some(error),
127 warnings: Vec::new(),
128 }
129 }
130}
131
132#[allow(clippy::disallowed_methods)]
135pub async fn handle_verify_commit(
136 cmd: VerifyCommitCommand,
137 env_config: &EnvironmentConfig,
138) -> Result<()> {
139 let auths_home = match auths_sdk::paths::auths_home() {
142 Ok(h) => h,
143 Err(e) => return handle_error(&cmd, 2, &format!("Could not locate ~/.auths: {e}")),
144 };
145 let sdk_ctx =
148 match crate::factories::storage::build_auths_context(&auths_home, env_config, None) {
149 Ok(c) => c,
150 Err(e) => {
151 return handle_error(
152 &cmd,
153 2,
154 &format!("Could not build context for org-policy evaluation: {e}"),
155 );
156 }
157 };
158 let registry =
161 GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home));
162 let mut pinned_roots = super::verify_helpers::load_project_pinned_roots();
166 let mut bundle_kel: Option<(String, Vec<Event>)> = None;
167 if let Some(bundle_path) = &cmd.identity_bundle {
168 match load_bundle_trust(bundle_path, chrono::Utc::now()) {
169 Ok((root, kel)) => {
170 if !kel.is_empty() {
171 bundle_kel = Some((root.clone(), kel));
172 }
173 if !pinned_roots.contains(&root) {
174 pinned_roots.push(root);
175 }
176 }
177 Err(e) => return handle_error(&cmd, 2, &e),
178 }
179 }
180 let provider = auths_crypto::RingCryptoProvider;
181 let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home);
184
185 let commits = match resolve_commits(&cmd.commit) {
186 Ok(c) => c,
187 Err(e) => return handle_error(&cmd, 2, &e.to_string()),
188 };
189 let mut results = Vec::with_capacity(commits.len());
190 for commit_ref in &commits {
191 results.push(
192 verify_one_commit(
193 ®istry,
194 &pinned_roots,
195 &provider,
196 &receipt_lookup,
197 &sdk_ctx,
198 &cmd,
199 bundle_kel.as_ref(),
200 commit_ref,
201 )
202 .await,
203 );
204 }
205 output_results(&results)
206}
207
208fn load_bundle_trust(
213 path: &std::path::Path,
214 now: chrono::DateTime<chrono::Utc>,
215) -> std::result::Result<(String, Vec<Event>), String> {
216 let content = fs::read_to_string(path)
217 .map_err(|e| format!("could not read identity bundle {path:?}: {e}"))?;
218 let bundle: IdentityBundle = serde_json::from_str(&content)
219 .map_err(|e| format!("identity bundle {path:?} is not valid JSON: {e}"))?;
220 let root = auths_sdk::workflows::commit_trust::trusted_root_from_bundle(&bundle, now)
221 .map_err(|e| e.to_string())?;
222 let kel: Vec<Event> = bundle
223 .kel
224 .iter()
225 .map(|v| serde_json::from_value(v.clone()))
226 .collect::<std::result::Result<_, _>>()
227 .map_err(|e| format!("identity bundle {path:?} carries an unparseable KEL: {e}"))?;
228 Ok((root, kel))
229}
230
231fn resolve_commits(commit_spec: &str) -> Result<Vec<String>> {
233 if commit_spec.contains("..") {
234 let output = git_command(&["rev-list", commit_spec])
236 .output()
237 .context("Failed to run git rev-list")?;
238
239 if !output.status.success() {
240 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
241 let lower = stderr.to_lowercase();
242
243 if lower.contains("unknown revision") || lower.contains("bad revision") {
244 return Err(anyhow!(
245 "{}",
246 format_commit_range_hint(commit_spec, stderr.trim())
247 ));
248 }
249
250 return Err(anyhow!("Invalid commit range: {}", stderr.trim()));
251 }
252
253 let commits: Vec<String> = std::str::from_utf8(&output.stdout)
254 .context("Invalid UTF-8 in git output")?
255 .lines()
256 .map(|s| s.to_string())
257 .collect();
258
259 if commits.is_empty() {
260 return Err(anyhow!("No commits in specified range"));
261 }
262 Ok(commits)
263 } else {
264 let sha = resolve_commit_sha(commit_spec)?;
266 Ok(vec![sha])
267 }
268}
269
270fn format_commit_range_hint(commit_spec: &str, raw_stderr: &str) -> String {
272 let hint = if commit_spec.contains('~') || commit_spec.contains('^') {
273 "This repository may not have enough commits for that range. \
274 Try a smaller offset (e.g. HEAD~1..HEAD) or verify with `git log --oneline`."
275 } else if commit_spec.contains("..") {
276 "One or both refs in the range do not exist. \
277 Check branch/tag names with `git branch -a` or `git tag -l`."
278 } else {
279 "The commit reference could not be resolved. \
280 Verify it exists with `git log --oneline`."
281 };
282
283 format!("Failed to resolve commit range '{commit_spec}': {raw_stderr}\n\nHint: {hint}")
284}
285
286fn try_load_attestation_from_ref(commit_sha: &str) -> Option<Attestation> {
294 let ref_name = format!("refs/auths/commits/{}", commit_sha);
295
296 let stdout = crate::subprocess::git_silent(&["show", &ref_name])?;
297 serde_json::from_str(&stdout).ok()
298}
299
300fn extract_oidc_binding_display(attestation: &Attestation) -> Option<OidcBindingDisplay> {
310 attestation
311 .oidc_binding
312 .as_ref()
313 .map(|binding| OidcBindingDisplay {
314 issuer: binding.issuer.clone(),
315 subject: binding.subject.clone(),
316 audience: binding.audience.clone(),
317 platform: binding.platform.clone(),
318 normalized_claims: binding.normalized_claims.clone(),
319 })
320}
321
322async fn resolve_signer_kel(
334 registry: &dyn RegistryBackend,
335 cmd: &VerifyCommitCommand,
336 bundle_kel: Option<&(String, Vec<Event>)>,
337 did: &str,
338) -> Result<Vec<Event>, String> {
339 if let Some((bundle_did, events)) = bundle_kel
343 && bundle_did == did
344 {
345 let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
346 auths_sdk::keri::verify_prefix_binding(&prefix, events).map_err(|e| e.to_string())?;
347 return Ok(events.clone());
348 }
349 if let Some(oobi_base) = &cmd.oobi {
350 let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
351 let resolver = HttpOobiResolver::new(oobi_base.clone()).map_err(|e| e.to_string())?;
352 let events = resolver
353 .fetch_kel(&prefix)
354 .await
355 .map_err(|e| e.to_string())?;
356 auths_sdk::keri::verify_prefix_binding(&prefix, &events).map_err(|e| e.to_string())?;
357 Ok(events)
358 } else {
359 let chain = match &cmd.remote {
360 Some(url) => auths_sdk::keri::KelResolverChain::with_remote(registry, url.clone()),
361 None => auths_sdk::keri::KelResolverChain::local(registry),
362 };
363 chain.resolve_kel(did).map_err(|e| e.to_string())
364 }
365}
366
367#[allow(clippy::too_many_arguments)]
374async fn verify_one_commit(
375 registry: &dyn RegistryBackend,
376 pinned_roots: &[String],
377 provider: &dyn auths_crypto::CryptoProvider,
378 receipt_lookup: &dyn WitnessReceiptLookup,
379 sdk_ctx: &auths_sdk::context::AuthsContext,
380 cmd: &VerifyCommitCommand,
381 bundle_kel: Option<&(String, Vec<Event>)>,
382 commit_ref: &str,
383) -> VerifyCommitResult {
384 let sha = match resolve_commit_sha(commit_ref) {
385 Ok(sha) => sha,
386 Err(e) => {
387 return VerifyCommitResult::failure(
388 commit_ref.to_string(),
389 format!("Failed to resolve commit: {e}"),
390 );
391 }
392 };
393
394 let raw_commit = match raw_commit_object(&sha) {
395 Ok(c) => c,
396 Err(e) => return VerifyCommitResult::failure(sha, e.to_string()),
397 };
398
399 let (root_did, device_did) =
400 match auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit) {
401 Some(pair) => pair,
402 None => {
403 return VerifyCommitResult::failure(
404 sha,
405 "Commit carries no Auths-Id/Auths-Device trailer — it was not signed by \
406 `auths sign` (or predates KEL-native signing). Nothing to verify against."
407 .to_string(),
408 );
409 }
410 };
411
412 let device_kel = match resolve_signer_kel(registry, cmd, bundle_kel, &device_did).await {
417 Ok(events) => events,
418 Err(e) => {
419 return VerifyCommitResult::failure(
420 sha,
421 format!("Device KEL for {device_did} could not be resolved: {e}"),
422 );
423 }
424 };
425 let root_kel = match resolve_signer_kel(registry, cmd, bundle_kel, &root_did).await {
426 Ok(events) => events,
427 Err(e) => {
428 return VerifyCommitResult::failure(
429 sha,
430 format!("Root KEL for {root_did} could not be resolved: {e}"),
431 );
432 }
433 };
434
435 let policy = if cmd.require_witnesses {
436 VerifierWitnessPolicy::RequireWitnesses
437 } else {
438 VerifierWitnessPolicy::Warn
439 };
440 let witnessed = verify_commit_against_kel_witnessed(
441 raw_commit.as_bytes(),
442 &device_kel,
443 &root_kel,
444 pinned_roots,
445 provider,
446 receipt_lookup,
447 policy,
448 )
449 .await;
450 let mut result = verdict_to_result(sha.clone(), witnessed.verdict);
451 match witnessed.witness {
452 WitnessGateStatus::NotRequired => {}
453 WitnessGateStatus::Met => result.witness_gate = Some("met".to_string()),
454 WitnessGateStatus::UnderQuorum {
455 collected,
456 required,
457 } => {
458 result.witness_gate = Some(format!("{collected} of {required} (under quorum)"));
459 result.warnings.push(format!(
460 "Witness quorum not met for the signer's root KEL: {collected} of {required} \
461 receipts (verifying anyway; pass --require-witnesses to fail closed)."
462 ));
463 }
464 }
465
466 if let Ok(Some(quorum)) = verify_witnesses(cmd, None).await {
467 if quorum.verified < quorum.required {
468 result.valid = false;
469 if result.error.is_none() {
470 result.error = Some(format!(
471 "Witness quorum not met: {}/{}",
472 quorum.verified, quorum.required
473 ));
474 }
475 }
476 result.witness_quorum = Some(quorum);
477 }
478
479 result.oidc_binding =
480 try_load_attestation_from_ref(&sha).and_then(|att| extract_oidc_binding_display(&att));
481
482 if result.valid {
486 let now = chrono::Utc::now();
487 match auths_sdk::workflows::commit_trust::evaluate_commit_policy(
488 sdk_ctx,
489 &root_did,
490 &device_did,
491 now,
492 ) {
493 Ok(auths_sdk::workflows::commit_trust::PolicyOutcome::Evaluated(decision))
494 if !decision.is_allowed() =>
495 {
496 result.valid = false;
497 result.chain_valid = Some(false);
498 result.error = Some(format!(
499 "Org policy denied this commit: {} [{}]",
500 decision.message, decision.reason
501 ));
502 }
503 Ok(_) => {}
504 Err(e) => {
505 result.valid = false;
507 result.error = Some(format!("Org policy could not be evaluated: {e}"));
508 }
509 }
510 }
511
512 result
513}
514
515fn raw_commit_object(sha: &str) -> Result<String> {
518 let output = git_command(&["cat-file", "commit", sha])
519 .output()
520 .context("Failed to run git cat-file")?;
521 if !output.status.success() {
522 return Err(anyhow!(
523 "git cat-file commit {sha} failed: {}",
524 String::from_utf8_lossy(&output.stderr)
525 ));
526 }
527 String::from_utf8(output.stdout).context("Commit object is not valid UTF-8")
528}
529
530fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResult {
533 let mut result = VerifyCommitResult::failure(commit, String::new());
534 match verdict {
535 CommitVerdict::Valid {
536 signer_did,
537 root_did,
538 duplicitous_root,
539 } => {
540 result.valid = true;
541 result.ssh_valid = Some(true);
542 result.signer = Some(signer_did);
543 result.error = None;
544 if duplicitous_root {
545 result.warnings.push(format!(
546 "Root {root_did} shows KEL duplicity (a fork) — trusting the first event \
547 seen. Resolve with `auths device remove`."
548 ));
549 }
550 }
551 CommitVerdict::Unsigned => {
552 result.error = Some("No signature found".to_string());
553 }
554 CommitVerdict::GpgUnsupported => {
555 result.error = Some("GPG signatures not supported, use SSH signing".to_string());
556 }
557 CommitVerdict::SshSignatureInvalid => {
558 result.ssh_valid = Some(false);
559 result.error = Some(
560 "SSH signature is invalid (commit tampered, wrong namespace, or bad signature)"
561 .to_string(),
562 );
563 }
564 CommitVerdict::DeviceKelInvalid(why) => {
565 result.error = Some(format!("Device KEL failed to replay: {why}"));
566 }
567 CommitVerdict::RootKelInvalid(why) => {
568 result.error = Some(format!("Root KEL failed to replay: {why}"));
569 }
570 CommitVerdict::RootNotPinned(root) => {
571 result.error = Some(format!(
572 "Root {root} is not a pinned trusted root. Pin it in .auths/roots to trust \
573 commits delegated under it."
574 ));
575 }
576 CommitVerdict::RootAbandoned => {
577 result.error =
578 Some("Root identity is abandoned (its KEL was rotated to a null key)".to_string());
579 }
580 CommitVerdict::NotDelegatedByClaimedRoot {
581 device_did,
582 root_did,
583 } => {
584 result.error = Some(format!(
585 "Device {device_did} is not delegated by the claimed root {root_did}"
586 ));
587 }
588 CommitVerdict::DelegationSealNotFound => {
589 result.error = Some(
590 "Root never anchored this device's delegated inception (no delegation seal)"
591 .to_string(),
592 );
593 }
594 CommitVerdict::DeviceRevoked => {
595 result.error = Some("Device delegation has been revoked by the root".to_string());
596 }
597 CommitVerdict::SignedAfterRevocation {
598 signed_at,
599 revoked_at,
600 ..
601 } => {
602 result.error = Some(format!(
603 "Commit was signed at/after the delegator revoked it (signed at KEL position {signed_at}, revoked at {revoked_at})"
604 ));
605 }
606 CommitVerdict::OutsideAgentScope { capability, .. } => {
607 result.error = Some(format!(
608 "Agent signed exercising capability '{capability}', outside its delegator-anchored scope"
609 ));
610 }
611 CommitVerdict::AgentExpired {
612 expired_at,
613 signed_at,
614 ..
615 } => {
616 result.error = Some(format!(
617 "Agent delegation expired (expired at {expired_at}, signed at {signed_at})"
618 ));
619 }
620 CommitVerdict::SignerKeyMismatch => {
621 result.ssh_valid = Some(false);
622 result.error = Some("Signing key is not the device's current key".to_string());
623 }
624 CommitVerdict::SignedBySupersededKey => {
625 result.ssh_valid = Some(false);
626 result.error = Some(
627 "Commit was signed by a superseded device key (the device has since rotated)"
628 .to_string(),
629 );
630 }
631 CommitVerdict::WitnessQuorumNotMet {
632 root_did,
633 collected,
634 required,
635 } => {
636 result.error = Some(format!(
637 "Witness quorum not met for root {root_did}: {collected} of {required} required \
638 receipts. Drop --require-witnesses to verify with a warning instead."
639 ));
640 }
641 }
642 result
643}
644
645async fn verify_witnesses(
647 cmd: &VerifyCommitCommand,
648 bundle: Option<&IdentityBundle>,
649) -> Result<Option<WitnessQuorum>> {
650 let receipts_path = match cmd.witness_receipts {
651 Some(ref p) => p,
652 None => return Ok(None),
653 };
654
655 let receipts_bytes = fs::read(receipts_path)
656 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
657
658 let receipts: Vec<SignedReceipt> =
659 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
660
661 let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
662
663 let config = WitnessVerifyConfig {
664 receipts: &receipts,
665 witness_keys: &witness_keys,
666 threshold: cmd.witness_threshold,
667 };
668
669 if let Some(bundle) = bundle
671 && !bundle.attestation_chain.is_empty()
672 {
673 let root_pk_bytes = hex::decode(bundle.public_key_hex.as_str())
674 .context("Invalid public key hex in bundle")?;
675 let root_pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &root_pk_bytes)
676 .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
677
678 let report = verify_chain_with_witnesses(&bundle.attestation_chain, &root_pk, &config)
679 .await
680 .context("Witness chain verification failed")?;
681
682 return Ok(report.witness_quorum);
683 }
684
685 let provider = auths_crypto::RingCryptoProvider;
687 let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await;
688 Ok(Some(quorum))
689}
690
691fn output_results(results: &[VerifyCommitResult]) -> Result<()> {
693 let all_valid = results.iter().all(|r| r.valid);
694
695 if is_json_mode() {
696 if results.len() == 1 {
697 println!("{}", serde_json::to_string(&results[0])?);
698 } else {
699 println!("{}", serde_json::to_string(&results)?);
700 }
701 } else if results.len() == 1 {
702 let r = &results[0];
703 if r.valid {
704 if let Some(ref signer) = r.signer {
705 print!("Commit {} verified: signed by {}", r.commit, signer);
706 } else {
707 print!("Commit {} verified", r.commit);
708 }
709 print_chain_witness_summary(r);
710 println!();
711 } else {
712 eprint!("Verification failed for {}", r.commit);
713 if let Some(ref error) = r.error {
714 eprint!(": {}", error);
715 }
716 print_chain_witness_summary_stderr(r);
717 eprintln!();
718 }
719 for w in &r.warnings {
720 eprintln!("Warning: {}", w);
721 }
722 } else {
723 for r in results {
724 print!(
725 "{}: {}",
726 &r.commit[..8.min(r.commit.len())],
727 format_result_text(r)
728 );
729 println!();
730 }
731 }
732
733 if all_valid {
734 Ok(())
735 } else {
736 std::process::exit(1);
737 }
738}
739
740fn format_result_text(result: &VerifyCommitResult) -> String {
742 let status = if result.valid { "valid" } else { "INVALID" };
743
744 let mut parts = vec![status.to_string()];
745
746 if let Some(ref signer) = result.signer {
747 parts.push(format!("signer: {}", signer));
748 }
749
750 if let Some(cv) = result.chain_valid {
751 let chain_desc = if cv {
752 "chain: valid".to_string()
753 } else if let Some(ref report) = result.chain_report {
754 format!("chain: {}", format_chain_status(&report.status))
755 } else {
756 "chain: invalid".to_string()
757 };
758 parts.push(chain_desc);
759 }
760
761 if let Some(ref q) = result.witness_quorum {
762 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
763 }
764
765 if let Some(ref gate) = result.witness_gate {
766 parts.push(format!("witness-gate: {gate}"));
767 }
768
769 if let Some(ref binding) = result.oidc_binding {
770 parts.push(format!("oidc: {}", binding.issuer));
771 }
772
773 if let Some(ref error) = result.error
774 && result.signer.is_none()
775 && result.chain_valid.is_none()
776 && result.witness_quorum.is_none()
777 {
778 parts.push(error.clone());
779 }
780
781 if parts.len() == 1 {
782 parts[0].clone()
783 } else {
784 format!("{} ({})", parts[0], parts[1..].join(", "))
785 }
786}
787
788fn format_chain_status(status: &auths_verifier::VerificationStatus) -> String {
790 match status {
791 auths_verifier::VerificationStatus::Valid => "valid".to_string(),
792 auths_verifier::VerificationStatus::Expired { at } => {
793 format!("expired at {}", at.to_rfc3339())
794 }
795 auths_verifier::VerificationStatus::Revoked { at } => match at {
796 Some(t) => format!("revoked at {}", t.to_rfc3339()),
797 None => "revoked".to_string(),
798 },
799 auths_verifier::VerificationStatus::InvalidSignature { step } => {
800 format!("invalid signature at step {}", step)
801 }
802 auths_verifier::VerificationStatus::BrokenChain { missing_link } => {
803 format!("broken chain: {}", missing_link)
804 }
805 auths_verifier::VerificationStatus::InsufficientWitnesses { required, verified } => {
806 format!("witnesses: {}/{} quorum not met", verified, required)
807 }
808 }
809}
810
811fn print_chain_witness_summary(r: &VerifyCommitResult) {
813 let mut parts = Vec::new();
814
815 if let Some(cv) = r.chain_valid {
816 if cv {
817 parts.push("chain: valid".to_string());
818 } else {
819 parts.push("chain: invalid".to_string());
820 }
821 }
822
823 if let Some(ref q) = r.witness_quorum {
824 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
825 }
826
827 if let Some(ref gate) = r.witness_gate {
828 parts.push(format!("witness-gate: {gate}"));
829 }
830
831 if let Some(ref binding) = r.oidc_binding {
832 parts.push(format!("oidc: {} ({})", binding.issuer, binding.subject));
833 }
834
835 if !parts.is_empty() {
836 print!(" ({})", parts.join(", "));
837 }
838}
839
840fn print_chain_witness_summary_stderr(r: &VerifyCommitResult) {
842 if let Some(cv) = r.chain_valid
843 && !cv
844 && let Some(ref report) = r.chain_report
845 {
846 eprint!(" (chain: {})", format_chain_status(&report.status));
847 }
848 if let Some(ref q) = r.witness_quorum
849 && q.verified < q.required
850 {
851 eprint!(" (witnesses: {}/{} quorum not met)", q.verified, q.required);
852 }
853}
854
855fn resolve_commit_sha(commit_ref: &str) -> Result<String> {
856 super::git_helpers::resolve_commit_sha(commit_ref)
857}
858
859fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> {
860 if is_json_mode() {
861 let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string());
862 println!("{}", serde_json::to_string(&result)?);
863 } else {
864 eprintln!("Error: {}", message);
865 }
866 std::process::exit(exit_code);
867}
868
869impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand {
870 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
871 let rt = tokio::runtime::Runtime::new()?;
872 rt.block_on(handle_verify_commit(self.clone(), &ctx.env_config))
873 }
874}
875
876#[cfg(test)]
877#[allow(clippy::disallowed_methods)]
878mod tests {
879 use super::*;
880
881 #[test]
882 fn verify_commit_result_failure_helper() {
883 let r = VerifyCommitResult::failure("abc123".into(), "bad sig".into());
884 assert!(!r.valid);
885 assert_eq!(r.commit, "abc123");
886 assert_eq!(r.error.as_deref(), Some("bad sig"));
887 assert!(r.ssh_valid.is_none());
888 assert!(r.chain_valid.is_none());
889 assert!(r.witness_quorum.is_none());
890 }
891
892 #[test]
893 fn verify_commit_result_json_includes_new_fields() {
894 let r = VerifyCommitResult {
895 commit: "abc123".into(),
896 valid: true,
897 ssh_valid: Some(true),
898 chain_valid: Some(true),
899 chain_report: None,
900 witness_quorum: Some(WitnessQuorum {
901 required: 2,
902 verified: 2,
903 receipts: vec![],
904 }),
905 witness_gate: Some("met".into()),
906 signer: Some("did:keri:test".into()),
907 oidc_binding: None,
908 error: None,
909 warnings: vec!["expiring soon".into()],
910 };
911 let json = serde_json::to_string(&r).unwrap();
912 assert!(json.contains("\"ssh_valid\":true"));
913 assert!(json.contains("\"chain_valid\":true"));
914 assert!(json.contains("\"witness_quorum\""));
915 assert!(json.contains("\"warnings\":[\"expiring soon\"]"));
916 }
917
918 #[test]
919 fn verify_commit_result_json_omits_none_fields() {
920 let r = VerifyCommitResult::failure("abc".into(), "err".into());
921 let json = serde_json::to_string(&r).unwrap();
922 assert!(!json.contains("ssh_valid"));
923 assert!(!json.contains("chain_valid"));
924 assert!(!json.contains("chain_report"));
925 assert!(!json.contains("witness_quorum"));
926 assert!(!json.contains("warnings"));
927 }
928
929 #[test]
930 fn format_result_text_valid_ssh_only() {
931 let r = VerifyCommitResult {
932 commit: "abc12345".into(),
933 valid: true,
934 ssh_valid: Some(true),
935 chain_valid: None,
936 chain_report: None,
937 witness_quorum: None,
938 witness_gate: None,
939 signer: Some("did:keri:test".into()),
940 oidc_binding: None,
941 error: None,
942 warnings: vec![],
943 };
944 let text = format_result_text(&r);
945 assert!(text.contains("valid"));
946 assert!(text.contains("signer: did:keri:test"));
947 }
948
949 #[test]
950 fn format_result_text_valid_with_chain_and_witnesses() {
951 let r = VerifyCommitResult {
952 commit: "abc12345".into(),
953 valid: true,
954 ssh_valid: Some(true),
955 chain_valid: Some(true),
956 chain_report: Some(VerificationReport::valid(vec![])),
957 witness_quorum: Some(WitnessQuorum {
958 required: 2,
959 verified: 2,
960 receipts: vec![],
961 }),
962 witness_gate: Some("met".into()),
963 signer: Some("did:keri:test".into()),
964 oidc_binding: None,
965 error: None,
966 warnings: vec![],
967 };
968 let text = format_result_text(&r);
969 assert!(text.contains("chain: valid"));
970 assert!(text.contains("witnesses: 2/2"));
971 assert!(text.contains("witness-gate: met"));
972 }
973
974 #[test]
975 fn verify_output_shows_quorum() {
976 let mut r = VerifyCommitResult::failure("abc".into(), String::new());
977 r.valid = true;
978 r.error = None;
979 r.witness_gate = Some("2 of 3 (under quorum)".into());
980
981 let text = format_result_text(&r);
982 assert!(text.contains("witness-gate: 2 of 3 (under quorum)"));
983 let json = serde_json::to_string(&r).unwrap();
984 assert!(json.contains("\"witness_gate\":\"2 of 3 (under quorum)\""));
985 }
986
987 #[test]
988 fn verify_output_flags_fork() {
989 let result = verdict_to_result(
991 "sha".into(),
992 CommitVerdict::Valid {
993 signer_did: "did:keri:dev".into(),
994 root_did: "did:keri:root".into(),
995 duplicitous_root: true,
996 },
997 );
998 assert!(result.valid);
999 assert!(
1000 result
1001 .warnings
1002 .iter()
1003 .any(|w| w.contains("fork") || w.contains("duplicity")),
1004 "expected a fork/duplicity warning, got {:?}",
1005 result.warnings
1006 );
1007 }
1008
1009 #[test]
1010 fn format_result_text_invalid_with_error() {
1011 let r = VerifyCommitResult::failure("abc12345".into(), "No signature found".into());
1012 let text = format_result_text(&r);
1013 assert!(text.contains("INVALID"));
1014 assert!(text.contains("No signature found"));
1015 }
1016}