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, true)?;
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 if let Some(ka) = &req.key_attestation {
240 ka.validate()?;
241 }
242 acdp_types::keyless::check_key_attestation_presence(
243 &req.signature.key_id,
244 req.key_attestation.is_some(),
245 )?;
246
247 validate_signature_length(&req.signature.algorithm, &req.signature.value)?;
248 validate_did_key_key_id_form(&req.signature.key_id)?;
249 ContentHash::parse(req.content_hash.as_str())?;
250
251 if let Some(prev) = &req.supersedes {
253 CtxId::parse(prev.as_str())?;
254 }
255 for ancestor in &req.derived_from {
256 CtxId::parse(ancestor.as_str())?;
257 }
258 if let Some(lineage) = &req.lineage_id {
259 acdp_types::primitives::LineageId::parse(lineage.as_str())?;
260 }
261
262 if let Some(v) = &req.acdp_version {
264 validate_semver_pattern("acdp_version", v)?;
265 }
266
267 match (&req.supersedes, req.version) {
269 (None, 1) => {}
270 (None, v) => {
271 return Err(AcdpError::SchemaViolation(format!(
272 "first-version publish requires version=1, got {v}"
273 )));
274 }
275 (Some(_), v) if v >= 2 => {}
276 (Some(_), v) => {
277 return Err(AcdpError::SchemaViolation(format!(
278 "supersession publish requires version >= 2, got {v}"
279 )));
280 }
281 }
282
283 if req.version == 1 && req.lineage_id.is_some() {
291 return Err(AcdpError::SchemaViolation(
292 "lineage_id MUST NOT be set on v1 publish requests (RFC-ACDP-0003 §2.2)".into(),
293 ));
294 }
295
296 Ok(())
297}
298
299pub fn validate_body(body: &Body) -> Result<(), AcdpError> {
301 validate_body_inner(
302 body, true, true,
303 )
304}
305
306pub fn validate_body_schema(body: &Body) -> Result<(), AcdpError> {
314 validate_body_inner(
315 body, true, false,
316 )
317}
318
319pub fn validate_body_structural(body: &Body) -> Result<(), AcdpError> {
330 validate_body_inner(
331 body, false, true,
332 )
333}
334
335fn validate_body_inner(
336 body: &Body,
337 check_embedded_hashes: bool,
338 resolve_keys: bool,
339) -> Result<(), AcdpError> {
340 validate_title(&body.title)?;
341 validate_optional_string(
342 body.description.as_deref(),
343 "description",
344 MAX_DESCRIPTION_LEN,
345 )?;
346 validate_optional_string(body.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
347 validate_optional_string(body.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
348
349 validate_agent_did(&body.agent_id, resolve_keys)?;
350 for c in &body.contributors {
351 validate_loose_did(c)?;
352 }
353 validate_unique_array("contributors", &body.contributors, MAX_CONTRIBUTORS)?;
354 validate_unique_array("derived_from", &body.derived_from, MAX_DERIVED_FROM)?;
355
356 if let Some(tags) = &body.tags {
357 validate_tags(tags)?;
358 }
359 if let Some(audience) = &body.audience {
360 validate_unique_array("audience", audience, MAX_AUDIENCE)?;
361 for did in audience {
362 validate_loose_did(did)?;
363 }
364 }
365 validate_visibility_audience(&body.visibility, body.audience.as_deref())?;
366
367 if let Some(dp) = &body.data_period {
368 if dp.start > dp.end {
369 return Err(AcdpError::SchemaViolation(
370 "data_period.start must not be after data_period.end".into(),
371 ));
372 }
373 }
374
375 if let Some(meta) = &body.metadata {
376 validate_metadata(meta)?;
377 }
378
379 validate_extensions(&body.extensions)?;
383
384 for dr in &body.data_refs {
385 if check_embedded_hashes {
386 validate_data_ref(dr)?;
387 } else {
388 validate_data_ref_structural(dr)?;
389 }
390 }
391
392 if let Some(anchors) = &body.anchors {
393 validate_anchors(anchors)?;
394 }
395
396 acdp_types::keyless::check_key_attestation_presence(
399 &body.signature.key_id,
400 body.key_attestation()?.is_some(),
401 )?;
402
403 validate_signature_length(&body.signature.algorithm, &body.signature.value)?;
404 if resolve_keys {
405 validate_did_key_key_id_form(&body.signature.key_id)?;
406 }
407 validate_identifiers(&body.ctx_id, &body.lineage_id, &body.content_hash)?;
408
409 if let Some(prev) = &body.supersedes {
411 CtxId::parse(prev.as_str())?;
412 }
413 for ancestor in &body.derived_from {
414 CtxId::parse(ancestor.as_str())?;
415 }
416
417 if let Some(v) = &body.acdp_version {
418 validate_semver_pattern("acdp_version", v)?;
419 }
420
421 let _ = &body.created_at; validate_origin_registry(&body.origin_registry)?;
423
424 let _ = std::any::type_name::<Status>();
426 let _: &Visibility = &body.visibility;
427
428 Ok(())
429}
430
431pub fn validate_identifiers(
433 ctx_id: &CtxId,
434 lineage_id: &LineageId,
435 content_hash: &ContentHash,
436) -> Result<(), AcdpError> {
437 CtxId::parse(ctx_id.as_str())?;
438 LineageId::parse(lineage_id.as_str())?;
439 ContentHash::parse(content_hash.as_str())?;
440 Ok(())
441}
442
443pub fn validate_data_ref(dr: &DataRef) -> Result<(), AcdpError> {
448 validate_data_ref_structural(dr)?;
449 if dr.embedded.is_some() {
454 verify_embedded_hash(dr)?;
455 }
456 Ok(())
457}
458
459pub fn validate_data_ref_structural(dr: &DataRef) -> Result<(), AcdpError> {
465 match (&dr.location, &dr.embedded) {
467 (None, None) => {
468 return Err(AcdpError::SchemaViolation(
469 "DataRef requires exactly one of 'location' or 'embedded' (got neither)".into(),
470 ));
471 }
472 (Some(_), Some(_)) => {
473 return Err(AcdpError::SchemaViolation(
474 "DataRef requires exactly one of 'location' or 'embedded' (got both)".into(),
475 ));
476 }
477 _ => {}
478 }
479
480 if let Some(ch) = &dr.content_hash {
488 ContentHash::parse(ch.as_str())?;
489 }
490
491 if let Some(desc) = &dr.description {
492 if desc.len() > MAX_DATA_REF_DESCRIPTION_LEN {
493 return Err(AcdpError::SchemaViolation(format!(
494 "DataRef.description {} chars exceeds {} limit",
495 desc.len(),
496 MAX_DATA_REF_DESCRIPTION_LEN
497 )));
498 }
499 }
500
501 if let Some(loc) = &dr.location {
502 validate_location(loc)?;
503 }
504 if let Some(emb) = &dr.embedded {
505 validate_embedded(emb)?;
506 }
507
508 Ok(())
509}
510
511fn validate_location(loc: &Location) -> Result<(), AcdpError> {
512 match loc {
513 Location::Uri(uri) => validate_uri_location(uri),
514 Location::Structured(map) => validate_structured_locator(map),
515 }
516}
517
518fn validate_uri_location(uri: &str) -> Result<(), AcdpError> {
519 if uri.len() < 3 || uri.len() > MAX_URI_LEN {
520 return Err(AcdpError::SchemaViolation(format!(
521 "DataRef.location URI length {} not in 3..={}",
522 uri.len(),
523 MAX_URI_LEN
524 )));
525 }
526 let (scheme, rest) = uri
528 .split_once(':')
529 .ok_or_else(|| AcdpError::SchemaViolation(format!("URI missing scheme: {uri}")))?;
530 if scheme.is_empty()
531 || !scheme
532 .chars()
533 .next()
534 .is_some_and(|c| c.is_ascii_lowercase())
535 || !scheme
536 .chars()
537 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '.' | '-'))
538 {
539 return Err(AcdpError::SchemaViolation(format!(
540 "URI scheme '{scheme}' invalid; must match [a-z][a-z0-9+.-]*"
541 )));
542 }
543 if let Some(after_slashes) = rest.strip_prefix("//") {
545 if let Some(authority_end) = after_slashes.find(['/', '?', '#']) {
546 let authority = &after_slashes[..authority_end];
547 if authority.contains('@') {
548 return Err(AcdpError::SchemaViolation(format!(
549 "URI MUST NOT contain credentials in userinfo: {uri}"
550 )));
551 }
552 } else if after_slashes.contains('@') {
553 return Err(AcdpError::SchemaViolation(format!(
554 "URI MUST NOT contain credentials in userinfo: {uri}"
555 )));
556 }
557 }
558 Ok(())
559}
560
561fn validate_structured_locator(
562 map: &serde_json::Map<String, serde_json::Value>,
563) -> Result<(), AcdpError> {
564 let scheme = map.get("scheme").and_then(|v| v.as_str()).ok_or_else(|| {
565 AcdpError::SchemaViolation("structured locator missing required 'scheme'".into())
566 })?;
567 if !is_dotted_namespace_scheme(scheme) {
568 return Err(AcdpError::SchemaViolation(format!(
569 "structured locator scheme '{scheme}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
570 )));
571 }
572 Ok(())
573}
574
575fn is_dotted_namespace_scheme(s: &str) -> bool {
576 let parts: Vec<&str> = s.split('.').collect();
577 if parts.len() < 2 {
578 return false;
579 }
580 parts.iter().all(|part| {
581 !part.is_empty()
582 && part.chars().next().is_some_and(|c| c.is_ascii_lowercase())
583 && part
584 .chars()
585 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
586 })
587}
588
589fn validate_anchors(anchors: &[AnchorEntry]) -> Result<(), AcdpError> {
594 if anchors.is_empty() {
595 return Err(AcdpError::SchemaViolation(
596 "anchors MUST be omitted entirely (never sent as an empty array) when there is \
597 nothing to anchor — the absent-when-empty convention (RFC-ACDP-0016 \u{a7}4)"
598 .into(),
599 ));
600 }
601 validate_unique_array("anchors", anchors, MAX_ANCHORS)?;
602 for anchor in anchors {
603 if !is_dotted_namespace_scheme(&anchor.scheme) {
604 return Err(AcdpError::SchemaViolation(format!(
605 "anchor scheme '{}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$ \
606 (RFC-ACDP-0016 \u{a7}4)",
607 anchor.scheme
608 )));
609 }
610 ContentHash::parse(anchor.content_hash.as_str())?;
611 }
612 Ok(())
613}
614
615fn validate_embedded(emb: &EmbeddedContent) -> Result<(), AcdpError> {
616 match emb.encoding {
618 EmbeddedEncoding::Utf8 | EmbeddedEncoding::Base64 => {
619 if !emb.content.is_string() {
620 return Err(AcdpError::SchemaViolation(format!(
621 "embedded {:?} content MUST be a JSON string",
622 emb.encoding
623 )));
624 }
625 }
626 EmbeddedEncoding::Json => {}
627 }
628 if let Some(ch) = &emb.content_hash {
633 ContentHash::parse(ch.as_str())?;
634 }
635 let decoded = embedded_decoded_bytes(emb)?;
637 if decoded.len() > MAX_EMBEDDED_BYTES {
638 return Err(AcdpError::EmbeddedTooLarge(format!(
639 "embedded decoded size {} bytes exceeds {} limit",
640 decoded.len(),
641 MAX_EMBEDDED_BYTES
642 )));
643 }
644 Ok(())
645}
646
647pub fn embedded_decoded_bytes(emb: &EmbeddedContent) -> Result<Vec<u8>, AcdpError> {
653 Ok(match emb.encoding {
654 EmbeddedEncoding::Json => try_canonicalize_value(&emb.content)?,
655 EmbeddedEncoding::Utf8 => {
656 let s = emb.content.as_str().ok_or_else(|| {
657 AcdpError::SchemaViolation("utf8 embedded content must be a JSON string".into())
658 })?;
659 s.as_bytes().to_vec()
660 }
661 EmbeddedEncoding::Base64 => {
662 let s = emb.content.as_str().ok_or_else(|| {
663 AcdpError::SchemaViolation("base64 embedded content must be a JSON string".into())
664 })?;
665 STANDARD
666 .decode(s)
667 .map_err(|e| AcdpError::SchemaViolation(format!("base64 decode failed: {e}")))?
668 }
669 })
670}
671
672pub fn compute_embedded_hash(emb: &EmbeddedContent) -> Result<ContentHash, AcdpError> {
674 let bytes = embedded_decoded_bytes(emb)?;
675 let digest = Sha256::digest(&bytes);
676 Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
677}
678
679pub fn verify_embedded_hash(dr: &DataRef) -> Result<(), AcdpError> {
707 let Some(emb) = &dr.embedded else {
708 return Ok(());
709 };
710 let Some(embedded_hash) = &emb.content_hash else {
711 return Ok(());
712 };
713 let recomputed = compute_embedded_hash(emb)?;
714 if &recomputed != embedded_hash {
715 return Err(AcdpError::DataRefHashMismatch(format!(
716 "embedded.content_hash mismatch: declared {}, computed {}",
717 embedded_hash.as_str(),
718 recomputed.as_str()
719 )));
720 }
721 Ok(())
722}
723
724pub fn validate_metadata(value: &serde_json::Value) -> Result<(), AcdpError> {
729 validate_json_object_limits(value, "metadata")
730}
731
732fn validate_json_object_limits(value: &serde_json::Value, field: &str) -> Result<(), AcdpError> {
737 let obj = value
738 .as_object()
739 .ok_or_else(|| AcdpError::SchemaViolation(format!("{field} must be a JSON object")))?;
740 if obj.len() > MAX_METADATA_PROPERTIES {
741 return Err(AcdpError::SchemaViolation(format!(
742 "{field} has {} top-level properties, exceeds {} limit",
743 obj.len(),
744 MAX_METADATA_PROPERTIES
745 )));
746 }
747 let depth = json_depth(value);
748 if depth > MAX_METADATA_DEPTH {
749 return Err(AcdpError::SchemaViolation(format!(
750 "{field} nesting depth {depth} exceeds {MAX_METADATA_DEPTH}"
751 )));
752 }
753 let canonical_size = try_canonicalize_value(value)?.len();
754 if canonical_size > MAX_METADATA_JCS_BYTES {
755 return Err(AcdpError::SchemaViolation(format!(
756 "{field} JCS-canonical size {canonical_size} bytes exceeds {MAX_METADATA_JCS_BYTES}"
757 )));
758 }
759 Ok(())
760}
761
762pub fn validate_extensions(
765 extensions: &serde_json::Map<String, serde_json::Value>,
766) -> Result<(), AcdpError> {
767 if extensions.is_empty() {
768 return Ok(());
769 }
770 let value = serde_json::Value::Object(extensions.clone());
774 validate_json_object_limits(&value, "extensions")
775}
776
777fn json_depth(v: &serde_json::Value) -> usize {
787 const MAX_JSON_DEPTH_SCAN: usize = 256;
789 fn go(v: &serde_json::Value, budget: usize) -> usize {
790 if budget == 0 {
791 return 1; }
793 match v {
794 serde_json::Value::Object(map) => {
795 1 + map.values().map(|x| go(x, budget - 1)).max().unwrap_or(0)
796 }
797 serde_json::Value::Array(arr) => {
798 1 + arr.iter().map(|x| go(x, budget - 1)).max().unwrap_or(0)
799 }
800 _ => 0,
801 }
802 }
803 go(v, MAX_JSON_DEPTH_SCAN)
804}
805
806fn validate_visibility_audience(
809 vis: &Visibility,
810 audience: Option<&[AgentDid]>,
811) -> Result<(), AcdpError> {
812 match vis {
813 Visibility::Restricted => {
814 if audience.is_none_or(|a| a.is_empty()) {
815 return Err(AcdpError::SchemaViolation(
816 "visibility:restricted requires a non-empty audience".into(),
817 ));
818 }
819 }
820 Visibility::Public => {
821 if audience.is_some_and(|a| !a.is_empty()) {
822 return Err(AcdpError::SchemaViolation(
823 "visibility:public MUST NOT include audience".into(),
824 ));
825 }
826 }
827 Visibility::Private => {}
828 }
829 Ok(())
830}
831
832fn validate_title(title: &str) -> Result<(), AcdpError> {
835 if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
836 return Err(AcdpError::SchemaViolation(format!(
837 "title length {} not in 1..={}",
838 title.chars().count(),
839 MAX_TITLE_LEN
840 )));
841 }
842 Ok(())
843}
844
845fn validate_optional_string(s: Option<&str>, name: &str, max_len: usize) -> Result<(), AcdpError> {
846 if let Some(value) = s {
847 if value.chars().count() > max_len {
848 return Err(AcdpError::SchemaViolation(format!(
849 "{name} length {} exceeds {max_len}",
850 value.chars().count()
851 )));
852 }
853 }
854 Ok(())
855}
856
857fn validate_unique_array<T: PartialEq + std::fmt::Debug>(
858 name: &str,
859 items: &[T],
860 max: usize,
861) -> Result<(), AcdpError> {
862 if items.len() > max {
863 return Err(AcdpError::SchemaViolation(format!(
864 "{name} has {} items, exceeds {max}",
865 items.len()
866 )));
867 }
868 for (i, item) in items.iter().enumerate() {
869 if items[i + 1..].iter().any(|other| other == item) {
870 return Err(AcdpError::SchemaViolation(format!(
871 "{name} contains duplicate entry: {item:?}"
872 )));
873 }
874 }
875 Ok(())
876}
877
878fn validate_tags(tags: &[String]) -> Result<(), AcdpError> {
879 if tags.len() > MAX_TAGS {
880 return Err(AcdpError::SchemaViolation(format!(
881 "tags has {} entries, exceeds {}",
882 tags.len(),
883 MAX_TAGS
884 )));
885 }
886 for tag in tags {
887 validate_tag(tag)?;
888 }
889 for (i, tag) in tags.iter().enumerate() {
891 if tags[i + 1..].iter().any(|t| t == tag) {
892 return Err(AcdpError::SchemaViolation(format!(
893 "tags contains duplicate entry: {tag}"
894 )));
895 }
896 }
897 Ok(())
898}
899
900fn validate_tag(tag: &str) -> Result<(), AcdpError> {
901 if tag.is_empty() || tag.len() > MAX_TAG_LEN {
902 return Err(AcdpError::SchemaViolation(format!(
903 "tag '{tag}' length not in 1..={MAX_TAG_LEN}"
904 )));
905 }
906 let mut chars = tag.chars();
907 let first = chars.next().unwrap();
908 if !first.is_ascii_alphanumeric() {
909 return Err(AcdpError::SchemaViolation(format!(
910 "tag '{tag}' first char must be alphanumeric"
911 )));
912 }
913 if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) {
914 return Err(AcdpError::SchemaViolation(format!(
915 "tag '{tag}' must match [A-Za-z0-9][A-Za-z0-9_.-]*"
916 )));
917 }
918 Ok(())
919}
920
921fn validate_did_key_key_id_form(key_id: &str) -> Result<(), AcdpError> {
941 if !key_id.starts_with("did:key:") {
942 return Ok(());
943 }
944 acdp_did::key::resolve_did_key_url(key_id)?;
954 Ok(())
955}
956
957fn validate_agent_did(did: &AgentDid, resolve: bool) -> Result<(), AcdpError> {
958 if did.as_str().starts_with("did:key:") {
959 AgentDid::parse(did.as_str())?;
960 if !resolve {
961 return Ok(());
962 }
963 acdp_did::key::resolve_did_key(did.as_str())?;
967 return Ok(());
968 }
969 AgentDid::parse_web(did.as_str())?;
970 Ok(())
971}
972
973fn validate_origin_registry(s: &str) -> Result<(), AcdpError> {
981 if s.is_empty() {
982 return Err(AcdpError::SchemaViolation(
983 "origin_registry must be a non-empty DNS hostname".into(),
984 ));
985 }
986 if s.starts_with("did:") {
987 return Err(AcdpError::SchemaViolation(format!(
988 "origin_registry must be a DNS hostname, not a DID URI (got '{s}'); \
989 use the bare authority — capabilities.registry_did carries the did:web form"
990 )));
991 }
992 if s.contains("://") {
993 return Err(AcdpError::SchemaViolation(format!(
994 "origin_registry must be a DNS hostname, not a URL (got '{s}')"
995 )));
996 }
997 if s.ends_with('.') || s.starts_with('.') {
998 return Err(AcdpError::SchemaViolation(format!(
999 "origin_registry must be a syntactically valid DNS hostname (got '{s}')"
1000 )));
1001 }
1002 if !acdp_types::primitives::is_valid_dns_authority(s) {
1008 return Err(AcdpError::SchemaViolation(format!(
1009 "origin_registry '{s}' is not a valid DNS hostname (must be lowercase \
1010 labels of [a-z0-9-] separated by dots, e.g. 'registry.example.com')"
1011 )));
1012 }
1013 Ok(())
1014}
1015
1016fn validate_loose_did(did: &AgentDid) -> Result<(), AcdpError> {
1026 AgentDid::parse(did.as_str())?;
1027 Ok(())
1028}
1029
1030fn validate_namespaced_context_type(value: &str) -> Result<(), AcdpError> {
1033 let (ns, name) = value.split_once(':').ok_or_else(|| {
1035 AcdpError::SchemaViolation(format!(
1036 "context_type '{value}' missing namespace separator"
1037 ))
1038 })?;
1039 if ns.is_empty()
1040 || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1041 || !ns
1042 .chars()
1043 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
1044 {
1045 return Err(AcdpError::SchemaViolation(format!(
1046 "context_type namespace '{ns}' must match [a-z][a-z0-9_]*"
1047 )));
1048 }
1049 if name.is_empty()
1050 || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1051 || !name
1052 .chars()
1053 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
1054 {
1055 return Err(AcdpError::SchemaViolation(format!(
1056 "context_type name '{name}' must match [a-z][a-z0-9_-]*"
1057 )));
1058 }
1059 Ok(())
1060}
1061
1062trait ContextTypeExt {
1063 fn namespaced_form(&self) -> Option<&str>;
1064}
1065
1066impl ContextTypeExt for ContextType {
1067 fn namespaced_form(&self) -> Option<&str> {
1068 match self {
1069 ContextType::Custom(s) => Some(s.as_str()),
1070 _ => None,
1071 }
1072 }
1073}
1074
1075fn validate_semver_pattern(name: &str, value: &str) -> Result<(), AcdpError> {
1078 let parts: Vec<&str> = value.split('.').collect();
1079 let ok = parts.len() == 3
1080 && parts
1081 .iter()
1082 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
1083 if !ok {
1084 return Err(AcdpError::SchemaViolation(format!(
1085 "{name} '{value}' must match the semver pattern ^\\d+\\.\\d+\\.\\d+$"
1086 )));
1087 }
1088 Ok(())
1089}
1090
1091fn validate_signature_length(algorithm: &str, value_b64: &str) -> Result<(), AcdpError> {
1092 let expected = match algorithm {
1093 "ed25519" => Some(ED25519_SIG_B64_LEN),
1094 "ecdsa-p256" => Some(ECDSA_P256_SIG_B64_LEN),
1095 _ => None,
1096 };
1097 if let Some(n) = expected {
1098 if value_b64.len() != n {
1099 return Err(AcdpError::InvalidSignature(format!(
1100 "signature.value for '{algorithm}' must be {n} base64 chars, got {}",
1101 value_b64.len()
1102 )));
1103 }
1104 }
1105 Ok(())
1106}
1107
1108#[cfg(test)]
1111mod tests {
1112 use super::*;
1113 use acdp_types::data_ref::DataRefType;
1114 use serde_json::json;
1115
1116 fn embedded_json(v: serde_json::Value) -> EmbeddedContent {
1117 EmbeddedContent {
1118 encoding: EmbeddedEncoding::Json,
1119 content: v,
1120 content_hash: None,
1121 }
1122 }
1123
1124 fn embedded_json_with_hash(v: serde_json::Value, hash: ContentHash) -> EmbeddedContent {
1125 EmbeddedContent {
1126 encoding: EmbeddedEncoding::Json,
1127 content: v,
1128 content_hash: Some(hash),
1129 }
1130 }
1131
1132 #[test]
1135 fn origin_registry_accepts_valid_hostname() {
1136 validate_origin_registry("registry.example.com").unwrap();
1137 validate_origin_registry("reg.example").unwrap();
1138 validate_origin_registry("a-b-c.io").unwrap();
1139 }
1140
1141 #[test]
1142 fn origin_registry_rejects_uppercase() {
1143 assert!(matches!(
1144 validate_origin_registry("REGISTRY.EXAMPLE.COM"),
1145 Err(AcdpError::SchemaViolation(_))
1146 ));
1147 }
1148
1149 #[test]
1150 fn origin_registry_rejects_underscore() {
1151 assert!(matches!(
1152 validate_origin_registry("registry_example.com"),
1153 Err(AcdpError::SchemaViolation(_))
1154 ));
1155 }
1156
1157 #[test]
1158 fn origin_registry_rejects_hyphen_label_edges() {
1159 assert!(matches!(
1160 validate_origin_registry("registry-.com"),
1161 Err(AcdpError::SchemaViolation(_))
1162 ));
1163 assert!(matches!(
1164 validate_origin_registry("-registry.example.com"),
1165 Err(AcdpError::SchemaViolation(_))
1166 ));
1167 }
1168
1169 #[test]
1172 fn data_ref_neither_location_nor_embedded_rejected() {
1173 let dr = DataRef {
1174 ref_type: DataRefType::PrimaryResult,
1175 description: None,
1176 size_bytes: None,
1177 format: None,
1178 schema_version: None,
1179 content_hash: None,
1180 location: None,
1181 embedded: None,
1182 extensions: serde_json::Map::new(),
1183 };
1184 assert!(matches!(
1185 validate_data_ref(&dr),
1186 Err(AcdpError::SchemaViolation(_))
1187 ));
1188 }
1189
1190 #[test]
1191 fn data_ref_both_location_and_embedded_rejected() {
1192 let dr = DataRef {
1193 ref_type: DataRefType::PrimaryResult,
1194 description: None,
1195 size_bytes: None,
1196 format: None,
1197 schema_version: None,
1198 content_hash: None,
1199 location: Some(Location::Uri("https://x/y".into())),
1200 embedded: Some(embedded_json(json!({"a": 1}))),
1201 extensions: serde_json::Map::new(),
1202 };
1203 assert!(matches!(
1204 validate_data_ref(&dr),
1205 Err(AcdpError::SchemaViolation(_))
1206 ));
1207 }
1208
1209 #[test]
1210 fn data_ref_malformed_root_content_hash_rejected() {
1211 let dr = DataRef {
1212 ref_type: DataRefType::PrimaryResult,
1213 description: None,
1214 size_bytes: None,
1215 format: None,
1216 schema_version: None,
1217 content_hash: Some(ContentHash("not-a-content-hash".into())),
1218 location: Some(Location::Uri("https://x/y".into())),
1219 embedded: None,
1220 extensions: serde_json::Map::new(),
1221 };
1222 let err = validate_data_ref_structural(&dr)
1223 .expect_err("a malformed root content_hash must be caught structurally");
1224 assert!(
1225 matches!(err, AcdpError::SchemaViolation(ref msg) if msg.contains("content_hash")),
1226 "expected a SchemaViolation naming content_hash, got {err:?}"
1227 );
1228 }
1229
1230 #[test]
1231 fn data_ref_malformed_embedded_content_hash_rejected() {
1232 let dr = DataRef {
1233 ref_type: DataRefType::PrimaryResult,
1234 description: None,
1235 size_bytes: None,
1236 format: None,
1237 schema_version: None,
1238 content_hash: None,
1239 location: None,
1240 embedded: Some(EmbeddedContent {
1241 encoding: EmbeddedEncoding::Utf8,
1242 content: serde_json::Value::String("hello".into()),
1243 content_hash: Some(ContentHash("sha256:not-hex".into())),
1244 }),
1245 extensions: serde_json::Map::new(),
1246 };
1247 let err = validate_data_ref_structural(&dr)
1248 .expect_err("a malformed embedded content_hash must be caught structurally");
1249 assert!(
1250 matches!(err, AcdpError::SchemaViolation(ref msg) if msg.contains("content_hash")),
1251 "expected a SchemaViolation naming content_hash, got {err:?}"
1252 );
1253 }
1254
1255 #[test]
1258 fn uri_credentials_rejected() {
1259 let dr = DataRef::uri(DataRefType::RawData, "https://user:pass@example.com/data");
1260 assert!(matches!(
1261 validate_data_ref(&dr),
1262 Err(AcdpError::SchemaViolation(_))
1263 ));
1264 }
1265
1266 #[test]
1267 fn uri_without_scheme_rejected() {
1268 let dr = DataRef::uri(DataRefType::RawData, "no-scheme");
1269 assert!(matches!(
1270 validate_data_ref(&dr),
1271 Err(AcdpError::SchemaViolation(_))
1272 ));
1273 }
1274
1275 #[test]
1276 fn uri_too_long_rejected() {
1277 let long_uri = format!("https://x.com/{}", "a".repeat(MAX_URI_LEN));
1278 let dr = DataRef::uri(DataRefType::RawData, long_uri);
1279 assert!(matches!(
1280 validate_data_ref(&dr),
1281 Err(AcdpError::SchemaViolation(_))
1282 ));
1283 }
1284
1285 #[test]
1288 fn structured_locator_missing_scheme_rejected() {
1289 let mut map = serde_json::Map::new();
1290 map.insert("offset".into(), json!(42));
1291 let dr = DataRef {
1292 ref_type: DataRefType::RawData,
1293 description: None,
1294 size_bytes: None,
1295 format: None,
1296 schema_version: None,
1297 content_hash: None,
1298 location: Some(Location::Structured(map)),
1299 embedded: None,
1300 extensions: serde_json::Map::new(),
1301 };
1302 assert!(matches!(
1303 validate_data_ref(&dr),
1304 Err(AcdpError::SchemaViolation(_))
1305 ));
1306 }
1307
1308 #[test]
1309 fn structured_locator_bad_scheme_rejected() {
1310 let err =
1314 DataRef::try_structured(DataRefType::RawData, "not_dotted", serde_json::Map::new())
1315 .unwrap_err();
1316 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1317
1318 let mut bad = serde_json::Map::new();
1321 bad.insert(
1322 "scheme".into(),
1323 serde_json::Value::String("not_dotted".into()),
1324 );
1325 let dr = DataRef {
1326 ref_type: DataRefType::RawData,
1327 description: None,
1328 size_bytes: None,
1329 format: None,
1330 schema_version: None,
1331 content_hash: None,
1332 location: Some(Location::Structured(bad)),
1333 embedded: None,
1334 extensions: serde_json::Map::new(),
1335 };
1336 assert!(matches!(
1337 validate_data_ref(&dr),
1338 Err(AcdpError::SchemaViolation(_))
1339 ));
1340 }
1341
1342 #[test]
1343 fn structured_locator_valid() {
1344 let mut extra = serde_json::Map::new();
1345 extra.insert("topic".into(), json!("events"));
1346 let dr = DataRef::structured(DataRefType::RawData, "kafka.offset", extra);
1347 validate_data_ref(&dr).unwrap();
1348 }
1349
1350 #[test]
1353 fn embedded_utf8_must_be_string() {
1354 let dr = DataRef {
1355 ref_type: DataRefType::PrimaryResult,
1356 description: None,
1357 size_bytes: None,
1358 format: None,
1359 schema_version: None,
1360 content_hash: None,
1361 location: None,
1362 embedded: Some(EmbeddedContent {
1363 encoding: EmbeddedEncoding::Utf8,
1364 content: json!(42),
1365 content_hash: None,
1366 }),
1367 extensions: serde_json::Map::new(),
1368 };
1369 assert!(matches!(
1370 validate_data_ref(&dr),
1371 Err(AcdpError::SchemaViolation(_))
1372 ));
1373 }
1374
1375 #[test]
1376 fn embedded_too_large_rejected() {
1377 let big = "a".repeat(70 * 1024);
1379 let dr = DataRef::embedded_utf8(DataRefType::PrimaryResult, big);
1380 assert!(matches!(
1381 validate_data_ref(&dr),
1382 Err(AcdpError::EmbeddedTooLarge(_))
1383 ));
1384 }
1385
1386 #[test]
1389 fn embedded_hash_json_round_trip() {
1390 let emb = embedded_json(json!({"b": 2, "a": 1}));
1391 let h = compute_embedded_hash(&emb).unwrap();
1392 let expected = {
1394 let bytes = b"{\"a\":1,\"b\":2}";
1395 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
1396 };
1397 assert_eq!(h.as_str(), expected);
1398 }
1399
1400 #[test]
1401 fn embedded_hash_utf8() {
1402 let emb = EmbeddedContent {
1403 encoding: EmbeddedEncoding::Utf8,
1404 content: json!("hello"),
1405 content_hash: None,
1406 };
1407 let h = compute_embedded_hash(&emb).unwrap();
1408 let expected = format!("sha256:{}", hex::encode(Sha256::digest(b"hello")));
1409 assert_eq!(h.as_str(), expected);
1410 }
1411
1412 #[test]
1413 fn embedded_hash_base64() {
1414 let raw = b"binary data";
1415 let b64 = STANDARD.encode(raw);
1416 let emb = EmbeddedContent {
1417 encoding: EmbeddedEncoding::Base64,
1418 content: json!(b64),
1419 content_hash: None,
1420 };
1421 let h = compute_embedded_hash(&emb).unwrap();
1422 let expected = format!("sha256:{}", hex::encode(Sha256::digest(raw)));
1423 assert_eq!(h.as_str(), expected);
1424 }
1425
1426 #[test]
1433 fn verify_embedded_hash_ignores_root_only_content_hash() {
1434 let emb = embedded_json(json!({"x": 1}));
1435 let dr = DataRef {
1436 ref_type: DataRefType::PrimaryResult,
1437 description: None,
1438 size_bytes: None,
1439 format: None,
1440 schema_version: None,
1441 content_hash: Some(ContentHash("sha256:0000".into())),
1442 location: None,
1443 embedded: Some(emb),
1444 extensions: serde_json::Map::new(),
1445 };
1446 verify_embedded_hash(&dr).unwrap();
1447 }
1448
1449 #[test]
1452 fn metadata_too_many_properties_rejected() {
1453 let mut obj = serde_json::Map::new();
1454 for i in 0..101 {
1455 obj.insert(format!("k{i}"), json!(i));
1456 }
1457 assert!(matches!(
1458 validate_metadata(&serde_json::Value::Object(obj)),
1459 Err(AcdpError::SchemaViolation(_))
1460 ));
1461 }
1462
1463 #[test]
1464 fn metadata_too_deep_rejected() {
1465 let mut v = json!("leaf");
1467 for _ in 0..10 {
1468 let mut o = serde_json::Map::new();
1469 o.insert("a".into(), v);
1470 v = serde_json::Value::Object(o);
1471 }
1472 assert!(matches!(
1473 validate_metadata(&v),
1474 Err(AcdpError::SchemaViolation(_))
1475 ));
1476 }
1477
1478 #[test]
1479 fn metadata_too_large_rejected() {
1480 let big = "a".repeat(70 * 1024);
1481 let v = json!({"big": big});
1482 assert!(matches!(
1483 validate_metadata(&v),
1484 Err(AcdpError::SchemaViolation(_))
1485 ));
1486 }
1487
1488 #[test]
1489 fn metadata_must_be_object() {
1490 assert!(matches!(
1491 validate_metadata(&json!([1, 2, 3])),
1492 Err(AcdpError::SchemaViolation(_))
1493 ));
1494 }
1495
1496 #[test]
1499 fn public_with_audience_rejected() {
1500 let aud = vec![AgentDid::new("did:web:x")];
1501 assert!(matches!(
1502 validate_visibility_audience(&Visibility::Public, Some(&aud)),
1503 Err(AcdpError::SchemaViolation(_))
1504 ));
1505 }
1506
1507 #[test]
1508 fn public_with_empty_audience_ok() {
1509 validate_visibility_audience(&Visibility::Public, Some(&[])).unwrap();
1510 validate_visibility_audience(&Visibility::Public, None).unwrap();
1511 }
1512
1513 #[test]
1514 fn restricted_without_audience_rejected() {
1515 assert!(matches!(
1516 validate_visibility_audience(&Visibility::Restricted, None),
1517 Err(AcdpError::SchemaViolation(_))
1518 ));
1519 }
1520
1521 #[test]
1524 fn data_period_start_after_end_rejected_via_builder() {
1525 use acdp_crypto::SigningKey;
1526 use acdp_producer::Producer;
1527 use acdp_types::body::DataPeriod;
1528 use chrono::TimeZone;
1529
1530 let p = Producer::new(
1531 SigningKey::from_bytes(&[0u8; 32]),
1532 AgentDid::new("did:web:agents.example.com:test"),
1533 "did:web:agents.example.com:test#key-1",
1534 );
1535 let err = p
1536 .publish_request()
1537 .title("t")
1538 .context_type(ContextType::DataSnapshot)
1539 .data_period(DataPeriod {
1540 start: chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
1541 end: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1542 })
1543 .build()
1544 .unwrap_err();
1545 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1546 }
1547
1548 #[test]
1551 fn tag_pattern_validation() {
1552 validate_tag("hello").unwrap();
1553 validate_tag("Q1-2026").unwrap();
1554 validate_tag("a_b.c").unwrap();
1555 assert!(validate_tag("-bad").is_err());
1557 assert!(validate_tag("space here").is_err());
1559 assert!(validate_tag("").is_err());
1561 }
1562
1563 #[test]
1564 fn duplicate_tags_rejected() {
1565 let tags = vec!["a".to_string(), "b".to_string(), "a".to_string()];
1566 assert!(validate_tags(&tags).is_err());
1567 }
1568
1569 #[test]
1572 fn ed25519_sig_must_be_88_chars() {
1573 assert!(validate_signature_length("ed25519", "AAAA").is_err());
1574 validate_signature_length("ed25519", &"A".repeat(88)).unwrap();
1575 validate_signature_length("future-alg", "any").unwrap();
1577 }
1578
1579 #[test]
1582 fn namespaced_context_type_pattern() {
1583 validate_namespaced_context_type("finance:portfolio_snapshot").unwrap();
1584 assert!(validate_namespaced_context_type("Finance:portfolio").is_err());
1585 assert!(validate_namespaced_context_type("finance:Portfolio").is_err());
1586 assert!(validate_namespaced_context_type("no-colon").is_err());
1587 }
1588
1589 #[test]
1593 fn acdp_version_pattern_rejects_non_semver() {
1594 validate_semver_pattern("acdp_version", "0.1.0").unwrap();
1595 validate_semver_pattern("acdp_version", "10.20.30").unwrap();
1596 assert!(validate_semver_pattern("acdp_version", "0.1.0-rc.1").is_err());
1597 assert!(validate_semver_pattern("acdp_version", "0.0").is_err());
1598 assert!(validate_semver_pattern("acdp_version", "vee.zero.zero").is_err());
1599 }
1600
1601 #[test]
1604 fn derived_from_malformed_ctx_id_rejected() {
1605 use acdp_crypto::SigningKey;
1606 use acdp_producer::Producer;
1607
1608 let p = Producer::new(
1609 SigningKey::from_bytes(&[0u8; 32]),
1610 AgentDid::new("did:web:agents.example.com:test"),
1611 "did:web:agents.example.com:test#key-1",
1612 );
1613 let err = p
1614 .publish_request()
1615 .title("t")
1616 .context_type(ContextType::DataSnapshot)
1617 .derived_from(vec![CtxId("not-a-ctx-id".into())])
1618 .build()
1619 .unwrap_err();
1620 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1621 }
1622
1623 #[test]
1630 fn embedded_content_hash_absent_root_mismatch_not_an_error() {
1631 use acdp_types::data_ref::DataRefType;
1632 let dr = DataRef {
1633 ref_type: DataRefType::PrimaryResult,
1634 description: None,
1635 size_bytes: None,
1636 format: None,
1637 schema_version: None,
1638 content_hash: Some(ContentHash("sha256:0000".into())),
1639 location: None,
1640 embedded: Some(EmbeddedContent {
1641 encoding: EmbeddedEncoding::Json,
1642 content: json!({"x": 1}),
1643 content_hash: None,
1644 }),
1645 extensions: serde_json::Map::new(),
1646 };
1647 verify_embedded_hash(&dr).unwrap();
1648 }
1649
1650 #[test]
1654 fn both_content_hashes_consistent_accepted() {
1655 use acdp_types::data_ref::DataRefType;
1656 let emb = embedded_json(json!({"a": 1, "b": 2}));
1657 let hash = compute_embedded_hash(&emb).unwrap();
1658 let dr = DataRef {
1659 ref_type: DataRefType::PrimaryResult,
1660 description: None,
1661 size_bytes: None,
1662 format: None,
1663 schema_version: None,
1664 content_hash: Some(hash.clone()),
1665 location: None,
1666 embedded: Some(embedded_json_with_hash(json!({"a": 1, "b": 2}), hash)),
1667 extensions: serde_json::Map::new(),
1668 };
1669 verify_embedded_hash(&dr).unwrap();
1670 }
1671
1672 #[test]
1684 fn root_content_hash_disagreement_accepted_when_embedded_correct() {
1685 use acdp_types::data_ref::DataRefType;
1686 let emb = embedded_json(json!({"a": 1, "b": 2}));
1687 let correct_hash = compute_embedded_hash(&emb).unwrap();
1688 let wrong_hash = ContentHash(
1689 "sha256:0000000000000000000000000000000000000000000000000000000000000000".into(),
1690 );
1691 let dr = DataRef {
1692 ref_type: DataRefType::PrimaryResult,
1693 description: None,
1694 size_bytes: None,
1695 format: None,
1696 schema_version: None,
1697 content_hash: Some(wrong_hash),
1698 location: None,
1699 embedded: Some(embedded_json_with_hash(
1700 json!({"a": 1, "b": 2}),
1701 correct_hash,
1702 )),
1703 extensions: serde_json::Map::new(),
1704 };
1705 verify_embedded_hash(&dr).unwrap();
1706 }
1707
1708 #[test]
1710 fn audience_uniqueness_rejected() {
1711 let dup = vec![
1712 AgentDid::new("did:web:a.example.com"),
1713 AgentDid::new("did:web:a.example.com"),
1714 ];
1715 let err = validate_unique_array("audience", &dup, MAX_AUDIENCE).unwrap_err();
1716 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1717 }
1718
1719 #[test]
1722 fn extensions_empty_ok() {
1723 validate_extensions(&serde_json::Map::new()).unwrap();
1724 }
1725
1726 #[test]
1727 fn extensions_small_forward_compat_accepted() {
1728 let mut ext = serde_json::Map::new();
1730 ext.insert("priority".into(), json!("high"));
1731 ext.insert("custom".into(), json!({"k": [1, 2, 3]}));
1732 validate_extensions(&ext).unwrap();
1733 }
1734
1735 #[test]
1736 fn extensions_too_many_properties_rejected() {
1737 let mut ext = serde_json::Map::new();
1738 for i in 0..(MAX_METADATA_PROPERTIES + 1) {
1739 ext.insert(format!("k{i}"), json!(i));
1740 }
1741 let err = validate_extensions(&ext).unwrap_err();
1742 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1743 }
1744
1745 #[test]
1746 fn extensions_oversized_jcs_rejected() {
1747 let mut ext = serde_json::Map::new();
1748 ext.insert("blob".into(), json!("x".repeat(MAX_METADATA_JCS_BYTES + 1)));
1749 let err = validate_extensions(&ext).unwrap_err();
1750 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1751 }
1752
1753 #[test]
1754 fn extensions_too_deep_rejected() {
1755 let mut v = json!(0);
1757 for _ in 0..(MAX_METADATA_DEPTH + 2) {
1758 v = json!({ "n": v });
1759 }
1760 let mut ext = serde_json::Map::new();
1761 ext.insert("deep".into(), v);
1762 let err = validate_extensions(&ext).unwrap_err();
1763 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1764 }
1765
1766 #[test]
1767 fn json_depth_clamps_past_scan_budget() {
1768 let mut v = json!(0);
1773 for _ in 0..400 {
1774 v = json!([v]);
1775 }
1776 assert!(json_depth(&v) > MAX_METADATA_DEPTH);
1777 assert!(acdp_crypto::try_canonicalize_value(&v).is_err());
1778 }
1779}
1780
1781#[cfg(test)]
1782mod capabilities_0_3_0_tests {
1783 use super::*;
1784 use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1785
1786 fn caps(version: &str, supports_idem: bool, mppm: Option<u64>) -> CapabilitiesDocument {
1787 CapabilitiesDocument {
1788 acdp_version: version.into(),
1789 registry_did: "did:web:registry.example.com".into(),
1790 supported_signature_algorithms: vec!["ed25519".into()],
1791 supported_did_methods: vec!["did:web".into()],
1792 profiles: vec!["acdp-registry-core".into()],
1793 limits: Limits {
1794 max_payload_bytes: 1_048_576,
1795 max_embedded_bytes: 65_536,
1796 idempotency_key_ttl_seconds: if supports_idem { Some(86_400) } else { None },
1797 max_publish_per_minute: mppm,
1798 change_retention_seconds: None,
1799 max_change_filters: None,
1800 max_changes_page: None,
1801 max_subscriptions_per_requester: None,
1802 },
1803 read_authentication_methods: vec![],
1804 anonymous_public_reads: false,
1805 supports_idempotency_key: supports_idem,
1806 extensions: Default::default(),
1807 }
1808 }
1809
1810 #[test]
1813 fn max_publish_per_minute_bounds() {
1814 assert!(validate_capabilities(&caps("0.1.0", false, Some(600))).is_ok());
1815 let err = validate_capabilities(&caps("0.1.0", false, Some(0)))
1816 .expect_err("zero MUST be rejected");
1817 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1818 }
1819
1820 #[test]
1825 fn idempotency_required_at_0_3_0() {
1826 assert!(validate_capabilities(&caps("0.1.0", false, None)).is_ok());
1827 assert!(validate_capabilities(&caps("0.2.0", false, None)).is_ok());
1828 assert!(validate_capabilities(&caps("0.3.0", true, None)).is_ok());
1829 assert!(validate_capabilities(&caps("0.4.0", true, None)).is_ok());
1830 for v in ["0.3.0", "0.4.0", "1.0.0"] {
1831 let err = validate_capabilities(&caps(v, false, None))
1832 .expect_err("version >= 0.3.0 without idempotency MUST be rejected");
1833 assert!(matches!(err, AcdpError::SchemaViolation(_)), "{v}: {err:?}");
1834 }
1835 }
1836}