1use acdp_crypto::hash::{compute_content_hash, derive_lineage_id};
6use acdp_primitives::error::AcdpError;
7use acdp_types::{
8 body::Body,
9 capabilities::CapabilitiesDocument,
10 primitives::{ContentHash, CtxId, LineageId},
11 publish::PublishRequest,
12 revocation::KeyRevocation,
13};
14
15#[derive(Debug)]
18pub struct ValidatedPublish {
19 pub recomputed_hash: ContentHash,
21}
22
23pub struct PublishValidator<'a> {
29 caps: &'a CapabilitiesDocument,
30 own_authority: Option<&'a str>,
31}
32
33impl<'a> PublishValidator<'a> {
34 pub fn new(caps: &'a CapabilitiesDocument) -> Self {
36 Self {
37 caps,
38 own_authority: None,
39 }
40 }
41
42 pub fn for_authority(caps: &'a CapabilitiesDocument, own_authority: &'a str) -> Self {
50 Self {
51 caps,
52 own_authority: Some(own_authority),
53 }
54 }
55
56 pub fn validate_post_schema(
75 &self,
76 req: &PublishRequest,
77 raw_body_bytes: usize,
78 ) -> Result<ValidatedPublish, AcdpError> {
79 acdp_validation::validate_publish_request(req)?;
88 self.validate_registry_limits_and_crypto(req, raw_body_bytes)
89 }
90
91 #[deprecated(
100 since = "0.1.0",
101 note = "Use validate_post_schema; this alias no longer skips runtime validation"
102 )]
103 pub fn validate_structural(
104 &self,
105 req: &PublishRequest,
106 raw_body_bytes: usize,
107 ) -> Result<ValidatedPublish, AcdpError> {
108 self.validate_post_schema(req, raw_body_bytes)
109 }
110
111 fn validate_registry_limits_and_crypto(
115 &self,
116 req: &PublishRequest,
117 raw_body_bytes: usize,
118 ) -> Result<ValidatedPublish, AcdpError> {
119 if raw_body_bytes as u64 > self.caps.limits.max_payload_bytes {
121 return Err(AcdpError::SchemaViolation(format!(
122 "payload {} bytes exceeds limit {}",
123 raw_body_bytes, self.caps.limits.max_payload_bytes
124 )));
125 }
126
127 for dr in &req.data_refs {
130 if let Some(emb) = &dr.embedded {
131 let decoded = acdp_validation::embedded_decoded_bytes(emb)?;
132 if decoded.len() as u64 > self.caps.limits.max_embedded_bytes {
133 return Err(AcdpError::EmbeddedTooLarge(format!(
134 "embedded data reference {} bytes exceeds {} limit",
135 decoded.len(),
136 self.caps.limits.max_embedded_bytes
137 )));
138 }
139 acdp_validation::verify_embedded_hash(dr)?;
142 }
143 }
144
145 let body_val = serde_json::to_value(req)?;
147 let recomputed = compute_content_hash(&body_val)?;
148 if recomputed != req.content_hash {
149 return Err(AcdpError::HashMismatch {
150 stored: req.content_hash.clone(),
151 recomputed: recomputed.clone(),
152 });
153 }
154
155 if !self
157 .caps
158 .supported_signature_algorithms
159 .iter()
160 .any(|a| a == &req.signature.algorithm)
161 {
162 return Err(AcdpError::SchemaViolation(format!(
163 "unsupported algorithm '{}'; registry supports {:?}",
164 req.signature.algorithm, self.caps.supported_signature_algorithms,
165 )));
166 }
167
168 let agent_method = req
175 .agent_id
176 .as_str()
177 .splitn(3, ':')
178 .take(2)
179 .collect::<Vec<_>>()
180 .join(":");
181 if !self.caps.supports_did_method(&agent_method) {
182 return Err(AcdpError::KeyResolution(format!(
183 "agent_id method '{agent_method}' is not in this registry's \
184 supported_did_methods {:?}",
185 self.caps.supported_did_methods
186 )));
187 }
188
189 let key_id = &req.signature.key_id;
191 let did_part = key_id.split_once('#').map(|(d, _)| d).ok_or_else(|| {
192 AcdpError::KeyResolution(format!("key_id '{key_id}' has no '#fragment'"))
193 })?;
194
195 if did_part != req.agent_id.as_str() {
196 return Err(AcdpError::KeyNotAuthorized(format!(
197 "key_id DID '{did_part}' ≠ agent_id '{}'",
198 req.agent_id
199 )));
200 }
201
202 if let (Some(own), Some(target)) = (self.own_authority, &req.supersedes) {
204 let target_authority = target.authority();
205 if target_authority != own {
206 return Err(AcdpError::SupersededTarget {
207 reason: acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported,
208 message: format!(
209 "supersedes target on '{target_authority}' rejected by '{own}'; \
210 v0.1.0 only allows same-registry supersession"
211 ),
212 });
213 }
214 }
215
216 if req.context_type.is_key_revocation()
221 && key_revocation_gate_applies(&self.caps.acdp_version)
222 {
223 let revocation = KeyRevocation::from_publish_request(req)?;
224 self.check_revocation_controller(req, &revocation)?;
225 }
226
227 Ok(ValidatedPublish {
230 recomputed_hash: recomputed,
231 })
232 }
233
234 fn check_revocation_controller(
259 &self,
260 req: &PublishRequest,
261 revocation: &KeyRevocation,
262 ) -> Result<(), AcdpError> {
263 let controller_present = req
264 .metadata
265 .as_ref()
266 .and_then(|m| m.as_object())
267 .is_some_and(|m| m.contains_key("revoked_key_controller"));
268
269 let agent_is_registry = req.agent_id.as_str() == self.caps.registry_did;
270 let controller_differs = revocation.revoked_key_controller != req.agent_id;
271
272 if controller_present && controller_differs && !agent_is_registry {
273 return Err(AcdpError::SchemaViolation(format!(
275 "metadata.revoked_key_controller '{}' differs from agent_id '{}', but \
276 agent_id is not this registry's own DID ('{}'); a controller different \
277 from agent_id is only valid on a §6 registry-attested revocation \
278 (RFC-ACDP-0014 §4, §6)",
279 revocation.revoked_key_controller, req.agent_id, self.caps.registry_did
280 )));
281 }
282
283 if !controller_present && agent_is_registry {
284 return Err(AcdpError::SchemaViolation(format!(
286 "key-revocation published under this registry's own DID ('{}') has no \
287 metadata.revoked_key_controller; a registry-attested revocation MUST \
288 name the affected producer's DID as the controller (RFC-ACDP-0014 §4, §6)",
289 self.caps.registry_did
290 )));
291 }
292
293 Ok(())
294 }
295}
296
297fn is_well_formed_version(v: &str) -> bool {
304 let parts: Vec<&str> = v.split('.').collect();
305 parts.len() == 3
306 && parts
307 .iter()
308 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
309}
310
311pub fn key_revocation_gate_applies(acdp_version: &str) -> bool {
334 if !is_well_formed_version(acdp_version) {
335 return true;
336 }
337 let mut parts = acdp_version.split('.');
338 let major: u64 = match parts.next().and_then(|p| p.parse().ok()) {
339 Some(m) => m,
340 None => return true,
341 };
342 let minor: u64 = match parts.next().and_then(|p| p.parse().ok()) {
343 Some(m) => m,
344 None => return true,
345 };
346 major > 0 || minor >= 3
347}
348
349pub fn check_revocation_supersession(prev: &Body, req: &PublishRequest) -> Result<(), AcdpError> {
435 if !prev.context_type.is_key_revocation() {
436 return Ok(());
439 }
440
441 if !req.context_type.is_key_revocation() {
442 return Err(AcdpError::SchemaViolation(format!(
445 "ctx_id '{}' is a key-revocation context and MAY only be superseded by \
446 another key-revocation context (RFC-ACDP-0014 §4); the incoming publish \
447 from agent_id '{}' has type '{}'",
448 prev.ctx_id,
449 req.agent_id,
450 context_type_label(&req.context_type),
451 )));
452 }
453
454 let prev_revocation = match KeyRevocation::from_body(prev) {
457 Ok(r) => r,
458 Err(_) => {
459 return Ok(());
477 }
478 };
479
480 let incoming_revocation = KeyRevocation::from_publish_request(req)?;
481
482 if prev_revocation.trust_class == incoming_revocation.trust_class {
483 Ok(())
486 } else {
487 Err(AcdpError::SchemaViolation(format!(
489 "ctx_id '{}' is a key-revocation with signer class {:?}; the incoming \
490 supersession from agent_id '{}' is a key-revocation with signer class {:?} \
491 — a revocation MAY only be superseded by another key-revocation from the \
492 same signer class (RFC-ACDP-0014 §4)",
493 prev.ctx_id, prev_revocation.trust_class, req.agent_id, incoming_revocation.trust_class,
494 )))
495 }
496}
497
498fn context_type_label(context_type: &acdp_types::primitives::ContextType) -> String {
502 serde_json::to_value(context_type)
503 .ok()
504 .and_then(|v| v.as_str().map(str::to_owned))
505 .unwrap_or_else(|| "<unrepresentable>".into())
506}
507
508pub fn assign_identifiers(
521 authority: &str,
522 supersedes: &Option<CtxId>,
523 first_version_ctx_id: Option<&CtxId>,
524 _validated: &ValidatedPublish,
525) -> Result<(CtxId, LineageId), AcdpError> {
526 let uuid = uuid::Uuid::new_v4();
527 let ctx_id = CtxId(format!("acdp://{authority}/{uuid}"));
528 let lineage_source: &CtxId = match (supersedes, first_version_ctx_id) {
529 (None, _) => &ctx_id,
530 (Some(_), Some(v1)) => v1,
531 (Some(_), None) => {
532 return Err(AcdpError::SchemaViolation(
533 "supersession assignment requires the v1 ctx_id to derive lineage_id".into(),
534 ));
535 }
536 };
537 let lineage_id = derive_lineage_id(lineage_source);
538 Ok((ctx_id, lineage_id))
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use acdp_crypto::SigningKey;
545 use acdp_producer::Producer;
546 use acdp_types::{
547 capabilities::Limits,
548 primitives::{AgentDid, ContextType, Visibility},
549 revocation::RevocationTrustClass,
550 };
551
552 fn test_caps() -> CapabilitiesDocument {
553 CapabilitiesDocument {
554 acdp_version: "0.1.0".into(),
555 registry_did: "did:web:registry.example.com".into(),
556 supported_signature_algorithms: vec!["ed25519".into()],
557 supported_did_methods: vec!["did:web".into()],
558 profiles: vec!["acdp-registry-core".into()],
559 limits: Limits {
560 max_payload_bytes: 1_048_576,
561 max_embedded_bytes: 65_536,
562 idempotency_key_ttl_seconds: None,
563 max_publish_per_minute: None,
564 },
565 read_authentication_methods: vec![],
566 anonymous_public_reads: true,
567 supports_idempotency_key: false,
568 extensions: Default::default(),
569 }
570 }
571
572 fn test_request() -> PublishRequest {
573 let key = SigningKey::from_bytes(&[0u8; 32]);
574 let p = Producer::new(
575 key,
576 AgentDid::new("did:web:agents.example.com:test-producer"),
577 "did:web:agents.example.com:test-producer#key-1",
578 );
579 p.publish_request()
580 .title("Golden test vector — minimal first version")
581 .context_type(ContextType::DataSnapshot)
582 .visibility(Visibility::Public)
583 .build()
584 .unwrap()
585 }
586
587 #[test]
588 fn happy_path_validates() {
589 let caps = test_caps();
590 let v = PublishValidator::new(&caps);
591 let req = test_request();
592 let raw_len = serde_json::to_vec(&req).unwrap().len();
593 v.validate_post_schema(&req, raw_len).unwrap();
594 }
595
596 #[test]
597 fn payload_too_large_rejected() {
598 let mut caps = test_caps();
599 caps.limits.max_payload_bytes = 10;
600 let v = PublishValidator::new(&caps);
601 let req = test_request();
602 let err = v.validate_post_schema(&req, 1024).unwrap_err();
603 assert!(matches!(err, AcdpError::SchemaViolation(_)));
604 }
605
606 #[test]
607 fn unsupported_algorithm_rejected() {
608 let mut caps = test_caps();
609 caps.supported_signature_algorithms = vec!["secp256k1".into()];
610 let v = PublishValidator::new(&caps);
611 let req = test_request();
612 let err = v.validate_post_schema(&req, 1024).unwrap_err();
613 assert!(matches!(err, AcdpError::SchemaViolation(_)));
614 }
615
616 #[test]
617 fn key_id_without_fragment_rejected() {
618 let caps = test_caps();
619 let v = PublishValidator::new(&caps);
620 let mut req = test_request();
621 req.signature.key_id = "did:web:agents.example.com:test-producer".into();
622 let err = v.validate_post_schema(&req, 1024).unwrap_err();
623 assert!(matches!(err, AcdpError::KeyResolution(_)));
624 }
625
626 #[test]
627 fn key_id_did_must_match_agent_id() {
628 let caps = test_caps();
629 let v = PublishValidator::new(&caps);
630 let mut req = test_request();
631 req.signature.key_id = "did:web:other.example.com:attacker#key-1".into();
632 let err = v.validate_post_schema(&req, 1024).unwrap_err();
633 assert!(matches!(err, AcdpError::KeyNotAuthorized(_)));
634 }
635
636 #[test]
637 fn tampered_hash_detected() {
638 let caps = test_caps();
639 let v = PublishValidator::new(&caps);
640 let mut req = test_request();
641 req.title = "tampered title".into();
642 let err = v.validate_post_schema(&req, 1024).unwrap_err();
643 assert!(matches!(err, AcdpError::HashMismatch { .. }));
644 }
645
646 #[test]
647 fn assign_identifiers_first_version_derives_lineage_from_new_id() {
648 let v = ValidatedPublish {
649 recomputed_hash: ContentHash("sha256:abcd".into()),
650 };
651 let (ctx_id, lineage_id) =
652 assign_identifiers("registry.example.com", &None, None, &v).unwrap();
653 let expected = derive_lineage_id(&ctx_id);
654 assert_eq!(lineage_id, expected);
655 }
656
657 #[test]
658 fn assign_identifiers_supersession_uses_v1_ctx_id() {
659 let v = ValidatedPublish {
660 recomputed_hash: ContentHash("sha256:abcd".into()),
661 };
662 let v1 = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
663 let supersedes = Some(CtxId(
664 "acdp://registry.example.com/12345678-1234-4321-8123-123456781299".into(),
665 ));
666 let (_new_id, lineage_id) =
667 assign_identifiers("registry.example.com", &supersedes, Some(&v1), &v).unwrap();
668 assert_eq!(lineage_id, derive_lineage_id(&v1));
669 }
670
671 #[test]
672 fn cross_registry_supersession_rejected() {
673 let caps = test_caps();
674 let v = PublishValidator::for_authority(&caps, "registry.example.com");
675 let key = SigningKey::from_bytes(&[0u8; 32]);
677 let p = Producer::new(
678 key,
679 AgentDid::new("did:web:agents.example.com:test-producer"),
680 "did:web:agents.example.com:test-producer#key-1",
681 );
682 let other_reg =
683 CtxId("acdp://other.example.com/12345678-1234-4321-8123-123456781234".into());
684 let req = p
685 .supersede(other_reg)
686 .version(2)
687 .title("v2")
688 .context_type(ContextType::DataSnapshot)
689 .build()
690 .unwrap();
691 let raw_len = serde_json::to_vec(&req).unwrap().len();
692 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
693 match err {
694 AcdpError::SupersededTarget { reason, .. } => {
695 assert_eq!(
696 reason,
697 acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported
698 );
699 }
700 other => panic!("expected SupersededTarget, got {other:?}"),
701 }
702 }
703
704 #[test]
705 fn same_registry_supersession_passes_authority_check() {
706 let caps = test_caps();
707 let v = PublishValidator::for_authority(&caps, "registry.example.com");
708 let key = SigningKey::from_bytes(&[0u8; 32]);
709 let p = Producer::new(
710 key,
711 AgentDid::new("did:web:agents.example.com:test-producer"),
712 "did:web:agents.example.com:test-producer#key-1",
713 );
714 let same = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
715 let req = p
716 .supersede(same)
717 .version(2)
718 .title("v2")
719 .context_type(ContextType::DataSnapshot)
720 .build()
721 .unwrap();
722 let raw_len = serde_json::to_vec(&req).unwrap().len();
723 v.validate_post_schema(&req, raw_len).unwrap();
724 }
725
726 #[test]
727 fn assign_identifiers_supersession_without_v1_id_rejected() {
728 let v = ValidatedPublish {
729 recomputed_hash: ContentHash("sha256:abcd".into()),
730 };
731 let supersedes = Some(CtxId("acdp://x/y".into()));
732 let err = assign_identifiers("registry.example.com", &supersedes, None, &v).unwrap_err();
733 assert!(matches!(err, AcdpError::SchemaViolation(_)));
734 }
735
736 fn test_caps_v030() -> CapabilitiesDocument {
739 CapabilitiesDocument {
740 acdp_version: "0.3.0".into(),
741 ..test_caps()
742 }
743 }
744
745 fn test_caps_v020() -> CapabilitiesDocument {
746 CapabilitiesDocument {
747 acdp_version: "0.2.0".into(),
748 ..test_caps()
749 }
750 }
751
752 const REVOCATION_PRODUCER_DID: &str = "did:web:agents.example.com:test-producer";
753 const REVOCATION_OTHER_PRODUCER_DID: &str = "did:web:agents.example.com:other-producer";
754 const REVOCATION_FP: &str =
755 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
756 const REVOCATION_SINCE: &str = "2026-05-01T00:00:00.000Z";
757
758 fn valid_revocation_metadata() -> serde_json::Value {
759 serde_json::json!({
760 "revoked_key_fingerprint": REVOCATION_FP,
761 "compromised_since": REVOCATION_SINCE,
762 })
763 }
764
765 fn build_revocation_request(
770 agent_did: &str,
771 metadata: serde_json::Value,
772 acdp_version: &str,
773 ) -> PublishRequest {
774 let key = SigningKey::from_bytes(&[0u8; 32]);
775 let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
776 p.publish_request()
777 .title("Key revocation test")
778 .context_type(ContextType::KeyRevocation)
779 .visibility(Visibility::Public)
780 .acdp_version(acdp_version)
781 .metadata(metadata)
782 .build()
783 .unwrap()
784 }
785
786 #[test]
789 fn revocation_arm1_absent_controller_accepted_at_0_3_0() {
790 let caps = test_caps_v030();
791 let v = PublishValidator::new(&caps);
792 let req = build_revocation_request(
793 REVOCATION_PRODUCER_DID,
794 valid_revocation_metadata(),
795 "0.3.0",
796 );
797 let raw_len = serde_json::to_vec(&req).unwrap().len();
798 v.validate_post_schema(&req, raw_len).unwrap();
799 }
800
801 #[test]
804 fn revocation_arm2_explicit_matching_controller_accepted_at_0_3_0() {
805 let caps = test_caps_v030();
806 let v = PublishValidator::new(&caps);
807 let mut meta = valid_revocation_metadata();
808 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
809 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
810 let raw_len = serde_json::to_vec(&req).unwrap().len();
811 v.validate_post_schema(&req, raw_len).unwrap();
812 }
813
814 #[test]
817 fn revocation_arm3_registry_attested_accepted_at_0_3_0() {
818 let caps = test_caps_v030();
819 let registry_did = caps.registry_did.clone();
820 let v = PublishValidator::new(&caps);
821 let mut meta = valid_revocation_metadata();
822 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
823 let req = build_revocation_request(®istry_did, meta, "0.3.0");
824 let raw_len = serde_json::to_vec(&req).unwrap().len();
825 v.validate_post_schema(&req, raw_len).unwrap();
826 }
827
828 #[test]
831 fn revocation_arm4_mismatched_controller_rejected_at_0_3_0() {
832 let caps = test_caps_v030();
833 let v = PublishValidator::new(&caps);
834 let mut meta = valid_revocation_metadata();
835 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
836 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
837 let raw_len = serde_json::to_vec(&req).unwrap().len();
838 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
839 assert!(matches!(err, AcdpError::SchemaViolation(_)));
840 }
841
842 #[test]
843 fn revocation_arm4_accepted_at_0_2_0_positive_control() {
844 let caps = test_caps_v020();
845 let v = PublishValidator::new(&caps);
846 let mut meta = valid_revocation_metadata();
847 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
848 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
849 let raw_len = serde_json::to_vec(&req).unwrap().len();
850 v.validate_post_schema(&req, raw_len).unwrap();
851 }
852
853 #[test]
860 fn revocation_arm5_absent_controller_under_registry_did_rejected_at_0_3_0() {
861 let caps = test_caps_v030();
862 let registry_did = caps.registry_did.clone();
863 let v = PublishValidator::new(&caps);
864 let req = build_revocation_request(®istry_did, valid_revocation_metadata(), "0.3.0");
865 let raw_len = serde_json::to_vec(&req).unwrap().len();
866 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
867 assert!(matches!(err, AcdpError::SchemaViolation(_)));
868 }
869
870 #[test]
871 fn revocation_arm5_accepted_at_0_2_0_positive_control() {
872 let caps = test_caps_v020();
873 let registry_did = caps.registry_did.clone();
874 let v = PublishValidator::new(&caps);
875 let req = build_revocation_request(®istry_did, valid_revocation_metadata(), "0.3.0");
876 let raw_len = serde_json::to_vec(&req).unwrap().len();
877 v.validate_post_schema(&req, raw_len).unwrap();
878 }
879
880 #[test]
881 fn revocation_non_public_visibility_rejected_at_0_3_0() {
882 let caps = test_caps_v030();
883 let v = PublishValidator::new(&caps);
884 let key = SigningKey::from_bytes(&[0u8; 32]);
885 let p = Producer::new(
886 key,
887 AgentDid::new(REVOCATION_PRODUCER_DID),
888 format!("{REVOCATION_PRODUCER_DID}#key-1"),
889 );
890 let req = p
891 .publish_request()
892 .title("Key revocation test")
893 .context_type(ContextType::KeyRevocation)
894 .visibility(Visibility::Restricted)
895 .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
896 .acdp_version("0.3.0")
897 .metadata(valid_revocation_metadata())
898 .build()
899 .unwrap();
900 let raw_len = serde_json::to_vec(&req).unwrap().len();
901 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
902 assert!(matches!(err, AcdpError::SchemaViolation(_)));
903 }
904
905 #[test]
906 fn revocation_non_public_visibility_accepted_at_0_2_0_positive_control() {
907 let caps = test_caps_v020();
908 let v = PublishValidator::new(&caps);
909 let key = SigningKey::from_bytes(&[0u8; 32]);
910 let p = Producer::new(
911 key,
912 AgentDid::new(REVOCATION_PRODUCER_DID),
913 format!("{REVOCATION_PRODUCER_DID}#key-1"),
914 );
915 let req = p
916 .publish_request()
917 .title("Key revocation test")
918 .context_type(ContextType::KeyRevocation)
919 .visibility(Visibility::Restricted)
920 .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
921 .acdp_version("0.3.0")
922 .metadata(valid_revocation_metadata())
923 .build()
924 .unwrap();
925 let raw_len = serde_json::to_vec(&req).unwrap().len();
926 v.validate_post_schema(&req, raw_len).unwrap();
927 }
928
929 #[test]
930 fn revocation_missing_fingerprint_rejected_at_0_3_0() {
931 let caps = test_caps_v030();
932 let v = PublishValidator::new(&caps);
933 let mut meta = valid_revocation_metadata();
934 meta.as_object_mut()
935 .unwrap()
936 .remove("revoked_key_fingerprint");
937 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
938 let raw_len = serde_json::to_vec(&req).unwrap().len();
939 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
940 assert!(matches!(err, AcdpError::SchemaViolation(_)));
941 }
942
943 #[test]
944 fn revocation_missing_fingerprint_accepted_at_0_2_0_positive_control() {
945 let caps = test_caps_v020();
946 let v = PublishValidator::new(&caps);
947 let mut meta = valid_revocation_metadata();
948 meta.as_object_mut()
949 .unwrap()
950 .remove("revoked_key_fingerprint");
951 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
952 let raw_len = serde_json::to_vec(&req).unwrap().len();
953 v.validate_post_schema(&req, raw_len).unwrap();
954 }
955
956 #[test]
957 fn revocation_missing_compromised_since_rejected_at_0_3_0() {
958 let caps = test_caps_v030();
959 let v = PublishValidator::new(&caps);
960 let mut meta = valid_revocation_metadata();
961 meta.as_object_mut().unwrap().remove("compromised_since");
962 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
963 let raw_len = serde_json::to_vec(&req).unwrap().len();
964 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
965 assert!(matches!(err, AcdpError::SchemaViolation(_)));
966 }
967
968 #[test]
969 fn revocation_missing_compromised_since_accepted_at_0_2_0_positive_control() {
970 let caps = test_caps_v020();
971 let v = PublishValidator::new(&caps);
972 let mut meta = valid_revocation_metadata();
973 meta.as_object_mut().unwrap().remove("compromised_since");
974 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
975 let raw_len = serde_json::to_vec(&req).unwrap().len();
976 v.validate_post_schema(&req, raw_len).unwrap();
977 }
978
979 #[test]
980 fn revocation_malformed_fingerprint_rejected_at_0_3_0() {
981 let caps = test_caps_v030();
982 let v = PublishValidator::new(&caps);
983 let mut meta = valid_revocation_metadata();
984 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
985 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
986 let raw_len = serde_json::to_vec(&req).unwrap().len();
987 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
988 assert!(matches!(err, AcdpError::SchemaViolation(_)));
989 }
990
991 #[test]
992 fn revocation_malformed_fingerprint_accepted_at_0_2_0_positive_control() {
993 let caps = test_caps_v020();
994 let v = PublishValidator::new(&caps);
995 let mut meta = valid_revocation_metadata();
996 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
997 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
998 let raw_len = serde_json::to_vec(&req).unwrap().len();
999 v.validate_post_schema(&req, raw_len).unwrap();
1000 }
1001
1002 #[test]
1003 fn revocation_non_canonical_compromised_since_rejected_at_0_3_0() {
1004 let caps = test_caps_v030();
1005 let v = PublishValidator::new(&caps);
1006 let mut meta = valid_revocation_metadata();
1007 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1008 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1009 let raw_len = serde_json::to_vec(&req).unwrap().len();
1010 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1011 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1012 }
1013
1014 #[test]
1015 fn revocation_non_canonical_compromised_since_accepted_at_0_2_0_positive_control() {
1016 let caps = test_caps_v020();
1017 let v = PublishValidator::new(&caps);
1018 let mut meta = valid_revocation_metadata();
1019 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1020 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1021 let raw_len = serde_json::to_vec(&req).unwrap().len();
1022 v.validate_post_schema(&req, raw_len).unwrap();
1023 }
1024
1025 #[test]
1026 fn revocation_reason_over_limit_rejected_at_0_3_0() {
1027 let caps = test_caps_v030();
1028 let v = PublishValidator::new(&caps);
1029 let mut meta = valid_revocation_metadata();
1030 meta["reason"] =
1031 serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1032 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1033 let raw_len = serde_json::to_vec(&req).unwrap().len();
1034 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1035 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1036 }
1037
1038 #[test]
1039 fn revocation_reason_over_limit_accepted_at_0_2_0_positive_control() {
1040 let caps = test_caps_v020();
1041 let v = PublishValidator::new(&caps);
1042 let mut meta = valid_revocation_metadata();
1043 meta["reason"] =
1044 serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1045 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1046 let raw_len = serde_json::to_vec(&req).unwrap().len();
1047 v.validate_post_schema(&req, raw_len).unwrap();
1048 }
1049
1050 #[test]
1055 fn revocation_gate_fails_closed_on_unparseable_acdp_version() {
1056 let mut caps = test_caps_v030();
1057 caps.acdp_version = "not-a-version".into();
1058 let v = PublishValidator::new(&caps);
1059 let mut meta = valid_revocation_metadata();
1060 meta.as_object_mut()
1061 .unwrap()
1062 .remove("revoked_key_fingerprint");
1063 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1064 let raw_len = serde_json::to_vec(&req).unwrap().len();
1065 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1066 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1067 }
1068
1069 #[test]
1070 fn revocation_gate_fails_closed_on_empty_acdp_version() {
1071 let mut caps = test_caps_v030();
1072 caps.acdp_version = "".into();
1073 let v = PublishValidator::new(&caps);
1074 let mut meta = valid_revocation_metadata();
1075 meta.as_object_mut()
1076 .unwrap()
1077 .remove("revoked_key_fingerprint");
1078 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1079 let raw_len = serde_json::to_vec(&req).unwrap().len();
1080 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1081 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1082 }
1083
1084 #[test]
1087 fn non_key_revocation_body_unaffected_by_gate_at_0_3_0() {
1088 let caps = test_caps_v030();
1089 let v = PublishValidator::new(&caps);
1090 let req = test_request();
1091 let raw_len = serde_json::to_vec(&req).unwrap().len();
1092 v.validate_post_schema(&req, raw_len).unwrap();
1093 }
1094
1095 #[test]
1104 fn key_revocation_gate_truth_table() {
1105 let cases: &[(&str, bool)] = &[
1106 ("0.3x.0", true),
1109 ("0. 3.0", true),
1112 ("0.2.0 ", true),
1116 ("0.2.0;", true),
1117 ("0.x.1", true),
1119 ("0.\u{0663}.0", true),
1123 ("not-a-version", true),
1126 ("", true),
1127 ("0.3.0", true),
1129 ("0.4.0", true),
1130 ("1.0.0", true),
1131 ("0.2.9", false),
1133 ("0.2.0", false),
1134 ];
1135 for (input, expected) in cases {
1136 assert_eq!(
1137 key_revocation_gate_applies(input),
1138 *expected,
1139 "input {input:?} should gate {}",
1140 if *expected { "ON" } else { "OFF" }
1141 );
1142 }
1143 }
1144
1145 fn build_revocation_request_with_type(
1151 agent_did: &str,
1152 metadata: serde_json::Value,
1153 acdp_version: &str,
1154 context_type: ContextType,
1155 ) -> PublishRequest {
1156 let key = SigningKey::from_bytes(&[0u8; 32]);
1157 let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
1158 p.publish_request()
1159 .title("Key revocation test (interim §10 type)")
1160 .context_type(context_type)
1161 .visibility(Visibility::Public)
1162 .acdp_version(acdp_version)
1163 .metadata(metadata)
1164 .build()
1165 .unwrap()
1166 }
1167
1168 #[test]
1174 fn revocation_interim_custom_type_valid_body_accepted_at_0_3_0() {
1175 let caps = test_caps_v030();
1176 let v = PublishValidator::new(&caps);
1177 let req = build_revocation_request_with_type(
1178 REVOCATION_PRODUCER_DID,
1179 valid_revocation_metadata(),
1180 "0.3.0",
1181 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1182 );
1183 let raw_len = serde_json::to_vec(&req).unwrap().len();
1184 v.validate_post_schema(&req, raw_len).unwrap();
1185 }
1186
1187 #[test]
1188 fn revocation_interim_custom_type_violation_rejected_at_0_3_0() {
1189 let caps = test_caps_v030();
1190 let v = PublishValidator::new(&caps);
1191 let mut meta = valid_revocation_metadata();
1192 meta.as_object_mut()
1193 .unwrap()
1194 .remove("revoked_key_fingerprint");
1195 let req = build_revocation_request_with_type(
1196 REVOCATION_PRODUCER_DID,
1197 meta,
1198 "0.3.0",
1199 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1200 );
1201 let raw_len = serde_json::to_vec(&req).unwrap().len();
1202 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1203 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1204 }
1205
1206 fn body_from_request(req: &PublishRequest) -> Body {
1214 Body::from_publish_request(
1215 req,
1216 CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000001".into()),
1217 LineageId(format!("lin:sha256:{}", "0".repeat(64))),
1218 "registry.example.com",
1219 chrono::DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
1220 .unwrap()
1221 .with_timezone(&chrono::Utc),
1222 )
1223 }
1224
1225 #[test]
1229 fn revocation_supersession_same_class_allowed_t_earlier() {
1230 let prev_req = build_revocation_request(
1231 REVOCATION_PRODUCER_DID,
1232 valid_revocation_metadata(), "0.3.0",
1234 );
1235 let prev = body_from_request(&prev_req);
1236
1237 let mut meta = valid_revocation_metadata();
1238 meta["compromised_since"] = serde_json::json!("2026-04-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1240
1241 check_revocation_supersession(&prev, &req)
1242 .expect("same signer class supersession must be allowed regardless of T direction");
1243 }
1244
1245 #[test]
1249 fn revocation_supersession_different_class_rejected() {
1250 let prev_req = build_revocation_request(
1251 REVOCATION_PRODUCER_DID,
1252 valid_revocation_metadata(), "0.3.0",
1254 );
1255 let prev = body_from_request(&prev_req);
1256
1257 let registry_did = test_caps().registry_did;
1258 let mut meta = valid_revocation_metadata();
1259 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1260 let req = build_revocation_request(®istry_did, meta, "0.3.0"); let err = check_revocation_supersession(&prev, &req).unwrap_err();
1263 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1264 }
1265
1266 #[test]
1271 fn revocation_superseded_by_non_revocation_rejected() {
1272 let prev_req = build_revocation_request(
1273 REVOCATION_PRODUCER_DID,
1274 valid_revocation_metadata(),
1275 "0.3.0",
1276 );
1277 let prev = body_from_request(&prev_req);
1278 let req = test_request(); let err = check_revocation_supersession(&prev, &req).unwrap_err();
1281 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1282 }
1283
1284 #[test]
1289 fn non_revocation_predecessor_superseded_by_revocation_allowed() {
1290 let prev_req = test_request();
1291 let prev = body_from_request(&prev_req);
1292 let req = build_revocation_request(
1293 REVOCATION_PRODUCER_DID,
1294 valid_revocation_metadata(),
1295 "0.3.0",
1296 );
1297
1298 check_revocation_supersession(&prev, &req)
1299 .expect("a non-revocation predecessor is out of scope for this §4 row");
1300 }
1301
1302 #[test]
1318 fn revocation_supersession_same_class_narrowing_t_allowed_at_publish() {
1319 let mut prev_meta = valid_revocation_metadata();
1320 prev_meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00.000Z");
1321 let prev_req = build_revocation_request(REVOCATION_PRODUCER_DID, prev_meta, "0.3.0");
1322 let prev = body_from_request(&prev_req);
1323
1324 let mut meta = valid_revocation_metadata();
1325 meta["compromised_since"] = serde_json::json!("2026-06-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1327
1328 check_revocation_supersession(&prev, &req).expect(
1329 "narrowing the compromise window (T moved later) is allowed at publish time; \
1330 this function enforces only type + signer class, not compromised_since \
1331 direction (RFC-ACDP-0014 §4:58)",
1332 );
1333 }
1334
1335 #[test]
1341 fn revocation_supersession_malformed_predecessor_skips_class_comparison() {
1342 let key = SigningKey::from_bytes(&[0u8; 32]);
1343 let p = Producer::new(
1344 key,
1345 AgentDid::new(REVOCATION_PRODUCER_DID),
1346 format!("{REVOCATION_PRODUCER_DID}#key-1"),
1347 );
1348 let prev_req = p
1349 .publish_request()
1350 .title("Malformed pre-0.3.0 key-revocation (no metadata)")
1351 .context_type(ContextType::KeyRevocation)
1352 .visibility(Visibility::Public)
1353 .acdp_version("0.2.0")
1354 .build()
1355 .unwrap();
1356 let prev = body_from_request(&prev_req);
1357 assert!(
1358 KeyRevocation::from_body(&prev).is_err(),
1359 "fixture must actually fail from_body, or this test proves nothing"
1360 );
1361
1362 let req = build_revocation_request(
1363 REVOCATION_OTHER_PRODUCER_DID,
1364 valid_revocation_metadata(),
1365 "0.3.0",
1366 );
1367
1368 check_revocation_supersession(&prev, &req).expect(
1369 "arm 6b: a malformed predecessor skips the class comparison but a \
1370 well-formed key-revocation successor is still allowed",
1371 );
1372 }
1373
1374 #[test]
1381 fn revocation_supersession_malformed_predecessor_still_blocks_non_revocation_successor() {
1382 let key = SigningKey::from_bytes(&[0u8; 32]);
1383 let p = Producer::new(
1384 key,
1385 AgentDid::new(REVOCATION_PRODUCER_DID),
1386 format!("{REVOCATION_PRODUCER_DID}#key-1"),
1387 );
1388 let prev_req = p
1389 .publish_request()
1390 .title("Malformed pre-0.3.0 key-revocation (no metadata)")
1391 .context_type(ContextType::KeyRevocation)
1392 .visibility(Visibility::Public)
1393 .acdp_version("0.2.0")
1394 .build()
1395 .unwrap();
1396 let prev = body_from_request(&prev_req);
1397 assert!(
1398 KeyRevocation::from_body(&prev).is_err(),
1399 "fixture must actually fail from_body, or this test proves nothing"
1400 );
1401
1402 let req = test_request(); let err = check_revocation_supersession(&prev, &req).unwrap_err();
1405 assert!(
1406 matches!(err, AcdpError::SchemaViolation(_)),
1407 "arm 3's type rule must still reject a non-revocation successor even when the \
1408 predecessor is malformed and the class comparison is skipped"
1409 );
1410 }
1411
1412 #[test]
1423 fn revocation_supersession_same_did_class_flip_rejected() {
1424 let caps = test_caps_v030();
1425 let v = PublishValidator::new(&caps);
1426 let registry_did = caps.registry_did.clone();
1427
1428 let mut prev_meta = valid_revocation_metadata();
1429 prev_meta["revoked_key_controller"] = serde_json::json!(registry_did);
1430 let prev_req = build_revocation_request(®istry_did, prev_meta, "0.3.0"); let prev_revocation = KeyRevocation::from_publish_request(&prev_req).unwrap();
1432 assert_eq!(
1433 prev_revocation.trust_class,
1434 RevocationTrustClass::ProducerSigned
1435 );
1436 v.check_revocation_controller(&prev_req, &prev_revocation)
1437 .expect("PREV fixture must be a legitimately publishable revocation");
1438 let prev = body_from_request(&prev_req);
1439
1440 let mut meta = valid_revocation_metadata();
1441 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1442 let req = build_revocation_request(®istry_did, meta, "0.3.0"); let in_revocation = KeyRevocation::from_publish_request(&req).unwrap();
1444 assert_eq!(
1445 in_revocation.trust_class,
1446 RevocationTrustClass::RegistryAttested
1447 );
1448 v.check_revocation_controller(&req, &in_revocation)
1449 .expect("IN fixture must be a legitimately publishable revocation");
1450
1451 let err = check_revocation_supersession(&prev, &req).unwrap_err();
1452 assert!(
1453 matches!(err, AcdpError::SchemaViolation(_)),
1454 "same agent_id on both sides must NOT be enough to allow this supersession — \
1455 the criterion is signer class, not DID"
1456 );
1457 }
1458
1459 #[test]
1467 fn revocation_supersession_cross_did_same_class_allowed() {
1468 let prev_req = build_revocation_request(
1469 REVOCATION_PRODUCER_DID,
1470 valid_revocation_metadata(), "0.3.0",
1472 );
1473 let prev = body_from_request(&prev_req);
1474
1475 let req = build_revocation_request(
1476 REVOCATION_OTHER_PRODUCER_DID, valid_revocation_metadata(), "0.3.0",
1479 );
1480
1481 check_revocation_supersession(&prev, &req).expect(
1482 "cross-DID, same signer class (ProducerSigned) must be allowed — \
1483 RFC-ACDP-0014 §13 blesses cross-producer supersession; the criterion is \
1484 class, not DID",
1485 );
1486 }
1487}