1pub mod pgp_exempt;
32
33use std::collections::{BTreeMap, BTreeSet, HashMap};
34use std::path::{Path, PathBuf};
35use std::process::Command;
36use std::sync::Arc;
37
38use anyhow::{Context, Result, bail};
39use serde::Serialize;
40use ssh_key::{SshSig, public::KeyData};
41use trql_client::{HttpsTransport, HttpsTransportConfig, TrqlClient, TrqlError, TrqpQuery};
42use vgi_core::{
43 GIT_SSHSIG_NAMESPACE, ed25519_keys_from_doc, normalize_sshsig_armor, split_signed_commit,
44};
45use vta_sdk::display_name::{DisplayName, NameBook, NameSource};
46
47use crate::pgp_exempt::ExemptKeyring;
48
49#[derive(Debug, Clone)]
51pub struct VerifyTrustArgs {
52 pub repo_dir: PathBuf,
54 pub range: String,
56 pub signers_file: PathBuf,
58 pub registry_url: String,
60 pub registry_did: String,
62 pub authority_did: String,
64 pub action: String,
66 pub resource: String,
68 pub fallback_resource: Option<String>,
74 pub exempt_keyring: Option<PathBuf>,
78 pub resolve_agent_names: bool,
87 pub json: bool,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize)]
93#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
94pub enum CommitStatus {
95 Unsigned,
97 Malformed(String),
99 UnknownKey { fingerprint: String },
101 BadSignature { signer_did: String },
103 Unauthorized { signer_did: String },
105 RegistryUnavailable { signer_did: String, error: String },
108 PgpRejected { detail: String },
111 Exempt { fingerprint: String },
114 Trusted {
118 signer_did: String,
119 resource: String,
120 },
121}
122
123impl CommitStatus {
124 pub fn is_trusted(&self) -> bool {
126 matches!(self, Self::Trusted { .. })
127 }
128
129 pub fn passes(&self) -> bool {
131 matches!(self, Self::Trusted { .. } | Self::Exempt { .. })
132 }
133}
134
135#[derive(Debug, Clone, Serialize)]
137#[serde(rename_all = "camelCase")]
138pub struct CommitVerdict {
139 pub sha: String,
140 #[serde(flatten)]
141 pub status: CommitStatus,
142}
143
144#[derive(Debug, Serialize)]
146#[serde(rename_all = "camelCase")]
147pub struct TrustReport {
148 pub ok: bool,
149 pub commits: Vec<CommitVerdict>,
150 pub unresolved_signers: BTreeMap<String, String>,
153 pub signer_names: BTreeMap<String, DisplayName>,
157}
158
159#[derive(Debug, Default)]
165pub struct ResolvedSigners {
166 pub keys: HashMap<[u8; 32], String>,
168 pub unresolved: BTreeMap<String, String>,
170 pub names: NameBook,
172}
173
174impl ResolvedSigners {
175 #[must_use]
178 pub fn from_keys(keys: HashMap<[u8; 32], String>) -> Self {
179 Self {
180 keys,
181 ..Self::default()
182 }
183 }
184}
185
186pub async fn handle_verify_trust(args: VerifyTrustArgs) -> Result<i32> {
189 let signer_dids = load_signers(&args.repo_dir, &args.signers_file)?;
190 let exempt = load_exempt_keyring(&args)?;
191 let signers = resolve_signer_keys(&signer_dids, args.resolve_agent_names).await?;
192 let report = verify_with_keys(&args, &signers, exempt.as_ref()).await?;
193 print_report(&args, &report)?;
194 Ok(if report.ok { 0 } else { 1 })
195}
196
197pub async fn verify_with_keys(
200 args: &VerifyTrustArgs,
201 signers: &ResolvedSigners,
202 exempt: Option<&ExemptKeyring>,
203) -> Result<TrustReport> {
204 let shas = list_commits(&args.repo_dir, &args.range)?;
205
206 let mut checked = Vec::with_capacity(shas.len());
208 let mut signer_dids = BTreeSet::new();
209 for sha in shas {
210 let raw = read_commit_raw(&args.repo_dir, &sha)?;
211 let signature = check_commit_signature(&raw, &signers.keys, exempt);
212 if let SignatureCheck::Valid { signer_did } = &signature {
213 signer_dids.insert(signer_did.clone());
214 }
215 checked.push((sha, signature));
216 }
217
218 let decisions = query_registry(args, &signer_dids).await?;
220
221 let commits: Vec<CommitVerdict> = checked
222 .into_iter()
223 .map(|(sha, signature)| CommitVerdict {
224 sha,
225 status: status_of(signature, &decisions),
226 })
227 .collect();
228
229 let signer_names = signer_dids
232 .iter()
233 .filter_map(|did| {
234 signers
235 .names
236 .get(did)
237 .map(|name| (did.clone(), name.clone()))
238 })
239 .collect();
240
241 let ok = commits.iter().all(|c| c.status.passes());
243 Ok(TrustReport {
244 ok,
245 commits,
246 unresolved_signers: signers.unresolved.clone(),
247 signer_names,
248 })
249}
250
251#[derive(Debug, Clone, PartialEq)]
255pub enum SignatureCheck {
256 Unsigned,
257 Malformed(String),
258 UnknownKey { fingerprint: String },
259 BadSignature { signer_did: String },
260 PgpRejected { detail: String },
261 Exempt { fingerprint: String },
262 Valid { signer_did: String },
263}
264
265pub fn check_commit_signature(
267 raw: &[u8],
268 signer_keys: &HashMap<[u8; 32], String>,
269 exempt: Option<&ExemptKeyring>,
270) -> SignatureCheck {
271 let (payload, pem) = match split_signed_commit(raw) {
272 Ok(Some(parts)) => parts,
273 Ok(None) => return SignatureCheck::Unsigned,
274 Err(e) => return SignatureCheck::Malformed(e.to_string()),
275 };
276 if pem.starts_with("-----BEGIN PGP SIGNATURE-----") {
279 let Some(keyring) = exempt else {
280 return SignatureCheck::PgpRejected {
281 detail: "PGP-signed commit, but no exempt keyring is configured".to_string(),
282 };
283 };
284 return match keyring.verify(&pem, &payload) {
285 Ok(fingerprint) => SignatureCheck::Exempt { fingerprint },
286 Err(detail) => SignatureCheck::PgpRejected { detail },
287 };
288 }
289 let sig = match SshSig::from_pem(normalize_sshsig_armor(&pem).as_bytes()) {
290 Ok(sig) => sig,
291 Err(e) => return SignatureCheck::Malformed(format!("sshsig did not parse: {e}")),
292 };
293 let KeyData::Ed25519(embedded) = sig.public_key() else {
294 return SignatureCheck::Malformed(format!(
295 "unsupported signature algorithm: {}",
296 sig.algorithm()
297 ));
298 };
299 let key_bytes: [u8; 32] = embedded.0;
300 let Some(signer_did) = signer_keys.get(&key_bytes) else {
301 return SignatureCheck::UnknownKey {
302 fingerprint: hex::encode(key_bytes),
303 };
304 };
305 let public_key = ssh_key::PublicKey::from(sig.public_key().clone());
306 match public_key.verify(GIT_SSHSIG_NAMESPACE, &payload, &sig) {
307 Ok(()) => SignatureCheck::Valid {
308 signer_did: signer_did.clone(),
309 },
310 Err(_) => SignatureCheck::BadSignature {
311 signer_did: signer_did.clone(),
312 },
313 }
314}
315
316fn load_exempt_keyring(args: &VerifyTrustArgs) -> Result<Option<ExemptKeyring>> {
318 let Some(path) = &args.exempt_keyring else {
319 return Ok(None);
320 };
321 let path = if path.is_absolute() {
322 path.clone()
323 } else {
324 args.repo_dir.join(path)
325 };
326 Ok(Some(ExemptKeyring::load(&path)?))
327}
328
329pub fn load_signers(repo_dir: &Path, signers_file: &Path) -> Result<Vec<String>> {
335 let path = if signers_file.is_absolute() {
336 signers_file.to_path_buf()
337 } else {
338 repo_dir.join(signers_file)
339 };
340 let text = std::fs::read_to_string(&path)
341 .with_context(|| format!("cannot read signer index {}", path.display()))?;
342 let dids = parse_signers(&text)?;
343 if dids.is_empty() {
344 bail!("signer index {} declares no DIDs", path.display());
345 }
346 Ok(dids)
347}
348
349pub fn parse_signers(text: &str) -> Result<Vec<String>> {
352 let mut dids = Vec::new();
353 for (number, line) in text.lines().enumerate() {
354 let entry = line.trim();
355 if entry.is_empty() || entry.starts_with('#') {
356 continue;
357 }
358 if !entry.starts_with("did:") {
359 bail!("signer index line {}: not a DID: {entry}", number + 1);
360 }
361 dids.push(entry.to_string());
362 }
363 Ok(dids)
364}
365
366pub async fn resolve_signer_keys(
376 dids: &[String],
377 resolve_agent_names: bool,
378) -> Result<ResolvedSigners> {
379 use affinidi_tdk::TDK;
380 use affinidi_tdk::common::config::TDKConfig;
381 use affinidi_tdk::did_resolver::config::DIDCacheConfigBuilder;
382
383 let tdk = TDK::new(
387 TDKConfig::builder()
388 .with_load_environment(false)
389 .with_did_resolver_config(
390 DIDCacheConfigBuilder::default()
391 .with_resolve_shortcuts(resolve_agent_names)
392 .build(),
393 )
394 .build()
395 .context("TDK config")?,
396 None,
397 )
398 .await
399 .context("TDK init")?;
400
401 let mut signers = ResolvedSigners::default();
402 for did in dids {
403 match tdk.did_resolver().resolve(did).await {
404 Ok(response) => {
405 let name = signer_display_name(
409 response.shortcut.as_ref().map(|s| s.label()),
410 &vta_sdk::display_name::agent_name::claimed_names(&response.doc),
411 );
412 if let Some(name) = name {
413 signers.names.insert(did.clone(), name);
414 }
415
416 let doc = serde_json::to_value(&response.doc)
417 .with_context(|| format!("DID document for {did} did not serialize"))?;
418 let published = ed25519_keys_from_doc(&doc);
419 if published.is_empty() {
420 signers.unresolved.insert(
421 did.clone(),
422 "DID document publishes no Ed25519 verification keys".to_string(),
423 );
424 }
425 for key in published {
426 signers.keys.insert(key, did.clone());
427 }
428 }
429 Err(e) => {
430 signers
431 .unresolved
432 .insert(did.clone(), format!("resolution failed: {e}"));
433 }
434 }
435 }
436 Ok(signers)
437}
438
439fn signer_display_name(verified: Option<&str>, claimed: &[String]) -> Option<DisplayName> {
450 if let Some(name) = verified {
451 return Some(DisplayName::new(
452 name,
453 NameSource::AgentName { verified: true },
454 ));
455 }
456 claimed
457 .first()
458 .map(|name| DisplayName::new(name, NameSource::AgentName { verified: false }))
459}
460
461type RegistryDecisions = BTreeMap<String, Result<Option<String>, String>>;
467
468async fn query_registry(
470 args: &VerifyTrustArgs,
471 signer_dids: &BTreeSet<String>,
472) -> Result<RegistryDecisions> {
473 let mut decisions = RegistryDecisions::new();
474 if signer_dids.is_empty() {
475 return Ok(decisions);
476 }
477 let transport = HttpsTransport::new(HttpsTransportConfig::new(&args.registry_url))?;
478 let client = TrqlClient::new(Arc::new(transport), &args.registry_did);
479 let mut resources = vec![args.resource.clone()];
481 if let Some(fallback) = &args.fallback_resource
482 && fallback != &args.resource
483 {
484 resources.push(fallback.clone());
485 }
486 for did in signer_dids {
487 let mut decision: Result<Option<String>, String> = Ok(None);
488 for resource in &resources {
489 let query = TrqpQuery::new(did, &args.authority_did, &args.action, resource);
490 match client.authorization(query).await {
491 Ok(response) if response.authorized => {
492 decision = Ok(Some(resource.clone()));
493 break;
494 }
495 Ok(_) => {}
496 Err(e @ TrqlError::Rejected { .. }) => {
497 tracing::debug!("registry rejected the query for {did}: {e}");
501 }
502 Err(e) => {
503 decision = Err(e.to_string());
506 break;
507 }
508 }
509 }
510 decisions.insert(did.clone(), decision);
511 }
512 Ok(decisions)
513}
514
515fn status_of(signature: SignatureCheck, decisions: &RegistryDecisions) -> CommitStatus {
517 match signature {
518 SignatureCheck::Unsigned => CommitStatus::Unsigned,
519 SignatureCheck::Malformed(detail) => CommitStatus::Malformed(detail),
520 SignatureCheck::UnknownKey { fingerprint } => CommitStatus::UnknownKey { fingerprint },
521 SignatureCheck::BadSignature { signer_did } => CommitStatus::BadSignature { signer_did },
522 SignatureCheck::PgpRejected { detail } => CommitStatus::PgpRejected { detail },
523 SignatureCheck::Exempt { fingerprint } => CommitStatus::Exempt { fingerprint },
524 SignatureCheck::Valid { signer_did } => match decisions.get(&signer_did) {
525 Some(Ok(Some(resource))) => CommitStatus::Trusted {
526 signer_did,
527 resource: resource.clone(),
528 },
529 Some(Ok(None)) => CommitStatus::Unauthorized { signer_did },
530 Some(Err(error)) => CommitStatus::RegistryUnavailable {
531 signer_did,
532 error: error.clone(),
533 },
534 None => CommitStatus::RegistryUnavailable {
535 signer_did,
536 error: "no registry decision recorded".to_string(),
537 },
538 },
539 }
540}
541
542pub fn list_commits(repo_dir: &Path, range: &str) -> Result<Vec<String>> {
546 let output = git(repo_dir, &["rev-list", "--reverse", range])?;
547 Ok(output.lines().map(str::to_string).collect())
548}
549
550pub fn read_commit_raw(repo_dir: &Path, sha: &str) -> Result<Vec<u8>> {
552 let output = Command::new("git")
553 .arg("-C")
554 .arg(repo_dir)
555 .args(["cat-file", "commit", sha])
556 .output()
557 .context("running git cat-file")?;
558 if !output.status.success() {
559 bail!(
560 "git cat-file commit {sha} failed: {}",
561 String::from_utf8_lossy(&output.stderr)
562 );
563 }
564 Ok(output.stdout)
565}
566
567fn git(repo_dir: &Path, args: &[&str]) -> Result<String> {
568 let output = Command::new("git")
569 .arg("-C")
570 .arg(repo_dir)
571 .args(args)
572 .output()
573 .with_context(|| format!("running git {}", args.join(" ")))?;
574 if !output.status.success() {
575 bail!(
576 "git {} failed: {}",
577 args.join(" "),
578 String::from_utf8_lossy(&output.stderr)
579 );
580 }
581 Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
582}
583
584fn print_report(args: &VerifyTrustArgs, report: &TrustReport) -> Result<()> {
587 if args.json {
588 println!("{}", serde_json::to_string_pretty(report)?);
589 return Ok(());
590 }
591
592 let signer = |did: &str| render_signer(report, did);
596
597 for commit in &report.commits {
598 let short = &commit.sha[..commit.sha.len().min(12)];
599 match &commit.status {
600 CommitStatus::Trusted {
601 signer_did,
602 resource,
603 } => {
604 println!(
605 "TRUSTED {short} {} (via {resource})",
606 signer(signer_did)
607 );
608 }
609 CommitStatus::Exempt { fingerprint } => {
610 println!("EXEMPT {short} PGP-signed by exempt platform key {fingerprint}");
611 }
612 CommitStatus::PgpRejected { detail } => {
613 println!("PGP-REJECTED {short} {detail}");
614 }
615 CommitStatus::Unauthorized { signer_did } => {
616 println!(
617 "UNAUTHORIZED {short} {} is not authorized by the registry",
618 signer(signer_did)
619 );
620 }
621 CommitStatus::RegistryUnavailable { signer_did, error } => {
622 println!(
623 "UNAVAILABLE {short} signed by {}; registry check failed: {error}",
624 signer(signer_did)
625 );
626 }
627 CommitStatus::BadSignature { signer_did } => {
628 println!(
629 "BAD-SIG {short} signature by {} does not verify",
630 signer(signer_did)
631 );
632 }
633 CommitStatus::UnknownKey { fingerprint } => {
634 println!(
635 "UNKNOWN-KEY {short} key {fingerprint} is published by no declared signer"
636 );
637 }
638 CommitStatus::Malformed(detail) => {
639 println!("MALFORMED {short} {detail}");
640 }
641 CommitStatus::Unsigned => {
642 println!("UNSIGNED {short} commit carries no signature");
643 }
644 }
645 }
646 for (did, reason) in &report.unresolved_signers {
647 println!("WARNING declared signer {did}: {reason}");
648 }
649
650 print_signer_block(args, report);
651
652 let passing = report.commits.iter().filter(|c| c.status.passes()).count();
653 println!(
654 "{}: {passing}/{} commits pass",
655 if report.ok { "PASS" } else { "FAIL" },
656 report.commits.len()
657 );
658 Ok(())
659}
660
661fn render_signer(report: &TrustReport, did: &str) -> String {
665 match report.signer_names.get(did) {
666 Some(name) if name.is_trusted() => {
667 format!(
668 "{} ({})",
669 name.name,
670 vta_sdk::display_name::shorten_did(did)
671 )
672 }
673 Some(name) => format!(
674 "{}{} ({})",
675 name.name,
676 vta_sdk::display_name::UNVERIFIED_SUFFIX,
677 vta_sdk::display_name::shorten_did(did)
678 ),
679 None => vta_sdk::display_name::shorten_did(did),
680 }
681}
682
683fn print_signer_block(args: &VerifyTrustArgs, report: &TrustReport) {
688 if report.signer_names.is_empty() {
689 return;
690 }
691 println!();
692 println!("Signers:");
693 for (did, name) in &report.signer_names {
694 let tag = if name.is_trusted() {
695 String::new()
696 } else {
697 format!(" {}", vta_sdk::display_name::UNVERIFIED_SUFFIX.trim())
698 };
699 println!(" {}{tag}", name.name);
700 println!(" {did}");
701 }
702 if !args.resolve_agent_names && report.signer_names.values().any(|n| !n.is_trusted()) {
703 println!();
704 println!(
705 " Names above are claimed by the DID and were not checked. Pass \
706 --resolve-agent-names to resolve each claim back to its DID."
707 );
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 #![allow(clippy::unwrap_used, clippy::expect_used)]
714
715 use super::*;
716 use ed25519_dalek::SigningKey;
717 use vgi_core::create_ssh_signature;
718
719 fn test_key() -> (SigningKey, [u8; 32]) {
720 let signing = SigningKey::from_bytes(&[7u8; 32]);
721 let public = signing.verifying_key().to_bytes();
722 (signing, public)
723 }
724
725 fn unsigned_commit() -> String {
726 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
727 author A U Thor <a@example.com> 1700000000 +0000\n\
728 committer A U Thor <a@example.com> 1700000000 +0000\n\
729 \n\
730 a message\n"
731 .to_string()
732 }
733
734 fn signed_commit(payload: &str, armored: &str) -> String {
737 let (headers, body) = payload.split_once("\n\n").unwrap();
738 let mut sig_header = String::from("gpgsig ");
739 let mut lines = armored.trim_end().split('\n');
740 sig_header.push_str(lines.next().unwrap());
741 for line in lines {
742 sig_header.push('\n');
743 sig_header.push(' ');
744 sig_header.push_str(line);
745 }
746 format!("{headers}\n{sig_header}\n\n{body}")
747 }
748
749 fn sign_commit(payload: &str, key: &SigningKey) -> String {
750 let armored = create_ssh_signature(
751 key,
752 &key.verifying_key(),
753 GIT_SSHSIG_NAMESPACE,
754 payload.as_bytes(),
755 )
756 .unwrap();
757 signed_commit(payload, &armored)
758 }
759
760 #[test]
761 fn split_returns_none_for_unsigned_commit() {
762 assert!(
763 split_signed_commit(unsigned_commit().as_bytes())
764 .unwrap()
765 .is_none()
766 );
767 }
768
769 #[test]
770 fn split_recovers_exact_payload_and_signature() {
771 let payload = unsigned_commit();
772 let (key, _) = test_key();
773 let commit = sign_commit(&payload, &key);
774
775 let (recovered_payload, pem) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
776 assert_eq!(recovered_payload, payload.as_bytes());
777 assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
778 assert!(pem.trim_end().ends_with("-----END SSH SIGNATURE-----"));
779 }
780
781 #[test]
782 fn our_encoder_and_the_decoder_agree() {
783 let payload = unsigned_commit();
786 let (key, public) = test_key();
787 let commit = sign_commit(&payload, &key);
788 let keys = HashMap::from([(public, "did:example:signer".to_string())]);
789
790 let check = check_commit_signature(commit.as_bytes(), &keys, None);
791 assert_eq!(
792 check,
793 SignatureCheck::Valid {
794 signer_did: "did:example:signer".to_string()
795 }
796 );
797 }
798
799 #[test]
800 fn legacy_76_column_armor_still_verifies() {
801 let payload = unsigned_commit();
804 let (key, public) = test_key();
805 let armored = create_ssh_signature(
806 &key,
807 &key.verifying_key(),
808 GIT_SSHSIG_NAMESPACE,
809 payload.as_bytes(),
810 )
811 .unwrap();
812 let body: String = armored
813 .lines()
814 .filter(|l| !l.starts_with("-----"))
815 .collect();
816 let mut legacy = String::from("-----BEGIN SSH SIGNATURE-----\n");
817 for chunk in body.as_bytes().chunks(76) {
818 legacy.push_str(std::str::from_utf8(chunk).unwrap());
819 legacy.push('\n');
820 }
821 legacy.push_str("-----END SSH SIGNATURE-----\n");
822
823 let commit = signed_commit(&payload, &legacy);
824 let keys = HashMap::from([(public, "did:example:signer".to_string())]);
825 assert_eq!(
826 check_commit_signature(commit.as_bytes(), &keys, None),
827 SignatureCheck::Valid {
828 signer_did: "did:example:signer".to_string()
829 }
830 );
831 }
832
833 #[test]
834 fn unknown_key_is_reported_with_fingerprint() {
835 let payload = unsigned_commit();
836 let (key, _) = test_key();
837 let commit = sign_commit(&payload, &key);
838
839 let check = check_commit_signature(commit.as_bytes(), &HashMap::new(), None);
840 assert!(matches!(check, SignatureCheck::UnknownKey { .. }));
841 }
842
843 #[test]
844 fn tampered_payload_is_a_bad_signature() {
845 let payload = unsigned_commit();
846 let (key, public) = test_key();
847 let commit = sign_commit(&payload, &key).replace("a message", "b message");
848 let keys = HashMap::from([(public, "did:example:signer".to_string())]);
849
850 let check = check_commit_signature(commit.as_bytes(), &keys, None);
851 assert_eq!(
852 check,
853 SignatureCheck::BadSignature {
854 signer_did: "did:example:signer".to_string()
855 }
856 );
857 }
858
859 #[test]
860 fn unsigned_commit_is_unsigned() {
861 let check = check_commit_signature(unsigned_commit().as_bytes(), &HashMap::new(), None);
862 assert_eq!(check, SignatureCheck::Unsigned);
863 }
864
865 #[test]
866 fn signers_index_parses_and_rejects_non_dids() {
867 let parsed =
868 parse_signers("# team\n did:webvh:abc:example.com \n\ndid:webvh:def:example.com\n")
869 .unwrap();
870 assert_eq!(parsed.len(), 2);
871 assert!(parse_signers("not-a-did\n").is_err());
872 }
873
874 #[test]
875 fn statuses_compose_signature_and_registry_decisions() {
876 let did = "did:example:signer".to_string();
877 let mut decisions = RegistryDecisions::new();
878 decisions.insert(did.clone(), Ok(Some("example/repo".to_string())));
879 assert!(
880 status_of(
881 SignatureCheck::Valid {
882 signer_did: did.clone()
883 },
884 &decisions
885 )
886 .is_trusted()
887 );
888
889 decisions.insert(did.clone(), Ok(None));
890 assert_eq!(
891 status_of(
892 SignatureCheck::Valid {
893 signer_did: did.clone()
894 },
895 &decisions
896 ),
897 CommitStatus::Unauthorized {
898 signer_did: did.clone()
899 }
900 );
901
902 decisions.insert(did.clone(), Err("connect refused".to_string()));
903 assert!(matches!(
904 status_of(SignatureCheck::Valid { signer_did: did }, &decisions),
905 CommitStatus::RegistryUnavailable { .. }
906 ));
907 }
908
909 #[test]
912 fn a_verified_shortcut_is_the_name() {
913 let name = signer_display_name(
914 Some("example.com/@alice"),
915 &["https://example.com/@alice".to_string()],
916 )
917 .unwrap();
918 assert_eq!(name.name, "example.com/@alice");
919 assert!(name.is_trusted());
920 }
921
922 #[test]
923 fn an_unchecked_claim_is_never_trusted() {
924 let name =
927 signer_display_name(None, &["https://mybank.com/@treasury".to_string()]).unwrap();
928 assert_eq!(name.source, NameSource::AgentName { verified: false });
929 assert!(!name.is_trusted());
930 }
931
932 #[test]
933 fn a_signer_claiming_nothing_has_no_name() {
934 assert!(signer_display_name(None, &[]).is_none());
935 }
936
937 #[test]
938 fn an_unverified_name_renders_tagged_beside_its_did() {
939 let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
940 let report = TrustReport {
941 ok: true,
942 commits: Vec::new(),
943 unresolved_signers: BTreeMap::new(),
944 signer_names: BTreeMap::from([(
945 did.to_string(),
946 DisplayName::new(
947 "mybank.com/@treasury",
948 NameSource::AgentName { verified: false },
949 ),
950 )]),
951 };
952 let rendered = render_signer(&report, did);
953 assert!(
954 rendered.contains("unverified"),
955 "an unchecked claim must never render as a plain name: {rendered}"
956 );
957 assert!(
958 rendered.contains("example.com"),
959 "the DID must stay visible beside the name: {rendered}"
960 );
961 }
962
963 #[test]
964 fn an_unnamed_signer_falls_back_to_its_did() {
965 let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
966 let report = TrustReport {
967 ok: true,
968 commits: Vec::new(),
969 unresolved_signers: BTreeMap::new(),
970 signer_names: BTreeMap::new(),
971 };
972 assert_eq!(
973 render_signer(&report, did),
974 vta_sdk::display_name::shorten_did(did)
975 );
976 }
977}