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 max_publish_per_minute: None,
277 },
278 read_authentication_methods: vec![],
279 anonymous_public_reads: true,
280 supports_idempotency_key: false,
281 extensions: Default::default(),
282 }
283 }
284
285 fn test_request() -> PublishRequest {
286 let key = SigningKey::from_bytes(&[0u8; 32]);
287 let p = Producer::new(
288 key,
289 AgentDid::new("did:web:agents.example.com:test-producer"),
290 "did:web:agents.example.com:test-producer#key-1",
291 );
292 p.publish_request()
293 .title("Golden test vector — minimal first version")
294 .context_type(ContextType::DataSnapshot)
295 .visibility(Visibility::Public)
296 .build()
297 .unwrap()
298 }
299
300 #[test]
301 fn happy_path_validates() {
302 let caps = test_caps();
303 let v = PublishValidator::new(&caps);
304 let req = test_request();
305 let raw_len = serde_json::to_vec(&req).unwrap().len();
306 v.validate_post_schema(&req, raw_len).unwrap();
307 }
308
309 #[test]
310 fn payload_too_large_rejected() {
311 let mut caps = test_caps();
312 caps.limits.max_payload_bytes = 10;
313 let v = PublishValidator::new(&caps);
314 let req = test_request();
315 let err = v.validate_post_schema(&req, 1024).unwrap_err();
316 assert!(matches!(err, AcdpError::SchemaViolation(_)));
317 }
318
319 #[test]
320 fn unsupported_algorithm_rejected() {
321 let mut caps = test_caps();
322 caps.supported_signature_algorithms = vec!["secp256k1".into()];
323 let v = PublishValidator::new(&caps);
324 let req = test_request();
325 let err = v.validate_post_schema(&req, 1024).unwrap_err();
326 assert!(matches!(err, AcdpError::SchemaViolation(_)));
327 }
328
329 #[test]
330 fn key_id_without_fragment_rejected() {
331 let caps = test_caps();
332 let v = PublishValidator::new(&caps);
333 let mut req = test_request();
334 req.signature.key_id = "did:web:agents.example.com:test-producer".into();
335 let err = v.validate_post_schema(&req, 1024).unwrap_err();
336 assert!(matches!(err, AcdpError::KeyResolution(_)));
337 }
338
339 #[test]
340 fn key_id_did_must_match_agent_id() {
341 let caps = test_caps();
342 let v = PublishValidator::new(&caps);
343 let mut req = test_request();
344 req.signature.key_id = "did:web:other.example.com:attacker#key-1".into();
345 let err = v.validate_post_schema(&req, 1024).unwrap_err();
346 assert!(matches!(err, AcdpError::KeyNotAuthorized(_)));
347 }
348
349 #[test]
350 fn tampered_hash_detected() {
351 let caps = test_caps();
352 let v = PublishValidator::new(&caps);
353 let mut req = test_request();
354 req.title = "tampered title".into();
355 let err = v.validate_post_schema(&req, 1024).unwrap_err();
356 assert!(matches!(err, AcdpError::HashMismatch { .. }));
357 }
358
359 #[test]
360 fn assign_identifiers_first_version_derives_lineage_from_new_id() {
361 let v = ValidatedPublish {
362 recomputed_hash: ContentHash("sha256:abcd".into()),
363 };
364 let (ctx_id, lineage_id) =
365 assign_identifiers("registry.example.com", &None, None, &v).unwrap();
366 let expected = derive_lineage_id(&ctx_id);
367 assert_eq!(lineage_id, expected);
368 }
369
370 #[test]
371 fn assign_identifiers_supersession_uses_v1_ctx_id() {
372 let v = ValidatedPublish {
373 recomputed_hash: ContentHash("sha256:abcd".into()),
374 };
375 let v1 = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
376 let supersedes = Some(CtxId(
377 "acdp://registry.example.com/12345678-1234-4321-8123-123456781299".into(),
378 ));
379 let (_new_id, lineage_id) =
380 assign_identifiers("registry.example.com", &supersedes, Some(&v1), &v).unwrap();
381 assert_eq!(lineage_id, derive_lineage_id(&v1));
382 }
383
384 #[test]
385 fn cross_registry_supersession_rejected() {
386 let caps = test_caps();
387 let v = PublishValidator::for_authority(&caps, "registry.example.com");
388 let key = SigningKey::from_bytes(&[0u8; 32]);
390 let p = Producer::new(
391 key,
392 AgentDid::new("did:web:agents.example.com:test-producer"),
393 "did:web:agents.example.com:test-producer#key-1",
394 );
395 let other_reg =
396 CtxId("acdp://other.example.com/12345678-1234-4321-8123-123456781234".into());
397 let req = p
398 .supersede(other_reg)
399 .version(2)
400 .title("v2")
401 .context_type(ContextType::DataSnapshot)
402 .build()
403 .unwrap();
404 let raw_len = serde_json::to_vec(&req).unwrap().len();
405 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
406 match err {
407 AcdpError::SupersededTarget { reason, .. } => {
408 assert_eq!(
409 reason,
410 acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported
411 );
412 }
413 other => panic!("expected SupersededTarget, got {other:?}"),
414 }
415 }
416
417 #[test]
418 fn same_registry_supersession_passes_authority_check() {
419 let caps = test_caps();
420 let v = PublishValidator::for_authority(&caps, "registry.example.com");
421 let key = SigningKey::from_bytes(&[0u8; 32]);
422 let p = Producer::new(
423 key,
424 AgentDid::new("did:web:agents.example.com:test-producer"),
425 "did:web:agents.example.com:test-producer#key-1",
426 );
427 let same = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
428 let req = p
429 .supersede(same)
430 .version(2)
431 .title("v2")
432 .context_type(ContextType::DataSnapshot)
433 .build()
434 .unwrap();
435 let raw_len = serde_json::to_vec(&req).unwrap().len();
436 v.validate_post_schema(&req, raw_len).unwrap();
437 }
438
439 #[test]
440 fn assign_identifiers_supersession_without_v1_id_rejected() {
441 let v = ValidatedPublish {
442 recomputed_hash: ContentHash("sha256:abcd".into()),
443 };
444 let supersedes = Some(CtxId("acdp://x/y".into()));
445 let err = assign_identifiers("registry.example.com", &supersedes, None, &v).unwrap_err();
446 assert!(matches!(err, AcdpError::SchemaViolation(_)));
447 }
448}