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 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(
294 body, true, true,
295 )
296}
297
298pub fn validate_body_schema(body: &Body) -> Result<(), AcdpError> {
306 validate_body_inner(
307 body, true, false,
308 )
309}
310
311pub fn validate_body_structural(body: &Body) -> Result<(), AcdpError> {
322 validate_body_inner(
323 body, false, true,
324 )
325}
326
327fn validate_body_inner(
328 body: &Body,
329 check_embedded_hashes: bool,
330 resolve_keys: bool,
331) -> Result<(), AcdpError> {
332 validate_title(&body.title)?;
333 validate_optional_string(
334 body.description.as_deref(),
335 "description",
336 MAX_DESCRIPTION_LEN,
337 )?;
338 validate_optional_string(body.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
339 validate_optional_string(body.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
340
341 validate_agent_did(&body.agent_id, resolve_keys)?;
342 for c in &body.contributors {
343 validate_loose_did(c)?;
344 }
345 validate_unique_array("contributors", &body.contributors, MAX_CONTRIBUTORS)?;
346 validate_unique_array("derived_from", &body.derived_from, MAX_DERIVED_FROM)?;
347
348 if let Some(tags) = &body.tags {
349 validate_tags(tags)?;
350 }
351 if let Some(audience) = &body.audience {
352 validate_unique_array("audience", audience, MAX_AUDIENCE)?;
353 for did in audience {
354 validate_loose_did(did)?;
355 }
356 }
357 validate_visibility_audience(&body.visibility, body.audience.as_deref())?;
358
359 if let Some(dp) = &body.data_period {
360 if dp.start > dp.end {
361 return Err(AcdpError::SchemaViolation(
362 "data_period.start must not be after data_period.end".into(),
363 ));
364 }
365 }
366
367 if let Some(meta) = &body.metadata {
368 validate_metadata(meta)?;
369 }
370
371 validate_extensions(&body.extensions)?;
375
376 for dr in &body.data_refs {
377 if check_embedded_hashes {
378 validate_data_ref(dr)?;
379 } else {
380 validate_data_ref_structural(dr)?;
381 }
382 }
383
384 if let Some(anchors) = &body.anchors {
385 validate_anchors(anchors)?;
386 }
387
388 validate_signature_length(&body.signature.algorithm, &body.signature.value)?;
389 if resolve_keys {
390 validate_did_key_key_id_form(&body.signature.key_id)?;
391 }
392 validate_identifiers(&body.ctx_id, &body.lineage_id, &body.content_hash)?;
393
394 if let Some(prev) = &body.supersedes {
396 CtxId::parse(prev.as_str())?;
397 }
398 for ancestor in &body.derived_from {
399 CtxId::parse(ancestor.as_str())?;
400 }
401
402 if let Some(v) = &body.acdp_version {
403 validate_semver_pattern("acdp_version", v)?;
404 }
405
406 let _ = &body.created_at; validate_origin_registry(&body.origin_registry)?;
408
409 let _ = std::any::type_name::<Status>();
411 let _: &Visibility = &body.visibility;
412
413 Ok(())
414}
415
416pub fn validate_identifiers(
418 ctx_id: &CtxId,
419 lineage_id: &LineageId,
420 content_hash: &ContentHash,
421) -> Result<(), AcdpError> {
422 CtxId::parse(ctx_id.as_str())?;
423 LineageId::parse(lineage_id.as_str())?;
424 ContentHash::parse(content_hash.as_str())?;
425 Ok(())
426}
427
428pub fn validate_data_ref(dr: &DataRef) -> Result<(), AcdpError> {
433 validate_data_ref_structural(dr)?;
434 if dr.embedded.is_some() {
439 verify_embedded_hash(dr)?;
440 }
441 Ok(())
442}
443
444pub fn validate_data_ref_structural(dr: &DataRef) -> Result<(), AcdpError> {
450 match (&dr.location, &dr.embedded) {
452 (None, None) => {
453 return Err(AcdpError::SchemaViolation(
454 "DataRef requires exactly one of 'location' or 'embedded' (got neither)".into(),
455 ));
456 }
457 (Some(_), Some(_)) => {
458 return Err(AcdpError::SchemaViolation(
459 "DataRef requires exactly one of 'location' or 'embedded' (got both)".into(),
460 ));
461 }
462 _ => {}
463 }
464
465 if let Some(ch) = &dr.content_hash {
473 ContentHash::parse(ch.as_str())?;
474 }
475
476 if let Some(desc) = &dr.description {
477 if desc.len() > MAX_DATA_REF_DESCRIPTION_LEN {
478 return Err(AcdpError::SchemaViolation(format!(
479 "DataRef.description {} chars exceeds {} limit",
480 desc.len(),
481 MAX_DATA_REF_DESCRIPTION_LEN
482 )));
483 }
484 }
485
486 if let Some(loc) = &dr.location {
487 validate_location(loc)?;
488 }
489 if let Some(emb) = &dr.embedded {
490 validate_embedded(emb)?;
491 }
492
493 Ok(())
494}
495
496fn validate_location(loc: &Location) -> Result<(), AcdpError> {
497 match loc {
498 Location::Uri(uri) => validate_uri_location(uri),
499 Location::Structured(map) => validate_structured_locator(map),
500 }
501}
502
503fn validate_uri_location(uri: &str) -> Result<(), AcdpError> {
504 if uri.len() < 3 || uri.len() > MAX_URI_LEN {
505 return Err(AcdpError::SchemaViolation(format!(
506 "DataRef.location URI length {} not in 3..={}",
507 uri.len(),
508 MAX_URI_LEN
509 )));
510 }
511 let (scheme, rest) = uri
513 .split_once(':')
514 .ok_or_else(|| AcdpError::SchemaViolation(format!("URI missing scheme: {uri}")))?;
515 if scheme.is_empty()
516 || !scheme
517 .chars()
518 .next()
519 .is_some_and(|c| c.is_ascii_lowercase())
520 || !scheme
521 .chars()
522 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '.' | '-'))
523 {
524 return Err(AcdpError::SchemaViolation(format!(
525 "URI scheme '{scheme}' invalid; must match [a-z][a-z0-9+.-]*"
526 )));
527 }
528 if let Some(after_slashes) = rest.strip_prefix("//") {
530 if let Some(authority_end) = after_slashes.find(['/', '?', '#']) {
531 let authority = &after_slashes[..authority_end];
532 if authority.contains('@') {
533 return Err(AcdpError::SchemaViolation(format!(
534 "URI MUST NOT contain credentials in userinfo: {uri}"
535 )));
536 }
537 } else if after_slashes.contains('@') {
538 return Err(AcdpError::SchemaViolation(format!(
539 "URI MUST NOT contain credentials in userinfo: {uri}"
540 )));
541 }
542 }
543 Ok(())
544}
545
546fn validate_structured_locator(
547 map: &serde_json::Map<String, serde_json::Value>,
548) -> Result<(), AcdpError> {
549 let scheme = map.get("scheme").and_then(|v| v.as_str()).ok_or_else(|| {
550 AcdpError::SchemaViolation("structured locator missing required 'scheme'".into())
551 })?;
552 if !is_dotted_namespace_scheme(scheme) {
553 return Err(AcdpError::SchemaViolation(format!(
554 "structured locator scheme '{scheme}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
555 )));
556 }
557 Ok(())
558}
559
560fn is_dotted_namespace_scheme(s: &str) -> bool {
561 let parts: Vec<&str> = s.split('.').collect();
562 if parts.len() < 2 {
563 return false;
564 }
565 parts.iter().all(|part| {
566 !part.is_empty()
567 && part.chars().next().is_some_and(|c| c.is_ascii_lowercase())
568 && part
569 .chars()
570 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
571 })
572}
573
574fn validate_anchors(anchors: &[AnchorEntry]) -> Result<(), AcdpError> {
579 if anchors.is_empty() {
580 return Err(AcdpError::SchemaViolation(
581 "anchors MUST be omitted entirely (never sent as an empty array) when there is \
582 nothing to anchor — the absent-when-empty convention (RFC-ACDP-0016 \u{a7}4)"
583 .into(),
584 ));
585 }
586 validate_unique_array("anchors", anchors, MAX_ANCHORS)?;
587 for anchor in anchors {
588 if !is_dotted_namespace_scheme(&anchor.scheme) {
589 return Err(AcdpError::SchemaViolation(format!(
590 "anchor scheme '{}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$ \
591 (RFC-ACDP-0016 \u{a7}4)",
592 anchor.scheme
593 )));
594 }
595 ContentHash::parse(anchor.content_hash.as_str())?;
596 }
597 Ok(())
598}
599
600fn validate_embedded(emb: &EmbeddedContent) -> Result<(), AcdpError> {
601 match emb.encoding {
603 EmbeddedEncoding::Utf8 | EmbeddedEncoding::Base64 => {
604 if !emb.content.is_string() {
605 return Err(AcdpError::SchemaViolation(format!(
606 "embedded {:?} content MUST be a JSON string",
607 emb.encoding
608 )));
609 }
610 }
611 EmbeddedEncoding::Json => {}
612 }
613 if let Some(ch) = &emb.content_hash {
618 ContentHash::parse(ch.as_str())?;
619 }
620 let decoded = embedded_decoded_bytes(emb)?;
622 if decoded.len() > MAX_EMBEDDED_BYTES {
623 return Err(AcdpError::EmbeddedTooLarge(format!(
624 "embedded decoded size {} bytes exceeds {} limit",
625 decoded.len(),
626 MAX_EMBEDDED_BYTES
627 )));
628 }
629 Ok(())
630}
631
632pub fn embedded_decoded_bytes(emb: &EmbeddedContent) -> Result<Vec<u8>, AcdpError> {
638 Ok(match emb.encoding {
639 EmbeddedEncoding::Json => try_canonicalize_value(&emb.content)?,
640 EmbeddedEncoding::Utf8 => {
641 let s = emb.content.as_str().ok_or_else(|| {
642 AcdpError::SchemaViolation("utf8 embedded content must be a JSON string".into())
643 })?;
644 s.as_bytes().to_vec()
645 }
646 EmbeddedEncoding::Base64 => {
647 let s = emb.content.as_str().ok_or_else(|| {
648 AcdpError::SchemaViolation("base64 embedded content must be a JSON string".into())
649 })?;
650 STANDARD
651 .decode(s)
652 .map_err(|e| AcdpError::SchemaViolation(format!("base64 decode failed: {e}")))?
653 }
654 })
655}
656
657pub fn compute_embedded_hash(emb: &EmbeddedContent) -> Result<ContentHash, AcdpError> {
659 let bytes = embedded_decoded_bytes(emb)?;
660 let digest = Sha256::digest(&bytes);
661 Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
662}
663
664pub fn verify_embedded_hash(dr: &DataRef) -> Result<(), AcdpError> {
692 let Some(emb) = &dr.embedded else {
693 return Ok(());
694 };
695 let Some(embedded_hash) = &emb.content_hash else {
696 return Ok(());
697 };
698 let recomputed = compute_embedded_hash(emb)?;
699 if &recomputed != embedded_hash {
700 return Err(AcdpError::DataRefHashMismatch(format!(
701 "embedded.content_hash mismatch: declared {}, computed {}",
702 embedded_hash.as_str(),
703 recomputed.as_str()
704 )));
705 }
706 Ok(())
707}
708
709pub fn validate_metadata(value: &serde_json::Value) -> Result<(), AcdpError> {
714 validate_json_object_limits(value, "metadata")
715}
716
717fn validate_json_object_limits(value: &serde_json::Value, field: &str) -> Result<(), AcdpError> {
722 let obj = value
723 .as_object()
724 .ok_or_else(|| AcdpError::SchemaViolation(format!("{field} must be a JSON object")))?;
725 if obj.len() > MAX_METADATA_PROPERTIES {
726 return Err(AcdpError::SchemaViolation(format!(
727 "{field} has {} top-level properties, exceeds {} limit",
728 obj.len(),
729 MAX_METADATA_PROPERTIES
730 )));
731 }
732 let depth = json_depth(value);
733 if depth > MAX_METADATA_DEPTH {
734 return Err(AcdpError::SchemaViolation(format!(
735 "{field} nesting depth {depth} exceeds {MAX_METADATA_DEPTH}"
736 )));
737 }
738 let canonical_size = try_canonicalize_value(value)?.len();
739 if canonical_size > MAX_METADATA_JCS_BYTES {
740 return Err(AcdpError::SchemaViolation(format!(
741 "{field} JCS-canonical size {canonical_size} bytes exceeds {MAX_METADATA_JCS_BYTES}"
742 )));
743 }
744 Ok(())
745}
746
747pub fn validate_extensions(
750 extensions: &serde_json::Map<String, serde_json::Value>,
751) -> Result<(), AcdpError> {
752 if extensions.is_empty() {
753 return Ok(());
754 }
755 let value = serde_json::Value::Object(extensions.clone());
759 validate_json_object_limits(&value, "extensions")
760}
761
762fn json_depth(v: &serde_json::Value) -> usize {
772 const MAX_JSON_DEPTH_SCAN: usize = 256;
774 fn go(v: &serde_json::Value, budget: usize) -> usize {
775 if budget == 0 {
776 return 1; }
778 match v {
779 serde_json::Value::Object(map) => {
780 1 + map.values().map(|x| go(x, budget - 1)).max().unwrap_or(0)
781 }
782 serde_json::Value::Array(arr) => {
783 1 + arr.iter().map(|x| go(x, budget - 1)).max().unwrap_or(0)
784 }
785 _ => 0,
786 }
787 }
788 go(v, MAX_JSON_DEPTH_SCAN)
789}
790
791fn validate_visibility_audience(
794 vis: &Visibility,
795 audience: Option<&[AgentDid]>,
796) -> Result<(), AcdpError> {
797 match vis {
798 Visibility::Restricted => {
799 if audience.is_none_or(|a| a.is_empty()) {
800 return Err(AcdpError::SchemaViolation(
801 "visibility:restricted requires a non-empty audience".into(),
802 ));
803 }
804 }
805 Visibility::Public => {
806 if audience.is_some_and(|a| !a.is_empty()) {
807 return Err(AcdpError::SchemaViolation(
808 "visibility:public MUST NOT include audience".into(),
809 ));
810 }
811 }
812 Visibility::Private => {}
813 }
814 Ok(())
815}
816
817fn validate_title(title: &str) -> Result<(), AcdpError> {
820 if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
821 return Err(AcdpError::SchemaViolation(format!(
822 "title length {} not in 1..={}",
823 title.chars().count(),
824 MAX_TITLE_LEN
825 )));
826 }
827 Ok(())
828}
829
830fn validate_optional_string(s: Option<&str>, name: &str, max_len: usize) -> Result<(), AcdpError> {
831 if let Some(value) = s {
832 if value.chars().count() > max_len {
833 return Err(AcdpError::SchemaViolation(format!(
834 "{name} length {} exceeds {max_len}",
835 value.chars().count()
836 )));
837 }
838 }
839 Ok(())
840}
841
842fn validate_unique_array<T: PartialEq + std::fmt::Debug>(
843 name: &str,
844 items: &[T],
845 max: usize,
846) -> Result<(), AcdpError> {
847 if items.len() > max {
848 return Err(AcdpError::SchemaViolation(format!(
849 "{name} has {} items, exceeds {max}",
850 items.len()
851 )));
852 }
853 for (i, item) in items.iter().enumerate() {
854 if items[i + 1..].iter().any(|other| other == item) {
855 return Err(AcdpError::SchemaViolation(format!(
856 "{name} contains duplicate entry: {item:?}"
857 )));
858 }
859 }
860 Ok(())
861}
862
863fn validate_tags(tags: &[String]) -> Result<(), AcdpError> {
864 if tags.len() > MAX_TAGS {
865 return Err(AcdpError::SchemaViolation(format!(
866 "tags has {} entries, exceeds {}",
867 tags.len(),
868 MAX_TAGS
869 )));
870 }
871 for tag in tags {
872 validate_tag(tag)?;
873 }
874 for (i, tag) in tags.iter().enumerate() {
876 if tags[i + 1..].iter().any(|t| t == tag) {
877 return Err(AcdpError::SchemaViolation(format!(
878 "tags contains duplicate entry: {tag}"
879 )));
880 }
881 }
882 Ok(())
883}
884
885fn validate_tag(tag: &str) -> Result<(), AcdpError> {
886 if tag.is_empty() || tag.len() > MAX_TAG_LEN {
887 return Err(AcdpError::SchemaViolation(format!(
888 "tag '{tag}' length not in 1..={MAX_TAG_LEN}"
889 )));
890 }
891 let mut chars = tag.chars();
892 let first = chars.next().unwrap();
893 if !first.is_ascii_alphanumeric() {
894 return Err(AcdpError::SchemaViolation(format!(
895 "tag '{tag}' first char must be alphanumeric"
896 )));
897 }
898 if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) {
899 return Err(AcdpError::SchemaViolation(format!(
900 "tag '{tag}' must match [A-Za-z0-9][A-Za-z0-9_.-]*"
901 )));
902 }
903 Ok(())
904}
905
906fn validate_did_key_key_id_form(key_id: &str) -> Result<(), AcdpError> {
926 if !key_id.starts_with("did:key:") {
927 return Ok(());
928 }
929 acdp_did::key::resolve_did_key_url(key_id)?;
939 Ok(())
940}
941
942fn validate_agent_did(did: &AgentDid, resolve: bool) -> Result<(), AcdpError> {
943 if did.as_str().starts_with("did:key:") {
944 AgentDid::parse(did.as_str())?;
945 if !resolve {
946 return Ok(());
947 }
948 acdp_did::key::resolve_did_key(did.as_str())?;
952 return Ok(());
953 }
954 AgentDid::parse_web(did.as_str())?;
955 Ok(())
956}
957
958fn validate_origin_registry(s: &str) -> Result<(), AcdpError> {
966 if s.is_empty() {
967 return Err(AcdpError::SchemaViolation(
968 "origin_registry must be a non-empty DNS hostname".into(),
969 ));
970 }
971 if s.starts_with("did:") {
972 return Err(AcdpError::SchemaViolation(format!(
973 "origin_registry must be a DNS hostname, not a DID URI (got '{s}'); \
974 use the bare authority — capabilities.registry_did carries the did:web form"
975 )));
976 }
977 if s.contains("://") {
978 return Err(AcdpError::SchemaViolation(format!(
979 "origin_registry must be a DNS hostname, not a URL (got '{s}')"
980 )));
981 }
982 if s.ends_with('.') || s.starts_with('.') {
983 return Err(AcdpError::SchemaViolation(format!(
984 "origin_registry must be a syntactically valid DNS hostname (got '{s}')"
985 )));
986 }
987 if !acdp_types::primitives::is_valid_dns_authority(s) {
993 return Err(AcdpError::SchemaViolation(format!(
994 "origin_registry '{s}' is not a valid DNS hostname (must be lowercase \
995 labels of [a-z0-9-] separated by dots, e.g. 'registry.example.com')"
996 )));
997 }
998 Ok(())
999}
1000
1001fn validate_loose_did(did: &AgentDid) -> Result<(), AcdpError> {
1011 AgentDid::parse(did.as_str())?;
1012 Ok(())
1013}
1014
1015fn validate_namespaced_context_type(value: &str) -> Result<(), AcdpError> {
1018 let (ns, name) = value.split_once(':').ok_or_else(|| {
1020 AcdpError::SchemaViolation(format!(
1021 "context_type '{value}' missing namespace separator"
1022 ))
1023 })?;
1024 if ns.is_empty()
1025 || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1026 || !ns
1027 .chars()
1028 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
1029 {
1030 return Err(AcdpError::SchemaViolation(format!(
1031 "context_type namespace '{ns}' must match [a-z][a-z0-9_]*"
1032 )));
1033 }
1034 if name.is_empty()
1035 || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1036 || !name
1037 .chars()
1038 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
1039 {
1040 return Err(AcdpError::SchemaViolation(format!(
1041 "context_type name '{name}' must match [a-z][a-z0-9_-]*"
1042 )));
1043 }
1044 Ok(())
1045}
1046
1047trait ContextTypeExt {
1048 fn namespaced_form(&self) -> Option<&str>;
1049}
1050
1051impl ContextTypeExt for ContextType {
1052 fn namespaced_form(&self) -> Option<&str> {
1053 match self {
1054 ContextType::Custom(s) => Some(s.as_str()),
1055 _ => None,
1056 }
1057 }
1058}
1059
1060fn validate_semver_pattern(name: &str, value: &str) -> Result<(), AcdpError> {
1063 let parts: Vec<&str> = value.split('.').collect();
1064 let ok = parts.len() == 3
1065 && parts
1066 .iter()
1067 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
1068 if !ok {
1069 return Err(AcdpError::SchemaViolation(format!(
1070 "{name} '{value}' must match the semver pattern ^\\d+\\.\\d+\\.\\d+$"
1071 )));
1072 }
1073 Ok(())
1074}
1075
1076fn validate_signature_length(algorithm: &str, value_b64: &str) -> Result<(), AcdpError> {
1077 let expected = match algorithm {
1078 "ed25519" => Some(ED25519_SIG_B64_LEN),
1079 "ecdsa-p256" => Some(ECDSA_P256_SIG_B64_LEN),
1080 _ => None,
1081 };
1082 if let Some(n) = expected {
1083 if value_b64.len() != n {
1084 return Err(AcdpError::InvalidSignature(format!(
1085 "signature.value for '{algorithm}' must be {n} base64 chars, got {}",
1086 value_b64.len()
1087 )));
1088 }
1089 }
1090 Ok(())
1091}
1092
1093#[cfg(test)]
1096mod tests {
1097 use super::*;
1098 use acdp_types::data_ref::DataRefType;
1099 use serde_json::json;
1100
1101 fn embedded_json(v: serde_json::Value) -> EmbeddedContent {
1102 EmbeddedContent {
1103 encoding: EmbeddedEncoding::Json,
1104 content: v,
1105 content_hash: None,
1106 }
1107 }
1108
1109 fn embedded_json_with_hash(v: serde_json::Value, hash: ContentHash) -> EmbeddedContent {
1110 EmbeddedContent {
1111 encoding: EmbeddedEncoding::Json,
1112 content: v,
1113 content_hash: Some(hash),
1114 }
1115 }
1116
1117 #[test]
1120 fn origin_registry_accepts_valid_hostname() {
1121 validate_origin_registry("registry.example.com").unwrap();
1122 validate_origin_registry("reg.example").unwrap();
1123 validate_origin_registry("a-b-c.io").unwrap();
1124 }
1125
1126 #[test]
1127 fn origin_registry_rejects_uppercase() {
1128 assert!(matches!(
1129 validate_origin_registry("REGISTRY.EXAMPLE.COM"),
1130 Err(AcdpError::SchemaViolation(_))
1131 ));
1132 }
1133
1134 #[test]
1135 fn origin_registry_rejects_underscore() {
1136 assert!(matches!(
1137 validate_origin_registry("registry_example.com"),
1138 Err(AcdpError::SchemaViolation(_))
1139 ));
1140 }
1141
1142 #[test]
1143 fn origin_registry_rejects_hyphen_label_edges() {
1144 assert!(matches!(
1145 validate_origin_registry("registry-.com"),
1146 Err(AcdpError::SchemaViolation(_))
1147 ));
1148 assert!(matches!(
1149 validate_origin_registry("-registry.example.com"),
1150 Err(AcdpError::SchemaViolation(_))
1151 ));
1152 }
1153
1154 #[test]
1157 fn data_ref_neither_location_nor_embedded_rejected() {
1158 let dr = DataRef {
1159 ref_type: DataRefType::PrimaryResult,
1160 description: None,
1161 size_bytes: None,
1162 format: None,
1163 schema_version: None,
1164 content_hash: None,
1165 location: None,
1166 embedded: None,
1167 extensions: serde_json::Map::new(),
1168 };
1169 assert!(matches!(
1170 validate_data_ref(&dr),
1171 Err(AcdpError::SchemaViolation(_))
1172 ));
1173 }
1174
1175 #[test]
1176 fn data_ref_both_location_and_embedded_rejected() {
1177 let dr = DataRef {
1178 ref_type: DataRefType::PrimaryResult,
1179 description: None,
1180 size_bytes: None,
1181 format: None,
1182 schema_version: None,
1183 content_hash: None,
1184 location: Some(Location::Uri("https://x/y".into())),
1185 embedded: Some(embedded_json(json!({"a": 1}))),
1186 extensions: serde_json::Map::new(),
1187 };
1188 assert!(matches!(
1189 validate_data_ref(&dr),
1190 Err(AcdpError::SchemaViolation(_))
1191 ));
1192 }
1193
1194 #[test]
1195 fn data_ref_malformed_root_content_hash_rejected() {
1196 let dr = DataRef {
1197 ref_type: DataRefType::PrimaryResult,
1198 description: None,
1199 size_bytes: None,
1200 format: None,
1201 schema_version: None,
1202 content_hash: Some(ContentHash("not-a-content-hash".into())),
1203 location: Some(Location::Uri("https://x/y".into())),
1204 embedded: None,
1205 extensions: serde_json::Map::new(),
1206 };
1207 let err = validate_data_ref_structural(&dr)
1208 .expect_err("a malformed root content_hash must be caught structurally");
1209 assert!(
1210 matches!(err, AcdpError::SchemaViolation(ref msg) if msg.contains("content_hash")),
1211 "expected a SchemaViolation naming content_hash, got {err:?}"
1212 );
1213 }
1214
1215 #[test]
1216 fn data_ref_malformed_embedded_content_hash_rejected() {
1217 let dr = DataRef {
1218 ref_type: DataRefType::PrimaryResult,
1219 description: None,
1220 size_bytes: None,
1221 format: None,
1222 schema_version: None,
1223 content_hash: None,
1224 location: None,
1225 embedded: Some(EmbeddedContent {
1226 encoding: EmbeddedEncoding::Utf8,
1227 content: serde_json::Value::String("hello".into()),
1228 content_hash: Some(ContentHash("sha256:not-hex".into())),
1229 }),
1230 extensions: serde_json::Map::new(),
1231 };
1232 let err = validate_data_ref_structural(&dr)
1233 .expect_err("a malformed embedded content_hash must be caught structurally");
1234 assert!(
1235 matches!(err, AcdpError::SchemaViolation(ref msg) if msg.contains("content_hash")),
1236 "expected a SchemaViolation naming content_hash, got {err:?}"
1237 );
1238 }
1239
1240 #[test]
1243 fn uri_credentials_rejected() {
1244 let dr = DataRef::uri(DataRefType::RawData, "https://user:pass@example.com/data");
1245 assert!(matches!(
1246 validate_data_ref(&dr),
1247 Err(AcdpError::SchemaViolation(_))
1248 ));
1249 }
1250
1251 #[test]
1252 fn uri_without_scheme_rejected() {
1253 let dr = DataRef::uri(DataRefType::RawData, "no-scheme");
1254 assert!(matches!(
1255 validate_data_ref(&dr),
1256 Err(AcdpError::SchemaViolation(_))
1257 ));
1258 }
1259
1260 #[test]
1261 fn uri_too_long_rejected() {
1262 let long_uri = format!("https://x.com/{}", "a".repeat(MAX_URI_LEN));
1263 let dr = DataRef::uri(DataRefType::RawData, long_uri);
1264 assert!(matches!(
1265 validate_data_ref(&dr),
1266 Err(AcdpError::SchemaViolation(_))
1267 ));
1268 }
1269
1270 #[test]
1273 fn structured_locator_missing_scheme_rejected() {
1274 let mut map = serde_json::Map::new();
1275 map.insert("offset".into(), json!(42));
1276 let dr = DataRef {
1277 ref_type: DataRefType::RawData,
1278 description: None,
1279 size_bytes: None,
1280 format: None,
1281 schema_version: None,
1282 content_hash: None,
1283 location: Some(Location::Structured(map)),
1284 embedded: None,
1285 extensions: serde_json::Map::new(),
1286 };
1287 assert!(matches!(
1288 validate_data_ref(&dr),
1289 Err(AcdpError::SchemaViolation(_))
1290 ));
1291 }
1292
1293 #[test]
1294 fn structured_locator_bad_scheme_rejected() {
1295 let err =
1299 DataRef::try_structured(DataRefType::RawData, "not_dotted", serde_json::Map::new())
1300 .unwrap_err();
1301 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1302
1303 let mut bad = serde_json::Map::new();
1306 bad.insert(
1307 "scheme".into(),
1308 serde_json::Value::String("not_dotted".into()),
1309 );
1310 let dr = DataRef {
1311 ref_type: DataRefType::RawData,
1312 description: None,
1313 size_bytes: None,
1314 format: None,
1315 schema_version: None,
1316 content_hash: None,
1317 location: Some(Location::Structured(bad)),
1318 embedded: None,
1319 extensions: serde_json::Map::new(),
1320 };
1321 assert!(matches!(
1322 validate_data_ref(&dr),
1323 Err(AcdpError::SchemaViolation(_))
1324 ));
1325 }
1326
1327 #[test]
1328 fn structured_locator_valid() {
1329 let mut extra = serde_json::Map::new();
1330 extra.insert("topic".into(), json!("events"));
1331 let dr = DataRef::structured(DataRefType::RawData, "kafka.offset", extra);
1332 validate_data_ref(&dr).unwrap();
1333 }
1334
1335 #[test]
1338 fn embedded_utf8_must_be_string() {
1339 let dr = DataRef {
1340 ref_type: DataRefType::PrimaryResult,
1341 description: None,
1342 size_bytes: None,
1343 format: None,
1344 schema_version: None,
1345 content_hash: None,
1346 location: None,
1347 embedded: Some(EmbeddedContent {
1348 encoding: EmbeddedEncoding::Utf8,
1349 content: json!(42),
1350 content_hash: None,
1351 }),
1352 extensions: serde_json::Map::new(),
1353 };
1354 assert!(matches!(
1355 validate_data_ref(&dr),
1356 Err(AcdpError::SchemaViolation(_))
1357 ));
1358 }
1359
1360 #[test]
1361 fn embedded_too_large_rejected() {
1362 let big = "a".repeat(70 * 1024);
1364 let dr = DataRef::embedded_utf8(DataRefType::PrimaryResult, big);
1365 assert!(matches!(
1366 validate_data_ref(&dr),
1367 Err(AcdpError::EmbeddedTooLarge(_))
1368 ));
1369 }
1370
1371 #[test]
1374 fn embedded_hash_json_round_trip() {
1375 let emb = embedded_json(json!({"b": 2, "a": 1}));
1376 let h = compute_embedded_hash(&emb).unwrap();
1377 let expected = {
1379 let bytes = b"{\"a\":1,\"b\":2}";
1380 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
1381 };
1382 assert_eq!(h.as_str(), expected);
1383 }
1384
1385 #[test]
1386 fn embedded_hash_utf8() {
1387 let emb = EmbeddedContent {
1388 encoding: EmbeddedEncoding::Utf8,
1389 content: json!("hello"),
1390 content_hash: None,
1391 };
1392 let h = compute_embedded_hash(&emb).unwrap();
1393 let expected = format!("sha256:{}", hex::encode(Sha256::digest(b"hello")));
1394 assert_eq!(h.as_str(), expected);
1395 }
1396
1397 #[test]
1398 fn embedded_hash_base64() {
1399 let raw = b"binary data";
1400 let b64 = STANDARD.encode(raw);
1401 let emb = EmbeddedContent {
1402 encoding: EmbeddedEncoding::Base64,
1403 content: json!(b64),
1404 content_hash: None,
1405 };
1406 let h = compute_embedded_hash(&emb).unwrap();
1407 let expected = format!("sha256:{}", hex::encode(Sha256::digest(raw)));
1408 assert_eq!(h.as_str(), expected);
1409 }
1410
1411 #[test]
1418 fn verify_embedded_hash_ignores_root_only_content_hash() {
1419 let emb = embedded_json(json!({"x": 1}));
1420 let dr = DataRef {
1421 ref_type: DataRefType::PrimaryResult,
1422 description: None,
1423 size_bytes: None,
1424 format: None,
1425 schema_version: None,
1426 content_hash: Some(ContentHash("sha256:0000".into())),
1427 location: None,
1428 embedded: Some(emb),
1429 extensions: serde_json::Map::new(),
1430 };
1431 verify_embedded_hash(&dr).unwrap();
1432 }
1433
1434 #[test]
1437 fn metadata_too_many_properties_rejected() {
1438 let mut obj = serde_json::Map::new();
1439 for i in 0..101 {
1440 obj.insert(format!("k{i}"), json!(i));
1441 }
1442 assert!(matches!(
1443 validate_metadata(&serde_json::Value::Object(obj)),
1444 Err(AcdpError::SchemaViolation(_))
1445 ));
1446 }
1447
1448 #[test]
1449 fn metadata_too_deep_rejected() {
1450 let mut v = json!("leaf");
1452 for _ in 0..10 {
1453 let mut o = serde_json::Map::new();
1454 o.insert("a".into(), v);
1455 v = serde_json::Value::Object(o);
1456 }
1457 assert!(matches!(
1458 validate_metadata(&v),
1459 Err(AcdpError::SchemaViolation(_))
1460 ));
1461 }
1462
1463 #[test]
1464 fn metadata_too_large_rejected() {
1465 let big = "a".repeat(70 * 1024);
1466 let v = json!({"big": big});
1467 assert!(matches!(
1468 validate_metadata(&v),
1469 Err(AcdpError::SchemaViolation(_))
1470 ));
1471 }
1472
1473 #[test]
1474 fn metadata_must_be_object() {
1475 assert!(matches!(
1476 validate_metadata(&json!([1, 2, 3])),
1477 Err(AcdpError::SchemaViolation(_))
1478 ));
1479 }
1480
1481 #[test]
1484 fn public_with_audience_rejected() {
1485 let aud = vec![AgentDid::new("did:web:x")];
1486 assert!(matches!(
1487 validate_visibility_audience(&Visibility::Public, Some(&aud)),
1488 Err(AcdpError::SchemaViolation(_))
1489 ));
1490 }
1491
1492 #[test]
1493 fn public_with_empty_audience_ok() {
1494 validate_visibility_audience(&Visibility::Public, Some(&[])).unwrap();
1495 validate_visibility_audience(&Visibility::Public, None).unwrap();
1496 }
1497
1498 #[test]
1499 fn restricted_without_audience_rejected() {
1500 assert!(matches!(
1501 validate_visibility_audience(&Visibility::Restricted, None),
1502 Err(AcdpError::SchemaViolation(_))
1503 ));
1504 }
1505
1506 #[test]
1509 fn data_period_start_after_end_rejected_via_builder() {
1510 use acdp_crypto::SigningKey;
1511 use acdp_producer::Producer;
1512 use acdp_types::body::DataPeriod;
1513 use chrono::TimeZone;
1514
1515 let p = Producer::new(
1516 SigningKey::from_bytes(&[0u8; 32]),
1517 AgentDid::new("did:web:agents.example.com:test"),
1518 "did:web:agents.example.com:test#key-1",
1519 );
1520 let err = p
1521 .publish_request()
1522 .title("t")
1523 .context_type(ContextType::DataSnapshot)
1524 .data_period(DataPeriod {
1525 start: chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
1526 end: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1527 })
1528 .build()
1529 .unwrap_err();
1530 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1531 }
1532
1533 #[test]
1536 fn tag_pattern_validation() {
1537 validate_tag("hello").unwrap();
1538 validate_tag("Q1-2026").unwrap();
1539 validate_tag("a_b.c").unwrap();
1540 assert!(validate_tag("-bad").is_err());
1542 assert!(validate_tag("space here").is_err());
1544 assert!(validate_tag("").is_err());
1546 }
1547
1548 #[test]
1549 fn duplicate_tags_rejected() {
1550 let tags = vec!["a".to_string(), "b".to_string(), "a".to_string()];
1551 assert!(validate_tags(&tags).is_err());
1552 }
1553
1554 #[test]
1557 fn ed25519_sig_must_be_88_chars() {
1558 assert!(validate_signature_length("ed25519", "AAAA").is_err());
1559 validate_signature_length("ed25519", &"A".repeat(88)).unwrap();
1560 validate_signature_length("future-alg", "any").unwrap();
1562 }
1563
1564 #[test]
1567 fn namespaced_context_type_pattern() {
1568 validate_namespaced_context_type("finance:portfolio_snapshot").unwrap();
1569 assert!(validate_namespaced_context_type("Finance:portfolio").is_err());
1570 assert!(validate_namespaced_context_type("finance:Portfolio").is_err());
1571 assert!(validate_namespaced_context_type("no-colon").is_err());
1572 }
1573
1574 #[test]
1578 fn acdp_version_pattern_rejects_non_semver() {
1579 validate_semver_pattern("acdp_version", "0.1.0").unwrap();
1580 validate_semver_pattern("acdp_version", "10.20.30").unwrap();
1581 assert!(validate_semver_pattern("acdp_version", "0.1.0-rc.1").is_err());
1582 assert!(validate_semver_pattern("acdp_version", "0.0").is_err());
1583 assert!(validate_semver_pattern("acdp_version", "vee.zero.zero").is_err());
1584 }
1585
1586 #[test]
1589 fn derived_from_malformed_ctx_id_rejected() {
1590 use acdp_crypto::SigningKey;
1591 use acdp_producer::Producer;
1592
1593 let p = Producer::new(
1594 SigningKey::from_bytes(&[0u8; 32]),
1595 AgentDid::new("did:web:agents.example.com:test"),
1596 "did:web:agents.example.com:test#key-1",
1597 );
1598 let err = p
1599 .publish_request()
1600 .title("t")
1601 .context_type(ContextType::DataSnapshot)
1602 .derived_from(vec![CtxId("not-a-ctx-id".into())])
1603 .build()
1604 .unwrap_err();
1605 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1606 }
1607
1608 #[test]
1615 fn embedded_content_hash_absent_root_mismatch_not_an_error() {
1616 use acdp_types::data_ref::DataRefType;
1617 let dr = DataRef {
1618 ref_type: DataRefType::PrimaryResult,
1619 description: None,
1620 size_bytes: None,
1621 format: None,
1622 schema_version: None,
1623 content_hash: Some(ContentHash("sha256:0000".into())),
1624 location: None,
1625 embedded: Some(EmbeddedContent {
1626 encoding: EmbeddedEncoding::Json,
1627 content: json!({"x": 1}),
1628 content_hash: None,
1629 }),
1630 extensions: serde_json::Map::new(),
1631 };
1632 verify_embedded_hash(&dr).unwrap();
1633 }
1634
1635 #[test]
1639 fn both_content_hashes_consistent_accepted() {
1640 use acdp_types::data_ref::DataRefType;
1641 let emb = embedded_json(json!({"a": 1, "b": 2}));
1642 let hash = compute_embedded_hash(&emb).unwrap();
1643 let dr = DataRef {
1644 ref_type: DataRefType::PrimaryResult,
1645 description: None,
1646 size_bytes: None,
1647 format: None,
1648 schema_version: None,
1649 content_hash: Some(hash.clone()),
1650 location: None,
1651 embedded: Some(embedded_json_with_hash(json!({"a": 1, "b": 2}), hash)),
1652 extensions: serde_json::Map::new(),
1653 };
1654 verify_embedded_hash(&dr).unwrap();
1655 }
1656
1657 #[test]
1669 fn root_content_hash_disagreement_accepted_when_embedded_correct() {
1670 use acdp_types::data_ref::DataRefType;
1671 let emb = embedded_json(json!({"a": 1, "b": 2}));
1672 let correct_hash = compute_embedded_hash(&emb).unwrap();
1673 let wrong_hash = ContentHash(
1674 "sha256:0000000000000000000000000000000000000000000000000000000000000000".into(),
1675 );
1676 let dr = DataRef {
1677 ref_type: DataRefType::PrimaryResult,
1678 description: None,
1679 size_bytes: None,
1680 format: None,
1681 schema_version: None,
1682 content_hash: Some(wrong_hash),
1683 location: None,
1684 embedded: Some(embedded_json_with_hash(
1685 json!({"a": 1, "b": 2}),
1686 correct_hash,
1687 )),
1688 extensions: serde_json::Map::new(),
1689 };
1690 verify_embedded_hash(&dr).unwrap();
1691 }
1692
1693 #[test]
1695 fn audience_uniqueness_rejected() {
1696 let dup = vec![
1697 AgentDid::new("did:web:a.example.com"),
1698 AgentDid::new("did:web:a.example.com"),
1699 ];
1700 let err = validate_unique_array("audience", &dup, MAX_AUDIENCE).unwrap_err();
1701 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1702 }
1703
1704 #[test]
1707 fn extensions_empty_ok() {
1708 validate_extensions(&serde_json::Map::new()).unwrap();
1709 }
1710
1711 #[test]
1712 fn extensions_small_forward_compat_accepted() {
1713 let mut ext = serde_json::Map::new();
1715 ext.insert("priority".into(), json!("high"));
1716 ext.insert("custom".into(), json!({"k": [1, 2, 3]}));
1717 validate_extensions(&ext).unwrap();
1718 }
1719
1720 #[test]
1721 fn extensions_too_many_properties_rejected() {
1722 let mut ext = serde_json::Map::new();
1723 for i in 0..(MAX_METADATA_PROPERTIES + 1) {
1724 ext.insert(format!("k{i}"), json!(i));
1725 }
1726 let err = validate_extensions(&ext).unwrap_err();
1727 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1728 }
1729
1730 #[test]
1731 fn extensions_oversized_jcs_rejected() {
1732 let mut ext = serde_json::Map::new();
1733 ext.insert("blob".into(), json!("x".repeat(MAX_METADATA_JCS_BYTES + 1)));
1734 let err = validate_extensions(&ext).unwrap_err();
1735 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1736 }
1737
1738 #[test]
1739 fn extensions_too_deep_rejected() {
1740 let mut v = json!(0);
1742 for _ in 0..(MAX_METADATA_DEPTH + 2) {
1743 v = json!({ "n": v });
1744 }
1745 let mut ext = serde_json::Map::new();
1746 ext.insert("deep".into(), v);
1747 let err = validate_extensions(&ext).unwrap_err();
1748 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1749 }
1750
1751 #[test]
1752 fn json_depth_clamps_past_scan_budget() {
1753 let mut v = json!(0);
1758 for _ in 0..400 {
1759 v = json!([v]);
1760 }
1761 assert!(json_depth(&v) > MAX_METADATA_DEPTH);
1762 assert!(acdp_crypto::try_canonicalize_value(&v).is_err());
1763 }
1764}
1765
1766#[cfg(test)]
1767mod capabilities_0_3_0_tests {
1768 use super::*;
1769 use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1770
1771 fn caps(version: &str, supports_idem: bool, mppm: Option<u64>) -> CapabilitiesDocument {
1772 CapabilitiesDocument {
1773 acdp_version: version.into(),
1774 registry_did: "did:web:registry.example.com".into(),
1775 supported_signature_algorithms: vec!["ed25519".into()],
1776 supported_did_methods: vec!["did:web".into()],
1777 profiles: vec!["acdp-registry-core".into()],
1778 limits: Limits {
1779 max_payload_bytes: 1_048_576,
1780 max_embedded_bytes: 65_536,
1781 idempotency_key_ttl_seconds: if supports_idem { Some(86_400) } else { None },
1782 max_publish_per_minute: mppm,
1783 change_retention_seconds: None,
1784 max_change_filters: None,
1785 max_changes_page: None,
1786 max_subscriptions_per_requester: None,
1787 },
1788 read_authentication_methods: vec![],
1789 anonymous_public_reads: false,
1790 supports_idempotency_key: supports_idem,
1791 extensions: Default::default(),
1792 }
1793 }
1794
1795 #[test]
1798 fn max_publish_per_minute_bounds() {
1799 assert!(validate_capabilities(&caps("0.1.0", false, Some(600))).is_ok());
1800 let err = validate_capabilities(&caps("0.1.0", false, Some(0)))
1801 .expect_err("zero MUST be rejected");
1802 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1803 }
1804
1805 #[test]
1810 fn idempotency_required_at_0_3_0() {
1811 assert!(validate_capabilities(&caps("0.1.0", false, None)).is_ok());
1812 assert!(validate_capabilities(&caps("0.2.0", false, None)).is_ok());
1813 assert!(validate_capabilities(&caps("0.3.0", true, None)).is_ok());
1814 assert!(validate_capabilities(&caps("0.4.0", true, None)).is_ok());
1815 for v in ["0.3.0", "0.4.0", "1.0.0"] {
1816 let err = validate_capabilities(&caps(v, false, None))
1817 .expect_err("version >= 0.3.0 without idempotency MUST be rejected");
1818 assert!(matches!(err, AcdpError::SchemaViolation(_)), "{v}: {err:?}");
1819 }
1820 }
1821}