1use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
15use serde_json::{Value, json};
16use sha2::{Digest, Sha256};
17use thiserror::Error;
18
19use crate::canonical::canonical;
20use crate::signing::{b64decode, b64encode, make_key_id};
21
22pub const CARD_SCHEMA_VERSION: &str = "v3.2";
23pub const DID_METHOD: &str = "did:wire";
24
25pub const DID_METHOD_OP: &str = "did:wire:op";
29
30pub const DID_METHOD_ORG: &str = "did:wire:org";
32
33pub const LONG_FINGERPRINT_HEX_LEN: usize = 32;
38
39pub fn did_for_with_key(handle: &str, public_key: &[u8]) -> String {
48 if handle.starts_with("did:") {
49 return handle.to_string();
50 }
51 let suffix = crate::signing::fingerprint(public_key);
52 format!("{DID_METHOD}:{handle}-{suffix}")
53}
54
55pub fn did_for_op(handle: &str, public_key: &[u8]) -> String {
62 if handle.starts_with("did:wire:op:") {
63 return handle.to_string();
64 }
65 let suffix = long_fingerprint(public_key);
66 format!("{DID_METHOD_OP}:{handle}-{suffix}")
67}
68
69pub fn did_for_org(handle: &str, public_key: &[u8]) -> String {
73 if handle.starts_with("did:wire:org:") {
74 return handle.to_string();
75 }
76 let suffix = long_fingerprint(public_key);
77 format!("{DID_METHOD_ORG}:{handle}-{suffix}")
78}
79
80pub fn long_fingerprint(public_key: &[u8]) -> String {
84 let digest = Sha256::digest(public_key);
85 hex::encode(&digest[..16])
86}
87
88pub fn is_op_did(did: &str) -> bool {
92 let Some(rest) = did.strip_prefix("did:wire:op:") else {
93 return false;
94 };
95 has_long_hex_suffix(rest)
96}
97
98pub fn is_org_did(did: &str) -> bool {
100 let Some(rest) = did.strip_prefix("did:wire:org:") else {
101 return false;
102 };
103 has_long_hex_suffix(rest)
104}
105
106fn has_long_hex_suffix(s: &str) -> bool {
107 let Some(idx) = s.rfind('-') else {
108 return false;
109 };
110 let suffix = &s[idx + 1..];
111 suffix.len() == LONG_FINGERPRINT_HEX_LEN && suffix.chars().all(|c| c.is_ascii_hexdigit())
112}
113
114pub fn bare_handle(handle: &str) -> &str {
126 handle.split_once('@').map(|(n, _)| n).unwrap_or(handle)
127}
128
129pub fn display_handle_from_did(did: &str) -> &str {
133 let stripped = did.strip_prefix("did:wire:").unwrap_or(did);
134 if let Some(idx) = stripped.rfind('-') {
137 let suffix = &stripped[idx + 1..];
138 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
139 return &stripped[..idx];
140 }
141 }
142 stripped
143}
144
145pub type AgentCard = Value;
148
149#[derive(Debug, Error)]
150pub enum CardError {
151 #[error("missing field: {0}")]
152 MissingField(&'static str),
153 #[error("verify_keys is empty or malformed")]
154 NoVerifyKeys,
155 #[error("signature decode failed")]
156 BadSignature,
157 #[error("signature did not verify")]
158 SignatureRejected,
159}
160
161pub fn build_agent_card(
172 handle: &str,
173 public_key: &[u8],
174 name: Option<&str>,
175 capabilities: Option<Vec<String>>,
176 max_body_kb: Option<u64>,
177) -> AgentCard {
178 let display_name = name
179 .map(str::to_string)
180 .unwrap_or_else(|| capitalize(handle));
181 let caps = capabilities.unwrap_or_else(|| vec!["wire/v3.2".to_string()]);
182 let body_kb = max_body_kb.unwrap_or(64);
183
184 let key_id = make_key_id(handle, public_key);
185 let key_id_full = format!("ed25519:{key_id}");
186
187 json!({
188 "schema_version": CARD_SCHEMA_VERSION,
189 "did": did_for_with_key(handle, public_key),
190 "handle": handle,
191 "name": display_name,
192 "capabilities": caps,
193 "verify_keys": {
194 key_id_full: {
195 "key": b64encode(public_key),
196 "alg": "ed25519",
197 "active": true,
198 }
199 },
200 "policies": {
201 "max_message_body_kb": body_kb,
202 }
203 })
204}
205
206fn capitalize(s: &str) -> String {
208 let mut chars = s.chars();
209 match chars.next() {
210 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
211 None => String::new(),
212 }
213}
214
215#[derive(Debug, Clone)]
228pub struct OrgMembership {
229 pub org_did: String,
230 pub org_pubkey: String,
235 pub member_cert: String,
237}
238
239#[derive(Debug, Clone, Default)]
244pub struct IdentityClaims {
245 pub op_did: Option<String>,
250 pub op_cert: Option<String>,
254 pub op_pubkey: Option<String>,
259 pub org_memberships: Vec<OrgMembership>,
262 pub project: Option<String>,
264}
265
266pub fn with_identity_claims(
278 card: &AgentCard,
279 claims: &IdentityClaims,
280) -> Result<AgentCard, ClaimError> {
281 if let Some(op_did) = &claims.op_did
282 && !is_op_did(op_did)
283 {
284 return Err(ClaimError::InvalidOpDid(op_did.clone()));
285 }
286 for m in &claims.org_memberships {
287 if !is_org_did(&m.org_did) {
288 return Err(ClaimError::InvalidOrgDid(m.org_did.clone()));
289 }
290 }
291
292 let mut out = card.as_object().cloned().unwrap_or_default();
293
294 if let Some(op_did) = &claims.op_did {
295 out.insert("op_did".into(), Value::String(op_did.clone()));
296 }
297 if let Some(op_cert) = &claims.op_cert {
298 out.insert("op_cert".into(), Value::String(op_cert.clone()));
299 }
300 if let Some(op_pubkey) = &claims.op_pubkey {
301 out.insert("op_pubkey".into(), Value::String(op_pubkey.clone()));
302 }
303 if !claims.org_memberships.is_empty() {
304 let arr: Vec<Value> = claims
305 .org_memberships
306 .iter()
307 .map(|m| {
308 json!({
309 "org_did": m.org_did,
310 "org_pubkey": m.org_pubkey,
311 "member_cert": m.member_cert,
312 })
313 })
314 .collect();
315 out.insert("org_memberships".into(), Value::Array(arr));
316 }
317 if let Some(project) = &claims.project {
318 out.insert("project".into(), Value::String(project.clone()));
319 }
320
321 let has_any_op_claim = claims.op_did.is_some()
333 || claims.op_cert.is_some()
334 || claims.op_pubkey.is_some()
335 || !claims.org_memberships.is_empty();
336 if has_any_op_claim {
337 let current = out
338 .get("schema_version")
339 .and_then(Value::as_str)
340 .unwrap_or("v3.0");
341 let target = max_schema_version(current, CARD_SCHEMA_VERSION);
342 out.insert("schema_version".into(), Value::String(target.to_string()));
343 }
344
345 Ok(Value::Object(out))
346}
347
348fn max_schema_version<'a>(a: &'a str, b: &'a str) -> &'a str {
353 fn parse(s: &str) -> Option<(u32, u32)> {
354 let rest = s.strip_prefix('v')?;
355 let (maj, min) = rest.split_once('.')?;
356 Some((maj.parse().ok()?, min.parse().ok()?))
357 }
358 match (parse(a), parse(b)) {
359 (Some(pa), Some(pb)) => {
360 if pa >= pb {
361 a
362 } else {
363 b
364 }
365 }
366 (Some(_), None) => a,
368 (None, Some(_)) => b,
369 (None, None) => a,
370 }
371}
372
373#[derive(Debug, Error)]
374pub enum ClaimError {
375 #[error("op_did is not a well-formed did:wire:op:<handle>-<32hex>: {0}")]
376 InvalidOpDid(String),
377 #[error("org_did is not a well-formed did:wire:org:<handle>-<32hex>: {0}")]
378 InvalidOrgDid(String),
379}
380
381pub fn card_op_did(card: &AgentCard) -> Option<&str> {
383 card.get("op_did").and_then(Value::as_str)
384}
385
386pub fn card_op_cert(card: &AgentCard) -> Option<&str> {
388 card.get("op_cert").and_then(Value::as_str)
389}
390
391pub fn card_project(card: &AgentCard) -> Option<&str> {
393 card.get("project").and_then(Value::as_str)
394}
395
396pub fn card_org_memberships(card: &AgentCard) -> Vec<(&str, &str)> {
399 card.get("org_memberships")
400 .and_then(Value::as_array)
401 .map(|arr| {
402 arr.iter()
403 .filter_map(|entry| {
404 let org = entry.get("org_did").and_then(Value::as_str)?;
405 let cert = entry.get("member_cert").and_then(Value::as_str)?;
406 Some((org, cert))
407 })
408 .collect()
409 })
410 .unwrap_or_default()
411}
412
413pub fn card_canonical(card: &AgentCard) -> Vec<u8> {
415 canonical(card, false)
416}
417
418pub fn sign_agent_card(card: &AgentCard, private_key: &[u8]) -> AgentCard {
421 let mut sk_bytes = [0u8; 32];
422 sk_bytes.copy_from_slice(&private_key[..32]);
423 let sk = SigningKey::from_bytes(&sk_bytes);
424 let sig = sk.sign(&card_canonical(card));
425 let mut out = card.as_object().cloned().unwrap_or_default();
426 out.insert(
427 "signature".into(),
428 Value::String(b64encode(&sig.to_bytes())),
429 );
430 Value::Object(out)
431}
432
433pub fn verify_agent_card(card: &AgentCard) -> Result<(), CardError> {
436 let signature_b64 = card
437 .get("signature")
438 .and_then(Value::as_str)
439 .ok_or(CardError::MissingField("signature"))?;
440
441 let verify_keys = card
442 .get("verify_keys")
443 .and_then(Value::as_object)
444 .ok_or(CardError::MissingField("verify_keys"))?;
445
446 let (_kid, key_record) = verify_keys.iter().next().ok_or(CardError::NoVerifyKeys)?;
447 let pk_b64 = key_record
448 .get("key")
449 .and_then(Value::as_str)
450 .ok_or(CardError::MissingField("verify_keys[*].key"))?;
451 let pk_bytes = b64decode(pk_b64).map_err(|_| CardError::BadSignature)?;
452 if pk_bytes.len() != 32 {
453 return Err(CardError::BadSignature);
454 }
455 let mut pk_arr = [0u8; 32];
456 pk_arr.copy_from_slice(&pk_bytes);
457 let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|_| CardError::BadSignature)?;
458
459 let sig_bytes = b64decode(signature_b64).map_err(|_| CardError::BadSignature)?;
460 if sig_bytes.len() != 64 {
461 return Err(CardError::BadSignature);
462 }
463 let mut sig_arr = [0u8; 64];
464 sig_arr.copy_from_slice(&sig_bytes);
465 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
466
467 vk.verify(&card_canonical(card), &sig)
468 .map_err(|_| CardError::SignatureRejected)
469}
470
471pub fn compute_sas(public_key_a: &[u8], public_key_b: &[u8]) -> String {
477 let (lo, hi) = if public_key_a <= public_key_b {
478 (public_key_a, public_key_b)
479 } else {
480 (public_key_b, public_key_a)
481 };
482 let mut h = Sha256::new();
483 h.update(lo);
484 h.update(hi);
485 let digest = h.finalize();
486 let n = u32::from_be_bytes([digest[28], digest[29], digest[30], digest[31]]);
488 format!("{:06}", n % 1_000_000)
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::signing::generate_keypair;
495
496 #[test]
497 fn did_method_constant() {
498 assert_eq!(DID_METHOD, "did:wire");
499 }
500
501 #[test]
502 fn build_minimal_card() {
503 let (_, pk) = generate_keypair();
504 let card = build_agent_card("paul", &pk, None, None, None);
505 assert_eq!(card["schema_version"], CARD_SCHEMA_VERSION);
506 let did = card["did"].as_str().unwrap();
508 assert!(did.starts_with("did:wire:paul-"), "got: {did}");
509 assert_eq!(did.len(), "did:wire:paul-".len() + 8);
510 assert_eq!(card["handle"], "paul");
511 assert_eq!(card["name"], "Paul");
512 let vks = card["verify_keys"].as_object().unwrap();
513 assert_eq!(vks.len(), 1);
514 assert_eq!(card["policies"]["max_message_body_kb"], 64);
515 }
516
517 #[test]
518 fn build_card_with_overrides() {
519 let (_, pk) = generate_keypair();
520 let card = build_agent_card(
521 "carol",
522 &pk,
523 Some("Carol's Agent"),
524 Some(vec!["custom-cap".to_string()]),
525 Some(128),
526 );
527 assert_eq!(card["name"], "Carol's Agent");
528 assert_eq!(card["capabilities"], json!(["custom-cap"]));
529 assert_eq!(card["policies"]["max_message_body_kb"], 128);
530 }
531
532 #[test]
533 fn build_card_does_not_carry_v02_fields() {
534 let (_, pk) = generate_keypair();
535 let card = build_agent_card("paul", &pk, None, None, None);
536 let obj = card.as_object().unwrap();
537 for v02 in [
538 "registries",
539 "onboard_endpoint",
540 "wire_raw_url_template",
541 "revoked_at",
542 ] {
543 assert!(
544 !obj.contains_key(v02),
545 "v0.2+ field {v02} leaked into v0.1 card"
546 );
547 }
548 }
549
550 #[test]
551 fn card_canonical_excludes_signature() {
552 let v = json!({"schema_version": "v3.1", "did": "did:wire:paul", "signature": "sig"});
553 let bytes = card_canonical(&v);
554 assert!(!String::from_utf8_lossy(&bytes).contains("signature"));
555 }
556
557 #[test]
558 fn card_canonical_sort_keys_stable() {
559 let a = json!({"b": 1, "a": 2, "did": "did:wire:paul"});
560 let b = json!({"did": "did:wire:paul", "a": 2, "b": 1});
561 assert_eq!(card_canonical(&a), card_canonical(&b));
562 }
563
564 #[test]
565 fn sign_verify_roundtrip() {
566 let (sk, pk) = generate_keypair();
567 let card = build_agent_card("paul", &pk, None, None, None);
568 let signed = sign_agent_card(&card, &sk);
569 assert!(signed.get("signature").is_some());
570 verify_agent_card(&signed).unwrap();
571 }
572
573 #[test]
574 fn verify_rejects_unsigned_card() {
575 let (_, pk) = generate_keypair();
576 let card = build_agent_card("paul", &pk, None, None, None);
577 let err = verify_agent_card(&card).unwrap_err();
578 assert!(matches!(err, CardError::MissingField("signature")));
579 }
580
581 #[test]
582 fn verify_rejects_tampered_card() {
583 let (sk, pk) = generate_keypair();
584 let mut signed = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
585 signed["name"] = json!("TamperedName");
586 let err = verify_agent_card(&signed).unwrap_err();
587 assert!(matches!(err, CardError::SignatureRejected));
588 }
589
590 #[test]
591 fn verify_rejects_card_with_no_verify_keys() {
592 let (sk, _) = generate_keypair();
593 let card = json!({"schema_version": "v3.1", "did": "did:wire:paul", "verify_keys": {}});
594 let signed = sign_agent_card(&card, &sk);
595 let err = verify_agent_card(&signed).unwrap_err();
596 assert!(matches!(err, CardError::NoVerifyKeys));
597 }
598
599 #[test]
600 fn compute_sas_is_6_digits() {
601 let (_, a) = generate_keypair();
602 let (_, b) = generate_keypair();
603 let sas = compute_sas(&a, &b);
604 assert_eq!(sas.len(), 6);
605 assert!(sas.chars().all(|c| c.is_ascii_digit()));
606 }
607
608 #[test]
609 fn compute_sas_bilateral_symmetric() {
610 let (_, a) = generate_keypair();
611 let (_, b) = generate_keypair();
612 assert_eq!(compute_sas(&a, &b), compute_sas(&b, &a));
613 }
614
615 #[test]
616 fn compute_sas_changes_with_inputs() {
617 let (_, a) = generate_keypair();
618 let (_, b) = generate_keypair();
619 let (_, c) = generate_keypair();
620 assert_ne!(compute_sas(&a, &b), compute_sas(&a, &c));
621 }
622
623 fn op_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
626 let (sk, pk) = generate_keypair();
627 (did_for_op(handle, &pk), sk.to_vec(), pk.to_vec())
628 }
629
630 fn org_did_for_test(handle: &str) -> (String, Vec<u8>, Vec<u8>) {
631 let (sk, pk) = generate_keypair();
632 (did_for_org(handle, &pk), sk.to_vec(), pk.to_vec())
633 }
634
635 #[test]
636 fn schema_version_is_v3_2() {
637 assert_eq!(CARD_SCHEMA_VERSION, "v3.2");
638 }
639
640 #[test]
641 fn op_did_has_long_hex_suffix_and_method_prefix() {
642 let (did, _, _) = op_did_for_test("darby");
643 assert!(did.starts_with("did:wire:op:darby-"), "got: {did}");
644 let tail = did.rsplit('-').next().unwrap();
645 assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
646 assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
647 }
648
649 #[test]
650 fn org_did_has_long_hex_suffix_and_method_prefix() {
651 let (did, _, _) = org_did_for_test("slanchaai");
652 assert!(did.starts_with("did:wire:org:slanchaai-"), "got: {did}");
653 let tail = did.rsplit('-').next().unwrap();
654 assert_eq!(tail.len(), LONG_FINGERPRINT_HEX_LEN);
655 assert!(tail.chars().all(|c| c.is_ascii_hexdigit()));
656 }
657
658 #[test]
659 fn op_did_passthrough_when_already_op_did() {
660 let (_, pk) = generate_keypair();
663 let did = did_for_op("darby", &pk);
664 let again = did_for_op(&did, &pk);
665 assert_eq!(did, again);
666 }
667
668 #[test]
669 fn is_op_did_rejects_session_did() {
670 let (_, pk) = generate_keypair();
672 let session_did = did_for_with_key("darby", &pk);
673 assert!(!is_op_did(&session_did));
674 assert!(!is_org_did(&session_did));
675 }
676
677 #[test]
678 fn is_op_did_rejects_org_did_and_vice_versa() {
679 let (op, _, _) = op_did_for_test("darby");
682 let (org, _, _) = org_did_for_test("slanchaai");
683 assert!(is_op_did(&op) && !is_org_did(&op));
684 assert!(is_org_did(&org) && !is_op_did(&org));
685 }
686
687 #[test]
688 fn is_op_did_rejects_short_hex_suffix() {
689 assert!(!is_op_did("did:wire:op:darby-deadbeef"));
692 assert!(!is_org_did("did:wire:org:slanchaai-deadbeef"));
693 }
694
695 #[test]
696 fn is_op_did_rejects_non_hex_suffix() {
697 let bad = format!("did:wire:op:darby-{}", "z".repeat(LONG_FINGERPRINT_HEX_LEN));
698 assert!(!is_op_did(&bad));
699 }
700
701 #[test]
702 fn with_identity_claims_attaches_all_fields() {
703 let (sk, pk) = generate_keypair();
704 let card = build_agent_card("vesper-valley", &pk, None, None, None);
705 let (op_did, _, op_pk) = op_did_for_test("darby");
706 let (org_did, _, org_pk) = org_did_for_test("slanchaai");
707 let op_pubkey = crate::signing::b64encode(&op_pk);
708 let org_pubkey = crate::signing::b64encode(&org_pk);
709 let claims = IdentityClaims {
710 op_did: Some(op_did.clone()),
711 op_cert: Some("AAAA".into()),
712 op_pubkey: Some(op_pubkey.clone()),
713 org_memberships: vec![OrgMembership {
714 org_did: org_did.clone(),
715 org_pubkey: org_pubkey.clone(),
716 member_cert: "BBBB".into(),
717 }],
718 project: Some("wire-codex-integration".into()),
719 };
720 let with = with_identity_claims(&card, &claims).unwrap();
721 assert_eq!(card_op_did(&with), Some(op_did.as_str()));
722 assert_eq!(card_op_cert(&with), Some("AAAA"));
723 assert_eq!(
724 with.get("op_pubkey").and_then(|v| v.as_str()),
725 Some(op_pubkey.as_str())
726 );
727 assert_eq!(card_project(&with), Some("wire-codex-integration"));
728 let orgs = card_org_memberships(&with);
729 assert_eq!(orgs.len(), 1);
730 assert_eq!(orgs[0], (org_did.as_str(), "BBBB"));
731 assert_eq!(
732 with.get("org_memberships").unwrap()[0]
733 .get("org_pubkey")
734 .and_then(|v| v.as_str()),
735 Some(org_pubkey.as_str())
736 );
737 let signed = sign_agent_card(&with, &sk);
739 verify_agent_card(&signed).unwrap();
740 }
741
742 #[test]
743 fn with_identity_claims_skips_absent_fields() {
744 let (_, pk) = generate_keypair();
747 let card = build_agent_card("vesper-valley", &pk, None, None, None);
748 let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
749 let obj = with.as_object().unwrap();
750 for field in ["op_did", "op_cert", "org_memberships", "project"] {
751 assert!(
752 !obj.contains_key(field),
753 "{field} leaked into claim-less card"
754 );
755 }
756 }
757
758 #[test]
759 fn with_identity_claims_rejects_malformed_op_did() {
760 let (_, pk) = generate_keypair();
761 let card = build_agent_card("vesper-valley", &pk, None, None, None);
762 let claims = IdentityClaims {
763 op_did: Some("did:wire:op:darby-deadbeef".into()),
765 ..Default::default()
766 };
767 let err = with_identity_claims(&card, &claims).unwrap_err();
768 assert!(matches!(err, ClaimError::InvalidOpDid(_)));
769 }
770
771 #[test]
772 fn with_identity_claims_rejects_malformed_org_did() {
773 let (_, pk) = generate_keypair();
774 let card = build_agent_card("vesper-valley", &pk, None, None, None);
775 let claims = IdentityClaims {
776 org_memberships: vec![OrgMembership {
777 org_did: "did:wire:slanchaai".into(),
778 org_pubkey: "AAAA".into(),
779 member_cert: "BBBB".into(),
780 }],
781 ..Default::default()
782 };
783 let err = with_identity_claims(&card, &claims).unwrap_err();
784 assert!(matches!(err, ClaimError::InvalidOrgDid(_)));
785 }
786
787 #[test]
788 fn build_agent_card_default_capability_advertises_v3_2() {
789 let (_, pk) = generate_keypair();
790 let card = build_agent_card("paul", &pk, None, None, None);
791 let caps = card["capabilities"].as_array().unwrap();
792 let has_v32 = caps.iter().any(|v| v.as_str() == Some("wire/v3.2"));
793 assert!(has_v32, "default caps should advertise wire/v3.2: {caps:?}");
794 }
795
796 #[test]
803 fn with_identity_claims_bumps_schema_version_when_op_did_attached() {
804 let (_, pk) = generate_keypair();
808 let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
809 card.as_object_mut()
811 .unwrap()
812 .insert("schema_version".into(), json!("v3.1"));
813 let (op_did, _, op_pk) = op_did_for_test("darby");
814 let claims = IdentityClaims {
815 op_did: Some(op_did),
816 op_pubkey: Some(crate::signing::b64encode(&op_pk)),
817 op_cert: Some("AAAA".into()),
818 ..Default::default()
819 };
820 let with = with_identity_claims(&card, &claims).unwrap();
821 assert_eq!(
822 with.get("schema_version").and_then(|v| v.as_str()),
823 Some(CARD_SCHEMA_VERSION),
824 "post-attach schema_version must bump to {CARD_SCHEMA_VERSION}",
825 );
826 }
827
828 #[test]
829 fn with_identity_claims_does_not_touch_schema_version_when_no_claims() {
830 let (_, pk) = generate_keypair();
834 let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
835 card.as_object_mut()
836 .unwrap()
837 .insert("schema_version".into(), json!("v3.1"));
838 let with = with_identity_claims(&card, &IdentityClaims::default()).unwrap();
839 assert_eq!(
840 with.get("schema_version").and_then(|v| v.as_str()),
841 Some("v3.1"),
842 "claim-less attach must NOT bump",
843 );
844 }
845
846 #[test]
847 fn with_identity_claims_never_downgrades_schema_version() {
848 let (_, pk) = generate_keypair();
852 let mut card = build_agent_card("vesper-valley", &pk, None, None, None);
853 card.as_object_mut()
854 .unwrap()
855 .insert("schema_version".into(), json!("v3.5"));
856 let (op_did, _, op_pk) = op_did_for_test("darby");
857 let claims = IdentityClaims {
858 op_did: Some(op_did),
859 op_pubkey: Some(crate::signing::b64encode(&op_pk)),
860 op_cert: Some("AAAA".into()),
861 ..Default::default()
862 };
863 let with = with_identity_claims(&card, &claims).unwrap();
864 assert_eq!(
865 with.get("schema_version").and_then(|v| v.as_str()),
866 Some("v3.5"),
867 "monotonic bump must not downgrade v3.5 to {CARD_SCHEMA_VERSION}",
868 );
869 }
870
871 #[test]
872 fn max_schema_version_compares_numerically_not_lexicographically() {
873 assert_eq!(max_schema_version("v3.10", "v3.2"), "v3.10");
876 assert_eq!(max_schema_version("v3.2", "v3.10"), "v3.10");
877 assert_eq!(max_schema_version("v3.2", "v3.2"), "v3.2");
878 assert_eq!(max_schema_version("v4.0", "v3.99"), "v4.0");
879 }
880
881 #[test]
882 fn max_schema_version_biases_to_parseable_on_malformed_input() {
883 assert_eq!(max_schema_version("garbage", "v3.2"), "v3.2");
886 assert_eq!(max_schema_version("v3.2", "garbage"), "v3.2");
887 assert_eq!(max_schema_version("garbage1", "garbage2"), "garbage1");
888 }
889}