Skip to main content

acdp_server/registry/
validator.rs

1//! Server-side publish validation pipeline — RFC-ACDP-0003 §2.1 (feature = "server").
2//!
3//! Runs steps 1–8 (validation) before any persistence occurs.
4
5use acdp_crypto::hash::{compute_content_hash, derive_lineage_id};
6use acdp_primitives::error::AcdpError;
7use acdp_types::{
8    capabilities::CapabilitiesDocument,
9    primitives::{ContentHash, CtxId, LineageId},
10    publish::PublishRequest,
11};
12
13/// Outcome of a successful validation — the registry can now assign
14/// identifiers and persist.
15#[derive(Debug)]
16pub struct ValidatedPublish {
17    /// The hash recomputed by the validator over ProducerContent.
18    pub recomputed_hash: ContentHash,
19}
20
21/// Stateless publish request validator.
22///
23/// Runs §2.1 steps 1–8 (structural and cryptographic checks).
24/// Steps 9+ (identifier assignment, lineage, supersession, persistence)
25/// are registry-implementation concerns.
26pub struct PublishValidator<'a> {
27    caps: &'a CapabilitiesDocument,
28    own_authority: Option<&'a str>,
29}
30
31impl<'a> PublishValidator<'a> {
32    /// Create a validator without same-registry supersession enforcement.
33    pub fn new(caps: &'a CapabilitiesDocument) -> Self {
34        Self {
35            caps,
36            own_authority: None,
37        }
38    }
39
40    /// Create a validator that rejects cross-registry supersession.
41    ///
42    /// `own_authority` is the registry's DNS authority (e.g.
43    /// `registry.example.com`). When set, a publish request whose
44    /// `supersedes` ctx_id has a different authority will be rejected with
45    /// [`AcdpError::SupersededTarget`] / `CrossRegistrySupersessionUnsupported`
46    /// (RFC-ACDP-0006 — v0.1.0 only allows same-registry supersession).
47    pub fn for_authority(caps: &'a CapabilitiesDocument, own_authority: &'a str) -> Self {
48        Self {
49            caps,
50            own_authority: Some(own_authority),
51        }
52    }
53
54    /// Validate a publish request through the structural / cryptographic
55    /// steps of RFC-ACDP-0003 §2.1, plus the cross-registry-supersession
56    /// guard if the validator was built with [`Self::for_authority`].
57    ///
58    /// Mapped steps from RFC-ACDP-0003 §2.1:
59    /// - **Step 1** (schema validation) — assumed performed upstream
60    ///   (e.g. by `validate_publish_request`).
61    /// - **Step 2** (payload size vs `limits.max_payload_bytes`).
62    /// - **Step 3** (embedded size vs `limits.max_embedded_bytes`).
63    /// - **Step 4** (hash recomputation over ProducerContent).
64    /// - **Step 5** (signature algorithm vs
65    ///   `supported_signature_algorithms`).
66    /// - **Step 6** (key_id DID portion equals `agent_id`).
67    /// - **Step 7–8** (DID resolution + signature verification) — async,
68    ///   handled separately by `acdp_verify::Verifier::verify_body`.
69    /// - Cross-registry supersession check (RFC-ACDP-0006): when an
70    ///   own-authority is configured, rejects supersedes targets on a
71    ///   different authority.
72    pub fn validate_post_schema(
73        &self,
74        req: &PublishRequest,
75        raw_body_bytes: usize,
76    ) -> Result<ValidatedPublish, AcdpError> {
77        // Run the full schema-aligned validation (string lengths, array
78        // uniqueness, DataRef oneOf + URI rules, metadata depth/size,
79        // visibility/audience invariants, did:web check, signature length,
80        // identifier patterns, version coherence) on top of the raw
81        // structural / cryptographic steps below. This makes
82        // `validate_post_schema` a complete RFC-ACDP-0003 §2.1
83        // implementation regardless of whether the producer side ran
84        // [`acdp_validation::validate_publish_request`] first.
85        acdp_validation::validate_publish_request(req)?;
86        self.validate_registry_limits_and_crypto(req, raw_body_bytes)
87    }
88
89    /// Deprecated alias — now routes through [`Self::validate_post_schema`].
90    ///
91    /// The previous implementation skipped the schema-level validation
92    /// (title length, metadata depth, DataRef integrity, did:web check,
93    /// version coherence, …). Callers using `validate_structural`
94    /// directly were silently bypassing those checks. The deprecated
95    /// alias now runs the full pipeline so existing call sites remain
96    /// safe; new code should call `validate_post_schema` explicitly.
97    #[deprecated(
98        since = "0.1.0",
99        note = "Use validate_post_schema; this alias no longer skips runtime validation"
100    )]
101    pub fn validate_structural(
102        &self,
103        req: &PublishRequest,
104        raw_body_bytes: usize,
105    ) -> Result<ValidatedPublish, AcdpError> {
106        self.validate_post_schema(req, raw_body_bytes)
107    }
108
109    /// Internal: registry-limit + cryptographic step list (no schema
110    /// validation). Keep private — bypassing the schema validation is
111    /// not a publishable surface.
112    fn validate_registry_limits_and_crypto(
113        &self,
114        req: &PublishRequest,
115        raw_body_bytes: usize,
116    ) -> Result<ValidatedPublish, AcdpError> {
117        // Step 2: payload size
118        if raw_body_bytes as u64 > self.caps.limits.max_payload_bytes {
119            return Err(AcdpError::SchemaViolation(format!(
120                "payload {} bytes exceeds limit {}",
121                raw_body_bytes, self.caps.limits.max_payload_bytes
122            )));
123        }
124
125        // Step 3: embedded size + optional embedded content_hash check
126        // (RFC-ACDP-0003 §2.1 step 3 last sentence; RFC-ACDP-0002 §6.6 #8).
127        for dr in &req.data_refs {
128            if let Some(emb) = &dr.embedded {
129                let decoded = acdp_validation::embedded_decoded_bytes(emb)?;
130                if decoded.len() as u64 > self.caps.limits.max_embedded_bytes {
131                    return Err(AcdpError::EmbeddedTooLarge(format!(
132                        "embedded data reference {} bytes exceeds {} limit",
133                        decoded.len(),
134                        self.caps.limits.max_embedded_bytes
135                    )));
136                }
137                // If the producer declared an embedded content_hash, recompute
138                // and verify per §2.1 step 3.
139                acdp_validation::verify_embedded_hash(dr)?;
140            }
141        }
142
143        // Step 4: hash recomputation over ProducerContent
144        let body_val = serde_json::to_value(req)?;
145        let recomputed = compute_content_hash(&body_val)?;
146        if recomputed != req.content_hash {
147            return Err(AcdpError::HashMismatch {
148                stored: req.content_hash.clone(),
149                recomputed: recomputed.clone(),
150            });
151        }
152
153        // Step 5: algorithm check
154        if !self
155            .caps
156            .supported_signature_algorithms
157            .iter()
158            .any(|a| a == &req.signature.algorithm)
159        {
160            return Err(AcdpError::SchemaViolation(format!(
161                "unsupported algorithm '{}'; registry supports {:?}",
162                req.signature.algorithm, self.caps.supported_signature_algorithms,
163            )));
164        }
165
166        // Step 5.5: DID-method gate — the producer's method must be one
167        // this registry advertises in `supported_did_methods` (ACDP 0.2:
168        // did:key acceptance is a per-registry capabilities decision;
169        // did:web is mandatory for every registry). Maps to
170        // `key_resolution_failed` (permanent): the registry has no
171        // resolver for the method, and no retry will grow one.
172        let agent_method = req
173            .agent_id
174            .as_str()
175            .splitn(3, ':')
176            .take(2)
177            .collect::<Vec<_>>()
178            .join(":");
179        if !self.caps.supports_did_method(&agent_method) {
180            return Err(AcdpError::KeyResolution(format!(
181                "agent_id method '{agent_method}' is not in this registry's \
182                 supported_did_methods {:?}",
183                self.caps.supported_did_methods
184            )));
185        }
186
187        // Step 6: key-id binding — DID portion must equal agent_id
188        let key_id = &req.signature.key_id;
189        let did_part = key_id.split_once('#').map(|(d, _)| d).ok_or_else(|| {
190            AcdpError::KeyResolution(format!("key_id '{key_id}' has no '#fragment'"))
191        })?;
192
193        if did_part != req.agent_id.as_str() {
194            return Err(AcdpError::KeyNotAuthorized(format!(
195                "key_id DID '{did_part}' ≠ agent_id '{}'",
196                req.agent_id
197            )));
198        }
199
200        // Cross-registry supersession check — v0.1.0 only allows same-registry.
201        if let (Some(own), Some(target)) = (self.own_authority, &req.supersedes) {
202            let target_authority = target.authority();
203            if target_authority != own {
204                return Err(AcdpError::SupersededTarget {
205                    reason: acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported,
206                    message: format!(
207                        "supersedes target on '{target_authority}' rejected by '{own}'; \
208                         v0.1.0 only allows same-registry supersession"
209                    ),
210                });
211            }
212        }
213
214        // Steps 7–8 (key resolution + signature verification) require async
215        // DID resolution; the caller should invoke Verifier::verify_body for those.
216        Ok(ValidatedPublish {
217            recomputed_hash: recomputed,
218        })
219    }
220}
221
222/// Assign registry identifiers after successful validation per
223/// RFC-ACDP-0001 §5.6.
224///
225/// For first-version publications (`supersedes == None`,
226/// `first_version_ctx_id == None`), `lineage_id` is derived from the newly
227/// assigned `ctx_id`. For supersession (`supersedes == Some(_)`), the
228/// caller MUST supply the v1 `ctx_id` of the lineage so `lineage_id` is
229/// derived from it — using the new ctx_id would orphan the supersession
230/// from its lineage.
231///
232/// Returns `SchemaViolation` if `supersedes` is set but
233/// `first_version_ctx_id` is not.
234pub fn assign_identifiers(
235    authority: &str,
236    supersedes: &Option<CtxId>,
237    first_version_ctx_id: Option<&CtxId>,
238    _validated: &ValidatedPublish,
239) -> Result<(CtxId, LineageId), AcdpError> {
240    let uuid = uuid::Uuid::new_v4();
241    let ctx_id = CtxId(format!("acdp://{authority}/{uuid}"));
242    let lineage_source: &CtxId = match (supersedes, first_version_ctx_id) {
243        (None, _) => &ctx_id,
244        (Some(_), Some(v1)) => v1,
245        (Some(_), None) => {
246            return Err(AcdpError::SchemaViolation(
247                "supersession assignment requires the v1 ctx_id to derive lineage_id".into(),
248            ));
249        }
250    };
251    let lineage_id = derive_lineage_id(lineage_source);
252    Ok((ctx_id, lineage_id))
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use acdp_crypto::SigningKey;
259    use acdp_producer::Producer;
260    use acdp_types::{
261        capabilities::Limits,
262        primitives::{AgentDid, ContextType, Visibility},
263    };
264
265    fn test_caps() -> CapabilitiesDocument {
266        CapabilitiesDocument {
267            acdp_version: "0.1.0".into(),
268            registry_did: "did:web:registry.example.com".into(),
269            supported_signature_algorithms: vec!["ed25519".into()],
270            supported_did_methods: vec!["did:web".into()],
271            profiles: vec!["acdp-registry-core".into()],
272            limits: Limits {
273                max_payload_bytes: 1_048_576,
274                max_embedded_bytes: 65_536,
275                idempotency_key_ttl_seconds: None,
276            },
277            read_authentication_methods: vec![],
278            anonymous_public_reads: true,
279            supports_idempotency_key: false,
280            extensions: Default::default(),
281        }
282    }
283
284    fn test_request() -> PublishRequest {
285        let key = SigningKey::from_bytes(&[0u8; 32]);
286        let p = Producer::new(
287            key,
288            AgentDid::new("did:web:agents.example.com:test-producer"),
289            "did:web:agents.example.com:test-producer#key-1",
290        );
291        p.publish_request()
292            .title("Golden test vector — minimal first version")
293            .context_type(ContextType::DataSnapshot)
294            .visibility(Visibility::Public)
295            .build()
296            .unwrap()
297    }
298
299    #[test]
300    fn happy_path_validates() {
301        let caps = test_caps();
302        let v = PublishValidator::new(&caps);
303        let req = test_request();
304        let raw_len = serde_json::to_vec(&req).unwrap().len();
305        v.validate_post_schema(&req, raw_len).unwrap();
306    }
307
308    #[test]
309    fn payload_too_large_rejected() {
310        let mut caps = test_caps();
311        caps.limits.max_payload_bytes = 10;
312        let v = PublishValidator::new(&caps);
313        let req = test_request();
314        let err = v.validate_post_schema(&req, 1024).unwrap_err();
315        assert!(matches!(err, AcdpError::SchemaViolation(_)));
316    }
317
318    #[test]
319    fn unsupported_algorithm_rejected() {
320        let mut caps = test_caps();
321        caps.supported_signature_algorithms = vec!["secp256k1".into()];
322        let v = PublishValidator::new(&caps);
323        let req = test_request();
324        let err = v.validate_post_schema(&req, 1024).unwrap_err();
325        assert!(matches!(err, AcdpError::SchemaViolation(_)));
326    }
327
328    #[test]
329    fn key_id_without_fragment_rejected() {
330        let caps = test_caps();
331        let v = PublishValidator::new(&caps);
332        let mut req = test_request();
333        req.signature.key_id = "did:web:agents.example.com:test-producer".into();
334        let err = v.validate_post_schema(&req, 1024).unwrap_err();
335        assert!(matches!(err, AcdpError::KeyResolution(_)));
336    }
337
338    #[test]
339    fn key_id_did_must_match_agent_id() {
340        let caps = test_caps();
341        let v = PublishValidator::new(&caps);
342        let mut req = test_request();
343        req.signature.key_id = "did:web:other.example.com:attacker#key-1".into();
344        let err = v.validate_post_schema(&req, 1024).unwrap_err();
345        assert!(matches!(err, AcdpError::KeyNotAuthorized(_)));
346    }
347
348    #[test]
349    fn tampered_hash_detected() {
350        let caps = test_caps();
351        let v = PublishValidator::new(&caps);
352        let mut req = test_request();
353        req.title = "tampered title".into();
354        let err = v.validate_post_schema(&req, 1024).unwrap_err();
355        assert!(matches!(err, AcdpError::HashMismatch { .. }));
356    }
357
358    #[test]
359    fn assign_identifiers_first_version_derives_lineage_from_new_id() {
360        let v = ValidatedPublish {
361            recomputed_hash: ContentHash("sha256:abcd".into()),
362        };
363        let (ctx_id, lineage_id) =
364            assign_identifiers("registry.example.com", &None, None, &v).unwrap();
365        let expected = derive_lineage_id(&ctx_id);
366        assert_eq!(lineage_id, expected);
367    }
368
369    #[test]
370    fn assign_identifiers_supersession_uses_v1_ctx_id() {
371        let v = ValidatedPublish {
372            recomputed_hash: ContentHash("sha256:abcd".into()),
373        };
374        let v1 = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
375        let supersedes = Some(CtxId(
376            "acdp://registry.example.com/12345678-1234-4321-8123-123456781299".into(),
377        ));
378        let (_new_id, lineage_id) =
379            assign_identifiers("registry.example.com", &supersedes, Some(&v1), &v).unwrap();
380        assert_eq!(lineage_id, derive_lineage_id(&v1));
381    }
382
383    #[test]
384    fn cross_registry_supersession_rejected() {
385        let caps = test_caps();
386        let v = PublishValidator::for_authority(&caps, "registry.example.com");
387        // Build a v2 request that supersedes a context on a different registry
388        let key = SigningKey::from_bytes(&[0u8; 32]);
389        let p = Producer::new(
390            key,
391            AgentDid::new("did:web:agents.example.com:test-producer"),
392            "did:web:agents.example.com:test-producer#key-1",
393        );
394        let other_reg =
395            CtxId("acdp://other.example.com/12345678-1234-4321-8123-123456781234".into());
396        let req = p
397            .supersede(other_reg)
398            .version(2)
399            .title("v2")
400            .context_type(ContextType::DataSnapshot)
401            .build()
402            .unwrap();
403        let raw_len = serde_json::to_vec(&req).unwrap().len();
404        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
405        match err {
406            AcdpError::SupersededTarget { reason, .. } => {
407                assert_eq!(
408                    reason,
409                    acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported
410                );
411            }
412            other => panic!("expected SupersededTarget, got {other:?}"),
413        }
414    }
415
416    #[test]
417    fn same_registry_supersession_passes_authority_check() {
418        let caps = test_caps();
419        let v = PublishValidator::for_authority(&caps, "registry.example.com");
420        let key = SigningKey::from_bytes(&[0u8; 32]);
421        let p = Producer::new(
422            key,
423            AgentDid::new("did:web:agents.example.com:test-producer"),
424            "did:web:agents.example.com:test-producer#key-1",
425        );
426        let same = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
427        let req = p
428            .supersede(same)
429            .version(2)
430            .title("v2")
431            .context_type(ContextType::DataSnapshot)
432            .build()
433            .unwrap();
434        let raw_len = serde_json::to_vec(&req).unwrap().len();
435        v.validate_post_schema(&req, raw_len).unwrap();
436    }
437
438    #[test]
439    fn assign_identifiers_supersession_without_v1_id_rejected() {
440        let v = ValidatedPublish {
441            recomputed_hash: ContentHash("sha256:abcd".into()),
442        };
443        let supersedes = Some(CtxId("acdp://x/y".into()));
444        let err = assign_identifiers("registry.example.com", &supersedes, None, &v).unwrap_err();
445        assert!(matches!(err, AcdpError::SchemaViolation(_)));
446    }
447}