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();
170 let mut bundle_kel: Option<(String, Vec<Event>)> = None;
171 if let Some(bundle_path) = &cmd.identity_bundle {
172 match load_bundle_trust(bundle_path, chrono::Utc::now()) {
173 Ok((root, kel)) => {
174 if !kel.is_empty() {
175 bundle_kel = Some((root.clone(), kel));
176 }
177 if !pinned_roots.contains(&root) {
178 pinned_roots.push(root);
179 }
180 }
181 Err(e) => return handle_error(&cmd, 2, &e),
182 }
183 } else if let Some(own_root) = auths_sdk::workflows::commit_trust::local_self_root(&sdk_ctx)
184 && !pinned_roots.contains(&own_root)
185 {
186 pinned_roots.push(own_root);
187 }
188 let provider = auths_crypto::RingCryptoProvider;
189 let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home);
192
193 let commits = match resolve_commits(&cmd.commit) {
194 Ok(c) => c,
195 Err(e) => return handle_error(&cmd, 2, &e.to_string()),
196 };
197 let mut results = Vec::with_capacity(commits.len());
198 for commit_ref in &commits {
199 results.push(
200 verify_one_commit(
201 ®istry,
202 &pinned_roots,
203 &provider,
204 &receipt_lookup,
205 &sdk_ctx,
206 &cmd,
207 bundle_kel.as_ref(),
208 commit_ref,
209 )
210 .await,
211 );
212 }
213 output_results(&results)
214}
215
216fn load_bundle_trust(
221 path: &std::path::Path,
222 now: chrono::DateTime<chrono::Utc>,
223) -> std::result::Result<(String, Vec<Event>), String> {
224 let content = fs::read_to_string(path)
225 .map_err(|e| format!("could not read identity bundle {path:?}: {e}"))?;
226 let bundle: IdentityBundle = serde_json::from_str(&content)
227 .map_err(|e| format!("identity bundle {path:?} is not valid JSON: {e}"))?;
228 let root = auths_sdk::workflows::commit_trust::trusted_root_from_bundle(&bundle, now)
229 .map_err(|e| e.to_string())?;
230 let kel: Vec<Event> = bundle
231 .kel
232 .iter()
233 .map(|v| serde_json::from_value(v.clone()))
234 .collect::<std::result::Result<_, _>>()
235 .map_err(|e| format!("identity bundle {path:?} carries an unparseable KEL: {e}"))?;
236 Ok((root, kel))
237}
238
239fn resolve_commits(commit_spec: &str) -> Result<Vec<String>> {
241 if commit_spec.contains("..") {
242 let output = git_command(&["rev-list", commit_spec])
244 .output()
245 .context("Failed to run git rev-list")?;
246
247 if !output.status.success() {
248 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
249 let lower = stderr.to_lowercase();
250
251 if lower.contains("unknown revision") || lower.contains("bad revision") {
252 return Err(anyhow!(
253 "{}",
254 format_commit_range_hint(commit_spec, stderr.trim())
255 ));
256 }
257
258 return Err(anyhow!("Invalid commit range: {}", stderr.trim()));
259 }
260
261 let commits: Vec<String> = std::str::from_utf8(&output.stdout)
262 .context("Invalid UTF-8 in git output")?
263 .lines()
264 .map(|s| s.to_string())
265 .collect();
266
267 if commits.is_empty() {
268 return Err(anyhow!("No commits in specified range"));
269 }
270 Ok(commits)
271 } else {
272 let sha = resolve_commit_sha(commit_spec)?;
274 Ok(vec![sha])
275 }
276}
277
278fn format_commit_range_hint(commit_spec: &str, raw_stderr: &str) -> String {
280 let hint = if commit_spec.contains('~') || commit_spec.contains('^') {
281 "This repository may not have enough commits for that range. \
282 Try a smaller offset (e.g. HEAD~1..HEAD) or verify with `git log --oneline`."
283 } else if commit_spec.contains("..") {
284 "One or both refs in the range do not exist. \
285 Check branch/tag names with `git branch -a` or `git tag -l`."
286 } else {
287 "The commit reference could not be resolved. \
288 Verify it exists with `git log --oneline`."
289 };
290
291 format!("Failed to resolve commit range '{commit_spec}': {raw_stderr}\n\nHint: {hint}")
292}
293
294fn try_load_attestation_from_ref(commit_sha: &str) -> Option<Attestation> {
302 let ref_name = format!("refs/auths/commits/{}", commit_sha);
303
304 let stdout = crate::subprocess::git_silent(&["show", &ref_name])?;
305 serde_json::from_str(&stdout).ok()
306}
307
308fn extract_oidc_binding_display(attestation: &Attestation) -> Option<OidcBindingDisplay> {
318 attestation
319 .oidc_binding
320 .as_ref()
321 .map(|binding| OidcBindingDisplay {
322 issuer: binding.issuer.clone(),
323 subject: binding.subject.clone(),
324 audience: binding.audience.clone(),
325 platform: binding.platform.clone(),
326 normalized_claims: binding.normalized_claims.clone(),
327 })
328}
329
330async fn resolve_signer_kel(
342 registry: &dyn RegistryBackend,
343 cmd: &VerifyCommitCommand,
344 bundle_kel: Option<&(String, Vec<Event>)>,
345 did: &str,
346) -> Result<Vec<Event>, String> {
347 if let Some((bundle_did, events)) = bundle_kel
351 && bundle_did == did
352 {
353 let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
354 auths_sdk::keri::verify_prefix_binding(&prefix, events).map_err(|e| e.to_string())?;
355 return Ok(events.clone());
356 }
357 if let Some(oobi_base) = &cmd.oobi {
358 let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
359 let resolver = HttpOobiResolver::new(oobi_base.clone()).map_err(|e| e.to_string())?;
360 let events = resolver
361 .fetch_kel(&prefix)
362 .await
363 .map_err(|e| e.to_string())?;
364 auths_sdk::keri::verify_prefix_binding(&prefix, &events).map_err(|e| e.to_string())?;
365 Ok(events)
366 } else {
367 let chain = match &cmd.remote {
368 Some(url) => auths_sdk::keri::KelResolverChain::with_remote(registry, url.clone()),
369 None => auths_sdk::keri::KelResolverChain::local(registry),
370 };
371 chain.resolve_kel(did).map_err(|e| e.to_string())
372 }
373}
374
375#[allow(clippy::too_many_arguments)]
382async fn verify_one_commit(
383 registry: &dyn RegistryBackend,
384 pinned_roots: &[String],
385 provider: &dyn auths_crypto::CryptoProvider,
386 receipt_lookup: &dyn WitnessReceiptLookup,
387 sdk_ctx: &auths_sdk::context::AuthsContext,
388 cmd: &VerifyCommitCommand,
389 bundle_kel: Option<&(String, Vec<Event>)>,
390 commit_ref: &str,
391) -> VerifyCommitResult {
392 let sha = match resolve_commit_sha(commit_ref) {
393 Ok(sha) => sha,
394 Err(e) => {
395 return VerifyCommitResult::failure(
396 commit_ref.to_string(),
397 format!("Failed to resolve commit: {e}"),
398 );
399 }
400 };
401
402 let raw_commit = match raw_commit_object(&sha) {
403 Ok(c) => c,
404 Err(e) => return VerifyCommitResult::failure(sha, e.to_string()),
405 };
406
407 let (root_did, device_did) =
408 match auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit) {
409 Some(pair) => pair,
410 None => {
411 return VerifyCommitResult::failure(
412 sha,
413 "Commit carries no Auths-Id/Auths-Device trailer. The prepare-commit-msg \
414 hook installed by `auths init` adds these on every commit — if this repo \
415 sets its own core.hooksPath (e.g. husky), the hook is bypassed; run \
416 `auths doctor` to check. Backfill existing commits with `auths sign <ref>` \
417 (rewrites the commit)."
418 .to_string(),
419 );
420 }
421 };
422
423 let device_kel = match resolve_signer_kel(registry, cmd, bundle_kel, &device_did).await {
428 Ok(events) => events,
429 Err(e) => {
430 return VerifyCommitResult::failure(
431 sha,
432 format!("Device KEL for {device_did} could not be resolved: {e}"),
433 );
434 }
435 };
436 let root_kel = match resolve_signer_kel(registry, cmd, bundle_kel, &root_did).await {
437 Ok(events) => events,
438 Err(e) => {
439 return VerifyCommitResult::failure(
440 sha,
441 format!("Root KEL for {root_did} could not be resolved: {e}"),
442 );
443 }
444 };
445
446 let policy = if cmd.require_witnesses {
447 VerifierWitnessPolicy::RequireWitnesses
448 } else {
449 VerifierWitnessPolicy::Warn
450 };
451 let witnessed = verify_commit_against_kel_witnessed(
452 raw_commit.as_bytes(),
453 &device_kel,
454 &root_kel,
455 pinned_roots,
456 provider,
457 receipt_lookup,
458 policy,
459 )
460 .await;
461 let mut result = verdict_to_result(sha.clone(), witnessed.verdict);
462 match witnessed.witness {
463 WitnessGateStatus::NotRequired => {}
464 WitnessGateStatus::Met => result.witness_gate = Some("met".to_string()),
465 WitnessGateStatus::UnderQuorum {
466 collected,
467 required,
468 } => {
469 result.witness_gate = Some(format!("{collected} of {required} (under quorum)"));
470 result.warnings.push(format!(
471 "Witness quorum not met for the signer's root KEL: {collected} of {required} \
472 receipts (verifying anyway; pass --require-witnesses to fail closed)."
473 ));
474 }
475 }
476
477 if let Ok(Some(quorum)) = verify_witnesses(cmd, None).await {
478 if quorum.verified < quorum.required {
479 result.valid = false;
480 if result.error.is_none() {
481 result.error = Some(format!(
482 "Witness quorum not met: {}/{}",
483 quorum.verified, quorum.required
484 ));
485 }
486 }
487 result.witness_quorum = Some(quorum);
488 }
489
490 result.oidc_binding =
491 try_load_attestation_from_ref(&sha).and_then(|att| extract_oidc_binding_display(&att));
492
493 if result.valid {
497 let now = chrono::Utc::now();
498 match auths_sdk::workflows::commit_trust::evaluate_commit_policy(
499 sdk_ctx,
500 &root_did,
501 &device_did,
502 now,
503 ) {
504 Ok(auths_sdk::workflows::commit_trust::PolicyOutcome::Evaluated(decision))
505 if !decision.is_allowed() =>
506 {
507 result.valid = false;
508 result.chain_valid = Some(false);
509 result.error = Some(format!(
510 "Org policy denied this commit: {} [{}]",
511 decision.message, decision.reason
512 ));
513 }
514 Ok(_) => {}
515 Err(e) => {
516 result.valid = false;
518 result.error = Some(format!("Org policy could not be evaluated: {e}"));
519 }
520 }
521 }
522
523 result
524}
525
526fn raw_commit_object(sha: &str) -> Result<String> {
529 let output = git_command(&["cat-file", "commit", sha])
530 .output()
531 .context("Failed to run git cat-file")?;
532 if !output.status.success() {
533 return Err(anyhow!(
534 "git cat-file commit {sha} failed: {}",
535 String::from_utf8_lossy(&output.stderr)
536 ));
537 }
538 String::from_utf8(output.stdout).context("Commit object is not valid UTF-8")
539}
540
541fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResult {
544 let mut result = VerifyCommitResult::failure(commit, String::new());
545 match verdict {
546 CommitVerdict::Valid {
547 signer_did,
548 root_did,
549 duplicitous_root,
550 } => {
551 result.valid = true;
552 result.ssh_valid = Some(true);
553 result.signer = Some(signer_did);
554 result.error = None;
555 if duplicitous_root {
556 result.warnings.push(format!(
557 "Root {root_did} shows KEL duplicity (a fork) — trusting the first event \
558 seen. Resolve with `auths device remove`."
559 ));
560 }
561 }
562 CommitVerdict::Unsigned => {
563 result.error = Some("No signature found".to_string());
564 }
565 CommitVerdict::GpgUnsupported => {
566 result.error = Some("GPG signatures not supported, use SSH signing".to_string());
567 }
568 CommitVerdict::SshSignatureInvalid => {
569 result.ssh_valid = Some(false);
570 result.error = Some(
571 "SSH signature is invalid (commit tampered, wrong namespace, or bad signature)"
572 .to_string(),
573 );
574 }
575 CommitVerdict::DeviceKelInvalid(why) => {
576 result.error = Some(format!("Device KEL failed to replay: {why}"));
577 }
578 CommitVerdict::RootKelInvalid(why) => {
579 result.error = Some(format!("Root KEL failed to replay: {why}"));
580 }
581 CommitVerdict::RootNotPinned(root) => {
582 result.error = Some(format!(
583 "Root {root} is not a pinned trusted root. Pin it in .auths/roots to trust \
584 commits delegated under it."
585 ));
586 }
587 CommitVerdict::RootAbandoned => {
588 result.error =
589 Some("Root identity is abandoned (its KEL was rotated to a null key)".to_string());
590 }
591 CommitVerdict::NotDelegatedByClaimedRoot {
592 device_did,
593 root_did,
594 } => {
595 result.error = Some(format!(
596 "Device {device_did} is not delegated by the claimed root {root_did}"
597 ));
598 }
599 CommitVerdict::DelegationSealNotFound => {
600 result.error = Some(
601 "Root never anchored this device's delegated inception (no delegation seal)"
602 .to_string(),
603 );
604 }
605 CommitVerdict::DeviceRevoked => {
606 result.error = Some("Device delegation has been revoked by the root".to_string());
607 }
608 CommitVerdict::SignedAfterRevocation {
609 signed_at,
610 revoked_at,
611 ..
612 } => {
613 result.error = Some(format!(
614 "Commit was signed at/after the delegator revoked it (signed at KEL position {signed_at}, revoked at {revoked_at})"
615 ));
616 }
617 CommitVerdict::OutsideAgentScope { capability, .. } => {
618 result.error = Some(format!(
619 "Agent signed exercising capability '{capability}', outside its delegator-anchored scope"
620 ));
621 }
622 CommitVerdict::AgentExpired {
623 expired_at,
624 signed_at,
625 ..
626 } => {
627 result.error = Some(format!(
628 "Agent delegation expired (expired at {expired_at}, signed at {signed_at})"
629 ));
630 }
631 CommitVerdict::SignerKeyMismatch => {
632 result.ssh_valid = Some(false);
633 result.error = Some("Signing key is not the device's current key".to_string());
634 }
635 CommitVerdict::SignedBySupersededKey => {
636 result.ssh_valid = Some(false);
637 result.error = Some(
638 "Commit was signed by a superseded device key (the device has since rotated)"
639 .to_string(),
640 );
641 }
642 CommitVerdict::WitnessQuorumNotMet {
643 root_did,
644 collected,
645 required,
646 } => {
647 result.error = Some(format!(
648 "Witness quorum not met for root {root_did}: {collected} of {required} required \
649 receipts. Drop --require-witnesses to verify with a warning instead."
650 ));
651 }
652 }
653 result
654}
655
656async fn verify_witnesses(
658 cmd: &VerifyCommitCommand,
659 bundle: Option<&IdentityBundle>,
660) -> Result<Option<WitnessQuorum>> {
661 let receipts_path = match cmd.witness_receipts {
662 Some(ref p) => p,
663 None => return Ok(None),
664 };
665
666 let receipts_bytes = fs::read(receipts_path)
667 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
668
669 let receipts: Vec<SignedReceipt> =
670 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
671
672 let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
673
674 let config = WitnessVerifyConfig {
675 receipts: &receipts,
676 witness_keys: &witness_keys,
677 threshold: cmd.witness_threshold,
678 };
679
680 if let Some(bundle) = bundle
682 && !bundle.attestation_chain.is_empty()
683 {
684 let root_pk_bytes = hex::decode(bundle.public_key_hex.as_str())
685 .context("Invalid public key hex in bundle")?;
686 let root_pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &root_pk_bytes)
687 .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
688
689 let report = verify_chain_with_witnesses(&bundle.attestation_chain, &root_pk, &config)
690 .await
691 .context("Witness chain verification failed")?;
692
693 return Ok(report.witness_quorum);
694 }
695
696 let provider = auths_crypto::RingCryptoProvider;
698 let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await;
699 Ok(Some(quorum))
700}
701
702fn output_results(results: &[VerifyCommitResult]) -> Result<()> {
704 let all_valid = results.iter().all(|r| r.valid);
705
706 if is_json_mode() {
707 if results.len() == 1 {
708 println!("{}", serde_json::to_string(&results[0])?);
709 } else {
710 println!("{}", serde_json::to_string(&results)?);
711 }
712 } else if results.len() == 1 {
713 let r = &results[0];
714 if r.valid {
715 if let Some(ref signer) = r.signer {
716 print!("Commit {} verified: signed by {}", r.commit, signer);
717 } else {
718 print!("Commit {} verified", r.commit);
719 }
720 print_chain_witness_summary(r);
721 println!();
722 } else {
723 eprint!("Verification failed for {}", r.commit);
724 if let Some(ref error) = r.error {
725 eprint!(": {}", error);
726 }
727 print_chain_witness_summary_stderr(r);
728 eprintln!();
729 }
730 for w in &r.warnings {
731 eprintln!("Warning: {}", w);
732 }
733 } else {
734 for r in results {
735 print!(
736 "{}: {}",
737 &r.commit[..8.min(r.commit.len())],
738 format_result_text(r)
739 );
740 println!();
741 }
742 }
743
744 if all_valid {
745 Ok(())
746 } else {
747 std::process::exit(1);
748 }
749}
750
751fn format_result_text(result: &VerifyCommitResult) -> String {
753 let status = if result.valid { "valid" } else { "INVALID" };
754
755 let mut parts = vec![status.to_string()];
756
757 if let Some(ref signer) = result.signer {
758 parts.push(format!("signer: {}", signer));
759 }
760
761 if let Some(cv) = result.chain_valid {
762 let chain_desc = if cv {
763 "chain: valid".to_string()
764 } else if let Some(ref report) = result.chain_report {
765 format!("chain: {}", format_chain_status(&report.status))
766 } else {
767 "chain: invalid".to_string()
768 };
769 parts.push(chain_desc);
770 }
771
772 if let Some(ref q) = result.witness_quorum {
773 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
774 }
775
776 if let Some(ref gate) = result.witness_gate {
777 parts.push(format!("witness-gate: {gate}"));
778 }
779
780 if let Some(ref binding) = result.oidc_binding {
781 parts.push(format!("oidc: {}", binding.issuer));
782 }
783
784 if let Some(ref error) = result.error
785 && result.signer.is_none()
786 && result.chain_valid.is_none()
787 && result.witness_quorum.is_none()
788 {
789 parts.push(error.clone());
790 }
791
792 if parts.len() == 1 {
793 parts[0].clone()
794 } else {
795 format!("{} ({})", parts[0], parts[1..].join(", "))
796 }
797}
798
799fn format_chain_status(status: &auths_verifier::VerificationStatus) -> String {
801 match status {
802 auths_verifier::VerificationStatus::Valid => "valid".to_string(),
803 auths_verifier::VerificationStatus::Expired { at } => {
804 format!("expired at {}", at.to_rfc3339())
805 }
806 auths_verifier::VerificationStatus::Revoked { at } => match at {
807 Some(t) => format!("revoked at {}", t.to_rfc3339()),
808 None => "revoked".to_string(),
809 },
810 auths_verifier::VerificationStatus::InvalidSignature { step } => {
811 format!("invalid signature at step {}", step)
812 }
813 auths_verifier::VerificationStatus::BrokenChain { missing_link } => {
814 format!("broken chain: {}", missing_link)
815 }
816 auths_verifier::VerificationStatus::InsufficientWitnesses { required, verified } => {
817 format!("witnesses: {}/{} quorum not met", verified, required)
818 }
819 }
820}
821
822fn print_chain_witness_summary(r: &VerifyCommitResult) {
824 let mut parts = Vec::new();
825
826 if let Some(cv) = r.chain_valid {
827 if cv {
828 parts.push("chain: valid".to_string());
829 } else {
830 parts.push("chain: invalid".to_string());
831 }
832 }
833
834 if let Some(ref q) = r.witness_quorum {
835 parts.push(format!("witnesses: {}/{}", q.verified, q.required));
836 }
837
838 if let Some(ref gate) = r.witness_gate {
839 parts.push(format!("witness-gate: {gate}"));
840 }
841
842 if let Some(ref binding) = r.oidc_binding {
843 parts.push(format!("oidc: {} ({})", binding.issuer, binding.subject));
844 }
845
846 if !parts.is_empty() {
847 print!(" ({})", parts.join(", "));
848 }
849}
850
851fn print_chain_witness_summary_stderr(r: &VerifyCommitResult) {
853 if let Some(cv) = r.chain_valid
854 && !cv
855 && let Some(ref report) = r.chain_report
856 {
857 eprint!(" (chain: {})", format_chain_status(&report.status));
858 }
859 if let Some(ref q) = r.witness_quorum
860 && q.verified < q.required
861 {
862 eprint!(" (witnesses: {}/{} quorum not met)", q.verified, q.required);
863 }
864}
865
866fn resolve_commit_sha(commit_ref: &str) -> Result<String> {
867 super::git_helpers::resolve_commit_sha(commit_ref)
868}
869
870fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> {
871 if is_json_mode() {
872 let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string());
873 println!("{}", serde_json::to_string(&result)?);
874 } else {
875 eprintln!("Error: {}", message);
876 }
877 std::process::exit(exit_code);
878}
879
880impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand {
881 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
882 let rt = tokio::runtime::Runtime::new()?;
883 rt.block_on(handle_verify_commit(self.clone(), &ctx.env_config))
884 }
885}
886
887#[cfg(test)]
888#[allow(clippy::disallowed_methods)]
889mod tests {
890 use super::*;
891
892 #[test]
893 fn verify_commit_result_failure_helper() {
894 let r = VerifyCommitResult::failure("abc123".into(), "bad sig".into());
895 assert!(!r.valid);
896 assert_eq!(r.commit, "abc123");
897 assert_eq!(r.error.as_deref(), Some("bad sig"));
898 assert!(r.ssh_valid.is_none());
899 assert!(r.chain_valid.is_none());
900 assert!(r.witness_quorum.is_none());
901 }
902
903 #[test]
904 fn verify_commit_result_json_includes_new_fields() {
905 let r = VerifyCommitResult {
906 commit: "abc123".into(),
907 valid: true,
908 ssh_valid: Some(true),
909 chain_valid: Some(true),
910 chain_report: None,
911 witness_quorum: Some(WitnessQuorum {
912 required: 2,
913 verified: 2,
914 receipts: vec![],
915 }),
916 witness_gate: Some("met".into()),
917 signer: Some("did:keri:test".into()),
918 oidc_binding: None,
919 error: None,
920 warnings: vec!["expiring soon".into()],
921 };
922 let json = serde_json::to_string(&r).unwrap();
923 assert!(json.contains("\"ssh_valid\":true"));
924 assert!(json.contains("\"chain_valid\":true"));
925 assert!(json.contains("\"witness_quorum\""));
926 assert!(json.contains("\"warnings\":[\"expiring soon\"]"));
927 }
928
929 #[test]
930 fn verify_commit_result_json_omits_none_fields() {
931 let r = VerifyCommitResult::failure("abc".into(), "err".into());
932 let json = serde_json::to_string(&r).unwrap();
933 assert!(!json.contains("ssh_valid"));
934 assert!(!json.contains("chain_valid"));
935 assert!(!json.contains("chain_report"));
936 assert!(!json.contains("witness_quorum"));
937 assert!(!json.contains("warnings"));
938 }
939
940 #[test]
941 fn format_result_text_valid_ssh_only() {
942 let r = VerifyCommitResult {
943 commit: "abc12345".into(),
944 valid: true,
945 ssh_valid: Some(true),
946 chain_valid: None,
947 chain_report: None,
948 witness_quorum: None,
949 witness_gate: None,
950 signer: Some("did:keri:test".into()),
951 oidc_binding: None,
952 error: None,
953 warnings: vec![],
954 };
955 let text = format_result_text(&r);
956 assert!(text.contains("valid"));
957 assert!(text.contains("signer: did:keri:test"));
958 }
959
960 #[test]
961 fn format_result_text_valid_with_chain_and_witnesses() {
962 let r = VerifyCommitResult {
963 commit: "abc12345".into(),
964 valid: true,
965 ssh_valid: Some(true),
966 chain_valid: Some(true),
967 chain_report: Some(VerificationReport::valid(vec![])),
968 witness_quorum: Some(WitnessQuorum {
969 required: 2,
970 verified: 2,
971 receipts: vec![],
972 }),
973 witness_gate: Some("met".into()),
974 signer: Some("did:keri:test".into()),
975 oidc_binding: None,
976 error: None,
977 warnings: vec![],
978 };
979 let text = format_result_text(&r);
980 assert!(text.contains("chain: valid"));
981 assert!(text.contains("witnesses: 2/2"));
982 assert!(text.contains("witness-gate: met"));
983 }
984
985 #[test]
986 fn verify_output_shows_quorum() {
987 let mut r = VerifyCommitResult::failure("abc".into(), String::new());
988 r.valid = true;
989 r.error = None;
990 r.witness_gate = Some("2 of 3 (under quorum)".into());
991
992 let text = format_result_text(&r);
993 assert!(text.contains("witness-gate: 2 of 3 (under quorum)"));
994 let json = serde_json::to_string(&r).unwrap();
995 assert!(json.contains("\"witness_gate\":\"2 of 3 (under quorum)\""));
996 }
997
998 #[test]
999 fn verify_output_flags_fork() {
1000 let result = verdict_to_result(
1002 "sha".into(),
1003 CommitVerdict::Valid {
1004 signer_did: "did:keri:dev".into(),
1005 root_did: "did:keri:root".into(),
1006 duplicitous_root: true,
1007 },
1008 );
1009 assert!(result.valid);
1010 assert!(
1011 result
1012 .warnings
1013 .iter()
1014 .any(|w| w.contains("fork") || w.contains("duplicity")),
1015 "expected a fork/duplicity warning, got {:?}",
1016 result.warnings
1017 );
1018 }
1019
1020 #[test]
1021 fn format_result_text_invalid_with_error() {
1022 let r = VerifyCommitResult::failure("abc12345".into(), "No signature found".into());
1023 let text = format_result_text(&r);
1024 assert!(text.contains("INVALID"));
1025 assert!(text.contains("No signature found"));
1026 }
1027}