1use crate::body::Body;
19use crate::publish::PublishRequest;
20use acdp_primitives::error::AcdpError;
21use acdp_primitives::primitives::{AgentDid, ContextType, CtxId, Visibility};
22pub use acdp_primitives::revocation::{
23 RevocationEffect, RevocationRefusal, RevocationSource, SourcedRevocation, UnknownReason,
24};
25use acdp_primitives::time::fmt_rfc3339_ms;
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29pub const MAX_REASON_CHARS: usize = 1024;
31
32pub use acdp_primitives::revocation::RevocationTrustClass;
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct KeyRevocation {
50 pub revoked_key_fingerprint: String,
54 pub compromised_since: DateTime<Utc>,
57 pub reason: Option<String>,
61 pub revoked_key_id: Option<String>,
64 pub revoked_key_controller: AgentDid,
69 pub publisher: AgentDid,
73 pub trust_class: RevocationTrustClass,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub ctx_id: Option<CtxId>,
88}
89
90impl KeyRevocation {
91 pub fn from_body(body: &Body) -> Result<Self, AcdpError> {
118 let mut revocation = Self::from_parts(
119 &body.context_type,
120 &body.visibility,
121 body.metadata.as_ref(),
122 &body.agent_id,
123 &body.signature.key_id,
124 )?;
125 revocation.ctx_id = Some(body.ctx_id.clone());
126 Ok(revocation)
127 }
128
129 #[must_use]
132 pub fn sourced(&self, source: RevocationSource) -> SourcedRevocation {
133 SourcedRevocation::new(
134 self.revoked_key_fingerprint.clone(),
135 self.compromised_since,
136 self.ctx_id.clone(),
137 self.publisher.clone(),
138 self.revoked_key_controller.clone(),
139 self.trust_class,
140 source,
141 )
142 }
143
144 pub fn from_publish_request(req: &PublishRequest) -> Result<Self, AcdpError> {
155 Self::from_parts(
156 &req.context_type,
157 &req.visibility,
158 req.metadata.as_ref(),
159 &req.agent_id,
160 &req.signature.key_id,
161 )
162 }
163
164 fn from_parts(
171 context_type: &ContextType,
172 visibility: &Visibility,
173 metadata: Option<&serde_json::Value>,
174 agent_id: &AgentDid,
175 signing_key_id: &str,
176 ) -> Result<Self, AcdpError> {
177 if !context_type.is_key_revocation() {
178 return Err(AcdpError::SchemaViolation(format!(
179 "not a key-revocation context: type is '{}' (RFC-ACDP-0014 §4 requires \
180 'key-revocation', or 'acdp:key-revocation' in the pre-0.3.0 interim form)",
181 serde_json::to_value(context_type)
182 .ok()
183 .and_then(|v| v.as_str().map(str::to_owned))
184 .unwrap_or_default()
185 )));
186 }
187 if *visibility != Visibility::Public {
188 return Err(AcdpError::SchemaViolation(
189 "a key-revocation context MUST be visibility 'public' — it is a safety \
190 broadcast; an audience-restricted revocation protects nobody outside the \
191 audience (RFC-ACDP-0014 §4)"
192 .into(),
193 ));
194 }
195
196 let meta = metadata.and_then(|m| m.as_object()).ok_or_else(|| {
197 AcdpError::SchemaViolation(
198 "key-revocation body has no metadata object; \
199 metadata.revoked_key_fingerprint and metadata.compromised_since are \
200 REQUIRED (RFC-ACDP-0014 §4)"
201 .into(),
202 )
203 })?;
204
205 let fingerprint = required_str(meta, "revoked_key_fingerprint")?;
206 if !is_sha256_fingerprint(fingerprint) {
207 return Err(AcdpError::SchemaViolation(format!(
208 "metadata.revoked_key_fingerprint '{fingerprint}' is not in the \
209 RFC-ACDP-0010 §6 form 'sha256:' + 64 lowercase hex (RFC-ACDP-0014 §4)"
210 )));
211 }
212
213 let since_raw = required_str(meta, "compromised_since")?;
214 let compromised_since = parse_canonical_ms(since_raw).ok_or_else(|| {
215 AcdpError::SchemaViolation(format!(
216 "metadata.compromised_since '{since_raw}' is not canonical \
217 millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3, RFC-ACDP-0014 §4)"
218 ))
219 })?;
220
221 let reason = optional_str(meta, "reason")?;
222 if let Some(r) = &reason {
223 if r.chars().count() > MAX_REASON_CHARS {
224 return Err(AcdpError::SchemaViolation(format!(
225 "metadata.reason exceeds {MAX_REASON_CHARS} characters (RFC-ACDP-0014 §4)"
226 )));
227 }
228 }
229 let revoked_key_id = optional_str(meta, "revoked_key_id")?;
230
231 let (revoked_key_controller, trust_class) =
232 match optional_str(meta, "revoked_key_controller")? {
233 None => (agent_id.clone(), RevocationTrustClass::ProducerSigned),
234 Some(c) => {
235 let controller = AgentDid::parse(&c)?;
236 if controller == *agent_id {
237 (controller, RevocationTrustClass::ProducerSigned)
240 } else {
241 (controller, RevocationTrustClass::RegistryAttested)
244 }
245 }
246 };
247
248 let revocation = KeyRevocation {
249 revoked_key_fingerprint: fingerprint.to_string(),
250 compromised_since,
251 reason,
252 revoked_key_id,
253 revoked_key_controller,
254 publisher: agent_id.clone(),
255 trust_class,
256 ctx_id: None,
257 };
258
259 if signing_key_id.starts_with("did:key:") {
264 if let Ok(material) = acdp_did::key::resolve_did_key_url(signing_key_id) {
265 if let Ok(fp) = acdp_crypto::fingerprint::fingerprint_did_key_material(&material) {
266 revocation.check_not_self_signed(&fp)?;
267 }
268 }
269 }
270
271 Ok(revocation)
272 }
273
274 pub fn check_not_self_signed(&self, signing_key_fingerprint: &str) -> Result<(), AcdpError> {
285 if signing_key_fingerprint == self.revoked_key_fingerprint {
286 return Err(AcdpError::KeyNotAuthorized(format!(
287 "revocation of key {} is signed by that same key — a key is not \
288 authorized to attest its own compromise; treat as unverified \
289 (RFC-ACDP-0014 §5 step 2)",
290 self.revoked_key_fingerprint
291 )));
292 }
293 Ok(())
294 }
295
296 pub fn revokes(&self, key_fingerprint: &str) -> bool {
299 self.revoked_key_fingerprint == key_fingerprint
300 }
301
302 pub fn check_not_self_signed_lenient(
320 req: &PublishRequest,
321 signing_key_fingerprint: &str,
322 ) -> Result<(), AcdpError> {
323 let Some(fingerprint) = req
324 .metadata
325 .as_ref()
326 .and_then(|m| m.as_object())
327 .and_then(|m| m.get("revoked_key_fingerprint"))
328 .and_then(|v| v.as_str())
329 else {
330 return Ok(());
331 };
332 if fingerprint == signing_key_fingerprint {
333 return Err(AcdpError::KeyNotAuthorized(format!(
334 "revocation of key {fingerprint} is signed by that same key — a key is not \
335 authorized to attest its own compromise; treat as unverified \
336 (RFC-ACDP-0014 §5 step 2)"
337 )));
338 }
339 Ok(())
340 }
341
342 pub fn check_not_self_signed_did_key_lenient(req: &PublishRequest) -> Result<(), AcdpError> {
355 if !req.signature.key_id.starts_with("did:key:") {
356 return Ok(());
357 }
358 let Ok(material) = acdp_did::key::resolve_did_key_url(&req.signature.key_id) else {
359 return Ok(());
360 };
361 let Ok(signer_fingerprint) =
362 acdp_crypto::fingerprint::fingerprint_did_key_material(&material)
363 else {
364 return Ok(());
365 };
366 Self::check_not_self_signed_lenient(req, &signer_fingerprint)
367 }
368
369 pub fn cross_check_registry_binding(
396 &self,
397 serving_authority: &str,
398 capabilities_registry_did: &str,
399 ) -> Result<(), AcdpError> {
400 let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
401 if self.publisher.as_str() != expected_did {
402 return Err(AcdpError::KeyNotAuthorized(format!(
403 "key-revocation publisher '{}' ≠ serving authority's DID '{expected_did}' \
404 (RFC-ACDP-0014 §6 steps 2–3)",
405 self.publisher
406 )));
407 }
408 if self.publisher.as_str() != capabilities_registry_did {
409 return Err(AcdpError::KeyNotAuthorized(format!(
410 "key-revocation publisher '{}' ≠ capabilities.registry_did \
411 '{capabilities_registry_did}' (RFC-ACDP-0014 §6 steps 2–3)",
412 self.publisher
413 )));
414 }
415 Ok(())
416 }
417}
418
419pub fn effective_boundary<'a>(
428 revocations: impl IntoIterator<Item = &'a KeyRevocation>,
429 key_fingerprint: &str,
430) -> Option<DateTime<Utc>> {
431 revocations
432 .into_iter()
433 .filter(|r| r.revokes(key_fingerprint))
434 .map(|r| r.compromised_since)
435 .min()
436}
437
438fn required_str<'m>(
439 meta: &'m serde_json::Map<String, serde_json::Value>,
440 key: &str,
441) -> Result<&'m str, AcdpError> {
442 meta.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
443 AcdpError::SchemaViolation(format!(
444 "key-revocation metadata.{key} is REQUIRED and must be a string \
445 (RFC-ACDP-0014 §4)"
446 ))
447 })
448}
449
450fn optional_str(
451 meta: &serde_json::Map<String, serde_json::Value>,
452 key: &str,
453) -> Result<Option<String>, AcdpError> {
454 match meta.get(key) {
455 None => Ok(None),
456 Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
457 Some(_) => Err(AcdpError::SchemaViolation(format!(
458 "key-revocation metadata.{key} must be a string when present (RFC-ACDP-0014 §4)"
459 ))),
460 }
461}
462
463fn is_sha256_fingerprint(s: &str) -> bool {
465 match s.strip_prefix("sha256:") {
466 Some(hex) => {
467 hex.len() == 64
468 && hex
469 .chars()
470 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
471 }
472 None => false,
473 }
474}
475
476fn parse_canonical_ms(raw: &str) -> Option<DateTime<Utc>> {
480 let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc);
481 (fmt_rfc3339_ms(parsed) == raw).then_some(parsed)
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::body::Signature;
488 use acdp_primitives::primitives::{ContentHash, CtxId, LineageId};
489
490 const PR_PRODUCER_DID: &str = "did:web:agents.example.com:pr-test-producer";
495 const PR_COMPROMISED_SINCE: &str = "2026-05-01T00:00:00.000Z";
496
497 fn pr_valid_metadata() -> serde_json::Value {
498 serde_json::json!({
499 "revoked_key_fingerprint": format!("sha256:{}", "a".repeat(64)),
500 "compromised_since": PR_COMPROMISED_SINCE,
501 })
502 }
503
504 fn publish_request_with_metadata(metadata: Option<serde_json::Value>) -> PublishRequest {
505 PublishRequest {
506 version: 1,
507 supersedes: None,
508 agent_id: AgentDid::new(PR_PRODUCER_DID),
509 contributors: vec![],
510 title: "Key revocation — key-1 compromised".into(),
511 context_type: ContextType::KeyRevocation,
512 data_refs: vec![],
513 derived_from: vec![],
514 visibility: Visibility::Public,
515 content_hash: ContentHash("sha256:0".into()),
516 signature: Signature {
517 algorithm: "ed25519".into(),
518 key_id: format!("{PR_PRODUCER_DID}#key-1"),
519 value: "A".repeat(88),
520 },
521 audience: None,
522 acdp_version: Some("0.3.0".into()),
523 description: None,
524 summary: None,
525 lineage_id: None,
526 tags: None,
527 domain: None,
528 expires_at: None,
529 data_period: None,
530 metadata,
531 schema_uri: None,
532 anchors: None,
533 key_attestation: None,
534 }
535 }
536
537 #[test]
541 fn from_publish_request_valid_case_is_accepted() {
542 let req = publish_request_with_metadata(Some(pr_valid_metadata()));
543 let rev =
544 KeyRevocation::from_publish_request(&req).expect("shape-conformant request must parse");
545 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
546 assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
547 assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
548 }
549
550 #[test]
551 fn from_publish_request_wrong_context_type_rejected() {
552 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
553 req.context_type = ContextType::Analysis;
554 assert!(matches!(
555 KeyRevocation::from_publish_request(&req),
556 Err(AcdpError::SchemaViolation(_))
557 ));
558 }
559
560 #[test]
561 fn from_publish_request_non_public_visibility_rejected() {
562 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
563 req.visibility = Visibility::Restricted;
564 assert!(matches!(
565 KeyRevocation::from_publish_request(&req),
566 Err(AcdpError::SchemaViolation(_))
567 ));
568 }
569
570 #[test]
571 fn from_publish_request_missing_metadata_rejected() {
572 let req = publish_request_with_metadata(None);
573 assert!(matches!(
574 KeyRevocation::from_publish_request(&req),
575 Err(AcdpError::SchemaViolation(_))
576 ));
577 }
578
579 #[test]
580 fn from_publish_request_missing_fingerprint_rejected() {
581 let mut meta = pr_valid_metadata();
582 meta.as_object_mut()
583 .unwrap()
584 .remove("revoked_key_fingerprint");
585 let req = publish_request_with_metadata(Some(meta));
586 assert!(matches!(
587 KeyRevocation::from_publish_request(&req),
588 Err(AcdpError::SchemaViolation(_))
589 ));
590 }
591
592 #[test]
593 fn from_publish_request_malformed_fingerprint_rejected() {
594 let mut meta = pr_valid_metadata();
595 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
596 let req = publish_request_with_metadata(Some(meta));
597 assert!(matches!(
598 KeyRevocation::from_publish_request(&req),
599 Err(AcdpError::SchemaViolation(_))
600 ));
601 }
602
603 #[test]
604 fn from_publish_request_missing_compromised_since_rejected() {
605 let mut meta = pr_valid_metadata();
606 meta.as_object_mut().unwrap().remove("compromised_since");
607 let req = publish_request_with_metadata(Some(meta));
608 assert!(matches!(
609 KeyRevocation::from_publish_request(&req),
610 Err(AcdpError::SchemaViolation(_))
611 ));
612 }
613
614 #[test]
615 fn from_publish_request_non_canonical_compromised_since_rejected() {
616 let mut meta = pr_valid_metadata();
617 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
620 let req = publish_request_with_metadata(Some(meta));
621 assert!(matches!(
622 KeyRevocation::from_publish_request(&req),
623 Err(AcdpError::SchemaViolation(_))
624 ));
625 }
626
627 #[test]
628 fn from_publish_request_reason_over_limit_rejected() {
629 let mut meta = pr_valid_metadata();
630 meta["reason"] = serde_json::json!("x".repeat(MAX_REASON_CHARS + 1));
631 let req = publish_request_with_metadata(Some(meta));
632 assert!(matches!(
633 KeyRevocation::from_publish_request(&req),
634 Err(AcdpError::SchemaViolation(_))
635 ));
636 }
637
638 #[test]
643 fn from_body_and_from_publish_request_agree_on_equivalent_input() {
644 let metadata = Some(pr_valid_metadata());
645 let req = publish_request_with_metadata(metadata.clone());
646 let body = body_from_pr_request(&req);
647
648 let from_body = KeyRevocation::from_body(&body).unwrap();
649 assert_eq!(from_body.ctx_id.as_ref(), Some(&body.ctx_id));
651 let mut from_request = KeyRevocation::from_publish_request(&req).unwrap();
652 assert_eq!(from_request.ctx_id, None);
653 from_request.ctx_id = Some(body.ctx_id.clone());
654 assert_eq!(from_request, from_body);
655 }
656
657 fn body_from_pr_request(req: &PublishRequest) -> Body {
661 Body::from_publish_request(
662 req,
663 CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000000".into()),
664 LineageId(format!("lin:sha256:{}", "0".repeat(64))),
665 "registry.example.com",
666 DateTime::parse_from_rfc3339("2026-05-02T08:00:00.000Z")
667 .unwrap()
668 .with_timezone(&Utc),
669 )
670 }
671
672 #[test]
680 fn from_body_and_from_publish_request_agree_on_error_message() {
681 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
683 req.visibility = Visibility::Restricted;
684 let body = body_from_pr_request(&req);
685 let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
686 let body_err = KeyRevocation::from_body(&body).unwrap_err();
687 assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
688 assert_eq!(pr_err.to_string(), body_err.to_string());
689
690 let mut meta = pr_valid_metadata();
692 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
693 let req = publish_request_with_metadata(Some(meta));
694 let body = body_from_pr_request(&req);
695 let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
696 let body_err = KeyRevocation::from_body(&body).unwrap_err();
697 assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
698 assert_eq!(pr_err.to_string(), body_err.to_string());
699 }
700
701 #[test]
708 fn from_publish_request_controller_equal_to_agent_id_is_producer_signed() {
709 let mut meta = pr_valid_metadata();
710 meta["revoked_key_controller"] = serde_json::json!(PR_PRODUCER_DID);
711 let req = publish_request_with_metadata(Some(meta));
712 let rev = KeyRevocation::from_publish_request(&req).unwrap();
713 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
714 assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
715 }
716
717 #[test]
721 fn from_publish_request_controller_different_from_agent_id_is_registry_attested() {
722 const OTHER_PRODUCER: &str = "did:web:agents.example.com:other-producer";
723 let mut meta = pr_valid_metadata();
724 meta["revoked_key_controller"] = serde_json::json!(OTHER_PRODUCER);
725 let req = publish_request_with_metadata(Some(meta));
726 let rev = KeyRevocation::from_publish_request(&req).unwrap();
727 assert_eq!(rev.trust_class, RevocationTrustClass::RegistryAttested);
728 assert_eq!(rev.revoked_key_controller.as_str(), OTHER_PRODUCER);
729 assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
730 }
731
732 #[test]
733 fn from_publish_request_controller_not_a_string_rejected() {
734 let mut meta = pr_valid_metadata();
735 meta["revoked_key_controller"] = serde_json::json!(42);
736 let req = publish_request_with_metadata(Some(meta));
737 assert!(matches!(
738 KeyRevocation::from_publish_request(&req),
739 Err(AcdpError::SchemaViolation(_))
740 ));
741 }
742
743 #[test]
744 fn from_publish_request_controller_invalid_did_rejected() {
745 let mut meta = pr_valid_metadata();
746 meta["revoked_key_controller"] = serde_json::json!("not-a-did");
747 let req = publish_request_with_metadata(Some(meta));
748 assert!(matches!(
749 KeyRevocation::from_publish_request(&req),
750 Err(AcdpError::SchemaViolation(_))
751 ));
752 }
753
754 fn did_key_fixture(seed: [u8; 32]) -> (String, String) {
770 let signing_key = acdp_crypto::SigningKey::from_bytes(&seed);
771 let public_key = signing_key.verifying_key_bytes();
772 let did = acdp_did::key::did_key_from_ed25519(&public_key);
773 let key_id = acdp_did::key::did_key_url(&did).unwrap();
774 let fingerprint = acdp_crypto::fingerprint::fingerprint_ed25519(&public_key);
775 (key_id, fingerprint)
776 }
777
778 #[test]
784 fn from_publish_request_did_key_self_revocation_rejected() {
785 let (key_id, fingerprint) = did_key_fixture([1u8; 32]);
786 let mut meta = pr_valid_metadata();
787 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
788 let mut req = publish_request_with_metadata(Some(meta));
789 req.signature.key_id = key_id;
790 assert!(matches!(
791 KeyRevocation::from_publish_request(&req),
792 Err(AcdpError::KeyNotAuthorized(_))
793 ));
794 }
795
796 #[test]
802 fn from_publish_request_did_key_different_key_accepted() {
803 let (key_id, _fingerprint) = did_key_fixture([2u8; 32]);
804 let meta = pr_valid_metadata(); let mut req = publish_request_with_metadata(Some(meta));
806 req.signature.key_id = key_id;
807 let rev = KeyRevocation::from_publish_request(&req)
808 .expect("did:key signer whose fingerprint differs from the revoked key must pass");
809 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
810 }
811
812 #[test]
819 fn from_publish_request_malformed_did_key_key_id_not_rejected_here() {
820 let (key_id, fingerprint) = did_key_fixture([3u8; 32]);
821 let malformed_key_id = format!("{}-not-the-msi", key_id); let mut meta = pr_valid_metadata();
823 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
824 let mut req = publish_request_with_metadata(Some(meta));
825 req.signature.key_id = malformed_key_id;
826 let rev = KeyRevocation::from_publish_request(&req).expect(
827 "malformed did:key key_id is left for signature verification, not rejected here",
828 );
829 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
830 }
831
832 fn registry_attested_rev(publisher: &str) -> KeyRevocation {
833 KeyRevocation {
834 revoked_key_fingerprint: format!("sha256:{}", "a1".repeat(32)),
835 compromised_since: parse_canonical_ms("2026-05-01T00:00:00.000Z").unwrap(),
836 reason: None,
837 revoked_key_id: None,
838 revoked_key_controller: AgentDid::new("did:web:agents.example.com:producer"),
839 publisher: AgentDid::new(publisher),
840 trust_class: RevocationTrustClass::RegistryAttested,
841 ctx_id: None,
842 }
843 }
844
845 #[test]
849 fn cross_check_registry_binding_success_and_both_failure_directions() {
850 let rev = registry_attested_rev("did:web:registry.example.com");
851
852 rev.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
853 .expect("serving authority and capabilities.registry_did both match publisher");
854
855 assert!(matches!(
857 rev.cross_check_registry_binding("hostile.example", "did:web:registry.example.com"),
858 Err(AcdpError::KeyNotAuthorized(_))
859 ));
860
861 assert!(matches!(
863 rev.cross_check_registry_binding("registry.example.com", "did:web:other.example"),
864 Err(AcdpError::KeyNotAuthorized(_))
865 ));
866 }
867
868 #[test]
873 fn cross_check_registry_binding_percent_encoded_port_authority() {
874 let rev = registry_attested_rev("did:web:localhost%3A8443");
875
876 rev.cross_check_registry_binding("localhost:8443", "did:web:localhost%3A8443")
877 .expect("host:port authority round-trips through authority_to_did_web");
878
879 assert!(matches!(
882 rev.cross_check_registry_binding("localhost", "did:web:localhost%3A8443"),
883 Err(AcdpError::KeyNotAuthorized(_))
884 ));
885 }
886
887 #[test]
888 fn fingerprint_form_edges() {
889 assert!(is_sha256_fingerprint(&format!(
890 "sha256:{}",
891 "a1".repeat(32)
892 )));
893 assert!(!is_sha256_fingerprint(&format!(
894 "sha256:{}",
895 "A1".repeat(32)
896 ))); assert!(!is_sha256_fingerprint(&format!(
898 "sha512:{}",
899 "a1".repeat(32)
900 ))); assert!(!is_sha256_fingerprint(&format!(
902 "sha256:{}",
903 "a1".repeat(31)
904 ))); assert!(!is_sha256_fingerprint("sha256:")); assert!(!is_sha256_fingerprint(&"a1".repeat(32))); }
908
909 #[test]
910 fn canonical_ms_timestamp_edges() {
911 assert!(parse_canonical_ms("2026-05-01T00:00:00.000Z").is_some());
912 for bad in [
914 "2026-05-01T00:00:00Z", "2026-05-01T00:00:00.0Z", "2026-05-01T00:00:00.000000Z", "2026-05-01T00:00:00.000+00:00", "2026-05-01 00:00:00.000Z", "not-a-time",
920 ] {
921 assert!(
922 parse_canonical_ms(bad).is_none(),
923 "{bad:?} must be rejected"
924 );
925 }
926 }
927
928 const EB_FP: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
935 const EB_OTHER_FP: &str =
936 "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
937
938 fn eb_rev(fp: &str, t: &str) -> KeyRevocation {
939 KeyRevocation {
940 revoked_key_fingerprint: fp.into(),
941 compromised_since: DateTime::parse_from_rfc3339(t).unwrap().with_timezone(&Utc),
942 reason: None,
943 revoked_key_id: None,
944 revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
945 publisher: AgentDid::new("did:web:agents.example.com:p"),
946 trust_class: RevocationTrustClass::ProducerSigned,
947 ctx_id: None,
948 }
949 }
950
951 #[test]
952 fn effective_boundary_empty_slice_is_none() {
953 let revs: [KeyRevocation; 0] = [];
954 assert_eq!(effective_boundary(&revs, EB_FP), None);
955 }
956
957 #[test]
958 fn effective_boundary_single_match() {
959 let revs = [eb_rev(EB_FP, "2026-05-01T00:00:00.000Z")];
960 assert_eq!(
961 effective_boundary(&revs, EB_FP),
962 Some(
963 DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
964 .unwrap()
965 .with_timezone(&Utc)
966 )
967 );
968 }
969
970 #[test]
974 fn effective_boundary_min_folds_across_multiple_entries_same_fingerprint() {
975 let revs = [
976 eb_rev(EB_FP, "2026-06-01T00:00:00.000Z"),
977 eb_rev(EB_FP, "2026-04-01T00:00:00.000Z"), eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
979 ];
980 assert_eq!(
981 effective_boundary(&revs, EB_FP),
982 Some(
983 DateTime::parse_from_rfc3339("2026-04-01T00:00:00.000Z")
984 .unwrap()
985 .with_timezone(&Utc)
986 )
987 );
988 }
989
990 #[test]
994 fn effective_boundary_ignores_non_matching_fingerprints() {
995 let revs = [
996 eb_rev(EB_OTHER_FP, "2026-01-01T00:00:00.000Z"),
997 eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
998 eb_rev(EB_OTHER_FP, "2026-02-01T00:00:00.000Z"),
999 ];
1000 assert_eq!(
1001 effective_boundary(&revs, EB_FP),
1002 Some(
1003 DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
1004 .unwrap()
1005 .with_timezone(&Utc)
1006 )
1007 );
1008 const UNRELATED_FP: &str =
1012 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1013 assert_eq!(effective_boundary(&revs, UNRELATED_FP), None);
1014 }
1015}