use crate::ux::format::is_json_mode;
use anyhow::{Context, Result, anyhow};
use auths_infra_http::HttpOobiResolver;
use auths_keri::Event;
use auths_keri::witness::{SignedReceipt, WitnessReceiptLookup};
use auths_sdk::core_config::EnvironmentConfig;
use auths_sdk::ports::RegistryBackend;
use auths_sdk::storage::{GitRegistryBackend, GitWitnessReceiptLookup, RegistryConfig};
use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig};
use auths_verifier::{
Attestation, CommitVerdict, IdentityBundle, VerificationReport, VerifierWitnessPolicy,
WitnessGateStatus, verify_chain_with_witnesses, verify_commit_against_kel_witnessed,
};
use clap::Parser;
use serde::Serialize;
use std::fs;
use std::path::PathBuf;
use crate::subprocess::git_command;
use super::verify_helpers::parse_witness_keys;
#[derive(Parser, Debug, Clone)]
#[command(about = "Verify Git commit signatures against Auths identity.")]
pub struct VerifyCommitCommand {
#[arg(default_value = "HEAD")]
pub commit: String,
#[arg(long = "witness-signatures")]
pub witness_receipts: Option<PathBuf>,
#[arg(long = "witnesses-required", default_value = "1")]
pub witness_threshold: usize,
#[arg(long, num_args = 1..)]
pub witness_keys: Vec<String>,
#[arg(long)]
pub remote: Option<String>,
#[arg(long)]
pub oobi: Option<String>,
#[arg(long = "require-witnesses")]
pub require_witnesses: bool,
#[arg(long, value_parser)]
pub identity_bundle: Option<PathBuf>,
}
#[derive(Serialize)]
struct VerifyCommitResult {
commit: String,
valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
ssh_valid: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
chain_valid: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
chain_report: Option<VerificationReport>,
#[serde(skip_serializing_if = "Option::is_none")]
witness_quorum: Option<WitnessQuorum>,
#[serde(skip_serializing_if = "Option::is_none")]
witness_gate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
signer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
oidc_binding: Option<OidcBindingDisplay>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
warnings: Vec<String>,
}
#[derive(Serialize)]
struct OidcBindingDisplay {
issuer: String,
subject: String,
audience: String,
#[serde(skip_serializing_if = "Option::is_none")]
platform: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
normalized_claims: Option<serde_json::Map<String, serde_json::Value>>,
}
impl VerifyCommitResult {
fn failure(commit: String, error: String) -> Self {
Self {
commit,
valid: false,
ssh_valid: None,
chain_valid: None,
chain_report: None,
witness_quorum: None,
witness_gate: None,
signer: None,
oidc_binding: None,
error: Some(error),
warnings: Vec::new(),
}
}
}
#[allow(clippy::disallowed_methods)]
pub async fn handle_verify_commit(
cmd: VerifyCommitCommand,
env_config: &EnvironmentConfig,
) -> Result<()> {
let auths_home = match auths_sdk::paths::auths_home() {
Ok(h) => h,
Err(e) => return handle_error(&cmd, 2, &format!("Could not locate ~/.auths: {e}")),
};
let sdk_ctx =
match crate::factories::storage::build_auths_context(&auths_home, env_config, None) {
Ok(c) => c,
Err(e) => {
return handle_error(
&cmd,
2,
&format!("Could not build context for org-policy evaluation: {e}"),
);
}
};
let registry =
GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home));
let mut pinned_roots = super::verify_helpers::load_project_pinned_roots();
let mut bundle_kel: Option<(String, Vec<Event>)> = None;
if let Some(bundle_path) = &cmd.identity_bundle {
match load_bundle_trust(bundle_path, chrono::Utc::now()) {
Ok((root, kel)) => {
if !kel.is_empty() {
bundle_kel = Some((root.clone(), kel));
}
if !pinned_roots.contains(&root) {
pinned_roots.push(root);
}
}
Err(e) => return handle_error(&cmd, 2, &e),
}
} else if let Some(own_root) = auths_sdk::workflows::commit_trust::local_self_root(&sdk_ctx)
&& !pinned_roots.contains(&own_root)
{
pinned_roots.push(own_root);
}
let provider = auths_crypto::RingCryptoProvider;
let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home);
let commits = match resolve_commits(&cmd.commit) {
Ok(c) => c,
Err(e) => return handle_error(&cmd, 2, &e.to_string()),
};
let mut results = Vec::with_capacity(commits.len());
for commit_ref in &commits {
results.push(
verify_one_commit(
®istry,
&pinned_roots,
&provider,
&receipt_lookup,
&sdk_ctx,
&cmd,
bundle_kel.as_ref(),
commit_ref,
)
.await,
);
}
output_results(&results)
}
fn load_bundle_trust(
path: &std::path::Path,
now: chrono::DateTime<chrono::Utc>,
) -> std::result::Result<(String, Vec<Event>), String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("could not read identity bundle {path:?}: {e}"))?;
let bundle: IdentityBundle = serde_json::from_str(&content)
.map_err(|e| format!("identity bundle {path:?} is not valid JSON: {e}"))?;
let root = auths_sdk::workflows::commit_trust::trusted_root_from_bundle(&bundle, now)
.map_err(|e| e.to_string())?;
let kel: Vec<Event> = bundle
.kel
.iter()
.map(|v| serde_json::from_value(v.clone()))
.collect::<std::result::Result<_, _>>()
.map_err(|e| format!("identity bundle {path:?} carries an unparseable KEL: {e}"))?;
Ok((root, kel))
}
fn resolve_commits(commit_spec: &str) -> Result<Vec<String>> {
if commit_spec.contains("..") {
let output = git_command(&["rev-list", commit_spec])
.output()
.context("Failed to run git rev-list")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let lower = stderr.to_lowercase();
if lower.contains("unknown revision") || lower.contains("bad revision") {
return Err(anyhow!(
"{}",
format_commit_range_hint(commit_spec, stderr.trim())
));
}
return Err(anyhow!("Invalid commit range: {}", stderr.trim()));
}
let commits: Vec<String> = std::str::from_utf8(&output.stdout)
.context("Invalid UTF-8 in git output")?
.lines()
.map(|s| s.to_string())
.collect();
if commits.is_empty() {
return Err(anyhow!("No commits in specified range"));
}
Ok(commits)
} else {
let sha = resolve_commit_sha(commit_spec)?;
Ok(vec![sha])
}
}
fn format_commit_range_hint(commit_spec: &str, raw_stderr: &str) -> String {
let hint = if commit_spec.contains('~') || commit_spec.contains('^') {
"This repository may not have enough commits for that range. \
Try a smaller offset (e.g. HEAD~1..HEAD) or verify with `git log --oneline`."
} else if commit_spec.contains("..") {
"One or both refs in the range do not exist. \
Check branch/tag names with `git branch -a` or `git tag -l`."
} else {
"The commit reference could not be resolved. \
Verify it exists with `git log --oneline`."
};
format!("Failed to resolve commit range '{commit_spec}': {raw_stderr}\n\nHint: {hint}")
}
fn try_load_attestation_from_ref(commit_sha: &str) -> Option<Attestation> {
let ref_name = format!("refs/auths/commits/{}", commit_sha);
let stdout = crate::subprocess::git_silent(&["show", &ref_name])?;
serde_json::from_str(&stdout).ok()
}
fn extract_oidc_binding_display(attestation: &Attestation) -> Option<OidcBindingDisplay> {
attestation
.oidc_binding
.as_ref()
.map(|binding| OidcBindingDisplay {
issuer: binding.issuer.clone(),
subject: binding.subject.clone(),
audience: binding.audience.clone(),
platform: binding.platform.clone(),
normalized_claims: binding.normalized_claims.clone(),
})
}
async fn resolve_signer_kel(
registry: &dyn RegistryBackend,
cmd: &VerifyCommitCommand,
bundle_kel: Option<&(String, Vec<Event>)>,
did: &str,
) -> Result<Vec<Event>, String> {
if let Some((bundle_did, events)) = bundle_kel
&& bundle_did == did
{
let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
auths_sdk::keri::verify_prefix_binding(&prefix, events).map_err(|e| e.to_string())?;
return Ok(events.clone());
}
if let Some(oobi_base) = &cmd.oobi {
let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
let resolver = HttpOobiResolver::new(oobi_base.clone()).map_err(|e| e.to_string())?;
let events = resolver
.fetch_kel(&prefix)
.await
.map_err(|e| e.to_string())?;
auths_sdk::keri::verify_prefix_binding(&prefix, &events).map_err(|e| e.to_string())?;
Ok(events)
} else {
let chain = match &cmd.remote {
Some(url) => auths_sdk::keri::KelResolverChain::with_remote(registry, url.clone()),
None => auths_sdk::keri::KelResolverChain::local(registry),
};
chain.resolve_kel(did).map_err(|e| e.to_string())
}
}
#[allow(clippy::too_many_arguments)]
async fn verify_one_commit(
registry: &dyn RegistryBackend,
pinned_roots: &[String],
provider: &dyn auths_crypto::CryptoProvider,
receipt_lookup: &dyn WitnessReceiptLookup,
sdk_ctx: &auths_sdk::context::AuthsContext,
cmd: &VerifyCommitCommand,
bundle_kel: Option<&(String, Vec<Event>)>,
commit_ref: &str,
) -> VerifyCommitResult {
let sha = match resolve_commit_sha(commit_ref) {
Ok(sha) => sha,
Err(e) => {
return VerifyCommitResult::failure(
commit_ref.to_string(),
format!("Failed to resolve commit: {e}"),
);
}
};
let raw_commit = match raw_commit_object(&sha) {
Ok(c) => c,
Err(e) => return VerifyCommitResult::failure(sha, e.to_string()),
};
let (root_did, device_did) =
match auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit) {
Some(pair) => pair,
None => {
return VerifyCommitResult::failure(
sha,
"Commit carries no Auths-Id/Auths-Device trailer. The prepare-commit-msg \
hook installed by `auths init` adds these on every commit — if this repo \
sets its own core.hooksPath (e.g. husky), the hook is bypassed; run \
`auths doctor` to check. Backfill existing commits with `auths sign <ref>` \
(rewrites the commit)."
.to_string(),
);
}
};
let device_kel = match resolve_signer_kel(registry, cmd, bundle_kel, &device_did).await {
Ok(events) => events,
Err(e) => {
return VerifyCommitResult::failure(
sha,
format!("Device KEL for {device_did} could not be resolved: {e}"),
);
}
};
let root_kel = match resolve_signer_kel(registry, cmd, bundle_kel, &root_did).await {
Ok(events) => events,
Err(e) => {
return VerifyCommitResult::failure(
sha,
format!("Root KEL for {root_did} could not be resolved: {e}"),
);
}
};
let policy = if cmd.require_witnesses {
VerifierWitnessPolicy::RequireWitnesses
} else {
VerifierWitnessPolicy::Warn
};
let witnessed = verify_commit_against_kel_witnessed(
raw_commit.as_bytes(),
&device_kel,
&root_kel,
pinned_roots,
provider,
receipt_lookup,
policy,
)
.await;
let mut result = verdict_to_result(sha.clone(), witnessed.verdict);
match witnessed.witness {
WitnessGateStatus::NotRequired => {}
WitnessGateStatus::Met => result.witness_gate = Some("met".to_string()),
WitnessGateStatus::UnderQuorum {
collected,
required,
} => {
result.witness_gate = Some(format!("{collected} of {required} (under quorum)"));
result.warnings.push(format!(
"Witness quorum not met for the signer's root KEL: {collected} of {required} \
receipts (verifying anyway; pass --require-witnesses to fail closed)."
));
}
}
if let Ok(Some(quorum)) = verify_witnesses(cmd, None).await {
if quorum.verified < quorum.required {
result.valid = false;
if result.error.is_none() {
result.error = Some(format!(
"Witness quorum not met: {}/{}",
quorum.verified, quorum.required
));
}
}
result.witness_quorum = Some(quorum);
}
result.oidc_binding =
try_load_attestation_from_ref(&sha).and_then(|att| extract_oidc_binding_display(&att));
if result.valid {
let now = chrono::Utc::now();
match auths_sdk::workflows::commit_trust::evaluate_commit_policy(
sdk_ctx,
&root_did,
&device_did,
now,
) {
Ok(auths_sdk::workflows::commit_trust::PolicyOutcome::Evaluated(decision))
if !decision.is_allowed() =>
{
result.valid = false;
result.chain_valid = Some(false);
result.error = Some(format!(
"Org policy denied this commit: {} [{}]",
decision.message, decision.reason
));
}
Ok(_) => {}
Err(e) => {
result.valid = false;
result.error = Some(format!("Org policy could not be evaluated: {e}"));
}
}
}
result
}
fn raw_commit_object(sha: &str) -> Result<String> {
let output = git_command(&["cat-file", "commit", sha])
.output()
.context("Failed to run git cat-file")?;
if !output.status.success() {
return Err(anyhow!(
"git cat-file commit {sha} failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
String::from_utf8(output.stdout).context("Commit object is not valid UTF-8")
}
fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResult {
let mut result = VerifyCommitResult::failure(commit, String::new());
match verdict {
CommitVerdict::Valid {
signer_did,
root_did,
duplicitous_root,
} => {
result.valid = true;
result.ssh_valid = Some(true);
result.signer = Some(signer_did);
result.error = None;
if duplicitous_root {
result.warnings.push(format!(
"Root {root_did} shows KEL duplicity (a fork) — trusting the first event \
seen. Resolve with `auths device remove`."
));
}
}
CommitVerdict::Unsigned => {
result.error = Some("No signature found".to_string());
}
CommitVerdict::GpgUnsupported => {
result.error = Some("GPG signatures not supported, use SSH signing".to_string());
}
CommitVerdict::SshSignatureInvalid => {
result.ssh_valid = Some(false);
result.error = Some(
"SSH signature is invalid (commit tampered, wrong namespace, or bad signature)"
.to_string(),
);
}
CommitVerdict::DeviceKelInvalid(why) => {
result.error = Some(format!("Device KEL failed to replay: {why}"));
}
CommitVerdict::RootKelInvalid(why) => {
result.error = Some(format!("Root KEL failed to replay: {why}"));
}
CommitVerdict::RootNotPinned(root) => {
result.error = Some(format!(
"Root {root} is not a pinned trusted root. Pin it in .auths/roots to trust \
commits delegated under it."
));
}
CommitVerdict::RootAbandoned => {
result.error =
Some("Root identity is abandoned (its KEL was rotated to a null key)".to_string());
}
CommitVerdict::NotDelegatedByClaimedRoot {
device_did,
root_did,
} => {
result.error = Some(format!(
"Device {device_did} is not delegated by the claimed root {root_did}"
));
}
CommitVerdict::DelegationSealNotFound => {
result.error = Some(
"Root never anchored this device's delegated inception (no delegation seal)"
.to_string(),
);
}
CommitVerdict::DeviceRevoked => {
result.error = Some("Device delegation has been revoked by the root".to_string());
}
CommitVerdict::SignedAfterRevocation {
signed_at,
revoked_at,
..
} => {
result.error = Some(format!(
"Commit was signed at/after the delegator revoked it (signed at KEL position {signed_at}, revoked at {revoked_at})"
));
}
CommitVerdict::OutsideAgentScope { capability, .. } => {
result.error = Some(format!(
"Agent signed exercising capability '{capability}', outside its delegator-anchored scope"
));
}
CommitVerdict::AgentExpired {
expired_at,
signed_at,
..
} => {
result.error = Some(format!(
"Agent delegation expired (expired at {expired_at}, signed at {signed_at})"
));
}
CommitVerdict::SignerKeyMismatch => {
result.ssh_valid = Some(false);
result.error = Some("Signing key is not the device's current key".to_string());
}
CommitVerdict::SignedBySupersededKey => {
result.ssh_valid = Some(false);
result.error = Some(
"Commit was signed by a superseded device key (the device has since rotated)"
.to_string(),
);
}
CommitVerdict::WitnessQuorumNotMet {
root_did,
collected,
required,
} => {
result.error = Some(format!(
"Witness quorum not met for root {root_did}: {collected} of {required} required \
receipts. Drop --require-witnesses to verify with a warning instead."
));
}
}
result
}
async fn verify_witnesses(
cmd: &VerifyCommitCommand,
bundle: Option<&IdentityBundle>,
) -> Result<Option<WitnessQuorum>> {
let receipts_path = match cmd.witness_receipts {
Some(ref p) => p,
None => return Ok(None),
};
let receipts_bytes = fs::read(receipts_path)
.with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
let receipts: Vec<SignedReceipt> =
serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
let witness_keys = parse_witness_keys(&cmd.witness_keys)?;
let config = WitnessVerifyConfig {
receipts: &receipts,
witness_keys: &witness_keys,
threshold: cmd.witness_threshold,
};
if let Some(bundle) = bundle
&& !bundle.attestation_chain.is_empty()
{
let root_pk_bytes = hex::decode(bundle.public_key_hex.as_str())
.context("Invalid public key hex in bundle")?;
let root_pk = auths_verifier::DevicePublicKey::try_new(bundle.curve, &root_pk_bytes)
.map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
let report = verify_chain_with_witnesses(&bundle.attestation_chain, &root_pk, &config)
.await
.context("Witness chain verification failed")?;
return Ok(report.witness_quorum);
}
let provider = auths_crypto::RingCryptoProvider;
let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await;
Ok(Some(quorum))
}
fn output_results(results: &[VerifyCommitResult]) -> Result<()> {
let all_valid = results.iter().all(|r| r.valid);
if is_json_mode() {
if results.len() == 1 {
println!("{}", serde_json::to_string(&results[0])?);
} else {
println!("{}", serde_json::to_string(&results)?);
}
} else if results.len() == 1 {
let r = &results[0];
if r.valid {
if let Some(ref signer) = r.signer {
print!("Commit {} verified: signed by {}", r.commit, signer);
} else {
print!("Commit {} verified", r.commit);
}
print_chain_witness_summary(r);
println!();
} else {
eprint!("Verification failed for {}", r.commit);
if let Some(ref error) = r.error {
eprint!(": {}", error);
}
print_chain_witness_summary_stderr(r);
eprintln!();
}
for w in &r.warnings {
eprintln!("Warning: {}", w);
}
} else {
for r in results {
print!(
"{}: {}",
&r.commit[..8.min(r.commit.len())],
format_result_text(r)
);
println!();
}
}
if all_valid {
Ok(())
} else {
std::process::exit(1);
}
}
fn format_result_text(result: &VerifyCommitResult) -> String {
let status = if result.valid { "valid" } else { "INVALID" };
let mut parts = vec![status.to_string()];
if let Some(ref signer) = result.signer {
parts.push(format!("signer: {}", signer));
}
if let Some(cv) = result.chain_valid {
let chain_desc = if cv {
"chain: valid".to_string()
} else if let Some(ref report) = result.chain_report {
format!("chain: {}", format_chain_status(&report.status))
} else {
"chain: invalid".to_string()
};
parts.push(chain_desc);
}
if let Some(ref q) = result.witness_quorum {
parts.push(format!("witnesses: {}/{}", q.verified, q.required));
}
if let Some(ref gate) = result.witness_gate {
parts.push(format!("witness-gate: {gate}"));
}
if let Some(ref binding) = result.oidc_binding {
parts.push(format!("oidc: {}", binding.issuer));
}
if let Some(ref error) = result.error
&& result.signer.is_none()
&& result.chain_valid.is_none()
&& result.witness_quorum.is_none()
{
parts.push(error.clone());
}
if parts.len() == 1 {
parts[0].clone()
} else {
format!("{} ({})", parts[0], parts[1..].join(", "))
}
}
fn format_chain_status(status: &auths_verifier::VerificationStatus) -> String {
match status {
auths_verifier::VerificationStatus::Valid => "valid".to_string(),
auths_verifier::VerificationStatus::Expired { at } => {
format!("expired at {}", at.to_rfc3339())
}
auths_verifier::VerificationStatus::Revoked { at } => match at {
Some(t) => format!("revoked at {}", t.to_rfc3339()),
None => "revoked".to_string(),
},
auths_verifier::VerificationStatus::InvalidSignature { step } => {
format!("invalid signature at step {}", step)
}
auths_verifier::VerificationStatus::BrokenChain { missing_link } => {
format!("broken chain: {}", missing_link)
}
auths_verifier::VerificationStatus::InsufficientWitnesses { required, verified } => {
format!("witnesses: {}/{} quorum not met", verified, required)
}
}
}
fn print_chain_witness_summary(r: &VerifyCommitResult) {
let mut parts = Vec::new();
if let Some(cv) = r.chain_valid {
if cv {
parts.push("chain: valid".to_string());
} else {
parts.push("chain: invalid".to_string());
}
}
if let Some(ref q) = r.witness_quorum {
parts.push(format!("witnesses: {}/{}", q.verified, q.required));
}
if let Some(ref gate) = r.witness_gate {
parts.push(format!("witness-gate: {gate}"));
}
if let Some(ref binding) = r.oidc_binding {
parts.push(format!("oidc: {} ({})", binding.issuer, binding.subject));
}
if !parts.is_empty() {
print!(" ({})", parts.join(", "));
}
}
fn print_chain_witness_summary_stderr(r: &VerifyCommitResult) {
if let Some(cv) = r.chain_valid
&& !cv
&& let Some(ref report) = r.chain_report
{
eprint!(" (chain: {})", format_chain_status(&report.status));
}
if let Some(ref q) = r.witness_quorum
&& q.verified < q.required
{
eprint!(" (witnesses: {}/{} quorum not met)", q.verified, q.required);
}
}
fn resolve_commit_sha(commit_ref: &str) -> Result<String> {
super::git_helpers::resolve_commit_sha(commit_ref)
}
fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> {
if is_json_mode() {
let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string());
println!("{}", serde_json::to_string(&result)?);
} else {
eprintln!("Error: {}", message);
}
std::process::exit(exit_code);
}
impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand {
fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(handle_verify_commit(self.clone(), &ctx.env_config))
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
#[test]
fn verify_commit_result_failure_helper() {
let r = VerifyCommitResult::failure("abc123".into(), "bad sig".into());
assert!(!r.valid);
assert_eq!(r.commit, "abc123");
assert_eq!(r.error.as_deref(), Some("bad sig"));
assert!(r.ssh_valid.is_none());
assert!(r.chain_valid.is_none());
assert!(r.witness_quorum.is_none());
}
#[test]
fn verify_commit_result_json_includes_new_fields() {
let r = VerifyCommitResult {
commit: "abc123".into(),
valid: true,
ssh_valid: Some(true),
chain_valid: Some(true),
chain_report: None,
witness_quorum: Some(WitnessQuorum {
required: 2,
verified: 2,
receipts: vec![],
}),
witness_gate: Some("met".into()),
signer: Some("did:keri:test".into()),
oidc_binding: None,
error: None,
warnings: vec!["expiring soon".into()],
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"ssh_valid\":true"));
assert!(json.contains("\"chain_valid\":true"));
assert!(json.contains("\"witness_quorum\""));
assert!(json.contains("\"warnings\":[\"expiring soon\"]"));
}
#[test]
fn verify_commit_result_json_omits_none_fields() {
let r = VerifyCommitResult::failure("abc".into(), "err".into());
let json = serde_json::to_string(&r).unwrap();
assert!(!json.contains("ssh_valid"));
assert!(!json.contains("chain_valid"));
assert!(!json.contains("chain_report"));
assert!(!json.contains("witness_quorum"));
assert!(!json.contains("warnings"));
}
#[test]
fn format_result_text_valid_ssh_only() {
let r = VerifyCommitResult {
commit: "abc12345".into(),
valid: true,
ssh_valid: Some(true),
chain_valid: None,
chain_report: None,
witness_quorum: None,
witness_gate: None,
signer: Some("did:keri:test".into()),
oidc_binding: None,
error: None,
warnings: vec![],
};
let text = format_result_text(&r);
assert!(text.contains("valid"));
assert!(text.contains("signer: did:keri:test"));
}
#[test]
fn format_result_text_valid_with_chain_and_witnesses() {
let r = VerifyCommitResult {
commit: "abc12345".into(),
valid: true,
ssh_valid: Some(true),
chain_valid: Some(true),
chain_report: Some(VerificationReport::valid(vec![])),
witness_quorum: Some(WitnessQuorum {
required: 2,
verified: 2,
receipts: vec![],
}),
witness_gate: Some("met".into()),
signer: Some("did:keri:test".into()),
oidc_binding: None,
error: None,
warnings: vec![],
};
let text = format_result_text(&r);
assert!(text.contains("chain: valid"));
assert!(text.contains("witnesses: 2/2"));
assert!(text.contains("witness-gate: met"));
}
#[test]
fn verify_output_shows_quorum() {
let mut r = VerifyCommitResult::failure("abc".into(), String::new());
r.valid = true;
r.error = None;
r.witness_gate = Some("2 of 3 (under quorum)".into());
let text = format_result_text(&r);
assert!(text.contains("witness-gate: 2 of 3 (under quorum)"));
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"witness_gate\":\"2 of 3 (under quorum)\""));
}
#[test]
fn verify_output_flags_fork() {
let result = verdict_to_result(
"sha".into(),
CommitVerdict::Valid {
signer_did: "did:keri:dev".into(),
root_did: "did:keri:root".into(),
duplicitous_root: true,
},
);
assert!(result.valid);
assert!(
result
.warnings
.iter()
.any(|w| w.contains("fork") || w.contains("duplicity")),
"expected a fork/duplicity warning, got {:?}",
result.warnings
);
}
#[test]
fn format_result_text_invalid_with_error() {
let r = VerifyCommitResult::failure("abc12345".into(), "No signature found".into());
let text = format_result_text(&r);
assert!(text.contains("INVALID"));
assert!(text.contains("No signature found"));
}
}