1use 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#[derive(Debug)]
16pub struct ValidatedPublish {
17 pub recomputed_hash: ContentHash,
19}
20
21pub struct PublishValidator<'a> {
27 caps: &'a CapabilitiesDocument,
28 own_authority: Option<&'a str>,
29}
30
31impl<'a> PublishValidator<'a> {
32 pub fn new(caps: &'a CapabilitiesDocument) -> Self {
34 Self {
35 caps,
36 own_authority: None,
37 }
38 }
39
40 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 pub fn validate_post_schema(
73 &self,
74 req: &PublishRequest,
75 raw_body_bytes: usize,
76 ) -> Result<ValidatedPublish, AcdpError> {
77 acdp_validation::validate_publish_request(req)?;
86 self.validate_registry_limits_and_crypto(req, raw_body_bytes)
87 }
88
89 #[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 fn validate_registry_limits_and_crypto(
113 &self,
114 req: &PublishRequest,
115 raw_body_bytes: usize,
116 ) -> Result<ValidatedPublish, AcdpError> {
117 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 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 acdp_validation::verify_embedded_hash(dr)?;
140 }
141 }
142
143 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 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 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 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 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 Ok(ValidatedPublish {
217 recomputed_hash: recomputed,
218 })
219 }
220}
221
222pub 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 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}