1use acdp_crypto::try_canonicalize_value;
23use acdp_primitives::error::AcdpError;
24use acdp_types::body::Body;
25use acdp_types::data_ref::{DataRef, EmbeddedContent, EmbeddedEncoding, Location};
26use acdp_types::primitives::{
27 AgentDid, ContentHash, ContextType, CtxId, LineageId, Status, Visibility,
28};
29use acdp_types::publish::PublishRequest;
30use base64::{engine::general_purpose::STANDARD, Engine};
31use sha2::{Digest, Sha256};
32
33const MAX_TITLE_LEN: usize = 500;
36const MAX_DESCRIPTION_LEN: usize = 5000;
37const MAX_SUMMARY_LEN: usize = 1000;
38const MAX_DOMAIN_LEN: usize = 200;
39const MAX_DATA_REF_DESCRIPTION_LEN: usize = 1000;
40const MAX_TAG_LEN: usize = 100;
41const MAX_CONTRIBUTORS: usize = 100;
42const MAX_TAGS: usize = 200;
43const MAX_DERIVED_FROM: usize = 1000;
44const MAX_AUDIENCE: usize = 1000;
45const MAX_METADATA_PROPERTIES: usize = 100;
46const MAX_METADATA_DEPTH: usize = 8;
47const MAX_METADATA_JCS_BYTES: usize = 65_536;
48const MAX_URI_LEN: usize = 4096;
49const MAX_EMBEDDED_BYTES: usize = 65_536;
50const ED25519_SIG_B64_LEN: usize = 88;
51const ECDSA_P256_SIG_B64_LEN: usize = 88;
52
53fn version_at_least(v: &str, major: u64, minor: u64) -> bool {
59 let mut it = v.split('.').filter_map(|p| p.parse::<u64>().ok());
60 match (it.next(), it.next()) {
61 (Some(ma), Some(mi)) => ma > major || (ma == major && mi >= minor),
62 _ => false,
63 }
64}
65
66pub fn validate_capabilities(caps: &acdp_types::CapabilitiesDocument) -> Result<(), AcdpError> {
86 validate_semver_pattern("acdp_version", &caps.acdp_version)?;
87
88 if let Some(mppm) = caps.limits.max_publish_per_minute {
91 if mppm < 1 {
92 return Err(AcdpError::SchemaViolation(
93 "capabilities.limits.max_publish_per_minute MUST be >= 1 \
94 (RFC-ACDP-0007 \u{a7}3.5 item 11)"
95 .into(),
96 ));
97 }
98 }
99
100 if version_at_least(&caps.acdp_version, 0, 3) && !caps.supports_idempotency_key {
105 return Err(AcdpError::SchemaViolation(
106 "capabilities advertising acdp_version >= 0.3.0 MUST set \
107 supports_idempotency_key: true (RFC-ACDP-0003 \u{a7}6.4, \
108 RFC-ACDP-0007 \u{a7}3.5 item 10)"
109 .into(),
110 ));
111 }
112
113 AgentDid::parse_web(caps.registry_did.as_str()).map_err(|e| {
114 AcdpError::SchemaViolation(format!(
115 "capabilities.registry_did must be did:web for v0.1.0: {e}"
116 ))
117 })?;
118
119 if !caps
120 .supported_signature_algorithms
121 .iter()
122 .any(|a| a == "ed25519")
123 {
124 return Err(AcdpError::SchemaViolation(
125 "capabilities.supported_signature_algorithms MUST contain 'ed25519' \
126 (RFC-ACDP-0001 §5.10)"
127 .into(),
128 ));
129 }
130
131 if !caps.supported_did_methods.iter().any(|m| m == "did:web") {
132 return Err(AcdpError::SchemaViolation(
133 "capabilities.supported_did_methods MUST contain 'did:web' \
134 (RFC-ACDP-0001 §5.4)"
135 .into(),
136 ));
137 }
138
139 if !caps.profiles.iter().any(|p| p == "acdp-registry-core") {
140 return Err(AcdpError::SchemaViolation(
141 "capabilities.profiles MUST contain 'acdp-registry-core' \
142 (RFC-ACDP-0001 §9.1)"
143 .into(),
144 ));
145 }
146
147 if caps.limits.max_embedded_bytes != 65_536 {
148 return Err(AcdpError::SchemaViolation(format!(
149 "capabilities.limits.max_embedded_bytes must be 65536 (fixed by \
150 RFC-ACDP-0007 §3.1), got {}",
151 caps.limits.max_embedded_bytes
152 )));
153 }
154
155 if caps.limits.max_payload_bytes < 1024 {
156 return Err(AcdpError::SchemaViolation(format!(
157 "capabilities.limits.max_payload_bytes must be ≥ 1024, got {}",
158 caps.limits.max_payload_bytes
159 )));
160 }
161
162 if caps.supports_idempotency_key {
163 let ttl = caps.limits.idempotency_key_ttl_seconds.ok_or_else(|| {
164 AcdpError::SchemaViolation(
165 "limits.idempotency_key_ttl_seconds is required when \
166 supports_idempotency_key is true (RFC-ACDP-0007 §3.2)"
167 .into(),
168 )
169 })?;
170 if !(86_400..=604_800).contains(&ttl) {
171 return Err(AcdpError::SchemaViolation(format!(
172 "limits.idempotency_key_ttl_seconds must be in 86400..=604800, got {ttl}"
173 )));
174 }
175 }
176
177 Ok(())
178}
179
180pub fn validate_publish_request(req: &PublishRequest) -> Result<(), AcdpError> {
185 validate_title(&req.title)?;
186 validate_optional_string(
187 req.description.as_deref(),
188 "description",
189 MAX_DESCRIPTION_LEN,
190 )?;
191 validate_optional_string(req.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
192 validate_optional_string(req.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
193
194 validate_agent_did(&req.agent_id)?;
195 for c in &req.contributors {
196 validate_loose_did(c)?;
197 }
198 validate_unique_array("contributors", &req.contributors, MAX_CONTRIBUTORS)?;
199 validate_unique_array("derived_from", &req.derived_from, MAX_DERIVED_FROM)?;
200
201 if let Some(tags) = &req.tags {
202 validate_tags(tags)?;
203 }
204 if let Some(audience) = &req.audience {
205 validate_unique_array("audience", audience, MAX_AUDIENCE)?;
206 for did in audience {
207 validate_loose_did(did)?;
208 }
209 }
210
211 validate_visibility_audience(&req.visibility, req.audience.as_deref())?;
212
213 if let Some(dp) = &req.data_period {
214 if dp.start > dp.end {
215 return Err(AcdpError::SchemaViolation(
216 "data_period.start must not be after data_period.end".into(),
217 ));
218 }
219 }
220
221 if let Some(ct) = &req.context_type.namespaced_form() {
222 validate_namespaced_context_type(ct)?;
223 }
224
225 if let Some(meta) = &req.metadata {
226 validate_metadata(meta)?;
227 }
228
229 for dr in &req.data_refs {
230 validate_data_ref(dr)?;
231 }
232
233 validate_signature_length(&req.signature.algorithm, &req.signature.value)?;
234 validate_did_key_key_id_form(&req.signature.key_id)?;
235 ContentHash::parse(req.content_hash.as_str())?;
236
237 if let Some(prev) = &req.supersedes {
239 CtxId::parse(prev.as_str())?;
240 }
241 for ancestor in &req.derived_from {
242 CtxId::parse(ancestor.as_str())?;
243 }
244 if let Some(lineage) = &req.lineage_id {
245 acdp_types::primitives::LineageId::parse(lineage.as_str())?;
246 }
247
248 if let Some(v) = &req.acdp_version {
250 validate_semver_pattern("acdp_version", v)?;
251 }
252
253 match (&req.supersedes, req.version) {
255 (None, 1) => {}
256 (None, v) => {
257 return Err(AcdpError::SchemaViolation(format!(
258 "first-version publish requires version=1, got {v}"
259 )));
260 }
261 (Some(_), v) if v >= 2 => {}
262 (Some(_), v) => {
263 return Err(AcdpError::SchemaViolation(format!(
264 "supersession publish requires version >= 2, got {v}"
265 )));
266 }
267 }
268
269 if req.version == 1 && req.lineage_id.is_some() {
277 return Err(AcdpError::SchemaViolation(
278 "lineage_id MUST NOT be set on v1 publish requests (RFC-ACDP-0003 §2.2)".into(),
279 ));
280 }
281
282 Ok(())
283}
284
285pub fn validate_body(body: &Body) -> Result<(), AcdpError> {
287 validate_body_inner(body, true)
288}
289
290pub fn validate_body_structural(body: &Body) -> Result<(), AcdpError> {
301 validate_body_inner(body, false)
302}
303
304fn validate_body_inner(body: &Body, check_embedded_hashes: bool) -> Result<(), AcdpError> {
305 validate_title(&body.title)?;
306 validate_optional_string(
307 body.description.as_deref(),
308 "description",
309 MAX_DESCRIPTION_LEN,
310 )?;
311 validate_optional_string(body.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
312 validate_optional_string(body.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
313
314 validate_agent_did(&body.agent_id)?;
315 for c in &body.contributors {
316 validate_loose_did(c)?;
317 }
318 validate_unique_array("contributors", &body.contributors, MAX_CONTRIBUTORS)?;
319 validate_unique_array("derived_from", &body.derived_from, MAX_DERIVED_FROM)?;
320
321 if let Some(tags) = &body.tags {
322 validate_tags(tags)?;
323 }
324 if let Some(audience) = &body.audience {
325 validate_unique_array("audience", audience, MAX_AUDIENCE)?;
326 for did in audience {
327 validate_loose_did(did)?;
328 }
329 }
330 validate_visibility_audience(&body.visibility, body.audience.as_deref())?;
331
332 if let Some(dp) = &body.data_period {
333 if dp.start > dp.end {
334 return Err(AcdpError::SchemaViolation(
335 "data_period.start must not be after data_period.end".into(),
336 ));
337 }
338 }
339
340 if let Some(meta) = &body.metadata {
341 validate_metadata(meta)?;
342 }
343
344 validate_extensions(&body.extensions)?;
348
349 for dr in &body.data_refs {
350 if check_embedded_hashes {
351 validate_data_ref(dr)?;
352 } else {
353 validate_data_ref_structural(dr)?;
354 }
355 }
356
357 validate_signature_length(&body.signature.algorithm, &body.signature.value)?;
358 validate_did_key_key_id_form(&body.signature.key_id)?;
359 validate_identifiers(&body.ctx_id, &body.lineage_id, &body.content_hash)?;
360
361 if let Some(prev) = &body.supersedes {
363 CtxId::parse(prev.as_str())?;
364 }
365 for ancestor in &body.derived_from {
366 CtxId::parse(ancestor.as_str())?;
367 }
368
369 if let Some(v) = &body.acdp_version {
370 validate_semver_pattern("acdp_version", v)?;
371 }
372
373 let _ = &body.created_at; validate_origin_registry(&body.origin_registry)?;
375
376 let _ = std::any::type_name::<Status>();
378 let _: &Visibility = &body.visibility;
379
380 Ok(())
381}
382
383pub fn validate_identifiers(
385 ctx_id: &CtxId,
386 lineage_id: &LineageId,
387 content_hash: &ContentHash,
388) -> Result<(), AcdpError> {
389 CtxId::parse(ctx_id.as_str())?;
390 LineageId::parse(lineage_id.as_str())?;
391 ContentHash::parse(content_hash.as_str())?;
392 Ok(())
393}
394
395pub fn validate_data_ref(dr: &DataRef) -> Result<(), AcdpError> {
400 validate_data_ref_structural(dr)?;
401 if dr.embedded.is_some() {
406 verify_embedded_hash(dr)?;
407 }
408 Ok(())
409}
410
411pub fn validate_data_ref_structural(dr: &DataRef) -> Result<(), AcdpError> {
417 match (&dr.location, &dr.embedded) {
419 (None, None) => {
420 return Err(AcdpError::SchemaViolation(
421 "DataRef requires exactly one of 'location' or 'embedded' (got neither)".into(),
422 ));
423 }
424 (Some(_), Some(_)) => {
425 return Err(AcdpError::SchemaViolation(
426 "DataRef requires exactly one of 'location' or 'embedded' (got both)".into(),
427 ));
428 }
429 _ => {}
430 }
431
432 if let Some(desc) = &dr.description {
433 if desc.len() > MAX_DATA_REF_DESCRIPTION_LEN {
434 return Err(AcdpError::SchemaViolation(format!(
435 "DataRef.description {} chars exceeds {} limit",
436 desc.len(),
437 MAX_DATA_REF_DESCRIPTION_LEN
438 )));
439 }
440 }
441
442 if let Some(loc) = &dr.location {
443 validate_location(loc)?;
444 }
445 if let Some(emb) = &dr.embedded {
446 validate_embedded(emb)?;
447 }
448
449 Ok(())
450}
451
452fn validate_location(loc: &Location) -> Result<(), AcdpError> {
453 match loc {
454 Location::Uri(uri) => validate_uri_location(uri),
455 Location::Structured(map) => validate_structured_locator(map),
456 }
457}
458
459fn validate_uri_location(uri: &str) -> Result<(), AcdpError> {
460 if uri.len() < 3 || uri.len() > MAX_URI_LEN {
461 return Err(AcdpError::SchemaViolation(format!(
462 "DataRef.location URI length {} not in 3..={}",
463 uri.len(),
464 MAX_URI_LEN
465 )));
466 }
467 let (scheme, rest) = uri
469 .split_once(':')
470 .ok_or_else(|| AcdpError::SchemaViolation(format!("URI missing scheme: {uri}")))?;
471 if scheme.is_empty()
472 || !scheme
473 .chars()
474 .next()
475 .is_some_and(|c| c.is_ascii_lowercase())
476 || !scheme
477 .chars()
478 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '.' | '-'))
479 {
480 return Err(AcdpError::SchemaViolation(format!(
481 "URI scheme '{scheme}' invalid; must match [a-z][a-z0-9+.-]*"
482 )));
483 }
484 if let Some(after_slashes) = rest.strip_prefix("//") {
486 if let Some(authority_end) = after_slashes.find(['/', '?', '#']) {
487 let authority = &after_slashes[..authority_end];
488 if authority.contains('@') {
489 return Err(AcdpError::SchemaViolation(format!(
490 "URI MUST NOT contain credentials in userinfo: {uri}"
491 )));
492 }
493 } else if after_slashes.contains('@') {
494 return Err(AcdpError::SchemaViolation(format!(
495 "URI MUST NOT contain credentials in userinfo: {uri}"
496 )));
497 }
498 }
499 Ok(())
500}
501
502fn validate_structured_locator(
503 map: &serde_json::Map<String, serde_json::Value>,
504) -> Result<(), AcdpError> {
505 let scheme = map.get("scheme").and_then(|v| v.as_str()).ok_or_else(|| {
506 AcdpError::SchemaViolation("structured locator missing required 'scheme'".into())
507 })?;
508 if !is_dotted_namespace_scheme(scheme) {
509 return Err(AcdpError::SchemaViolation(format!(
510 "structured locator scheme '{scheme}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
511 )));
512 }
513 Ok(())
514}
515
516fn is_dotted_namespace_scheme(s: &str) -> bool {
517 let parts: Vec<&str> = s.split('.').collect();
518 if parts.len() < 2 {
519 return false;
520 }
521 parts.iter().all(|part| {
522 !part.is_empty()
523 && part.chars().next().is_some_and(|c| c.is_ascii_lowercase())
524 && part
525 .chars()
526 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
527 })
528}
529
530fn validate_embedded(emb: &EmbeddedContent) -> Result<(), AcdpError> {
531 match emb.encoding {
533 EmbeddedEncoding::Utf8 | EmbeddedEncoding::Base64 => {
534 if !emb.content.is_string() {
535 return Err(AcdpError::SchemaViolation(format!(
536 "embedded {:?} content MUST be a JSON string",
537 emb.encoding
538 )));
539 }
540 }
541 EmbeddedEncoding::Json => {}
542 }
543 let decoded = embedded_decoded_bytes(emb)?;
545 if decoded.len() > MAX_EMBEDDED_BYTES {
546 return Err(AcdpError::EmbeddedTooLarge(format!(
547 "embedded decoded size {} bytes exceeds {} limit",
548 decoded.len(),
549 MAX_EMBEDDED_BYTES
550 )));
551 }
552 Ok(())
553}
554
555pub fn embedded_decoded_bytes(emb: &EmbeddedContent) -> Result<Vec<u8>, AcdpError> {
561 Ok(match emb.encoding {
562 EmbeddedEncoding::Json => try_canonicalize_value(&emb.content)?,
563 EmbeddedEncoding::Utf8 => {
564 let s = emb.content.as_str().ok_or_else(|| {
565 AcdpError::SchemaViolation("utf8 embedded content must be a JSON string".into())
566 })?;
567 s.as_bytes().to_vec()
568 }
569 EmbeddedEncoding::Base64 => {
570 let s = emb.content.as_str().ok_or_else(|| {
571 AcdpError::SchemaViolation("base64 embedded content must be a JSON string".into())
572 })?;
573 STANDARD
574 .decode(s)
575 .map_err(|e| AcdpError::SchemaViolation(format!("base64 decode failed: {e}")))?
576 }
577 })
578}
579
580pub fn compute_embedded_hash(emb: &EmbeddedContent) -> Result<ContentHash, AcdpError> {
582 let bytes = embedded_decoded_bytes(emb)?;
583 let digest = Sha256::digest(&bytes);
584 Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
585}
586
587pub fn verify_embedded_hash(dr: &DataRef) -> Result<(), AcdpError> {
596 let (Some(emb), Some(stored)) = (&dr.embedded, &dr.content_hash) else {
597 return Ok(());
598 };
599 let recomputed = compute_embedded_hash(emb)?;
600 if &recomputed != stored {
601 return Err(AcdpError::DataRefHashMismatch(format!(
602 "embedded content_hash mismatch: declared {}, computed {}",
603 stored.as_str(),
604 recomputed.as_str()
605 )));
606 }
607 Ok(())
608}
609
610pub fn validate_metadata(value: &serde_json::Value) -> Result<(), AcdpError> {
615 validate_json_object_limits(value, "metadata")
616}
617
618fn validate_json_object_limits(value: &serde_json::Value, field: &str) -> Result<(), AcdpError> {
623 let obj = value
624 .as_object()
625 .ok_or_else(|| AcdpError::SchemaViolation(format!("{field} must be a JSON object")))?;
626 if obj.len() > MAX_METADATA_PROPERTIES {
627 return Err(AcdpError::SchemaViolation(format!(
628 "{field} has {} top-level properties, exceeds {} limit",
629 obj.len(),
630 MAX_METADATA_PROPERTIES
631 )));
632 }
633 let depth = json_depth(value);
634 if depth > MAX_METADATA_DEPTH {
635 return Err(AcdpError::SchemaViolation(format!(
636 "{field} nesting depth {depth} exceeds {MAX_METADATA_DEPTH}"
637 )));
638 }
639 let canonical_size = try_canonicalize_value(value)?.len();
640 if canonical_size > MAX_METADATA_JCS_BYTES {
641 return Err(AcdpError::SchemaViolation(format!(
642 "{field} JCS-canonical size {canonical_size} bytes exceeds {MAX_METADATA_JCS_BYTES}"
643 )));
644 }
645 Ok(())
646}
647
648pub fn validate_extensions(
651 extensions: &serde_json::Map<String, serde_json::Value>,
652) -> Result<(), AcdpError> {
653 if extensions.is_empty() {
654 return Ok(());
655 }
656 let value = serde_json::Value::Object(extensions.clone());
660 validate_json_object_limits(&value, "extensions")
661}
662
663fn json_depth(v: &serde_json::Value) -> usize {
673 const MAX_JSON_DEPTH_SCAN: usize = 256;
675 fn go(v: &serde_json::Value, budget: usize) -> usize {
676 if budget == 0 {
677 return 1; }
679 match v {
680 serde_json::Value::Object(map) => {
681 1 + map.values().map(|x| go(x, budget - 1)).max().unwrap_or(0)
682 }
683 serde_json::Value::Array(arr) => {
684 1 + arr.iter().map(|x| go(x, budget - 1)).max().unwrap_or(0)
685 }
686 _ => 0,
687 }
688 }
689 go(v, MAX_JSON_DEPTH_SCAN)
690}
691
692fn validate_visibility_audience(
695 vis: &Visibility,
696 audience: Option<&[AgentDid]>,
697) -> Result<(), AcdpError> {
698 match vis {
699 Visibility::Restricted => {
700 if audience.is_none_or(|a| a.is_empty()) {
701 return Err(AcdpError::SchemaViolation(
702 "visibility:restricted requires a non-empty audience".into(),
703 ));
704 }
705 }
706 Visibility::Public => {
707 if audience.is_some_and(|a| !a.is_empty()) {
708 return Err(AcdpError::SchemaViolation(
709 "visibility:public MUST NOT include audience".into(),
710 ));
711 }
712 }
713 Visibility::Private => {}
714 }
715 Ok(())
716}
717
718fn validate_title(title: &str) -> Result<(), AcdpError> {
721 if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
722 return Err(AcdpError::SchemaViolation(format!(
723 "title length {} not in 1..={}",
724 title.chars().count(),
725 MAX_TITLE_LEN
726 )));
727 }
728 Ok(())
729}
730
731fn validate_optional_string(s: Option<&str>, name: &str, max_len: usize) -> Result<(), AcdpError> {
732 if let Some(value) = s {
733 if value.chars().count() > max_len {
734 return Err(AcdpError::SchemaViolation(format!(
735 "{name} length {} exceeds {max_len}",
736 value.chars().count()
737 )));
738 }
739 }
740 Ok(())
741}
742
743fn validate_unique_array<T: PartialEq + std::fmt::Debug>(
744 name: &str,
745 items: &[T],
746 max: usize,
747) -> Result<(), AcdpError> {
748 if items.len() > max {
749 return Err(AcdpError::SchemaViolation(format!(
750 "{name} has {} items, exceeds {max}",
751 items.len()
752 )));
753 }
754 for (i, item) in items.iter().enumerate() {
755 if items[i + 1..].iter().any(|other| other == item) {
756 return Err(AcdpError::SchemaViolation(format!(
757 "{name} contains duplicate entry: {item:?}"
758 )));
759 }
760 }
761 Ok(())
762}
763
764fn validate_tags(tags: &[String]) -> Result<(), AcdpError> {
765 if tags.len() > MAX_TAGS {
766 return Err(AcdpError::SchemaViolation(format!(
767 "tags has {} entries, exceeds {}",
768 tags.len(),
769 MAX_TAGS
770 )));
771 }
772 for tag in tags {
773 validate_tag(tag)?;
774 }
775 for (i, tag) in tags.iter().enumerate() {
777 if tags[i + 1..].iter().any(|t| t == tag) {
778 return Err(AcdpError::SchemaViolation(format!(
779 "tags contains duplicate entry: {tag}"
780 )));
781 }
782 }
783 Ok(())
784}
785
786fn validate_tag(tag: &str) -> Result<(), AcdpError> {
787 if tag.is_empty() || tag.len() > MAX_TAG_LEN {
788 return Err(AcdpError::SchemaViolation(format!(
789 "tag '{tag}' length not in 1..={MAX_TAG_LEN}"
790 )));
791 }
792 let mut chars = tag.chars();
793 let first = chars.next().unwrap();
794 if !first.is_ascii_alphanumeric() {
795 return Err(AcdpError::SchemaViolation(format!(
796 "tag '{tag}' first char must be alphanumeric"
797 )));
798 }
799 if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) {
800 return Err(AcdpError::SchemaViolation(format!(
801 "tag '{tag}' must match [A-Za-z0-9][A-Za-z0-9_.-]*"
802 )));
803 }
804 Ok(())
805}
806
807fn validate_did_key_key_id_form(key_id: &str) -> Result<(), AcdpError> {
827 if !key_id.starts_with("did:key:") {
828 return Ok(());
829 }
830 acdp_did::key::resolve_did_key_url(key_id).map_err(|e| {
831 AcdpError::SchemaViolation(format!(
832 "signature.key_id is not a well-formed did:key URL: {e}"
833 ))
834 })?;
835 Ok(())
836}
837
838fn validate_agent_did(did: &AgentDid) -> Result<(), AcdpError> {
839 if did.as_str().starts_with("did:key:") {
840 AgentDid::parse(did.as_str())?;
841 acdp_did::key::resolve_did_key(did.as_str()).map_err(|e| {
842 AcdpError::SchemaViolation(format!("agent_id is not a well-formed did:key: {e}"))
843 })?;
844 return Ok(());
845 }
846 AgentDid::parse_web(did.as_str())?;
847 Ok(())
848}
849
850fn validate_origin_registry(s: &str) -> Result<(), AcdpError> {
858 if s.is_empty() {
859 return Err(AcdpError::SchemaViolation(
860 "origin_registry must be a non-empty DNS hostname".into(),
861 ));
862 }
863 if s.starts_with("did:") {
864 return Err(AcdpError::SchemaViolation(format!(
865 "origin_registry must be a DNS hostname, not a DID URI (got '{s}'); \
866 use the bare authority — capabilities.registry_did carries the did:web form"
867 )));
868 }
869 if s.contains("://") {
870 return Err(AcdpError::SchemaViolation(format!(
871 "origin_registry must be a DNS hostname, not a URL (got '{s}')"
872 )));
873 }
874 if s.ends_with('.') || s.starts_with('.') {
875 return Err(AcdpError::SchemaViolation(format!(
876 "origin_registry must be a syntactically valid DNS hostname (got '{s}')"
877 )));
878 }
879 if !acdp_types::primitives::is_valid_dns_authority(s) {
885 return Err(AcdpError::SchemaViolation(format!(
886 "origin_registry '{s}' is not a valid DNS hostname (must be lowercase \
887 labels of [a-z0-9-] separated by dots, e.g. 'registry.example.com')"
888 )));
889 }
890 Ok(())
891}
892
893fn validate_loose_did(did: &AgentDid) -> Result<(), AcdpError> {
903 AgentDid::parse(did.as_str())?;
904 Ok(())
905}
906
907fn validate_namespaced_context_type(value: &str) -> Result<(), AcdpError> {
910 let (ns, name) = value.split_once(':').ok_or_else(|| {
912 AcdpError::SchemaViolation(format!(
913 "context_type '{value}' missing namespace separator"
914 ))
915 })?;
916 if ns.is_empty()
917 || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
918 || !ns
919 .chars()
920 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
921 {
922 return Err(AcdpError::SchemaViolation(format!(
923 "context_type namespace '{ns}' must match [a-z][a-z0-9_]*"
924 )));
925 }
926 if name.is_empty()
927 || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
928 || !name
929 .chars()
930 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
931 {
932 return Err(AcdpError::SchemaViolation(format!(
933 "context_type name '{name}' must match [a-z][a-z0-9_-]*"
934 )));
935 }
936 Ok(())
937}
938
939trait ContextTypeExt {
940 fn namespaced_form(&self) -> Option<&str>;
941}
942
943impl ContextTypeExt for ContextType {
944 fn namespaced_form(&self) -> Option<&str> {
945 match self {
946 ContextType::Custom(s) => Some(s.as_str()),
947 _ => None,
948 }
949 }
950}
951
952fn validate_semver_pattern(name: &str, value: &str) -> Result<(), AcdpError> {
955 let parts: Vec<&str> = value.split('.').collect();
956 let ok = parts.len() == 3
957 && parts
958 .iter()
959 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
960 if !ok {
961 return Err(AcdpError::SchemaViolation(format!(
962 "{name} '{value}' must match the semver pattern ^\\d+\\.\\d+\\.\\d+$"
963 )));
964 }
965 Ok(())
966}
967
968fn validate_signature_length(algorithm: &str, value_b64: &str) -> Result<(), AcdpError> {
969 let expected = match algorithm {
970 "ed25519" => Some(ED25519_SIG_B64_LEN),
971 "ecdsa-p256" => Some(ECDSA_P256_SIG_B64_LEN),
972 _ => None,
973 };
974 if let Some(n) = expected {
975 if value_b64.len() != n {
976 return Err(AcdpError::InvalidSignature(format!(
977 "signature.value for '{algorithm}' must be {n} base64 chars, got {}",
978 value_b64.len()
979 )));
980 }
981 }
982 Ok(())
983}
984
985#[cfg(test)]
988mod tests {
989 use super::*;
990 use acdp_types::data_ref::DataRefType;
991 use serde_json::json;
992
993 fn embedded_json(v: serde_json::Value) -> EmbeddedContent {
994 EmbeddedContent {
995 encoding: EmbeddedEncoding::Json,
996 content: v,
997 }
998 }
999
1000 #[test]
1003 fn origin_registry_accepts_valid_hostname() {
1004 validate_origin_registry("registry.example.com").unwrap();
1005 validate_origin_registry("reg.example").unwrap();
1006 validate_origin_registry("a-b-c.io").unwrap();
1007 }
1008
1009 #[test]
1010 fn origin_registry_rejects_uppercase() {
1011 assert!(matches!(
1012 validate_origin_registry("REGISTRY.EXAMPLE.COM"),
1013 Err(AcdpError::SchemaViolation(_))
1014 ));
1015 }
1016
1017 #[test]
1018 fn origin_registry_rejects_underscore() {
1019 assert!(matches!(
1020 validate_origin_registry("registry_example.com"),
1021 Err(AcdpError::SchemaViolation(_))
1022 ));
1023 }
1024
1025 #[test]
1026 fn origin_registry_rejects_hyphen_label_edges() {
1027 assert!(matches!(
1028 validate_origin_registry("registry-.com"),
1029 Err(AcdpError::SchemaViolation(_))
1030 ));
1031 assert!(matches!(
1032 validate_origin_registry("-registry.example.com"),
1033 Err(AcdpError::SchemaViolation(_))
1034 ));
1035 }
1036
1037 #[test]
1040 fn data_ref_neither_location_nor_embedded_rejected() {
1041 let dr = DataRef {
1042 ref_type: DataRefType::PrimaryResult,
1043 description: None,
1044 size_bytes: None,
1045 format: None,
1046 schema_version: None,
1047 content_hash: None,
1048 location: None,
1049 embedded: None,
1050 extensions: serde_json::Map::new(),
1051 };
1052 assert!(matches!(
1053 validate_data_ref(&dr),
1054 Err(AcdpError::SchemaViolation(_))
1055 ));
1056 }
1057
1058 #[test]
1059 fn data_ref_both_location_and_embedded_rejected() {
1060 let dr = DataRef {
1061 ref_type: DataRefType::PrimaryResult,
1062 description: None,
1063 size_bytes: None,
1064 format: None,
1065 schema_version: None,
1066 content_hash: None,
1067 location: Some(Location::Uri("https://x/y".into())),
1068 embedded: Some(embedded_json(json!({"a": 1}))),
1069 extensions: serde_json::Map::new(),
1070 };
1071 assert!(matches!(
1072 validate_data_ref(&dr),
1073 Err(AcdpError::SchemaViolation(_))
1074 ));
1075 }
1076
1077 #[test]
1080 fn uri_credentials_rejected() {
1081 let dr = DataRef::uri(DataRefType::RawData, "https://user:pass@example.com/data");
1082 assert!(matches!(
1083 validate_data_ref(&dr),
1084 Err(AcdpError::SchemaViolation(_))
1085 ));
1086 }
1087
1088 #[test]
1089 fn uri_without_scheme_rejected() {
1090 let dr = DataRef::uri(DataRefType::RawData, "no-scheme");
1091 assert!(matches!(
1092 validate_data_ref(&dr),
1093 Err(AcdpError::SchemaViolation(_))
1094 ));
1095 }
1096
1097 #[test]
1098 fn uri_too_long_rejected() {
1099 let long_uri = format!("https://x.com/{}", "a".repeat(MAX_URI_LEN));
1100 let dr = DataRef::uri(DataRefType::RawData, long_uri);
1101 assert!(matches!(
1102 validate_data_ref(&dr),
1103 Err(AcdpError::SchemaViolation(_))
1104 ));
1105 }
1106
1107 #[test]
1110 fn structured_locator_missing_scheme_rejected() {
1111 let mut map = serde_json::Map::new();
1112 map.insert("offset".into(), json!(42));
1113 let dr = DataRef {
1114 ref_type: DataRefType::RawData,
1115 description: None,
1116 size_bytes: None,
1117 format: None,
1118 schema_version: None,
1119 content_hash: None,
1120 location: Some(Location::Structured(map)),
1121 embedded: None,
1122 extensions: serde_json::Map::new(),
1123 };
1124 assert!(matches!(
1125 validate_data_ref(&dr),
1126 Err(AcdpError::SchemaViolation(_))
1127 ));
1128 }
1129
1130 #[test]
1131 fn structured_locator_bad_scheme_rejected() {
1132 let err =
1136 DataRef::try_structured(DataRefType::RawData, "not_dotted", serde_json::Map::new())
1137 .unwrap_err();
1138 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1139
1140 let mut bad = serde_json::Map::new();
1143 bad.insert(
1144 "scheme".into(),
1145 serde_json::Value::String("not_dotted".into()),
1146 );
1147 let dr = DataRef {
1148 ref_type: DataRefType::RawData,
1149 description: None,
1150 size_bytes: None,
1151 format: None,
1152 schema_version: None,
1153 content_hash: None,
1154 location: Some(Location::Structured(bad)),
1155 embedded: None,
1156 extensions: serde_json::Map::new(),
1157 };
1158 assert!(matches!(
1159 validate_data_ref(&dr),
1160 Err(AcdpError::SchemaViolation(_))
1161 ));
1162 }
1163
1164 #[test]
1165 fn structured_locator_valid() {
1166 let mut extra = serde_json::Map::new();
1167 extra.insert("topic".into(), json!("events"));
1168 let dr = DataRef::structured(DataRefType::RawData, "kafka.offset", extra);
1169 validate_data_ref(&dr).unwrap();
1170 }
1171
1172 #[test]
1175 fn embedded_utf8_must_be_string() {
1176 let dr = DataRef {
1177 ref_type: DataRefType::PrimaryResult,
1178 description: None,
1179 size_bytes: None,
1180 format: None,
1181 schema_version: None,
1182 content_hash: None,
1183 location: None,
1184 embedded: Some(EmbeddedContent {
1185 encoding: EmbeddedEncoding::Utf8,
1186 content: json!(42),
1187 }),
1188 extensions: serde_json::Map::new(),
1189 };
1190 assert!(matches!(
1191 validate_data_ref(&dr),
1192 Err(AcdpError::SchemaViolation(_))
1193 ));
1194 }
1195
1196 #[test]
1197 fn embedded_too_large_rejected() {
1198 let big = "a".repeat(70 * 1024);
1200 let dr = DataRef::embedded_utf8(DataRefType::PrimaryResult, big);
1201 assert!(matches!(
1202 validate_data_ref(&dr),
1203 Err(AcdpError::EmbeddedTooLarge(_))
1204 ));
1205 }
1206
1207 #[test]
1210 fn embedded_hash_json_round_trip() {
1211 let emb = embedded_json(json!({"b": 2, "a": 1}));
1212 let h = compute_embedded_hash(&emb).unwrap();
1213 let expected = {
1215 let bytes = b"{\"a\":1,\"b\":2}";
1216 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
1217 };
1218 assert_eq!(h.as_str(), expected);
1219 }
1220
1221 #[test]
1222 fn embedded_hash_utf8() {
1223 let emb = EmbeddedContent {
1224 encoding: EmbeddedEncoding::Utf8,
1225 content: json!("hello"),
1226 };
1227 let h = compute_embedded_hash(&emb).unwrap();
1228 let expected = format!("sha256:{}", hex::encode(Sha256::digest(b"hello")));
1229 assert_eq!(h.as_str(), expected);
1230 }
1231
1232 #[test]
1233 fn embedded_hash_base64() {
1234 let raw = b"binary data";
1235 let b64 = STANDARD.encode(raw);
1236 let emb = EmbeddedContent {
1237 encoding: EmbeddedEncoding::Base64,
1238 content: json!(b64),
1239 };
1240 let h = compute_embedded_hash(&emb).unwrap();
1241 let expected = format!("sha256:{}", hex::encode(Sha256::digest(raw)));
1242 assert_eq!(h.as_str(), expected);
1243 }
1244
1245 #[test]
1246 fn verify_embedded_hash_mismatch_detected() {
1247 let emb = embedded_json(json!({"x": 1}));
1248 let dr = DataRef {
1249 ref_type: DataRefType::PrimaryResult,
1250 description: None,
1251 size_bytes: None,
1252 format: None,
1253 schema_version: None,
1254 content_hash: Some(ContentHash("sha256:0000".into())),
1255 location: None,
1256 embedded: Some(emb),
1257 extensions: serde_json::Map::new(),
1258 };
1259 assert!(matches!(
1260 verify_embedded_hash(&dr),
1261 Err(AcdpError::DataRefHashMismatch(_))
1262 ));
1263 }
1264
1265 #[test]
1268 fn metadata_too_many_properties_rejected() {
1269 let mut obj = serde_json::Map::new();
1270 for i in 0..101 {
1271 obj.insert(format!("k{i}"), json!(i));
1272 }
1273 assert!(matches!(
1274 validate_metadata(&serde_json::Value::Object(obj)),
1275 Err(AcdpError::SchemaViolation(_))
1276 ));
1277 }
1278
1279 #[test]
1280 fn metadata_too_deep_rejected() {
1281 let mut v = json!("leaf");
1283 for _ in 0..10 {
1284 let mut o = serde_json::Map::new();
1285 o.insert("a".into(), v);
1286 v = serde_json::Value::Object(o);
1287 }
1288 assert!(matches!(
1289 validate_metadata(&v),
1290 Err(AcdpError::SchemaViolation(_))
1291 ));
1292 }
1293
1294 #[test]
1295 fn metadata_too_large_rejected() {
1296 let big = "a".repeat(70 * 1024);
1297 let v = json!({"big": big});
1298 assert!(matches!(
1299 validate_metadata(&v),
1300 Err(AcdpError::SchemaViolation(_))
1301 ));
1302 }
1303
1304 #[test]
1305 fn metadata_must_be_object() {
1306 assert!(matches!(
1307 validate_metadata(&json!([1, 2, 3])),
1308 Err(AcdpError::SchemaViolation(_))
1309 ));
1310 }
1311
1312 #[test]
1315 fn public_with_audience_rejected() {
1316 let aud = vec![AgentDid::new("did:web:x")];
1317 assert!(matches!(
1318 validate_visibility_audience(&Visibility::Public, Some(&aud)),
1319 Err(AcdpError::SchemaViolation(_))
1320 ));
1321 }
1322
1323 #[test]
1324 fn public_with_empty_audience_ok() {
1325 validate_visibility_audience(&Visibility::Public, Some(&[])).unwrap();
1326 validate_visibility_audience(&Visibility::Public, None).unwrap();
1327 }
1328
1329 #[test]
1330 fn restricted_without_audience_rejected() {
1331 assert!(matches!(
1332 validate_visibility_audience(&Visibility::Restricted, None),
1333 Err(AcdpError::SchemaViolation(_))
1334 ));
1335 }
1336
1337 #[test]
1340 fn data_period_start_after_end_rejected_via_builder() {
1341 use acdp_crypto::SigningKey;
1342 use acdp_producer::Producer;
1343 use acdp_types::body::DataPeriod;
1344 use chrono::TimeZone;
1345
1346 let p = Producer::new(
1347 SigningKey::from_bytes(&[0u8; 32]),
1348 AgentDid::new("did:web:agents.example.com:test"),
1349 "did:web:agents.example.com:test#key-1",
1350 );
1351 let err = p
1352 .publish_request()
1353 .title("t")
1354 .context_type(ContextType::DataSnapshot)
1355 .data_period(DataPeriod {
1356 start: chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
1357 end: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1358 })
1359 .build()
1360 .unwrap_err();
1361 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1362 }
1363
1364 #[test]
1367 fn tag_pattern_validation() {
1368 validate_tag("hello").unwrap();
1369 validate_tag("Q1-2026").unwrap();
1370 validate_tag("a_b.c").unwrap();
1371 assert!(validate_tag("-bad").is_err());
1373 assert!(validate_tag("space here").is_err());
1375 assert!(validate_tag("").is_err());
1377 }
1378
1379 #[test]
1380 fn duplicate_tags_rejected() {
1381 let tags = vec!["a".to_string(), "b".to_string(), "a".to_string()];
1382 assert!(validate_tags(&tags).is_err());
1383 }
1384
1385 #[test]
1388 fn ed25519_sig_must_be_88_chars() {
1389 assert!(validate_signature_length("ed25519", "AAAA").is_err());
1390 validate_signature_length("ed25519", &"A".repeat(88)).unwrap();
1391 validate_signature_length("future-alg", "any").unwrap();
1393 }
1394
1395 #[test]
1398 fn namespaced_context_type_pattern() {
1399 validate_namespaced_context_type("finance:portfolio_snapshot").unwrap();
1400 assert!(validate_namespaced_context_type("Finance:portfolio").is_err());
1401 assert!(validate_namespaced_context_type("finance:Portfolio").is_err());
1402 assert!(validate_namespaced_context_type("no-colon").is_err());
1403 }
1404
1405 #[test]
1409 fn acdp_version_pattern_rejects_non_semver() {
1410 validate_semver_pattern("acdp_version", "0.1.0").unwrap();
1411 validate_semver_pattern("acdp_version", "10.20.30").unwrap();
1412 assert!(validate_semver_pattern("acdp_version", "0.1.0-rc.1").is_err());
1413 assert!(validate_semver_pattern("acdp_version", "0.0").is_err());
1414 assert!(validate_semver_pattern("acdp_version", "vee.zero.zero").is_err());
1415 }
1416
1417 #[test]
1420 fn derived_from_malformed_ctx_id_rejected() {
1421 use acdp_crypto::SigningKey;
1422 use acdp_producer::Producer;
1423
1424 let p = Producer::new(
1425 SigningKey::from_bytes(&[0u8; 32]),
1426 AgentDid::new("did:web:agents.example.com:test"),
1427 "did:web:agents.example.com:test#key-1",
1428 );
1429 let err = p
1430 .publish_request()
1431 .title("t")
1432 .context_type(ContextType::DataSnapshot)
1433 .derived_from(vec![CtxId("not-a-ctx-id".into())])
1434 .build()
1435 .unwrap_err();
1436 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1437 }
1438
1439 #[test]
1442 fn embedded_content_hash_mismatch_caught() {
1443 use acdp_types::data_ref::DataRefType;
1444 let dr = DataRef {
1445 ref_type: DataRefType::PrimaryResult,
1446 description: None,
1447 size_bytes: None,
1448 format: None,
1449 schema_version: None,
1450 content_hash: Some(ContentHash("sha256:0000".into())),
1451 location: None,
1452 embedded: Some(EmbeddedContent {
1453 encoding: EmbeddedEncoding::Json,
1454 content: json!({"x": 1}),
1455 }),
1456 extensions: serde_json::Map::new(),
1457 };
1458 assert!(matches!(
1459 verify_embedded_hash(&dr),
1460 Err(AcdpError::DataRefHashMismatch(_))
1461 ));
1462 }
1463
1464 #[test]
1466 fn audience_uniqueness_rejected() {
1467 let dup = vec![
1468 AgentDid::new("did:web:a.example.com"),
1469 AgentDid::new("did:web:a.example.com"),
1470 ];
1471 let err = validate_unique_array("audience", &dup, MAX_AUDIENCE).unwrap_err();
1472 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1473 }
1474
1475 #[test]
1478 fn extensions_empty_ok() {
1479 validate_extensions(&serde_json::Map::new()).unwrap();
1480 }
1481
1482 #[test]
1483 fn extensions_small_forward_compat_accepted() {
1484 let mut ext = serde_json::Map::new();
1486 ext.insert("priority".into(), json!("high"));
1487 ext.insert("custom".into(), json!({"k": [1, 2, 3]}));
1488 validate_extensions(&ext).unwrap();
1489 }
1490
1491 #[test]
1492 fn extensions_too_many_properties_rejected() {
1493 let mut ext = serde_json::Map::new();
1494 for i in 0..(MAX_METADATA_PROPERTIES + 1) {
1495 ext.insert(format!("k{i}"), json!(i));
1496 }
1497 let err = validate_extensions(&ext).unwrap_err();
1498 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1499 }
1500
1501 #[test]
1502 fn extensions_oversized_jcs_rejected() {
1503 let mut ext = serde_json::Map::new();
1504 ext.insert("blob".into(), json!("x".repeat(MAX_METADATA_JCS_BYTES + 1)));
1505 let err = validate_extensions(&ext).unwrap_err();
1506 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1507 }
1508
1509 #[test]
1510 fn extensions_too_deep_rejected() {
1511 let mut v = json!(0);
1513 for _ in 0..(MAX_METADATA_DEPTH + 2) {
1514 v = json!({ "n": v });
1515 }
1516 let mut ext = serde_json::Map::new();
1517 ext.insert("deep".into(), v);
1518 let err = validate_extensions(&ext).unwrap_err();
1519 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1520 }
1521
1522 #[test]
1523 fn json_depth_clamps_past_scan_budget() {
1524 let mut v = json!(0);
1529 for _ in 0..400 {
1530 v = json!([v]);
1531 }
1532 assert!(json_depth(&v) > MAX_METADATA_DEPTH);
1533 assert!(acdp_crypto::try_canonicalize_value(&v).is_err());
1534 }
1535}
1536
1537#[cfg(test)]
1538mod capabilities_0_3_0_tests {
1539 use super::*;
1540 use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1541
1542 fn caps(version: &str, supports_idem: bool, mppm: Option<u64>) -> CapabilitiesDocument {
1543 CapabilitiesDocument {
1544 acdp_version: version.into(),
1545 registry_did: "did:web:registry.example.com".into(),
1546 supported_signature_algorithms: vec!["ed25519".into()],
1547 supported_did_methods: vec!["did:web".into()],
1548 profiles: vec!["acdp-registry-core".into()],
1549 limits: Limits {
1550 max_payload_bytes: 1_048_576,
1551 max_embedded_bytes: 65_536,
1552 idempotency_key_ttl_seconds: if supports_idem { Some(86_400) } else { None },
1553 max_publish_per_minute: mppm,
1554 },
1555 read_authentication_methods: vec![],
1556 anonymous_public_reads: false,
1557 supports_idempotency_key: supports_idem,
1558 extensions: Default::default(),
1559 }
1560 }
1561
1562 #[test]
1565 fn max_publish_per_minute_bounds() {
1566 assert!(validate_capabilities(&caps("0.1.0", false, Some(600))).is_ok());
1567 let err = validate_capabilities(&caps("0.1.0", false, Some(0)))
1568 .expect_err("zero MUST be rejected");
1569 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1570 }
1571
1572 #[test]
1577 fn idempotency_required_at_0_3_0() {
1578 assert!(validate_capabilities(&caps("0.1.0", false, None)).is_ok());
1579 assert!(validate_capabilities(&caps("0.2.0", false, None)).is_ok());
1580 assert!(validate_capabilities(&caps("0.3.0", true, None)).is_ok());
1581 assert!(validate_capabilities(&caps("0.4.0", true, None)).is_ok());
1582 for v in ["0.3.0", "0.4.0", "1.0.0"] {
1583 let err = validate_capabilities(&caps(v, false, None))
1584 .expect_err("version >= 0.3.0 without idempotency MUST be rejected");
1585 assert!(matches!(err, AcdpError::SchemaViolation(_)), "{v}: {err:?}");
1586 }
1587 }
1588}