1use acdp_crypto::try_canonicalize_value;
23use acdp_primitives::error::AcdpError;
24use acdp_types::anchor::AnchorEntry;
25use acdp_types::body::Body;
26use acdp_types::data_ref::{DataRef, EmbeddedContent, EmbeddedEncoding, Location};
27use acdp_types::primitives::{
28 AgentDid, ContentHash, ContextType, CtxId, LineageId, Status, Visibility,
29};
30use acdp_types::publish::PublishRequest;
31use base64::{engine::general_purpose::STANDARD, Engine};
32use sha2::{Digest, Sha256};
33
34const MAX_TITLE_LEN: usize = 500;
37const MAX_DESCRIPTION_LEN: usize = 5000;
38const MAX_SUMMARY_LEN: usize = 1000;
39const MAX_DOMAIN_LEN: usize = 200;
40const MAX_DATA_REF_DESCRIPTION_LEN: usize = 1000;
41const MAX_TAG_LEN: usize = 100;
42const MAX_CONTRIBUTORS: usize = 100;
43const MAX_TAGS: usize = 200;
44const MAX_DERIVED_FROM: usize = 1000;
45const MAX_AUDIENCE: usize = 1000;
46const MAX_METADATA_PROPERTIES: usize = 100;
47const MAX_METADATA_DEPTH: usize = 8;
48const MAX_METADATA_JCS_BYTES: usize = 65_536;
49const MAX_URI_LEN: usize = 4096;
50const MAX_ANCHORS: usize = 100;
51const MAX_EMBEDDED_BYTES: usize = 65_536;
52const ED25519_SIG_B64_LEN: usize = 88;
53const ECDSA_P256_SIG_B64_LEN: usize = 88;
54
55fn version_at_least(v: &str, major: u64, minor: u64) -> bool {
61 let mut it = v.split('.').filter_map(|p| p.parse::<u64>().ok());
62 match (it.next(), it.next()) {
63 (Some(ma), Some(mi)) => ma > major || (ma == major && mi >= minor),
64 _ => false,
65 }
66}
67
68pub fn validate_capabilities(caps: &acdp_types::CapabilitiesDocument) -> Result<(), AcdpError> {
88 validate_semver_pattern("acdp_version", &caps.acdp_version)?;
89
90 if let Some(mppm) = caps.limits.max_publish_per_minute {
93 if mppm < 1 {
94 return Err(AcdpError::SchemaViolation(
95 "capabilities.limits.max_publish_per_minute MUST be >= 1 \
96 (RFC-ACDP-0007 \u{a7}3.5 item 11)"
97 .into(),
98 ));
99 }
100 }
101
102 if version_at_least(&caps.acdp_version, 0, 3) && !caps.supports_idempotency_key {
107 return Err(AcdpError::SchemaViolation(
108 "capabilities advertising acdp_version >= 0.3.0 MUST set \
109 supports_idempotency_key: true (RFC-ACDP-0003 \u{a7}6.4, \
110 RFC-ACDP-0007 \u{a7}3.5 item 10)"
111 .into(),
112 ));
113 }
114
115 AgentDid::parse_web(caps.registry_did.as_str()).map_err(|e| {
116 AcdpError::SchemaViolation(format!(
117 "capabilities.registry_did must be did:web for v0.1.0: {e}"
118 ))
119 })?;
120
121 if !caps
122 .supported_signature_algorithms
123 .iter()
124 .any(|a| a == "ed25519")
125 {
126 return Err(AcdpError::SchemaViolation(
127 "capabilities.supported_signature_algorithms MUST contain 'ed25519' \
128 (RFC-ACDP-0001 §5.10)"
129 .into(),
130 ));
131 }
132
133 if !caps.supported_did_methods.iter().any(|m| m == "did:web") {
134 return Err(AcdpError::SchemaViolation(
135 "capabilities.supported_did_methods MUST contain 'did:web' \
136 (RFC-ACDP-0001 §5.4)"
137 .into(),
138 ));
139 }
140
141 if !caps.profiles.iter().any(|p| p == "acdp-registry-core") {
142 return Err(AcdpError::SchemaViolation(
143 "capabilities.profiles MUST contain 'acdp-registry-core' \
144 (RFC-ACDP-0001 §9.1)"
145 .into(),
146 ));
147 }
148
149 if caps.limits.max_embedded_bytes != 65_536 {
150 return Err(AcdpError::SchemaViolation(format!(
151 "capabilities.limits.max_embedded_bytes must be 65536 (fixed by \
152 RFC-ACDP-0007 §3.1), got {}",
153 caps.limits.max_embedded_bytes
154 )));
155 }
156
157 if caps.limits.max_payload_bytes < 1024 {
158 return Err(AcdpError::SchemaViolation(format!(
159 "capabilities.limits.max_payload_bytes must be ≥ 1024, got {}",
160 caps.limits.max_payload_bytes
161 )));
162 }
163
164 if caps.supports_idempotency_key {
165 let ttl = caps.limits.idempotency_key_ttl_seconds.ok_or_else(|| {
166 AcdpError::SchemaViolation(
167 "limits.idempotency_key_ttl_seconds is required when \
168 supports_idempotency_key is true (RFC-ACDP-0007 §3.2)"
169 .into(),
170 )
171 })?;
172 if !(86_400..=604_800).contains(&ttl) {
173 return Err(AcdpError::SchemaViolation(format!(
174 "limits.idempotency_key_ttl_seconds must be in 86400..=604800, got {ttl}"
175 )));
176 }
177 }
178
179 Ok(())
180}
181
182pub fn validate_publish_request(req: &PublishRequest) -> Result<(), AcdpError> {
187 validate_title(&req.title)?;
188 validate_optional_string(
189 req.description.as_deref(),
190 "description",
191 MAX_DESCRIPTION_LEN,
192 )?;
193 validate_optional_string(req.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
194 validate_optional_string(req.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
195
196 validate_agent_did(&req.agent_id)?;
197 for c in &req.contributors {
198 validate_loose_did(c)?;
199 }
200 validate_unique_array("contributors", &req.contributors, MAX_CONTRIBUTORS)?;
201 validate_unique_array("derived_from", &req.derived_from, MAX_DERIVED_FROM)?;
202
203 if let Some(tags) = &req.tags {
204 validate_tags(tags)?;
205 }
206 if let Some(audience) = &req.audience {
207 validate_unique_array("audience", audience, MAX_AUDIENCE)?;
208 for did in audience {
209 validate_loose_did(did)?;
210 }
211 }
212
213 validate_visibility_audience(&req.visibility, req.audience.as_deref())?;
214
215 if let Some(dp) = &req.data_period {
216 if dp.start > dp.end {
217 return Err(AcdpError::SchemaViolation(
218 "data_period.start must not be after data_period.end".into(),
219 ));
220 }
221 }
222
223 if let Some(ct) = &req.context_type.namespaced_form() {
224 validate_namespaced_context_type(ct)?;
225 }
226
227 if let Some(meta) = &req.metadata {
228 validate_metadata(meta)?;
229 }
230
231 for dr in &req.data_refs {
232 validate_data_ref(dr)?;
233 }
234
235 if let Some(anchors) = &req.anchors {
236 validate_anchors(anchors)?;
237 }
238
239 validate_signature_length(&req.signature.algorithm, &req.signature.value)?;
240 validate_did_key_key_id_form(&req.signature.key_id)?;
241 ContentHash::parse(req.content_hash.as_str())?;
242
243 if let Some(prev) = &req.supersedes {
245 CtxId::parse(prev.as_str())?;
246 }
247 for ancestor in &req.derived_from {
248 CtxId::parse(ancestor.as_str())?;
249 }
250 if let Some(lineage) = &req.lineage_id {
251 acdp_types::primitives::LineageId::parse(lineage.as_str())?;
252 }
253
254 if let Some(v) = &req.acdp_version {
256 validate_semver_pattern("acdp_version", v)?;
257 }
258
259 match (&req.supersedes, req.version) {
261 (None, 1) => {}
262 (None, v) => {
263 return Err(AcdpError::SchemaViolation(format!(
264 "first-version publish requires version=1, got {v}"
265 )));
266 }
267 (Some(_), v) if v >= 2 => {}
268 (Some(_), v) => {
269 return Err(AcdpError::SchemaViolation(format!(
270 "supersession publish requires version >= 2, got {v}"
271 )));
272 }
273 }
274
275 if req.version == 1 && req.lineage_id.is_some() {
283 return Err(AcdpError::SchemaViolation(
284 "lineage_id MUST NOT be set on v1 publish requests (RFC-ACDP-0003 §2.2)".into(),
285 ));
286 }
287
288 Ok(())
289}
290
291pub fn validate_body(body: &Body) -> Result<(), AcdpError> {
293 validate_body_inner(body, true)
294}
295
296pub fn validate_body_structural(body: &Body) -> Result<(), AcdpError> {
307 validate_body_inner(body, false)
308}
309
310fn validate_body_inner(body: &Body, check_embedded_hashes: bool) -> Result<(), AcdpError> {
311 validate_title(&body.title)?;
312 validate_optional_string(
313 body.description.as_deref(),
314 "description",
315 MAX_DESCRIPTION_LEN,
316 )?;
317 validate_optional_string(body.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
318 validate_optional_string(body.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
319
320 validate_agent_did(&body.agent_id)?;
321 for c in &body.contributors {
322 validate_loose_did(c)?;
323 }
324 validate_unique_array("contributors", &body.contributors, MAX_CONTRIBUTORS)?;
325 validate_unique_array("derived_from", &body.derived_from, MAX_DERIVED_FROM)?;
326
327 if let Some(tags) = &body.tags {
328 validate_tags(tags)?;
329 }
330 if let Some(audience) = &body.audience {
331 validate_unique_array("audience", audience, MAX_AUDIENCE)?;
332 for did in audience {
333 validate_loose_did(did)?;
334 }
335 }
336 validate_visibility_audience(&body.visibility, body.audience.as_deref())?;
337
338 if let Some(dp) = &body.data_period {
339 if dp.start > dp.end {
340 return Err(AcdpError::SchemaViolation(
341 "data_period.start must not be after data_period.end".into(),
342 ));
343 }
344 }
345
346 if let Some(meta) = &body.metadata {
347 validate_metadata(meta)?;
348 }
349
350 validate_extensions(&body.extensions)?;
354
355 for dr in &body.data_refs {
356 if check_embedded_hashes {
357 validate_data_ref(dr)?;
358 } else {
359 validate_data_ref_structural(dr)?;
360 }
361 }
362
363 if let Some(anchors) = &body.anchors {
364 validate_anchors(anchors)?;
365 }
366
367 validate_signature_length(&body.signature.algorithm, &body.signature.value)?;
368 validate_did_key_key_id_form(&body.signature.key_id)?;
369 validate_identifiers(&body.ctx_id, &body.lineage_id, &body.content_hash)?;
370
371 if let Some(prev) = &body.supersedes {
373 CtxId::parse(prev.as_str())?;
374 }
375 for ancestor in &body.derived_from {
376 CtxId::parse(ancestor.as_str())?;
377 }
378
379 if let Some(v) = &body.acdp_version {
380 validate_semver_pattern("acdp_version", v)?;
381 }
382
383 let _ = &body.created_at; validate_origin_registry(&body.origin_registry)?;
385
386 let _ = std::any::type_name::<Status>();
388 let _: &Visibility = &body.visibility;
389
390 Ok(())
391}
392
393pub fn validate_identifiers(
395 ctx_id: &CtxId,
396 lineage_id: &LineageId,
397 content_hash: &ContentHash,
398) -> Result<(), AcdpError> {
399 CtxId::parse(ctx_id.as_str())?;
400 LineageId::parse(lineage_id.as_str())?;
401 ContentHash::parse(content_hash.as_str())?;
402 Ok(())
403}
404
405pub fn validate_data_ref(dr: &DataRef) -> Result<(), AcdpError> {
410 validate_data_ref_structural(dr)?;
411 if dr.embedded.is_some() {
416 verify_embedded_hash(dr)?;
417 }
418 Ok(())
419}
420
421pub fn validate_data_ref_structural(dr: &DataRef) -> Result<(), AcdpError> {
427 match (&dr.location, &dr.embedded) {
429 (None, None) => {
430 return Err(AcdpError::SchemaViolation(
431 "DataRef requires exactly one of 'location' or 'embedded' (got neither)".into(),
432 ));
433 }
434 (Some(_), Some(_)) => {
435 return Err(AcdpError::SchemaViolation(
436 "DataRef requires exactly one of 'location' or 'embedded' (got both)".into(),
437 ));
438 }
439 _ => {}
440 }
441
442 if let Some(desc) = &dr.description {
443 if desc.len() > MAX_DATA_REF_DESCRIPTION_LEN {
444 return Err(AcdpError::SchemaViolation(format!(
445 "DataRef.description {} chars exceeds {} limit",
446 desc.len(),
447 MAX_DATA_REF_DESCRIPTION_LEN
448 )));
449 }
450 }
451
452 if let Some(loc) = &dr.location {
453 validate_location(loc)?;
454 }
455 if let Some(emb) = &dr.embedded {
456 validate_embedded(emb)?;
457 }
458
459 Ok(())
460}
461
462fn validate_location(loc: &Location) -> Result<(), AcdpError> {
463 match loc {
464 Location::Uri(uri) => validate_uri_location(uri),
465 Location::Structured(map) => validate_structured_locator(map),
466 }
467}
468
469fn validate_uri_location(uri: &str) -> Result<(), AcdpError> {
470 if uri.len() < 3 || uri.len() > MAX_URI_LEN {
471 return Err(AcdpError::SchemaViolation(format!(
472 "DataRef.location URI length {} not in 3..={}",
473 uri.len(),
474 MAX_URI_LEN
475 )));
476 }
477 let (scheme, rest) = uri
479 .split_once(':')
480 .ok_or_else(|| AcdpError::SchemaViolation(format!("URI missing scheme: {uri}")))?;
481 if scheme.is_empty()
482 || !scheme
483 .chars()
484 .next()
485 .is_some_and(|c| c.is_ascii_lowercase())
486 || !scheme
487 .chars()
488 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '.' | '-'))
489 {
490 return Err(AcdpError::SchemaViolation(format!(
491 "URI scheme '{scheme}' invalid; must match [a-z][a-z0-9+.-]*"
492 )));
493 }
494 if let Some(after_slashes) = rest.strip_prefix("//") {
496 if let Some(authority_end) = after_slashes.find(['/', '?', '#']) {
497 let authority = &after_slashes[..authority_end];
498 if authority.contains('@') {
499 return Err(AcdpError::SchemaViolation(format!(
500 "URI MUST NOT contain credentials in userinfo: {uri}"
501 )));
502 }
503 } else if after_slashes.contains('@') {
504 return Err(AcdpError::SchemaViolation(format!(
505 "URI MUST NOT contain credentials in userinfo: {uri}"
506 )));
507 }
508 }
509 Ok(())
510}
511
512fn validate_structured_locator(
513 map: &serde_json::Map<String, serde_json::Value>,
514) -> Result<(), AcdpError> {
515 let scheme = map.get("scheme").and_then(|v| v.as_str()).ok_or_else(|| {
516 AcdpError::SchemaViolation("structured locator missing required 'scheme'".into())
517 })?;
518 if !is_dotted_namespace_scheme(scheme) {
519 return Err(AcdpError::SchemaViolation(format!(
520 "structured locator scheme '{scheme}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
521 )));
522 }
523 Ok(())
524}
525
526fn is_dotted_namespace_scheme(s: &str) -> bool {
527 let parts: Vec<&str> = s.split('.').collect();
528 if parts.len() < 2 {
529 return false;
530 }
531 parts.iter().all(|part| {
532 !part.is_empty()
533 && part.chars().next().is_some_and(|c| c.is_ascii_lowercase())
534 && part
535 .chars()
536 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
537 })
538}
539
540fn validate_anchors(anchors: &[AnchorEntry]) -> Result<(), AcdpError> {
545 if anchors.is_empty() {
546 return Err(AcdpError::SchemaViolation(
547 "anchors MUST be omitted entirely (never sent as an empty array) when there is \
548 nothing to anchor — the absent-when-empty convention (RFC-ACDP-0016 \u{a7}4)"
549 .into(),
550 ));
551 }
552 validate_unique_array("anchors", anchors, MAX_ANCHORS)?;
553 for anchor in anchors {
554 if !is_dotted_namespace_scheme(&anchor.scheme) {
555 return Err(AcdpError::SchemaViolation(format!(
556 "anchor scheme '{}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$ \
557 (RFC-ACDP-0016 \u{a7}4)",
558 anchor.scheme
559 )));
560 }
561 ContentHash::parse(anchor.content_hash.as_str())?;
562 }
563 Ok(())
564}
565
566fn validate_embedded(emb: &EmbeddedContent) -> Result<(), AcdpError> {
567 match emb.encoding {
569 EmbeddedEncoding::Utf8 | EmbeddedEncoding::Base64 => {
570 if !emb.content.is_string() {
571 return Err(AcdpError::SchemaViolation(format!(
572 "embedded {:?} content MUST be a JSON string",
573 emb.encoding
574 )));
575 }
576 }
577 EmbeddedEncoding::Json => {}
578 }
579 let decoded = embedded_decoded_bytes(emb)?;
581 if decoded.len() > MAX_EMBEDDED_BYTES {
582 return Err(AcdpError::EmbeddedTooLarge(format!(
583 "embedded decoded size {} bytes exceeds {} limit",
584 decoded.len(),
585 MAX_EMBEDDED_BYTES
586 )));
587 }
588 Ok(())
589}
590
591pub fn embedded_decoded_bytes(emb: &EmbeddedContent) -> Result<Vec<u8>, AcdpError> {
597 Ok(match emb.encoding {
598 EmbeddedEncoding::Json => try_canonicalize_value(&emb.content)?,
599 EmbeddedEncoding::Utf8 => {
600 let s = emb.content.as_str().ok_or_else(|| {
601 AcdpError::SchemaViolation("utf8 embedded content must be a JSON string".into())
602 })?;
603 s.as_bytes().to_vec()
604 }
605 EmbeddedEncoding::Base64 => {
606 let s = emb.content.as_str().ok_or_else(|| {
607 AcdpError::SchemaViolation("base64 embedded content must be a JSON string".into())
608 })?;
609 STANDARD
610 .decode(s)
611 .map_err(|e| AcdpError::SchemaViolation(format!("base64 decode failed: {e}")))?
612 }
613 })
614}
615
616pub fn compute_embedded_hash(emb: &EmbeddedContent) -> Result<ContentHash, AcdpError> {
618 let bytes = embedded_decoded_bytes(emb)?;
619 let digest = Sha256::digest(&bytes);
620 Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
621}
622
623pub fn verify_embedded_hash(dr: &DataRef) -> Result<(), AcdpError> {
644 let Some(emb) = &dr.embedded else {
645 return Ok(());
646 };
647 if dr.content_hash.is_none() && emb.content_hash.is_none() {
648 return Ok(());
649 }
650 let recomputed = compute_embedded_hash(emb)?;
651 if let Some(embedded_hash) = &emb.content_hash {
652 if &recomputed != embedded_hash {
653 return Err(AcdpError::DataRefHashMismatch(format!(
654 "embedded.content_hash mismatch: declared {}, computed {}",
655 embedded_hash.as_str(),
656 recomputed.as_str()
657 )));
658 }
659 }
660 if let Some(root_hash) = &dr.content_hash {
661 if &recomputed != root_hash {
662 return Err(AcdpError::DataRefHashMismatch(format!(
663 "content_hash mismatch: declared {}, computed {}",
664 root_hash.as_str(),
665 recomputed.as_str()
666 )));
667 }
668 }
669 Ok(())
670}
671
672pub fn validate_metadata(value: &serde_json::Value) -> Result<(), AcdpError> {
677 validate_json_object_limits(value, "metadata")
678}
679
680fn validate_json_object_limits(value: &serde_json::Value, field: &str) -> Result<(), AcdpError> {
685 let obj = value
686 .as_object()
687 .ok_or_else(|| AcdpError::SchemaViolation(format!("{field} must be a JSON object")))?;
688 if obj.len() > MAX_METADATA_PROPERTIES {
689 return Err(AcdpError::SchemaViolation(format!(
690 "{field} has {} top-level properties, exceeds {} limit",
691 obj.len(),
692 MAX_METADATA_PROPERTIES
693 )));
694 }
695 let depth = json_depth(value);
696 if depth > MAX_METADATA_DEPTH {
697 return Err(AcdpError::SchemaViolation(format!(
698 "{field} nesting depth {depth} exceeds {MAX_METADATA_DEPTH}"
699 )));
700 }
701 let canonical_size = try_canonicalize_value(value)?.len();
702 if canonical_size > MAX_METADATA_JCS_BYTES {
703 return Err(AcdpError::SchemaViolation(format!(
704 "{field} JCS-canonical size {canonical_size} bytes exceeds {MAX_METADATA_JCS_BYTES}"
705 )));
706 }
707 Ok(())
708}
709
710pub fn validate_extensions(
713 extensions: &serde_json::Map<String, serde_json::Value>,
714) -> Result<(), AcdpError> {
715 if extensions.is_empty() {
716 return Ok(());
717 }
718 let value = serde_json::Value::Object(extensions.clone());
722 validate_json_object_limits(&value, "extensions")
723}
724
725fn json_depth(v: &serde_json::Value) -> usize {
735 const MAX_JSON_DEPTH_SCAN: usize = 256;
737 fn go(v: &serde_json::Value, budget: usize) -> usize {
738 if budget == 0 {
739 return 1; }
741 match v {
742 serde_json::Value::Object(map) => {
743 1 + map.values().map(|x| go(x, budget - 1)).max().unwrap_or(0)
744 }
745 serde_json::Value::Array(arr) => {
746 1 + arr.iter().map(|x| go(x, budget - 1)).max().unwrap_or(0)
747 }
748 _ => 0,
749 }
750 }
751 go(v, MAX_JSON_DEPTH_SCAN)
752}
753
754fn validate_visibility_audience(
757 vis: &Visibility,
758 audience: Option<&[AgentDid]>,
759) -> Result<(), AcdpError> {
760 match vis {
761 Visibility::Restricted => {
762 if audience.is_none_or(|a| a.is_empty()) {
763 return Err(AcdpError::SchemaViolation(
764 "visibility:restricted requires a non-empty audience".into(),
765 ));
766 }
767 }
768 Visibility::Public => {
769 if audience.is_some_and(|a| !a.is_empty()) {
770 return Err(AcdpError::SchemaViolation(
771 "visibility:public MUST NOT include audience".into(),
772 ));
773 }
774 }
775 Visibility::Private => {}
776 }
777 Ok(())
778}
779
780fn validate_title(title: &str) -> Result<(), AcdpError> {
783 if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
784 return Err(AcdpError::SchemaViolation(format!(
785 "title length {} not in 1..={}",
786 title.chars().count(),
787 MAX_TITLE_LEN
788 )));
789 }
790 Ok(())
791}
792
793fn validate_optional_string(s: Option<&str>, name: &str, max_len: usize) -> Result<(), AcdpError> {
794 if let Some(value) = s {
795 if value.chars().count() > max_len {
796 return Err(AcdpError::SchemaViolation(format!(
797 "{name} length {} exceeds {max_len}",
798 value.chars().count()
799 )));
800 }
801 }
802 Ok(())
803}
804
805fn validate_unique_array<T: PartialEq + std::fmt::Debug>(
806 name: &str,
807 items: &[T],
808 max: usize,
809) -> Result<(), AcdpError> {
810 if items.len() > max {
811 return Err(AcdpError::SchemaViolation(format!(
812 "{name} has {} items, exceeds {max}",
813 items.len()
814 )));
815 }
816 for (i, item) in items.iter().enumerate() {
817 if items[i + 1..].iter().any(|other| other == item) {
818 return Err(AcdpError::SchemaViolation(format!(
819 "{name} contains duplicate entry: {item:?}"
820 )));
821 }
822 }
823 Ok(())
824}
825
826fn validate_tags(tags: &[String]) -> Result<(), AcdpError> {
827 if tags.len() > MAX_TAGS {
828 return Err(AcdpError::SchemaViolation(format!(
829 "tags has {} entries, exceeds {}",
830 tags.len(),
831 MAX_TAGS
832 )));
833 }
834 for tag in tags {
835 validate_tag(tag)?;
836 }
837 for (i, tag) in tags.iter().enumerate() {
839 if tags[i + 1..].iter().any(|t| t == tag) {
840 return Err(AcdpError::SchemaViolation(format!(
841 "tags contains duplicate entry: {tag}"
842 )));
843 }
844 }
845 Ok(())
846}
847
848fn validate_tag(tag: &str) -> Result<(), AcdpError> {
849 if tag.is_empty() || tag.len() > MAX_TAG_LEN {
850 return Err(AcdpError::SchemaViolation(format!(
851 "tag '{tag}' length not in 1..={MAX_TAG_LEN}"
852 )));
853 }
854 let mut chars = tag.chars();
855 let first = chars.next().unwrap();
856 if !first.is_ascii_alphanumeric() {
857 return Err(AcdpError::SchemaViolation(format!(
858 "tag '{tag}' first char must be alphanumeric"
859 )));
860 }
861 if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) {
862 return Err(AcdpError::SchemaViolation(format!(
863 "tag '{tag}' must match [A-Za-z0-9][A-Za-z0-9_.-]*"
864 )));
865 }
866 Ok(())
867}
868
869fn validate_did_key_key_id_form(key_id: &str) -> Result<(), AcdpError> {
889 if !key_id.starts_with("did:key:") {
890 return Ok(());
891 }
892 acdp_did::key::resolve_did_key_url(key_id)?;
902 Ok(())
903}
904
905fn validate_agent_did(did: &AgentDid) -> Result<(), AcdpError> {
906 if did.as_str().starts_with("did:key:") {
907 AgentDid::parse(did.as_str())?;
908 acdp_did::key::resolve_did_key(did.as_str())?;
912 return Ok(());
913 }
914 AgentDid::parse_web(did.as_str())?;
915 Ok(())
916}
917
918fn validate_origin_registry(s: &str) -> Result<(), AcdpError> {
926 if s.is_empty() {
927 return Err(AcdpError::SchemaViolation(
928 "origin_registry must be a non-empty DNS hostname".into(),
929 ));
930 }
931 if s.starts_with("did:") {
932 return Err(AcdpError::SchemaViolation(format!(
933 "origin_registry must be a DNS hostname, not a DID URI (got '{s}'); \
934 use the bare authority — capabilities.registry_did carries the did:web form"
935 )));
936 }
937 if s.contains("://") {
938 return Err(AcdpError::SchemaViolation(format!(
939 "origin_registry must be a DNS hostname, not a URL (got '{s}')"
940 )));
941 }
942 if s.ends_with('.') || s.starts_with('.') {
943 return Err(AcdpError::SchemaViolation(format!(
944 "origin_registry must be a syntactically valid DNS hostname (got '{s}')"
945 )));
946 }
947 if !acdp_types::primitives::is_valid_dns_authority(s) {
953 return Err(AcdpError::SchemaViolation(format!(
954 "origin_registry '{s}' is not a valid DNS hostname (must be lowercase \
955 labels of [a-z0-9-] separated by dots, e.g. 'registry.example.com')"
956 )));
957 }
958 Ok(())
959}
960
961fn validate_loose_did(did: &AgentDid) -> Result<(), AcdpError> {
971 AgentDid::parse(did.as_str())?;
972 Ok(())
973}
974
975fn validate_namespaced_context_type(value: &str) -> Result<(), AcdpError> {
978 let (ns, name) = value.split_once(':').ok_or_else(|| {
980 AcdpError::SchemaViolation(format!(
981 "context_type '{value}' missing namespace separator"
982 ))
983 })?;
984 if ns.is_empty()
985 || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
986 || !ns
987 .chars()
988 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
989 {
990 return Err(AcdpError::SchemaViolation(format!(
991 "context_type namespace '{ns}' must match [a-z][a-z0-9_]*"
992 )));
993 }
994 if name.is_empty()
995 || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
996 || !name
997 .chars()
998 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
999 {
1000 return Err(AcdpError::SchemaViolation(format!(
1001 "context_type name '{name}' must match [a-z][a-z0-9_-]*"
1002 )));
1003 }
1004 Ok(())
1005}
1006
1007trait ContextTypeExt {
1008 fn namespaced_form(&self) -> Option<&str>;
1009}
1010
1011impl ContextTypeExt for ContextType {
1012 fn namespaced_form(&self) -> Option<&str> {
1013 match self {
1014 ContextType::Custom(s) => Some(s.as_str()),
1015 _ => None,
1016 }
1017 }
1018}
1019
1020fn validate_semver_pattern(name: &str, value: &str) -> Result<(), AcdpError> {
1023 let parts: Vec<&str> = value.split('.').collect();
1024 let ok = parts.len() == 3
1025 && parts
1026 .iter()
1027 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
1028 if !ok {
1029 return Err(AcdpError::SchemaViolation(format!(
1030 "{name} '{value}' must match the semver pattern ^\\d+\\.\\d+\\.\\d+$"
1031 )));
1032 }
1033 Ok(())
1034}
1035
1036fn validate_signature_length(algorithm: &str, value_b64: &str) -> Result<(), AcdpError> {
1037 let expected = match algorithm {
1038 "ed25519" => Some(ED25519_SIG_B64_LEN),
1039 "ecdsa-p256" => Some(ECDSA_P256_SIG_B64_LEN),
1040 _ => None,
1041 };
1042 if let Some(n) = expected {
1043 if value_b64.len() != n {
1044 return Err(AcdpError::InvalidSignature(format!(
1045 "signature.value for '{algorithm}' must be {n} base64 chars, got {}",
1046 value_b64.len()
1047 )));
1048 }
1049 }
1050 Ok(())
1051}
1052
1053#[cfg(test)]
1056mod tests {
1057 use super::*;
1058 use acdp_types::data_ref::DataRefType;
1059 use serde_json::json;
1060
1061 fn embedded_json(v: serde_json::Value) -> EmbeddedContent {
1062 EmbeddedContent {
1063 encoding: EmbeddedEncoding::Json,
1064 content: v,
1065 content_hash: None,
1066 }
1067 }
1068
1069 fn embedded_json_with_hash(v: serde_json::Value, hash: ContentHash) -> EmbeddedContent {
1070 EmbeddedContent {
1071 encoding: EmbeddedEncoding::Json,
1072 content: v,
1073 content_hash: Some(hash),
1074 }
1075 }
1076
1077 #[test]
1080 fn origin_registry_accepts_valid_hostname() {
1081 validate_origin_registry("registry.example.com").unwrap();
1082 validate_origin_registry("reg.example").unwrap();
1083 validate_origin_registry("a-b-c.io").unwrap();
1084 }
1085
1086 #[test]
1087 fn origin_registry_rejects_uppercase() {
1088 assert!(matches!(
1089 validate_origin_registry("REGISTRY.EXAMPLE.COM"),
1090 Err(AcdpError::SchemaViolation(_))
1091 ));
1092 }
1093
1094 #[test]
1095 fn origin_registry_rejects_underscore() {
1096 assert!(matches!(
1097 validate_origin_registry("registry_example.com"),
1098 Err(AcdpError::SchemaViolation(_))
1099 ));
1100 }
1101
1102 #[test]
1103 fn origin_registry_rejects_hyphen_label_edges() {
1104 assert!(matches!(
1105 validate_origin_registry("registry-.com"),
1106 Err(AcdpError::SchemaViolation(_))
1107 ));
1108 assert!(matches!(
1109 validate_origin_registry("-registry.example.com"),
1110 Err(AcdpError::SchemaViolation(_))
1111 ));
1112 }
1113
1114 #[test]
1117 fn data_ref_neither_location_nor_embedded_rejected() {
1118 let dr = DataRef {
1119 ref_type: DataRefType::PrimaryResult,
1120 description: None,
1121 size_bytes: None,
1122 format: None,
1123 schema_version: None,
1124 content_hash: None,
1125 location: None,
1126 embedded: None,
1127 extensions: serde_json::Map::new(),
1128 };
1129 assert!(matches!(
1130 validate_data_ref(&dr),
1131 Err(AcdpError::SchemaViolation(_))
1132 ));
1133 }
1134
1135 #[test]
1136 fn data_ref_both_location_and_embedded_rejected() {
1137 let dr = DataRef {
1138 ref_type: DataRefType::PrimaryResult,
1139 description: None,
1140 size_bytes: None,
1141 format: None,
1142 schema_version: None,
1143 content_hash: None,
1144 location: Some(Location::Uri("https://x/y".into())),
1145 embedded: Some(embedded_json(json!({"a": 1}))),
1146 extensions: serde_json::Map::new(),
1147 };
1148 assert!(matches!(
1149 validate_data_ref(&dr),
1150 Err(AcdpError::SchemaViolation(_))
1151 ));
1152 }
1153
1154 #[test]
1157 fn uri_credentials_rejected() {
1158 let dr = DataRef::uri(DataRefType::RawData, "https://user:pass@example.com/data");
1159 assert!(matches!(
1160 validate_data_ref(&dr),
1161 Err(AcdpError::SchemaViolation(_))
1162 ));
1163 }
1164
1165 #[test]
1166 fn uri_without_scheme_rejected() {
1167 let dr = DataRef::uri(DataRefType::RawData, "no-scheme");
1168 assert!(matches!(
1169 validate_data_ref(&dr),
1170 Err(AcdpError::SchemaViolation(_))
1171 ));
1172 }
1173
1174 #[test]
1175 fn uri_too_long_rejected() {
1176 let long_uri = format!("https://x.com/{}", "a".repeat(MAX_URI_LEN));
1177 let dr = DataRef::uri(DataRefType::RawData, long_uri);
1178 assert!(matches!(
1179 validate_data_ref(&dr),
1180 Err(AcdpError::SchemaViolation(_))
1181 ));
1182 }
1183
1184 #[test]
1187 fn structured_locator_missing_scheme_rejected() {
1188 let mut map = serde_json::Map::new();
1189 map.insert("offset".into(), json!(42));
1190 let dr = DataRef {
1191 ref_type: DataRefType::RawData,
1192 description: None,
1193 size_bytes: None,
1194 format: None,
1195 schema_version: None,
1196 content_hash: None,
1197 location: Some(Location::Structured(map)),
1198 embedded: None,
1199 extensions: serde_json::Map::new(),
1200 };
1201 assert!(matches!(
1202 validate_data_ref(&dr),
1203 Err(AcdpError::SchemaViolation(_))
1204 ));
1205 }
1206
1207 #[test]
1208 fn structured_locator_bad_scheme_rejected() {
1209 let err =
1213 DataRef::try_structured(DataRefType::RawData, "not_dotted", serde_json::Map::new())
1214 .unwrap_err();
1215 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1216
1217 let mut bad = serde_json::Map::new();
1220 bad.insert(
1221 "scheme".into(),
1222 serde_json::Value::String("not_dotted".into()),
1223 );
1224 let dr = DataRef {
1225 ref_type: DataRefType::RawData,
1226 description: None,
1227 size_bytes: None,
1228 format: None,
1229 schema_version: None,
1230 content_hash: None,
1231 location: Some(Location::Structured(bad)),
1232 embedded: None,
1233 extensions: serde_json::Map::new(),
1234 };
1235 assert!(matches!(
1236 validate_data_ref(&dr),
1237 Err(AcdpError::SchemaViolation(_))
1238 ));
1239 }
1240
1241 #[test]
1242 fn structured_locator_valid() {
1243 let mut extra = serde_json::Map::new();
1244 extra.insert("topic".into(), json!("events"));
1245 let dr = DataRef::structured(DataRefType::RawData, "kafka.offset", extra);
1246 validate_data_ref(&dr).unwrap();
1247 }
1248
1249 #[test]
1252 fn embedded_utf8_must_be_string() {
1253 let dr = DataRef {
1254 ref_type: DataRefType::PrimaryResult,
1255 description: None,
1256 size_bytes: None,
1257 format: None,
1258 schema_version: None,
1259 content_hash: None,
1260 location: None,
1261 embedded: Some(EmbeddedContent {
1262 encoding: EmbeddedEncoding::Utf8,
1263 content: json!(42),
1264 content_hash: None,
1265 }),
1266 extensions: serde_json::Map::new(),
1267 };
1268 assert!(matches!(
1269 validate_data_ref(&dr),
1270 Err(AcdpError::SchemaViolation(_))
1271 ));
1272 }
1273
1274 #[test]
1275 fn embedded_too_large_rejected() {
1276 let big = "a".repeat(70 * 1024);
1278 let dr = DataRef::embedded_utf8(DataRefType::PrimaryResult, big);
1279 assert!(matches!(
1280 validate_data_ref(&dr),
1281 Err(AcdpError::EmbeddedTooLarge(_))
1282 ));
1283 }
1284
1285 #[test]
1288 fn embedded_hash_json_round_trip() {
1289 let emb = embedded_json(json!({"b": 2, "a": 1}));
1290 let h = compute_embedded_hash(&emb).unwrap();
1291 let expected = {
1293 let bytes = b"{\"a\":1,\"b\":2}";
1294 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
1295 };
1296 assert_eq!(h.as_str(), expected);
1297 }
1298
1299 #[test]
1300 fn embedded_hash_utf8() {
1301 let emb = EmbeddedContent {
1302 encoding: EmbeddedEncoding::Utf8,
1303 content: json!("hello"),
1304 content_hash: None,
1305 };
1306 let h = compute_embedded_hash(&emb).unwrap();
1307 let expected = format!("sha256:{}", hex::encode(Sha256::digest(b"hello")));
1308 assert_eq!(h.as_str(), expected);
1309 }
1310
1311 #[test]
1312 fn embedded_hash_base64() {
1313 let raw = b"binary data";
1314 let b64 = STANDARD.encode(raw);
1315 let emb = EmbeddedContent {
1316 encoding: EmbeddedEncoding::Base64,
1317 content: json!(b64),
1318 content_hash: None,
1319 };
1320 let h = compute_embedded_hash(&emb).unwrap();
1321 let expected = format!("sha256:{}", hex::encode(Sha256::digest(raw)));
1322 assert_eq!(h.as_str(), expected);
1323 }
1324
1325 #[test]
1326 fn verify_embedded_hash_mismatch_detected() {
1327 let emb = embedded_json(json!({"x": 1}));
1328 let dr = DataRef {
1329 ref_type: DataRefType::PrimaryResult,
1330 description: None,
1331 size_bytes: None,
1332 format: None,
1333 schema_version: None,
1334 content_hash: Some(ContentHash("sha256:0000".into())),
1335 location: None,
1336 embedded: Some(emb),
1337 extensions: serde_json::Map::new(),
1338 };
1339 assert!(matches!(
1340 verify_embedded_hash(&dr),
1341 Err(AcdpError::DataRefHashMismatch(_))
1342 ));
1343 }
1344
1345 #[test]
1348 fn metadata_too_many_properties_rejected() {
1349 let mut obj = serde_json::Map::new();
1350 for i in 0..101 {
1351 obj.insert(format!("k{i}"), json!(i));
1352 }
1353 assert!(matches!(
1354 validate_metadata(&serde_json::Value::Object(obj)),
1355 Err(AcdpError::SchemaViolation(_))
1356 ));
1357 }
1358
1359 #[test]
1360 fn metadata_too_deep_rejected() {
1361 let mut v = json!("leaf");
1363 for _ in 0..10 {
1364 let mut o = serde_json::Map::new();
1365 o.insert("a".into(), v);
1366 v = serde_json::Value::Object(o);
1367 }
1368 assert!(matches!(
1369 validate_metadata(&v),
1370 Err(AcdpError::SchemaViolation(_))
1371 ));
1372 }
1373
1374 #[test]
1375 fn metadata_too_large_rejected() {
1376 let big = "a".repeat(70 * 1024);
1377 let v = json!({"big": big});
1378 assert!(matches!(
1379 validate_metadata(&v),
1380 Err(AcdpError::SchemaViolation(_))
1381 ));
1382 }
1383
1384 #[test]
1385 fn metadata_must_be_object() {
1386 assert!(matches!(
1387 validate_metadata(&json!([1, 2, 3])),
1388 Err(AcdpError::SchemaViolation(_))
1389 ));
1390 }
1391
1392 #[test]
1395 fn public_with_audience_rejected() {
1396 let aud = vec![AgentDid::new("did:web:x")];
1397 assert!(matches!(
1398 validate_visibility_audience(&Visibility::Public, Some(&aud)),
1399 Err(AcdpError::SchemaViolation(_))
1400 ));
1401 }
1402
1403 #[test]
1404 fn public_with_empty_audience_ok() {
1405 validate_visibility_audience(&Visibility::Public, Some(&[])).unwrap();
1406 validate_visibility_audience(&Visibility::Public, None).unwrap();
1407 }
1408
1409 #[test]
1410 fn restricted_without_audience_rejected() {
1411 assert!(matches!(
1412 validate_visibility_audience(&Visibility::Restricted, None),
1413 Err(AcdpError::SchemaViolation(_))
1414 ));
1415 }
1416
1417 #[test]
1420 fn data_period_start_after_end_rejected_via_builder() {
1421 use acdp_crypto::SigningKey;
1422 use acdp_producer::Producer;
1423 use acdp_types::body::DataPeriod;
1424 use chrono::TimeZone;
1425
1426 let p = Producer::new(
1427 SigningKey::from_bytes(&[0u8; 32]),
1428 AgentDid::new("did:web:agents.example.com:test"),
1429 "did:web:agents.example.com:test#key-1",
1430 );
1431 let err = p
1432 .publish_request()
1433 .title("t")
1434 .context_type(ContextType::DataSnapshot)
1435 .data_period(DataPeriod {
1436 start: chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
1437 end: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1438 })
1439 .build()
1440 .unwrap_err();
1441 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1442 }
1443
1444 #[test]
1447 fn tag_pattern_validation() {
1448 validate_tag("hello").unwrap();
1449 validate_tag("Q1-2026").unwrap();
1450 validate_tag("a_b.c").unwrap();
1451 assert!(validate_tag("-bad").is_err());
1453 assert!(validate_tag("space here").is_err());
1455 assert!(validate_tag("").is_err());
1457 }
1458
1459 #[test]
1460 fn duplicate_tags_rejected() {
1461 let tags = vec!["a".to_string(), "b".to_string(), "a".to_string()];
1462 assert!(validate_tags(&tags).is_err());
1463 }
1464
1465 #[test]
1468 fn ed25519_sig_must_be_88_chars() {
1469 assert!(validate_signature_length("ed25519", "AAAA").is_err());
1470 validate_signature_length("ed25519", &"A".repeat(88)).unwrap();
1471 validate_signature_length("future-alg", "any").unwrap();
1473 }
1474
1475 #[test]
1478 fn namespaced_context_type_pattern() {
1479 validate_namespaced_context_type("finance:portfolio_snapshot").unwrap();
1480 assert!(validate_namespaced_context_type("Finance:portfolio").is_err());
1481 assert!(validate_namespaced_context_type("finance:Portfolio").is_err());
1482 assert!(validate_namespaced_context_type("no-colon").is_err());
1483 }
1484
1485 #[test]
1489 fn acdp_version_pattern_rejects_non_semver() {
1490 validate_semver_pattern("acdp_version", "0.1.0").unwrap();
1491 validate_semver_pattern("acdp_version", "10.20.30").unwrap();
1492 assert!(validate_semver_pattern("acdp_version", "0.1.0-rc.1").is_err());
1493 assert!(validate_semver_pattern("acdp_version", "0.0").is_err());
1494 assert!(validate_semver_pattern("acdp_version", "vee.zero.zero").is_err());
1495 }
1496
1497 #[test]
1500 fn derived_from_malformed_ctx_id_rejected() {
1501 use acdp_crypto::SigningKey;
1502 use acdp_producer::Producer;
1503
1504 let p = Producer::new(
1505 SigningKey::from_bytes(&[0u8; 32]),
1506 AgentDid::new("did:web:agents.example.com:test"),
1507 "did:web:agents.example.com:test#key-1",
1508 );
1509 let err = p
1510 .publish_request()
1511 .title("t")
1512 .context_type(ContextType::DataSnapshot)
1513 .derived_from(vec![CtxId("not-a-ctx-id".into())])
1514 .build()
1515 .unwrap_err();
1516 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1517 }
1518
1519 #[test]
1522 fn embedded_content_hash_mismatch_caught() {
1523 use acdp_types::data_ref::DataRefType;
1524 let dr = DataRef {
1525 ref_type: DataRefType::PrimaryResult,
1526 description: None,
1527 size_bytes: None,
1528 format: None,
1529 schema_version: None,
1530 content_hash: Some(ContentHash("sha256:0000".into())),
1531 location: None,
1532 embedded: Some(EmbeddedContent {
1533 encoding: EmbeddedEncoding::Json,
1534 content: json!({"x": 1}),
1535 content_hash: None,
1536 }),
1537 extensions: serde_json::Map::new(),
1538 };
1539 assert!(matches!(
1540 verify_embedded_hash(&dr),
1541 Err(AcdpError::DataRefHashMismatch(_))
1542 ));
1543 }
1544
1545 #[test]
1549 fn both_content_hashes_consistent_accepted() {
1550 use acdp_types::data_ref::DataRefType;
1551 let emb = embedded_json(json!({"a": 1, "b": 2}));
1552 let hash = compute_embedded_hash(&emb).unwrap();
1553 let dr = DataRef {
1554 ref_type: DataRefType::PrimaryResult,
1555 description: None,
1556 size_bytes: None,
1557 format: None,
1558 schema_version: None,
1559 content_hash: Some(hash.clone()),
1560 location: None,
1561 embedded: Some(embedded_json_with_hash(json!({"a": 1, "b": 2}), hash)),
1562 extensions: serde_json::Map::new(),
1563 };
1564 verify_embedded_hash(&dr).unwrap();
1565 }
1566
1567 #[test]
1572 fn both_content_hashes_disagree_rejected() {
1573 use acdp_types::data_ref::DataRefType;
1574 let emb = embedded_json(json!({"a": 1, "b": 2}));
1575 let correct_hash = compute_embedded_hash(&emb).unwrap();
1576 let wrong_hash = ContentHash(
1577 "sha256:0000000000000000000000000000000000000000000000000000000000000000".into(),
1578 );
1579 let dr = DataRef {
1583 ref_type: DataRefType::PrimaryResult,
1584 description: None,
1585 size_bytes: None,
1586 format: None,
1587 schema_version: None,
1588 content_hash: Some(wrong_hash),
1589 location: None,
1590 embedded: Some(embedded_json_with_hash(
1591 json!({"a": 1, "b": 2}),
1592 correct_hash,
1593 )),
1594 extensions: serde_json::Map::new(),
1595 };
1596 assert!(matches!(
1597 verify_embedded_hash(&dr),
1598 Err(AcdpError::DataRefHashMismatch(_))
1599 ));
1600 }
1601
1602 #[test]
1604 fn audience_uniqueness_rejected() {
1605 let dup = vec![
1606 AgentDid::new("did:web:a.example.com"),
1607 AgentDid::new("did:web:a.example.com"),
1608 ];
1609 let err = validate_unique_array("audience", &dup, MAX_AUDIENCE).unwrap_err();
1610 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1611 }
1612
1613 #[test]
1616 fn extensions_empty_ok() {
1617 validate_extensions(&serde_json::Map::new()).unwrap();
1618 }
1619
1620 #[test]
1621 fn extensions_small_forward_compat_accepted() {
1622 let mut ext = serde_json::Map::new();
1624 ext.insert("priority".into(), json!("high"));
1625 ext.insert("custom".into(), json!({"k": [1, 2, 3]}));
1626 validate_extensions(&ext).unwrap();
1627 }
1628
1629 #[test]
1630 fn extensions_too_many_properties_rejected() {
1631 let mut ext = serde_json::Map::new();
1632 for i in 0..(MAX_METADATA_PROPERTIES + 1) {
1633 ext.insert(format!("k{i}"), json!(i));
1634 }
1635 let err = validate_extensions(&ext).unwrap_err();
1636 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1637 }
1638
1639 #[test]
1640 fn extensions_oversized_jcs_rejected() {
1641 let mut ext = serde_json::Map::new();
1642 ext.insert("blob".into(), json!("x".repeat(MAX_METADATA_JCS_BYTES + 1)));
1643 let err = validate_extensions(&ext).unwrap_err();
1644 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1645 }
1646
1647 #[test]
1648 fn extensions_too_deep_rejected() {
1649 let mut v = json!(0);
1651 for _ in 0..(MAX_METADATA_DEPTH + 2) {
1652 v = json!({ "n": v });
1653 }
1654 let mut ext = serde_json::Map::new();
1655 ext.insert("deep".into(), v);
1656 let err = validate_extensions(&ext).unwrap_err();
1657 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1658 }
1659
1660 #[test]
1661 fn json_depth_clamps_past_scan_budget() {
1662 let mut v = json!(0);
1667 for _ in 0..400 {
1668 v = json!([v]);
1669 }
1670 assert!(json_depth(&v) > MAX_METADATA_DEPTH);
1671 assert!(acdp_crypto::try_canonicalize_value(&v).is_err());
1672 }
1673}
1674
1675#[cfg(test)]
1676mod capabilities_0_3_0_tests {
1677 use super::*;
1678 use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1679
1680 fn caps(version: &str, supports_idem: bool, mppm: Option<u64>) -> CapabilitiesDocument {
1681 CapabilitiesDocument {
1682 acdp_version: version.into(),
1683 registry_did: "did:web:registry.example.com".into(),
1684 supported_signature_algorithms: vec!["ed25519".into()],
1685 supported_did_methods: vec!["did:web".into()],
1686 profiles: vec!["acdp-registry-core".into()],
1687 limits: Limits {
1688 max_payload_bytes: 1_048_576,
1689 max_embedded_bytes: 65_536,
1690 idempotency_key_ttl_seconds: if supports_idem { Some(86_400) } else { None },
1691 max_publish_per_minute: mppm,
1692 },
1693 read_authentication_methods: vec![],
1694 anonymous_public_reads: false,
1695 supports_idempotency_key: supports_idem,
1696 extensions: Default::default(),
1697 }
1698 }
1699
1700 #[test]
1703 fn max_publish_per_minute_bounds() {
1704 assert!(validate_capabilities(&caps("0.1.0", false, Some(600))).is_ok());
1705 let err = validate_capabilities(&caps("0.1.0", false, Some(0)))
1706 .expect_err("zero MUST be rejected");
1707 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1708 }
1709
1710 #[test]
1715 fn idempotency_required_at_0_3_0() {
1716 assert!(validate_capabilities(&caps("0.1.0", false, None)).is_ok());
1717 assert!(validate_capabilities(&caps("0.2.0", false, None)).is_ok());
1718 assert!(validate_capabilities(&caps("0.3.0", true, None)).is_ok());
1719 assert!(validate_capabilities(&caps("0.4.0", true, None)).is_ok());
1720 for v in ["0.3.0", "0.4.0", "1.0.0"] {
1721 let err = validate_capabilities(&caps(v, false, None))
1722 .expect_err("version >= 0.3.0 without idempotency MUST be rejected");
1723 assert!(matches!(err, AcdpError::SchemaViolation(_)), "{v}: {err:?}");
1724 }
1725 }
1726}