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