1use crate::body::Body;
19use crate::publish::PublishRequest;
20use acdp_primitives::error::AcdpError;
21use acdp_primitives::primitives::{AgentDid, ContextType, Visibility};
22use acdp_primitives::time::fmt_rfc3339_ms;
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25
26pub const MAX_REASON_CHARS: usize = 1024;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum RevocationTrustClass {
35 ProducerSigned,
39 RegistryAttested,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct KeyRevocation {
64 pub revoked_key_fingerprint: String,
68 pub compromised_since: DateTime<Utc>,
71 pub reason: Option<String>,
75 pub revoked_key_id: Option<String>,
78 pub revoked_key_controller: AgentDid,
83 pub publisher: AgentDid,
87 pub trust_class: RevocationTrustClass,
95}
96
97impl KeyRevocation {
98 pub fn from_body(body: &Body) -> Result<Self, AcdpError> {
125 Self::from_parts(
126 &body.context_type,
127 &body.visibility,
128 body.metadata.as_ref(),
129 &body.agent_id,
130 &body.signature.key_id,
131 )
132 }
133
134 pub fn from_publish_request(req: &PublishRequest) -> Result<Self, AcdpError> {
145 Self::from_parts(
146 &req.context_type,
147 &req.visibility,
148 req.metadata.as_ref(),
149 &req.agent_id,
150 &req.signature.key_id,
151 )
152 }
153
154 fn from_parts(
161 context_type: &ContextType,
162 visibility: &Visibility,
163 metadata: Option<&serde_json::Value>,
164 agent_id: &AgentDid,
165 signing_key_id: &str,
166 ) -> Result<Self, AcdpError> {
167 if !context_type.is_key_revocation() {
168 return Err(AcdpError::SchemaViolation(format!(
169 "not a key-revocation context: type is '{}' (RFC-ACDP-0014 §4 requires \
170 'key-revocation', or 'acdp:key-revocation' in the pre-0.3.0 interim form)",
171 serde_json::to_value(context_type)
172 .ok()
173 .and_then(|v| v.as_str().map(str::to_owned))
174 .unwrap_or_default()
175 )));
176 }
177 if *visibility != Visibility::Public {
178 return Err(AcdpError::SchemaViolation(
179 "a key-revocation context MUST be visibility 'public' — it is a safety \
180 broadcast; an audience-restricted revocation protects nobody outside the \
181 audience (RFC-ACDP-0014 §4)"
182 .into(),
183 ));
184 }
185
186 let meta = metadata.and_then(|m| m.as_object()).ok_or_else(|| {
187 AcdpError::SchemaViolation(
188 "key-revocation body has no metadata object; \
189 metadata.revoked_key_fingerprint and metadata.compromised_since are \
190 REQUIRED (RFC-ACDP-0014 §4)"
191 .into(),
192 )
193 })?;
194
195 let fingerprint = required_str(meta, "revoked_key_fingerprint")?;
196 if !is_sha256_fingerprint(fingerprint) {
197 return Err(AcdpError::SchemaViolation(format!(
198 "metadata.revoked_key_fingerprint '{fingerprint}' is not in the \
199 RFC-ACDP-0010 §6 form 'sha256:' + 64 lowercase hex (RFC-ACDP-0014 §4)"
200 )));
201 }
202
203 let since_raw = required_str(meta, "compromised_since")?;
204 let compromised_since = parse_canonical_ms(since_raw).ok_or_else(|| {
205 AcdpError::SchemaViolation(format!(
206 "metadata.compromised_since '{since_raw}' is not canonical \
207 millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3, RFC-ACDP-0014 §4)"
208 ))
209 })?;
210
211 let reason = optional_str(meta, "reason")?;
212 if let Some(r) = &reason {
213 if r.chars().count() > MAX_REASON_CHARS {
214 return Err(AcdpError::SchemaViolation(format!(
215 "metadata.reason exceeds {MAX_REASON_CHARS} characters (RFC-ACDP-0014 §4)"
216 )));
217 }
218 }
219 let revoked_key_id = optional_str(meta, "revoked_key_id")?;
220
221 let (revoked_key_controller, trust_class) =
222 match optional_str(meta, "revoked_key_controller")? {
223 None => (agent_id.clone(), RevocationTrustClass::ProducerSigned),
224 Some(c) => {
225 let controller = AgentDid::parse(&c)?;
226 if controller == *agent_id {
227 (controller, RevocationTrustClass::ProducerSigned)
230 } else {
231 (controller, RevocationTrustClass::RegistryAttested)
234 }
235 }
236 };
237
238 let revocation = KeyRevocation {
239 revoked_key_fingerprint: fingerprint.to_string(),
240 compromised_since,
241 reason,
242 revoked_key_id,
243 revoked_key_controller,
244 publisher: agent_id.clone(),
245 trust_class,
246 };
247
248 if signing_key_id.starts_with("did:key:") {
253 if let Ok(material) = acdp_did::key::resolve_did_key_url(signing_key_id) {
254 if let Ok(fp) = acdp_crypto::fingerprint::fingerprint_did_key_material(&material) {
255 revocation.check_not_self_signed(&fp)?;
256 }
257 }
258 }
259
260 Ok(revocation)
261 }
262
263 pub fn check_not_self_signed(&self, signing_key_fingerprint: &str) -> Result<(), AcdpError> {
274 if signing_key_fingerprint == self.revoked_key_fingerprint {
275 return Err(AcdpError::KeyNotAuthorized(format!(
276 "revocation of key {} is signed by that same key — a key is not \
277 authorized to attest its own compromise; treat as unverified \
278 (RFC-ACDP-0014 §5 step 2)",
279 self.revoked_key_fingerprint
280 )));
281 }
282 Ok(())
283 }
284
285 pub fn revokes(&self, key_fingerprint: &str) -> bool {
288 self.revoked_key_fingerprint == key_fingerprint
289 }
290
291 pub fn cross_check_registry_binding(
318 &self,
319 serving_authority: &str,
320 capabilities_registry_did: &str,
321 ) -> Result<(), AcdpError> {
322 let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
323 if self.publisher.as_str() != expected_did {
324 return Err(AcdpError::KeyNotAuthorized(format!(
325 "key-revocation publisher '{}' ≠ serving authority's DID '{expected_did}' \
326 (RFC-ACDP-0014 §6 steps 2–3)",
327 self.publisher
328 )));
329 }
330 if self.publisher.as_str() != capabilities_registry_did {
331 return Err(AcdpError::KeyNotAuthorized(format!(
332 "key-revocation publisher '{}' ≠ capabilities.registry_did \
333 '{capabilities_registry_did}' (RFC-ACDP-0014 §6 steps 2–3)",
334 self.publisher
335 )));
336 }
337 Ok(())
338 }
339}
340
341pub fn effective_boundary<'a>(
350 revocations: impl IntoIterator<Item = &'a KeyRevocation>,
351 key_fingerprint: &str,
352) -> Option<DateTime<Utc>> {
353 revocations
354 .into_iter()
355 .filter(|r| r.revokes(key_fingerprint))
356 .map(|r| r.compromised_since)
357 .min()
358}
359
360fn required_str<'m>(
361 meta: &'m serde_json::Map<String, serde_json::Value>,
362 key: &str,
363) -> Result<&'m str, AcdpError> {
364 meta.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
365 AcdpError::SchemaViolation(format!(
366 "key-revocation metadata.{key} is REQUIRED and must be a string \
367 (RFC-ACDP-0014 §4)"
368 ))
369 })
370}
371
372fn optional_str(
373 meta: &serde_json::Map<String, serde_json::Value>,
374 key: &str,
375) -> Result<Option<String>, AcdpError> {
376 match meta.get(key) {
377 None => Ok(None),
378 Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
379 Some(_) => Err(AcdpError::SchemaViolation(format!(
380 "key-revocation metadata.{key} must be a string when present (RFC-ACDP-0014 §4)"
381 ))),
382 }
383}
384
385fn is_sha256_fingerprint(s: &str) -> bool {
387 match s.strip_prefix("sha256:") {
388 Some(hex) => {
389 hex.len() == 64
390 && hex
391 .chars()
392 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
393 }
394 None => false,
395 }
396}
397
398fn parse_canonical_ms(raw: &str) -> Option<DateTime<Utc>> {
402 let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc);
403 (fmt_rfc3339_ms(parsed) == raw).then_some(parsed)
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::body::Signature;
410 use acdp_primitives::primitives::{ContentHash, CtxId, LineageId};
411
412 const PR_PRODUCER_DID: &str = "did:web:agents.example.com:pr-test-producer";
417 const PR_COMPROMISED_SINCE: &str = "2026-05-01T00:00:00.000Z";
418
419 fn pr_valid_metadata() -> serde_json::Value {
420 serde_json::json!({
421 "revoked_key_fingerprint": format!("sha256:{}", "a".repeat(64)),
422 "compromised_since": PR_COMPROMISED_SINCE,
423 })
424 }
425
426 fn publish_request_with_metadata(metadata: Option<serde_json::Value>) -> PublishRequest {
427 PublishRequest {
428 version: 1,
429 supersedes: None,
430 agent_id: AgentDid::new(PR_PRODUCER_DID),
431 contributors: vec![],
432 title: "Key revocation — key-1 compromised".into(),
433 context_type: ContextType::KeyRevocation,
434 data_refs: vec![],
435 derived_from: vec![],
436 visibility: Visibility::Public,
437 content_hash: ContentHash("sha256:0".into()),
438 signature: Signature {
439 algorithm: "ed25519".into(),
440 key_id: format!("{PR_PRODUCER_DID}#key-1"),
441 value: "A".repeat(88),
442 },
443 audience: None,
444 acdp_version: Some("0.3.0".into()),
445 description: None,
446 summary: None,
447 lineage_id: None,
448 tags: None,
449 domain: None,
450 expires_at: None,
451 data_period: None,
452 metadata,
453 schema_uri: None,
454 anchors: None,
455 }
456 }
457
458 #[test]
462 fn from_publish_request_valid_case_is_accepted() {
463 let req = publish_request_with_metadata(Some(pr_valid_metadata()));
464 let rev =
465 KeyRevocation::from_publish_request(&req).expect("shape-conformant request must parse");
466 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
467 assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
468 assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
469 }
470
471 #[test]
472 fn from_publish_request_wrong_context_type_rejected() {
473 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
474 req.context_type = ContextType::Analysis;
475 assert!(matches!(
476 KeyRevocation::from_publish_request(&req),
477 Err(AcdpError::SchemaViolation(_))
478 ));
479 }
480
481 #[test]
482 fn from_publish_request_non_public_visibility_rejected() {
483 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
484 req.visibility = Visibility::Restricted;
485 assert!(matches!(
486 KeyRevocation::from_publish_request(&req),
487 Err(AcdpError::SchemaViolation(_))
488 ));
489 }
490
491 #[test]
492 fn from_publish_request_missing_metadata_rejected() {
493 let req = publish_request_with_metadata(None);
494 assert!(matches!(
495 KeyRevocation::from_publish_request(&req),
496 Err(AcdpError::SchemaViolation(_))
497 ));
498 }
499
500 #[test]
501 fn from_publish_request_missing_fingerprint_rejected() {
502 let mut meta = pr_valid_metadata();
503 meta.as_object_mut()
504 .unwrap()
505 .remove("revoked_key_fingerprint");
506 let req = publish_request_with_metadata(Some(meta));
507 assert!(matches!(
508 KeyRevocation::from_publish_request(&req),
509 Err(AcdpError::SchemaViolation(_))
510 ));
511 }
512
513 #[test]
514 fn from_publish_request_malformed_fingerprint_rejected() {
515 let mut meta = pr_valid_metadata();
516 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
517 let req = publish_request_with_metadata(Some(meta));
518 assert!(matches!(
519 KeyRevocation::from_publish_request(&req),
520 Err(AcdpError::SchemaViolation(_))
521 ));
522 }
523
524 #[test]
525 fn from_publish_request_missing_compromised_since_rejected() {
526 let mut meta = pr_valid_metadata();
527 meta.as_object_mut().unwrap().remove("compromised_since");
528 let req = publish_request_with_metadata(Some(meta));
529 assert!(matches!(
530 KeyRevocation::from_publish_request(&req),
531 Err(AcdpError::SchemaViolation(_))
532 ));
533 }
534
535 #[test]
536 fn from_publish_request_non_canonical_compromised_since_rejected() {
537 let mut meta = pr_valid_metadata();
538 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
541 let req = publish_request_with_metadata(Some(meta));
542 assert!(matches!(
543 KeyRevocation::from_publish_request(&req),
544 Err(AcdpError::SchemaViolation(_))
545 ));
546 }
547
548 #[test]
549 fn from_publish_request_reason_over_limit_rejected() {
550 let mut meta = pr_valid_metadata();
551 meta["reason"] = serde_json::json!("x".repeat(MAX_REASON_CHARS + 1));
552 let req = publish_request_with_metadata(Some(meta));
553 assert!(matches!(
554 KeyRevocation::from_publish_request(&req),
555 Err(AcdpError::SchemaViolation(_))
556 ));
557 }
558
559 #[test]
564 fn from_body_and_from_publish_request_agree_on_equivalent_input() {
565 let metadata = Some(pr_valid_metadata());
566 let req = publish_request_with_metadata(metadata.clone());
567 let body = body_from_pr_request(&req);
568
569 assert_eq!(
570 KeyRevocation::from_publish_request(&req).unwrap(),
571 KeyRevocation::from_body(&body).unwrap()
572 );
573 }
574
575 fn body_from_pr_request(req: &PublishRequest) -> Body {
579 Body::from_publish_request(
580 req,
581 CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000000".into()),
582 LineageId(format!("lin:sha256:{}", "0".repeat(64))),
583 "registry.example.com",
584 DateTime::parse_from_rfc3339("2026-05-02T08:00:00.000Z")
585 .unwrap()
586 .with_timezone(&Utc),
587 )
588 }
589
590 #[test]
598 fn from_body_and_from_publish_request_agree_on_error_message() {
599 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
601 req.visibility = Visibility::Restricted;
602 let body = body_from_pr_request(&req);
603 let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
604 let body_err = KeyRevocation::from_body(&body).unwrap_err();
605 assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
606 assert_eq!(pr_err.to_string(), body_err.to_string());
607
608 let mut meta = pr_valid_metadata();
610 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
611 let req = publish_request_with_metadata(Some(meta));
612 let body = body_from_pr_request(&req);
613 let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
614 let body_err = KeyRevocation::from_body(&body).unwrap_err();
615 assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
616 assert_eq!(pr_err.to_string(), body_err.to_string());
617 }
618
619 #[test]
626 fn from_publish_request_controller_equal_to_agent_id_is_producer_signed() {
627 let mut meta = pr_valid_metadata();
628 meta["revoked_key_controller"] = serde_json::json!(PR_PRODUCER_DID);
629 let req = publish_request_with_metadata(Some(meta));
630 let rev = KeyRevocation::from_publish_request(&req).unwrap();
631 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
632 assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
633 }
634
635 #[test]
639 fn from_publish_request_controller_different_from_agent_id_is_registry_attested() {
640 const OTHER_PRODUCER: &str = "did:web:agents.example.com:other-producer";
641 let mut meta = pr_valid_metadata();
642 meta["revoked_key_controller"] = serde_json::json!(OTHER_PRODUCER);
643 let req = publish_request_with_metadata(Some(meta));
644 let rev = KeyRevocation::from_publish_request(&req).unwrap();
645 assert_eq!(rev.trust_class, RevocationTrustClass::RegistryAttested);
646 assert_eq!(rev.revoked_key_controller.as_str(), OTHER_PRODUCER);
647 assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
648 }
649
650 #[test]
651 fn from_publish_request_controller_not_a_string_rejected() {
652 let mut meta = pr_valid_metadata();
653 meta["revoked_key_controller"] = serde_json::json!(42);
654 let req = publish_request_with_metadata(Some(meta));
655 assert!(matches!(
656 KeyRevocation::from_publish_request(&req),
657 Err(AcdpError::SchemaViolation(_))
658 ));
659 }
660
661 #[test]
662 fn from_publish_request_controller_invalid_did_rejected() {
663 let mut meta = pr_valid_metadata();
664 meta["revoked_key_controller"] = serde_json::json!("not-a-did");
665 let req = publish_request_with_metadata(Some(meta));
666 assert!(matches!(
667 KeyRevocation::from_publish_request(&req),
668 Err(AcdpError::SchemaViolation(_))
669 ));
670 }
671
672 fn did_key_fixture(seed: [u8; 32]) -> (String, String) {
688 let signing_key = acdp_crypto::SigningKey::from_bytes(&seed);
689 let public_key = signing_key.verifying_key_bytes();
690 let did = acdp_did::key::did_key_from_ed25519(&public_key);
691 let key_id = acdp_did::key::did_key_url(&did).unwrap();
692 let fingerprint = acdp_crypto::fingerprint::fingerprint_ed25519(&public_key);
693 (key_id, fingerprint)
694 }
695
696 #[test]
702 fn from_publish_request_did_key_self_revocation_rejected() {
703 let (key_id, fingerprint) = did_key_fixture([1u8; 32]);
704 let mut meta = pr_valid_metadata();
705 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
706 let mut req = publish_request_with_metadata(Some(meta));
707 req.signature.key_id = key_id;
708 assert!(matches!(
709 KeyRevocation::from_publish_request(&req),
710 Err(AcdpError::KeyNotAuthorized(_))
711 ));
712 }
713
714 #[test]
720 fn from_publish_request_did_key_different_key_accepted() {
721 let (key_id, _fingerprint) = did_key_fixture([2u8; 32]);
722 let meta = pr_valid_metadata(); let mut req = publish_request_with_metadata(Some(meta));
724 req.signature.key_id = key_id;
725 let rev = KeyRevocation::from_publish_request(&req)
726 .expect("did:key signer whose fingerprint differs from the revoked key must pass");
727 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
728 }
729
730 #[test]
737 fn from_publish_request_malformed_did_key_key_id_not_rejected_here() {
738 let (key_id, fingerprint) = did_key_fixture([3u8; 32]);
739 let malformed_key_id = format!("{}-not-the-msi", key_id); let mut meta = pr_valid_metadata();
741 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
742 let mut req = publish_request_with_metadata(Some(meta));
743 req.signature.key_id = malformed_key_id;
744 let rev = KeyRevocation::from_publish_request(&req).expect(
745 "malformed did:key key_id is left for signature verification, not rejected here",
746 );
747 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
748 }
749
750 fn registry_attested_rev(publisher: &str) -> KeyRevocation {
751 KeyRevocation {
752 revoked_key_fingerprint: format!("sha256:{}", "a1".repeat(32)),
753 compromised_since: parse_canonical_ms("2026-05-01T00:00:00.000Z").unwrap(),
754 reason: None,
755 revoked_key_id: None,
756 revoked_key_controller: AgentDid::new("did:web:agents.example.com:producer"),
757 publisher: AgentDid::new(publisher),
758 trust_class: RevocationTrustClass::RegistryAttested,
759 }
760 }
761
762 #[test]
766 fn cross_check_registry_binding_success_and_both_failure_directions() {
767 let rev = registry_attested_rev("did:web:registry.example.com");
768
769 rev.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
770 .expect("serving authority and capabilities.registry_did both match publisher");
771
772 assert!(matches!(
774 rev.cross_check_registry_binding("hostile.example", "did:web:registry.example.com"),
775 Err(AcdpError::KeyNotAuthorized(_))
776 ));
777
778 assert!(matches!(
780 rev.cross_check_registry_binding("registry.example.com", "did:web:other.example"),
781 Err(AcdpError::KeyNotAuthorized(_))
782 ));
783 }
784
785 #[test]
790 fn cross_check_registry_binding_percent_encoded_port_authority() {
791 let rev = registry_attested_rev("did:web:localhost%3A8443");
792
793 rev.cross_check_registry_binding("localhost:8443", "did:web:localhost%3A8443")
794 .expect("host:port authority round-trips through authority_to_did_web");
795
796 assert!(matches!(
799 rev.cross_check_registry_binding("localhost", "did:web:localhost%3A8443"),
800 Err(AcdpError::KeyNotAuthorized(_))
801 ));
802 }
803
804 #[test]
805 fn fingerprint_form_edges() {
806 assert!(is_sha256_fingerprint(&format!(
807 "sha256:{}",
808 "a1".repeat(32)
809 )));
810 assert!(!is_sha256_fingerprint(&format!(
811 "sha256:{}",
812 "A1".repeat(32)
813 ))); assert!(!is_sha256_fingerprint(&format!(
815 "sha512:{}",
816 "a1".repeat(32)
817 ))); assert!(!is_sha256_fingerprint(&format!(
819 "sha256:{}",
820 "a1".repeat(31)
821 ))); assert!(!is_sha256_fingerprint("sha256:")); assert!(!is_sha256_fingerprint(&"a1".repeat(32))); }
825
826 #[test]
827 fn canonical_ms_timestamp_edges() {
828 assert!(parse_canonical_ms("2026-05-01T00:00:00.000Z").is_some());
829 for bad in [
831 "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",
837 ] {
838 assert!(
839 parse_canonical_ms(bad).is_none(),
840 "{bad:?} must be rejected"
841 );
842 }
843 }
844
845 const EB_FP: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
852 const EB_OTHER_FP: &str =
853 "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
854
855 fn eb_rev(fp: &str, t: &str) -> KeyRevocation {
856 KeyRevocation {
857 revoked_key_fingerprint: fp.into(),
858 compromised_since: DateTime::parse_from_rfc3339(t).unwrap().with_timezone(&Utc),
859 reason: None,
860 revoked_key_id: None,
861 revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
862 publisher: AgentDid::new("did:web:agents.example.com:p"),
863 trust_class: RevocationTrustClass::ProducerSigned,
864 }
865 }
866
867 #[test]
868 fn effective_boundary_empty_slice_is_none() {
869 let revs: [KeyRevocation; 0] = [];
870 assert_eq!(effective_boundary(&revs, EB_FP), None);
871 }
872
873 #[test]
874 fn effective_boundary_single_match() {
875 let revs = [eb_rev(EB_FP, "2026-05-01T00:00:00.000Z")];
876 assert_eq!(
877 effective_boundary(&revs, EB_FP),
878 Some(
879 DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
880 .unwrap()
881 .with_timezone(&Utc)
882 )
883 );
884 }
885
886 #[test]
890 fn effective_boundary_min_folds_across_multiple_entries_same_fingerprint() {
891 let revs = [
892 eb_rev(EB_FP, "2026-06-01T00:00:00.000Z"),
893 eb_rev(EB_FP, "2026-04-01T00:00:00.000Z"), eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
895 ];
896 assert_eq!(
897 effective_boundary(&revs, EB_FP),
898 Some(
899 DateTime::parse_from_rfc3339("2026-04-01T00:00:00.000Z")
900 .unwrap()
901 .with_timezone(&Utc)
902 )
903 );
904 }
905
906 #[test]
910 fn effective_boundary_ignores_non_matching_fingerprints() {
911 let revs = [
912 eb_rev(EB_OTHER_FP, "2026-01-01T00:00:00.000Z"),
913 eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
914 eb_rev(EB_OTHER_FP, "2026-02-01T00:00:00.000Z"),
915 ];
916 assert_eq!(
917 effective_boundary(&revs, EB_FP),
918 Some(
919 DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
920 .unwrap()
921 .with_timezone(&Utc)
922 )
923 );
924 const UNRELATED_FP: &str =
928 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
929 assert_eq!(effective_boundary(&revs, UNRELATED_FP), None);
930 }
931}