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(ch) = &dr.content_hash {
450 ContentHash::parse(ch.as_str())?;
451 }
452
453 if let Some(desc) = &dr.description {
454 if desc.len() > MAX_DATA_REF_DESCRIPTION_LEN {
455 return Err(AcdpError::SchemaViolation(format!(
456 "DataRef.description {} chars exceeds {} limit",
457 desc.len(),
458 MAX_DATA_REF_DESCRIPTION_LEN
459 )));
460 }
461 }
462
463 if let Some(loc) = &dr.location {
464 validate_location(loc)?;
465 }
466 if let Some(emb) = &dr.embedded {
467 validate_embedded(emb)?;
468 }
469
470 Ok(())
471}
472
473fn validate_location(loc: &Location) -> Result<(), AcdpError> {
474 match loc {
475 Location::Uri(uri) => validate_uri_location(uri),
476 Location::Structured(map) => validate_structured_locator(map),
477 }
478}
479
480fn validate_uri_location(uri: &str) -> Result<(), AcdpError> {
481 if uri.len() < 3 || uri.len() > MAX_URI_LEN {
482 return Err(AcdpError::SchemaViolation(format!(
483 "DataRef.location URI length {} not in 3..={}",
484 uri.len(),
485 MAX_URI_LEN
486 )));
487 }
488 let (scheme, rest) = uri
490 .split_once(':')
491 .ok_or_else(|| AcdpError::SchemaViolation(format!("URI missing scheme: {uri}")))?;
492 if scheme.is_empty()
493 || !scheme
494 .chars()
495 .next()
496 .is_some_and(|c| c.is_ascii_lowercase())
497 || !scheme
498 .chars()
499 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '.' | '-'))
500 {
501 return Err(AcdpError::SchemaViolation(format!(
502 "URI scheme '{scheme}' invalid; must match [a-z][a-z0-9+.-]*"
503 )));
504 }
505 if let Some(after_slashes) = rest.strip_prefix("//") {
507 if let Some(authority_end) = after_slashes.find(['/', '?', '#']) {
508 let authority = &after_slashes[..authority_end];
509 if authority.contains('@') {
510 return Err(AcdpError::SchemaViolation(format!(
511 "URI MUST NOT contain credentials in userinfo: {uri}"
512 )));
513 }
514 } else if after_slashes.contains('@') {
515 return Err(AcdpError::SchemaViolation(format!(
516 "URI MUST NOT contain credentials in userinfo: {uri}"
517 )));
518 }
519 }
520 Ok(())
521}
522
523fn validate_structured_locator(
524 map: &serde_json::Map<String, serde_json::Value>,
525) -> Result<(), AcdpError> {
526 let scheme = map.get("scheme").and_then(|v| v.as_str()).ok_or_else(|| {
527 AcdpError::SchemaViolation("structured locator missing required 'scheme'".into())
528 })?;
529 if !is_dotted_namespace_scheme(scheme) {
530 return Err(AcdpError::SchemaViolation(format!(
531 "structured locator scheme '{scheme}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
532 )));
533 }
534 Ok(())
535}
536
537fn is_dotted_namespace_scheme(s: &str) -> bool {
538 let parts: Vec<&str> = s.split('.').collect();
539 if parts.len() < 2 {
540 return false;
541 }
542 parts.iter().all(|part| {
543 !part.is_empty()
544 && part.chars().next().is_some_and(|c| c.is_ascii_lowercase())
545 && part
546 .chars()
547 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
548 })
549}
550
551fn validate_anchors(anchors: &[AnchorEntry]) -> Result<(), AcdpError> {
556 if anchors.is_empty() {
557 return Err(AcdpError::SchemaViolation(
558 "anchors MUST be omitted entirely (never sent as an empty array) when there is \
559 nothing to anchor — the absent-when-empty convention (RFC-ACDP-0016 \u{a7}4)"
560 .into(),
561 ));
562 }
563 validate_unique_array("anchors", anchors, MAX_ANCHORS)?;
564 for anchor in anchors {
565 if !is_dotted_namespace_scheme(&anchor.scheme) {
566 return Err(AcdpError::SchemaViolation(format!(
567 "anchor scheme '{}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$ \
568 (RFC-ACDP-0016 \u{a7}4)",
569 anchor.scheme
570 )));
571 }
572 ContentHash::parse(anchor.content_hash.as_str())?;
573 }
574 Ok(())
575}
576
577fn validate_embedded(emb: &EmbeddedContent) -> Result<(), AcdpError> {
578 match emb.encoding {
580 EmbeddedEncoding::Utf8 | EmbeddedEncoding::Base64 => {
581 if !emb.content.is_string() {
582 return Err(AcdpError::SchemaViolation(format!(
583 "embedded {:?} content MUST be a JSON string",
584 emb.encoding
585 )));
586 }
587 }
588 EmbeddedEncoding::Json => {}
589 }
590 if let Some(ch) = &emb.content_hash {
595 ContentHash::parse(ch.as_str())?;
596 }
597 let decoded = embedded_decoded_bytes(emb)?;
599 if decoded.len() > MAX_EMBEDDED_BYTES {
600 return Err(AcdpError::EmbeddedTooLarge(format!(
601 "embedded decoded size {} bytes exceeds {} limit",
602 decoded.len(),
603 MAX_EMBEDDED_BYTES
604 )));
605 }
606 Ok(())
607}
608
609pub fn embedded_decoded_bytes(emb: &EmbeddedContent) -> Result<Vec<u8>, AcdpError> {
615 Ok(match emb.encoding {
616 EmbeddedEncoding::Json => try_canonicalize_value(&emb.content)?,
617 EmbeddedEncoding::Utf8 => {
618 let s = emb.content.as_str().ok_or_else(|| {
619 AcdpError::SchemaViolation("utf8 embedded content must be a JSON string".into())
620 })?;
621 s.as_bytes().to_vec()
622 }
623 EmbeddedEncoding::Base64 => {
624 let s = emb.content.as_str().ok_or_else(|| {
625 AcdpError::SchemaViolation("base64 embedded content must be a JSON string".into())
626 })?;
627 STANDARD
628 .decode(s)
629 .map_err(|e| AcdpError::SchemaViolation(format!("base64 decode failed: {e}")))?
630 }
631 })
632}
633
634pub fn compute_embedded_hash(emb: &EmbeddedContent) -> Result<ContentHash, AcdpError> {
636 let bytes = embedded_decoded_bytes(emb)?;
637 let digest = Sha256::digest(&bytes);
638 Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
639}
640
641pub fn verify_embedded_hash(dr: &DataRef) -> Result<(), AcdpError> {
669 let Some(emb) = &dr.embedded else {
670 return Ok(());
671 };
672 let Some(embedded_hash) = &emb.content_hash else {
673 return Ok(());
674 };
675 let recomputed = compute_embedded_hash(emb)?;
676 if &recomputed != embedded_hash {
677 return Err(AcdpError::DataRefHashMismatch(format!(
678 "embedded.content_hash mismatch: declared {}, computed {}",
679 embedded_hash.as_str(),
680 recomputed.as_str()
681 )));
682 }
683 Ok(())
684}
685
686pub fn validate_metadata(value: &serde_json::Value) -> Result<(), AcdpError> {
691 validate_json_object_limits(value, "metadata")
692}
693
694fn validate_json_object_limits(value: &serde_json::Value, field: &str) -> Result<(), AcdpError> {
699 let obj = value
700 .as_object()
701 .ok_or_else(|| AcdpError::SchemaViolation(format!("{field} must be a JSON object")))?;
702 if obj.len() > MAX_METADATA_PROPERTIES {
703 return Err(AcdpError::SchemaViolation(format!(
704 "{field} has {} top-level properties, exceeds {} limit",
705 obj.len(),
706 MAX_METADATA_PROPERTIES
707 )));
708 }
709 let depth = json_depth(value);
710 if depth > MAX_METADATA_DEPTH {
711 return Err(AcdpError::SchemaViolation(format!(
712 "{field} nesting depth {depth} exceeds {MAX_METADATA_DEPTH}"
713 )));
714 }
715 let canonical_size = try_canonicalize_value(value)?.len();
716 if canonical_size > MAX_METADATA_JCS_BYTES {
717 return Err(AcdpError::SchemaViolation(format!(
718 "{field} JCS-canonical size {canonical_size} bytes exceeds {MAX_METADATA_JCS_BYTES}"
719 )));
720 }
721 Ok(())
722}
723
724pub fn validate_extensions(
727 extensions: &serde_json::Map<String, serde_json::Value>,
728) -> Result<(), AcdpError> {
729 if extensions.is_empty() {
730 return Ok(());
731 }
732 let value = serde_json::Value::Object(extensions.clone());
736 validate_json_object_limits(&value, "extensions")
737}
738
739fn json_depth(v: &serde_json::Value) -> usize {
749 const MAX_JSON_DEPTH_SCAN: usize = 256;
751 fn go(v: &serde_json::Value, budget: usize) -> usize {
752 if budget == 0 {
753 return 1; }
755 match v {
756 serde_json::Value::Object(map) => {
757 1 + map.values().map(|x| go(x, budget - 1)).max().unwrap_or(0)
758 }
759 serde_json::Value::Array(arr) => {
760 1 + arr.iter().map(|x| go(x, budget - 1)).max().unwrap_or(0)
761 }
762 _ => 0,
763 }
764 }
765 go(v, MAX_JSON_DEPTH_SCAN)
766}
767
768fn validate_visibility_audience(
771 vis: &Visibility,
772 audience: Option<&[AgentDid]>,
773) -> Result<(), AcdpError> {
774 match vis {
775 Visibility::Restricted => {
776 if audience.is_none_or(|a| a.is_empty()) {
777 return Err(AcdpError::SchemaViolation(
778 "visibility:restricted requires a non-empty audience".into(),
779 ));
780 }
781 }
782 Visibility::Public => {
783 if audience.is_some_and(|a| !a.is_empty()) {
784 return Err(AcdpError::SchemaViolation(
785 "visibility:public MUST NOT include audience".into(),
786 ));
787 }
788 }
789 Visibility::Private => {}
790 }
791 Ok(())
792}
793
794fn validate_title(title: &str) -> Result<(), AcdpError> {
797 if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
798 return Err(AcdpError::SchemaViolation(format!(
799 "title length {} not in 1..={}",
800 title.chars().count(),
801 MAX_TITLE_LEN
802 )));
803 }
804 Ok(())
805}
806
807fn validate_optional_string(s: Option<&str>, name: &str, max_len: usize) -> Result<(), AcdpError> {
808 if let Some(value) = s {
809 if value.chars().count() > max_len {
810 return Err(AcdpError::SchemaViolation(format!(
811 "{name} length {} exceeds {max_len}",
812 value.chars().count()
813 )));
814 }
815 }
816 Ok(())
817}
818
819fn validate_unique_array<T: PartialEq + std::fmt::Debug>(
820 name: &str,
821 items: &[T],
822 max: usize,
823) -> Result<(), AcdpError> {
824 if items.len() > max {
825 return Err(AcdpError::SchemaViolation(format!(
826 "{name} has {} items, exceeds {max}",
827 items.len()
828 )));
829 }
830 for (i, item) in items.iter().enumerate() {
831 if items[i + 1..].iter().any(|other| other == item) {
832 return Err(AcdpError::SchemaViolation(format!(
833 "{name} contains duplicate entry: {item:?}"
834 )));
835 }
836 }
837 Ok(())
838}
839
840fn validate_tags(tags: &[String]) -> Result<(), AcdpError> {
841 if tags.len() > MAX_TAGS {
842 return Err(AcdpError::SchemaViolation(format!(
843 "tags has {} entries, exceeds {}",
844 tags.len(),
845 MAX_TAGS
846 )));
847 }
848 for tag in tags {
849 validate_tag(tag)?;
850 }
851 for (i, tag) in tags.iter().enumerate() {
853 if tags[i + 1..].iter().any(|t| t == tag) {
854 return Err(AcdpError::SchemaViolation(format!(
855 "tags contains duplicate entry: {tag}"
856 )));
857 }
858 }
859 Ok(())
860}
861
862fn validate_tag(tag: &str) -> Result<(), AcdpError> {
863 if tag.is_empty() || tag.len() > MAX_TAG_LEN {
864 return Err(AcdpError::SchemaViolation(format!(
865 "tag '{tag}' length not in 1..={MAX_TAG_LEN}"
866 )));
867 }
868 let mut chars = tag.chars();
869 let first = chars.next().unwrap();
870 if !first.is_ascii_alphanumeric() {
871 return Err(AcdpError::SchemaViolation(format!(
872 "tag '{tag}' first char must be alphanumeric"
873 )));
874 }
875 if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) {
876 return Err(AcdpError::SchemaViolation(format!(
877 "tag '{tag}' must match [A-Za-z0-9][A-Za-z0-9_.-]*"
878 )));
879 }
880 Ok(())
881}
882
883fn validate_did_key_key_id_form(key_id: &str) -> Result<(), AcdpError> {
903 if !key_id.starts_with("did:key:") {
904 return Ok(());
905 }
906 acdp_did::key::resolve_did_key_url(key_id)?;
916 Ok(())
917}
918
919fn validate_agent_did(did: &AgentDid) -> Result<(), AcdpError> {
920 if did.as_str().starts_with("did:key:") {
921 AgentDid::parse(did.as_str())?;
922 acdp_did::key::resolve_did_key(did.as_str())?;
926 return Ok(());
927 }
928 AgentDid::parse_web(did.as_str())?;
929 Ok(())
930}
931
932fn validate_origin_registry(s: &str) -> Result<(), AcdpError> {
940 if s.is_empty() {
941 return Err(AcdpError::SchemaViolation(
942 "origin_registry must be a non-empty DNS hostname".into(),
943 ));
944 }
945 if s.starts_with("did:") {
946 return Err(AcdpError::SchemaViolation(format!(
947 "origin_registry must be a DNS hostname, not a DID URI (got '{s}'); \
948 use the bare authority — capabilities.registry_did carries the did:web form"
949 )));
950 }
951 if s.contains("://") {
952 return Err(AcdpError::SchemaViolation(format!(
953 "origin_registry must be a DNS hostname, not a URL (got '{s}')"
954 )));
955 }
956 if s.ends_with('.') || s.starts_with('.') {
957 return Err(AcdpError::SchemaViolation(format!(
958 "origin_registry must be a syntactically valid DNS hostname (got '{s}')"
959 )));
960 }
961 if !acdp_types::primitives::is_valid_dns_authority(s) {
967 return Err(AcdpError::SchemaViolation(format!(
968 "origin_registry '{s}' is not a valid DNS hostname (must be lowercase \
969 labels of [a-z0-9-] separated by dots, e.g. 'registry.example.com')"
970 )));
971 }
972 Ok(())
973}
974
975fn validate_loose_did(did: &AgentDid) -> Result<(), AcdpError> {
985 AgentDid::parse(did.as_str())?;
986 Ok(())
987}
988
989fn validate_namespaced_context_type(value: &str) -> Result<(), AcdpError> {
992 let (ns, name) = value.split_once(':').ok_or_else(|| {
994 AcdpError::SchemaViolation(format!(
995 "context_type '{value}' missing namespace separator"
996 ))
997 })?;
998 if ns.is_empty()
999 || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1000 || !ns
1001 .chars()
1002 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
1003 {
1004 return Err(AcdpError::SchemaViolation(format!(
1005 "context_type namespace '{ns}' must match [a-z][a-z0-9_]*"
1006 )));
1007 }
1008 if name.is_empty()
1009 || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1010 || !name
1011 .chars()
1012 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
1013 {
1014 return Err(AcdpError::SchemaViolation(format!(
1015 "context_type name '{name}' must match [a-z][a-z0-9_-]*"
1016 )));
1017 }
1018 Ok(())
1019}
1020
1021trait ContextTypeExt {
1022 fn namespaced_form(&self) -> Option<&str>;
1023}
1024
1025impl ContextTypeExt for ContextType {
1026 fn namespaced_form(&self) -> Option<&str> {
1027 match self {
1028 ContextType::Custom(s) => Some(s.as_str()),
1029 _ => None,
1030 }
1031 }
1032}
1033
1034fn validate_semver_pattern(name: &str, value: &str) -> Result<(), AcdpError> {
1037 let parts: Vec<&str> = value.split('.').collect();
1038 let ok = parts.len() == 3
1039 && parts
1040 .iter()
1041 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
1042 if !ok {
1043 return Err(AcdpError::SchemaViolation(format!(
1044 "{name} '{value}' must match the semver pattern ^\\d+\\.\\d+\\.\\d+$"
1045 )));
1046 }
1047 Ok(())
1048}
1049
1050fn validate_signature_length(algorithm: &str, value_b64: &str) -> Result<(), AcdpError> {
1051 let expected = match algorithm {
1052 "ed25519" => Some(ED25519_SIG_B64_LEN),
1053 "ecdsa-p256" => Some(ECDSA_P256_SIG_B64_LEN),
1054 _ => None,
1055 };
1056 if let Some(n) = expected {
1057 if value_b64.len() != n {
1058 return Err(AcdpError::InvalidSignature(format!(
1059 "signature.value for '{algorithm}' must be {n} base64 chars, got {}",
1060 value_b64.len()
1061 )));
1062 }
1063 }
1064 Ok(())
1065}
1066
1067#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use acdp_types::data_ref::DataRefType;
1073 use serde_json::json;
1074
1075 fn embedded_json(v: serde_json::Value) -> EmbeddedContent {
1076 EmbeddedContent {
1077 encoding: EmbeddedEncoding::Json,
1078 content: v,
1079 content_hash: None,
1080 }
1081 }
1082
1083 fn embedded_json_with_hash(v: serde_json::Value, hash: ContentHash) -> EmbeddedContent {
1084 EmbeddedContent {
1085 encoding: EmbeddedEncoding::Json,
1086 content: v,
1087 content_hash: Some(hash),
1088 }
1089 }
1090
1091 #[test]
1094 fn origin_registry_accepts_valid_hostname() {
1095 validate_origin_registry("registry.example.com").unwrap();
1096 validate_origin_registry("reg.example").unwrap();
1097 validate_origin_registry("a-b-c.io").unwrap();
1098 }
1099
1100 #[test]
1101 fn origin_registry_rejects_uppercase() {
1102 assert!(matches!(
1103 validate_origin_registry("REGISTRY.EXAMPLE.COM"),
1104 Err(AcdpError::SchemaViolation(_))
1105 ));
1106 }
1107
1108 #[test]
1109 fn origin_registry_rejects_underscore() {
1110 assert!(matches!(
1111 validate_origin_registry("registry_example.com"),
1112 Err(AcdpError::SchemaViolation(_))
1113 ));
1114 }
1115
1116 #[test]
1117 fn origin_registry_rejects_hyphen_label_edges() {
1118 assert!(matches!(
1119 validate_origin_registry("registry-.com"),
1120 Err(AcdpError::SchemaViolation(_))
1121 ));
1122 assert!(matches!(
1123 validate_origin_registry("-registry.example.com"),
1124 Err(AcdpError::SchemaViolation(_))
1125 ));
1126 }
1127
1128 #[test]
1131 fn data_ref_neither_location_nor_embedded_rejected() {
1132 let dr = DataRef {
1133 ref_type: DataRefType::PrimaryResult,
1134 description: None,
1135 size_bytes: None,
1136 format: None,
1137 schema_version: None,
1138 content_hash: None,
1139 location: None,
1140 embedded: None,
1141 extensions: serde_json::Map::new(),
1142 };
1143 assert!(matches!(
1144 validate_data_ref(&dr),
1145 Err(AcdpError::SchemaViolation(_))
1146 ));
1147 }
1148
1149 #[test]
1150 fn data_ref_both_location_and_embedded_rejected() {
1151 let dr = DataRef {
1152 ref_type: DataRefType::PrimaryResult,
1153 description: None,
1154 size_bytes: None,
1155 format: None,
1156 schema_version: None,
1157 content_hash: None,
1158 location: Some(Location::Uri("https://x/y".into())),
1159 embedded: Some(embedded_json(json!({"a": 1}))),
1160 extensions: serde_json::Map::new(),
1161 };
1162 assert!(matches!(
1163 validate_data_ref(&dr),
1164 Err(AcdpError::SchemaViolation(_))
1165 ));
1166 }
1167
1168 #[test]
1169 fn data_ref_malformed_root_content_hash_rejected() {
1170 let dr = DataRef {
1171 ref_type: DataRefType::PrimaryResult,
1172 description: None,
1173 size_bytes: None,
1174 format: None,
1175 schema_version: None,
1176 content_hash: Some(ContentHash("not-a-content-hash".into())),
1177 location: Some(Location::Uri("https://x/y".into())),
1178 embedded: None,
1179 extensions: serde_json::Map::new(),
1180 };
1181 let err = validate_data_ref_structural(&dr)
1182 .expect_err("a malformed root content_hash must be caught structurally");
1183 assert!(
1184 matches!(err, AcdpError::SchemaViolation(ref msg) if msg.contains("content_hash")),
1185 "expected a SchemaViolation naming content_hash, got {err:?}"
1186 );
1187 }
1188
1189 #[test]
1190 fn data_ref_malformed_embedded_content_hash_rejected() {
1191 let dr = DataRef {
1192 ref_type: DataRefType::PrimaryResult,
1193 description: None,
1194 size_bytes: None,
1195 format: None,
1196 schema_version: None,
1197 content_hash: None,
1198 location: None,
1199 embedded: Some(EmbeddedContent {
1200 encoding: EmbeddedEncoding::Utf8,
1201 content: serde_json::Value::String("hello".into()),
1202 content_hash: Some(ContentHash("sha256:not-hex".into())),
1203 }),
1204 extensions: serde_json::Map::new(),
1205 };
1206 let err = validate_data_ref_structural(&dr)
1207 .expect_err("a malformed embedded content_hash must be caught structurally");
1208 assert!(
1209 matches!(err, AcdpError::SchemaViolation(ref msg) if msg.contains("content_hash")),
1210 "expected a SchemaViolation naming content_hash, got {err:?}"
1211 );
1212 }
1213
1214 #[test]
1217 fn uri_credentials_rejected() {
1218 let dr = DataRef::uri(DataRefType::RawData, "https://user:pass@example.com/data");
1219 assert!(matches!(
1220 validate_data_ref(&dr),
1221 Err(AcdpError::SchemaViolation(_))
1222 ));
1223 }
1224
1225 #[test]
1226 fn uri_without_scheme_rejected() {
1227 let dr = DataRef::uri(DataRefType::RawData, "no-scheme");
1228 assert!(matches!(
1229 validate_data_ref(&dr),
1230 Err(AcdpError::SchemaViolation(_))
1231 ));
1232 }
1233
1234 #[test]
1235 fn uri_too_long_rejected() {
1236 let long_uri = format!("https://x.com/{}", "a".repeat(MAX_URI_LEN));
1237 let dr = DataRef::uri(DataRefType::RawData, long_uri);
1238 assert!(matches!(
1239 validate_data_ref(&dr),
1240 Err(AcdpError::SchemaViolation(_))
1241 ));
1242 }
1243
1244 #[test]
1247 fn structured_locator_missing_scheme_rejected() {
1248 let mut map = serde_json::Map::new();
1249 map.insert("offset".into(), json!(42));
1250 let dr = DataRef {
1251 ref_type: DataRefType::RawData,
1252 description: None,
1253 size_bytes: None,
1254 format: None,
1255 schema_version: None,
1256 content_hash: None,
1257 location: Some(Location::Structured(map)),
1258 embedded: None,
1259 extensions: serde_json::Map::new(),
1260 };
1261 assert!(matches!(
1262 validate_data_ref(&dr),
1263 Err(AcdpError::SchemaViolation(_))
1264 ));
1265 }
1266
1267 #[test]
1268 fn structured_locator_bad_scheme_rejected() {
1269 let err =
1273 DataRef::try_structured(DataRefType::RawData, "not_dotted", serde_json::Map::new())
1274 .unwrap_err();
1275 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1276
1277 let mut bad = serde_json::Map::new();
1280 bad.insert(
1281 "scheme".into(),
1282 serde_json::Value::String("not_dotted".into()),
1283 );
1284 let dr = DataRef {
1285 ref_type: DataRefType::RawData,
1286 description: None,
1287 size_bytes: None,
1288 format: None,
1289 schema_version: None,
1290 content_hash: None,
1291 location: Some(Location::Structured(bad)),
1292 embedded: None,
1293 extensions: serde_json::Map::new(),
1294 };
1295 assert!(matches!(
1296 validate_data_ref(&dr),
1297 Err(AcdpError::SchemaViolation(_))
1298 ));
1299 }
1300
1301 #[test]
1302 fn structured_locator_valid() {
1303 let mut extra = serde_json::Map::new();
1304 extra.insert("topic".into(), json!("events"));
1305 let dr = DataRef::structured(DataRefType::RawData, "kafka.offset", extra);
1306 validate_data_ref(&dr).unwrap();
1307 }
1308
1309 #[test]
1312 fn embedded_utf8_must_be_string() {
1313 let dr = DataRef {
1314 ref_type: DataRefType::PrimaryResult,
1315 description: None,
1316 size_bytes: None,
1317 format: None,
1318 schema_version: None,
1319 content_hash: None,
1320 location: None,
1321 embedded: Some(EmbeddedContent {
1322 encoding: EmbeddedEncoding::Utf8,
1323 content: json!(42),
1324 content_hash: None,
1325 }),
1326 extensions: serde_json::Map::new(),
1327 };
1328 assert!(matches!(
1329 validate_data_ref(&dr),
1330 Err(AcdpError::SchemaViolation(_))
1331 ));
1332 }
1333
1334 #[test]
1335 fn embedded_too_large_rejected() {
1336 let big = "a".repeat(70 * 1024);
1338 let dr = DataRef::embedded_utf8(DataRefType::PrimaryResult, big);
1339 assert!(matches!(
1340 validate_data_ref(&dr),
1341 Err(AcdpError::EmbeddedTooLarge(_))
1342 ));
1343 }
1344
1345 #[test]
1348 fn embedded_hash_json_round_trip() {
1349 let emb = embedded_json(json!({"b": 2, "a": 1}));
1350 let h = compute_embedded_hash(&emb).unwrap();
1351 let expected = {
1353 let bytes = b"{\"a\":1,\"b\":2}";
1354 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
1355 };
1356 assert_eq!(h.as_str(), expected);
1357 }
1358
1359 #[test]
1360 fn embedded_hash_utf8() {
1361 let emb = EmbeddedContent {
1362 encoding: EmbeddedEncoding::Utf8,
1363 content: json!("hello"),
1364 content_hash: None,
1365 };
1366 let h = compute_embedded_hash(&emb).unwrap();
1367 let expected = format!("sha256:{}", hex::encode(Sha256::digest(b"hello")));
1368 assert_eq!(h.as_str(), expected);
1369 }
1370
1371 #[test]
1372 fn embedded_hash_base64() {
1373 let raw = b"binary data";
1374 let b64 = STANDARD.encode(raw);
1375 let emb = EmbeddedContent {
1376 encoding: EmbeddedEncoding::Base64,
1377 content: json!(b64),
1378 content_hash: None,
1379 };
1380 let h = compute_embedded_hash(&emb).unwrap();
1381 let expected = format!("sha256:{}", hex::encode(Sha256::digest(raw)));
1382 assert_eq!(h.as_str(), expected);
1383 }
1384
1385 #[test]
1392 fn verify_embedded_hash_ignores_root_only_content_hash() {
1393 let emb = embedded_json(json!({"x": 1}));
1394 let dr = DataRef {
1395 ref_type: DataRefType::PrimaryResult,
1396 description: None,
1397 size_bytes: None,
1398 format: None,
1399 schema_version: None,
1400 content_hash: Some(ContentHash("sha256:0000".into())),
1401 location: None,
1402 embedded: Some(emb),
1403 extensions: serde_json::Map::new(),
1404 };
1405 verify_embedded_hash(&dr).unwrap();
1406 }
1407
1408 #[test]
1411 fn metadata_too_many_properties_rejected() {
1412 let mut obj = serde_json::Map::new();
1413 for i in 0..101 {
1414 obj.insert(format!("k{i}"), json!(i));
1415 }
1416 assert!(matches!(
1417 validate_metadata(&serde_json::Value::Object(obj)),
1418 Err(AcdpError::SchemaViolation(_))
1419 ));
1420 }
1421
1422 #[test]
1423 fn metadata_too_deep_rejected() {
1424 let mut v = json!("leaf");
1426 for _ in 0..10 {
1427 let mut o = serde_json::Map::new();
1428 o.insert("a".into(), v);
1429 v = serde_json::Value::Object(o);
1430 }
1431 assert!(matches!(
1432 validate_metadata(&v),
1433 Err(AcdpError::SchemaViolation(_))
1434 ));
1435 }
1436
1437 #[test]
1438 fn metadata_too_large_rejected() {
1439 let big = "a".repeat(70 * 1024);
1440 let v = json!({"big": big});
1441 assert!(matches!(
1442 validate_metadata(&v),
1443 Err(AcdpError::SchemaViolation(_))
1444 ));
1445 }
1446
1447 #[test]
1448 fn metadata_must_be_object() {
1449 assert!(matches!(
1450 validate_metadata(&json!([1, 2, 3])),
1451 Err(AcdpError::SchemaViolation(_))
1452 ));
1453 }
1454
1455 #[test]
1458 fn public_with_audience_rejected() {
1459 let aud = vec![AgentDid::new("did:web:x")];
1460 assert!(matches!(
1461 validate_visibility_audience(&Visibility::Public, Some(&aud)),
1462 Err(AcdpError::SchemaViolation(_))
1463 ));
1464 }
1465
1466 #[test]
1467 fn public_with_empty_audience_ok() {
1468 validate_visibility_audience(&Visibility::Public, Some(&[])).unwrap();
1469 validate_visibility_audience(&Visibility::Public, None).unwrap();
1470 }
1471
1472 #[test]
1473 fn restricted_without_audience_rejected() {
1474 assert!(matches!(
1475 validate_visibility_audience(&Visibility::Restricted, None),
1476 Err(AcdpError::SchemaViolation(_))
1477 ));
1478 }
1479
1480 #[test]
1483 fn data_period_start_after_end_rejected_via_builder() {
1484 use acdp_crypto::SigningKey;
1485 use acdp_producer::Producer;
1486 use acdp_types::body::DataPeriod;
1487 use chrono::TimeZone;
1488
1489 let p = Producer::new(
1490 SigningKey::from_bytes(&[0u8; 32]),
1491 AgentDid::new("did:web:agents.example.com:test"),
1492 "did:web:agents.example.com:test#key-1",
1493 );
1494 let err = p
1495 .publish_request()
1496 .title("t")
1497 .context_type(ContextType::DataSnapshot)
1498 .data_period(DataPeriod {
1499 start: chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
1500 end: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1501 })
1502 .build()
1503 .unwrap_err();
1504 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1505 }
1506
1507 #[test]
1510 fn tag_pattern_validation() {
1511 validate_tag("hello").unwrap();
1512 validate_tag("Q1-2026").unwrap();
1513 validate_tag("a_b.c").unwrap();
1514 assert!(validate_tag("-bad").is_err());
1516 assert!(validate_tag("space here").is_err());
1518 assert!(validate_tag("").is_err());
1520 }
1521
1522 #[test]
1523 fn duplicate_tags_rejected() {
1524 let tags = vec!["a".to_string(), "b".to_string(), "a".to_string()];
1525 assert!(validate_tags(&tags).is_err());
1526 }
1527
1528 #[test]
1531 fn ed25519_sig_must_be_88_chars() {
1532 assert!(validate_signature_length("ed25519", "AAAA").is_err());
1533 validate_signature_length("ed25519", &"A".repeat(88)).unwrap();
1534 validate_signature_length("future-alg", "any").unwrap();
1536 }
1537
1538 #[test]
1541 fn namespaced_context_type_pattern() {
1542 validate_namespaced_context_type("finance:portfolio_snapshot").unwrap();
1543 assert!(validate_namespaced_context_type("Finance:portfolio").is_err());
1544 assert!(validate_namespaced_context_type("finance:Portfolio").is_err());
1545 assert!(validate_namespaced_context_type("no-colon").is_err());
1546 }
1547
1548 #[test]
1552 fn acdp_version_pattern_rejects_non_semver() {
1553 validate_semver_pattern("acdp_version", "0.1.0").unwrap();
1554 validate_semver_pattern("acdp_version", "10.20.30").unwrap();
1555 assert!(validate_semver_pattern("acdp_version", "0.1.0-rc.1").is_err());
1556 assert!(validate_semver_pattern("acdp_version", "0.0").is_err());
1557 assert!(validate_semver_pattern("acdp_version", "vee.zero.zero").is_err());
1558 }
1559
1560 #[test]
1563 fn derived_from_malformed_ctx_id_rejected() {
1564 use acdp_crypto::SigningKey;
1565 use acdp_producer::Producer;
1566
1567 let p = Producer::new(
1568 SigningKey::from_bytes(&[0u8; 32]),
1569 AgentDid::new("did:web:agents.example.com:test"),
1570 "did:web:agents.example.com:test#key-1",
1571 );
1572 let err = p
1573 .publish_request()
1574 .title("t")
1575 .context_type(ContextType::DataSnapshot)
1576 .derived_from(vec![CtxId("not-a-ctx-id".into())])
1577 .build()
1578 .unwrap_err();
1579 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1580 }
1581
1582 #[test]
1589 fn embedded_content_hash_absent_root_mismatch_not_an_error() {
1590 use acdp_types::data_ref::DataRefType;
1591 let dr = DataRef {
1592 ref_type: DataRefType::PrimaryResult,
1593 description: None,
1594 size_bytes: None,
1595 format: None,
1596 schema_version: None,
1597 content_hash: Some(ContentHash("sha256:0000".into())),
1598 location: None,
1599 embedded: Some(EmbeddedContent {
1600 encoding: EmbeddedEncoding::Json,
1601 content: json!({"x": 1}),
1602 content_hash: None,
1603 }),
1604 extensions: serde_json::Map::new(),
1605 };
1606 verify_embedded_hash(&dr).unwrap();
1607 }
1608
1609 #[test]
1613 fn both_content_hashes_consistent_accepted() {
1614 use acdp_types::data_ref::DataRefType;
1615 let emb = embedded_json(json!({"a": 1, "b": 2}));
1616 let hash = compute_embedded_hash(&emb).unwrap();
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(hash.clone()),
1624 location: None,
1625 embedded: Some(embedded_json_with_hash(json!({"a": 1, "b": 2}), hash)),
1626 extensions: serde_json::Map::new(),
1627 };
1628 verify_embedded_hash(&dr).unwrap();
1629 }
1630
1631 #[test]
1643 fn root_content_hash_disagreement_accepted_when_embedded_correct() {
1644 use acdp_types::data_ref::DataRefType;
1645 let emb = embedded_json(json!({"a": 1, "b": 2}));
1646 let correct_hash = compute_embedded_hash(&emb).unwrap();
1647 let wrong_hash = ContentHash(
1648 "sha256:0000000000000000000000000000000000000000000000000000000000000000".into(),
1649 );
1650 let dr = DataRef {
1651 ref_type: DataRefType::PrimaryResult,
1652 description: None,
1653 size_bytes: None,
1654 format: None,
1655 schema_version: None,
1656 content_hash: Some(wrong_hash),
1657 location: None,
1658 embedded: Some(embedded_json_with_hash(
1659 json!({"a": 1, "b": 2}),
1660 correct_hash,
1661 )),
1662 extensions: serde_json::Map::new(),
1663 };
1664 verify_embedded_hash(&dr).unwrap();
1665 }
1666
1667 #[test]
1669 fn audience_uniqueness_rejected() {
1670 let dup = vec![
1671 AgentDid::new("did:web:a.example.com"),
1672 AgentDid::new("did:web:a.example.com"),
1673 ];
1674 let err = validate_unique_array("audience", &dup, MAX_AUDIENCE).unwrap_err();
1675 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1676 }
1677
1678 #[test]
1681 fn extensions_empty_ok() {
1682 validate_extensions(&serde_json::Map::new()).unwrap();
1683 }
1684
1685 #[test]
1686 fn extensions_small_forward_compat_accepted() {
1687 let mut ext = serde_json::Map::new();
1689 ext.insert("priority".into(), json!("high"));
1690 ext.insert("custom".into(), json!({"k": [1, 2, 3]}));
1691 validate_extensions(&ext).unwrap();
1692 }
1693
1694 #[test]
1695 fn extensions_too_many_properties_rejected() {
1696 let mut ext = serde_json::Map::new();
1697 for i in 0..(MAX_METADATA_PROPERTIES + 1) {
1698 ext.insert(format!("k{i}"), json!(i));
1699 }
1700 let err = validate_extensions(&ext).unwrap_err();
1701 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1702 }
1703
1704 #[test]
1705 fn extensions_oversized_jcs_rejected() {
1706 let mut ext = serde_json::Map::new();
1707 ext.insert("blob".into(), json!("x".repeat(MAX_METADATA_JCS_BYTES + 1)));
1708 let err = validate_extensions(&ext).unwrap_err();
1709 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1710 }
1711
1712 #[test]
1713 fn extensions_too_deep_rejected() {
1714 let mut v = json!(0);
1716 for _ in 0..(MAX_METADATA_DEPTH + 2) {
1717 v = json!({ "n": v });
1718 }
1719 let mut ext = serde_json::Map::new();
1720 ext.insert("deep".into(), v);
1721 let err = validate_extensions(&ext).unwrap_err();
1722 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1723 }
1724
1725 #[test]
1726 fn json_depth_clamps_past_scan_budget() {
1727 let mut v = json!(0);
1732 for _ in 0..400 {
1733 v = json!([v]);
1734 }
1735 assert!(json_depth(&v) > MAX_METADATA_DEPTH);
1736 assert!(acdp_crypto::try_canonicalize_value(&v).is_err());
1737 }
1738}
1739
1740#[cfg(test)]
1741mod capabilities_0_3_0_tests {
1742 use super::*;
1743 use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1744
1745 fn caps(version: &str, supports_idem: bool, mppm: Option<u64>) -> CapabilitiesDocument {
1746 CapabilitiesDocument {
1747 acdp_version: version.into(),
1748 registry_did: "did:web:registry.example.com".into(),
1749 supported_signature_algorithms: vec!["ed25519".into()],
1750 supported_did_methods: vec!["did:web".into()],
1751 profiles: vec!["acdp-registry-core".into()],
1752 limits: Limits {
1753 max_payload_bytes: 1_048_576,
1754 max_embedded_bytes: 65_536,
1755 idempotency_key_ttl_seconds: if supports_idem { Some(86_400) } else { None },
1756 max_publish_per_minute: mppm,
1757 },
1758 read_authentication_methods: vec![],
1759 anonymous_public_reads: false,
1760 supports_idempotency_key: supports_idem,
1761 extensions: Default::default(),
1762 }
1763 }
1764
1765 #[test]
1768 fn max_publish_per_minute_bounds() {
1769 assert!(validate_capabilities(&caps("0.1.0", false, Some(600))).is_ok());
1770 let err = validate_capabilities(&caps("0.1.0", false, Some(0)))
1771 .expect_err("zero MUST be rejected");
1772 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1773 }
1774
1775 #[test]
1780 fn idempotency_required_at_0_3_0() {
1781 assert!(validate_capabilities(&caps("0.1.0", false, None)).is_ok());
1782 assert!(validate_capabilities(&caps("0.2.0", false, None)).is_ok());
1783 assert!(validate_capabilities(&caps("0.3.0", true, None)).is_ok());
1784 assert!(validate_capabilities(&caps("0.4.0", true, None)).is_ok());
1785 for v in ["0.3.0", "0.4.0", "1.0.0"] {
1786 let err = validate_capabilities(&caps(v, false, None))
1787 .expect_err("version >= 0.3.0 without idempotency MUST be rejected");
1788 assert!(matches!(err, AcdpError::SchemaViolation(_)), "{v}: {err:?}");
1789 }
1790 }
1791}