Skip to main content

acdp_validation/
lib.rs

1//! Runtime validation against the ACDP schemas.
2//!
3//! The JSON schemas are the single source of truth for wire-shape constraints,
4//! but JSON Schema cannot express every invariant in the ACDP RFCs. This
5//! module implements the runtime checks the schema delegates to producers
6//! and registries:
7//!
8//! - String length / array uniqueness / array size limits
9//! - `data_period.start <= end`
10//! - `DataRef` oneOf (location XOR embedded), URI credential rejection,
11//!   structured-locator scheme pattern, embedded size cap, embedded
12//!   `content` typing per encoding
13//! - `metadata` runtime depth / JCS-size / property-count caps
14//! - `agent_id` DID pattern + `did:web` enforcement (v0.1.0)
15//! - Signature value length per algorithm
16//! - Embedded `content_hash` computation and verification
17//! - Identifier pattern validation (`ctx_id`, `lineage_id`, `content_hash`)
18//!
19//! Each function is independently usable; [`validate_publish_request`] and
20//! [`validate_body`] aggregate everything for end-to-end validation.
21
22use 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
34// ── Constants from the schemas ────────────────────────────────────────────────
35
36const 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
55// ── Capabilities ─────────────────────────────────────────────────────────────
56
57/// True when a `major.minor.patch` version string is >= the given
58/// major.minor (version-conditional capability checks; malformed
59/// versions are rejected by `validate_semver_pattern` first).
60fn 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
68/// Validate a [`acdp_types::CapabilitiesDocument`] against the
69/// runtime constraints listed in RFC-ACDP-0007 §3.
70///
71/// The JSON schema enforces *types*; this validator enforces the
72/// constraints the schema cannot express:
73///
74/// 1. `acdp_version` matches `^\d+\.\d+\.\d+$`.
75/// 2. `registry_did` is a v0.1.0 `did:web` DID.
76/// 3. `supported_signature_algorithms` MUST contain `"ed25519"`.
77/// 4. `supported_did_methods` MUST contain `"did:web"`.
78/// 5. `profiles` MUST contain `"acdp-registry-core"`.
79/// 6. `limits.max_embedded_bytes` MUST equal exactly 65536.
80/// 7. If `supports_idempotency_key` is `true`,
81///    `limits.idempotency_key_ttl_seconds` MUST be present and in
82///    `86400..=604800`.
83/// 8. `limits.max_payload_bytes` MUST be at least 1024 bytes.
84///
85/// Wired into `acdp::client::RegistryClient::capabilities` and
86/// `acdp::client::CrossRegistryResolver::resolve`.
87pub fn validate_capabilities(caps: &acdp_types::CapabilitiesDocument) -> Result<(), AcdpError> {
88    validate_semver_pattern("acdp_version", &caps.acdp_version)?;
89
90    // §3.5 item 11 *(0.3.0)*: limits.max_publish_per_minute, when
91    // present, MUST be an integer >= 1 (schema minimum).
92    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    // §3.5 item 10 *(0.3.0)* / RFC-ACDP-0003 §6.4: a registry
103    // advertising acdp_version >= 0.3.0 MUST support Idempotency-Key;
104    // supports_idempotency_key absent-or-false alongside such a version
105    // makes the document self-contradictory (fixture idem-007).
106    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
182// ── Top-level entry points ───────────────────────────────────────────────────
183
184/// Validate a complete [`PublishRequest`] against every schema constraint
185/// and runtime invariant.
186pub 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    // Identifier patterns on every supplied ctx_id
244    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    // acdp_version pattern (semver `^\d+\.\d+\.\d+$`)
255    if let Some(v) = &req.acdp_version {
256        validate_semver_pattern("acdp_version", v)?;
257    }
258
259    // Version coherence (also enforced by the builder)
260    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    // RFC-ACDP-0003 §2.2 / `acdp-publish-request.schema.json` allOf:
276    // v1 publications MUST NOT include lineage_id (the value would
277    // necessarily be wrong because the formula depends on the
278    // registry-assigned ctx_id). The builder enforces this too, but
279    // applying it here lets the validator stand alone for callers that
280    // do not go through `RequestBuilder` (e.g. the conformance harness,
281    // server-side validators).
282    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
291/// Validate a stored [`Body`] (retrieval-side check).
292pub fn validate_body(body: &Body) -> Result<(), AcdpError> {
293    validate_body_inner(body, /* check_embedded_hashes = */ true)
294}
295
296/// Same as [`validate_body`] but skips the embedded-`content_hash` recomputation.
297///
298/// Used by `acdp::client::VerifiedContext::fetch_report` so per-`DataRef`
299/// embedded-hash outcomes can be recorded individually rather than
300/// short-circuiting the whole verification. Callers that want the
301/// embedded-hash check MUST run [`verify_embedded_hash`] themselves —
302/// `fetch_report`'s recording loop is one such caller.
303///
304/// Production code that doesn't need partial-failure reporting should
305/// prefer [`validate_body`].
306pub fn validate_body_structural(body: &Body) -> Result<(), AcdpError> {
307    validate_body_inner(body, /* check_embedded_hashes = */ 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    // Forward-compat `extensions` (`#[serde(flatten)]`) are producer-
351    // controlled and flow into JCS + content_hash; cap them like metadata
352    // so they cannot bypass the §3.3 size/count/depth limits (P1-3).
353    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    // Every entry in supersedes / derived_from MUST be a valid ctx_id.
372    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; // schema-derived; serde already enforces RFC 3339
384    validate_origin_registry(&body.origin_registry)?;
385
386    // Avoid unused-import warnings on Status / Visibility
387    let _ = std::any::type_name::<Status>();
388    let _: &Visibility = &body.visibility;
389
390    Ok(())
391}
392
393/// Validate an identifier triple — convenient for retrieval-side use.
394pub 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
405// ── DataRef ──────────────────────────────────────────────────────────────────
406
407/// Validate a single [`DataRef`] against `acdp-data-ref.schema.json` and the
408/// runtime invariants the schema delegates.
409pub fn validate_data_ref(dr: &DataRef) -> Result<(), AcdpError> {
410    validate_data_ref_structural(dr)?;
411    // BUG-02: verify the declared content_hash against the decoded bytes
412    // (RFC-ACDP-0002 §6.6 #8). A producer-supplied wrong hash is a
413    // signed commitment to a misleading integrity claim, so we catch
414    // it at validate time, not just inside `PublishValidator`.
415    if dr.embedded.is_some() {
416        verify_embedded_hash(dr)?;
417    }
418    Ok(())
419}
420
421/// Same as [`validate_data_ref`] but skips the embedded-`content_hash`
422/// recomputation. Callers that want to report per-`DataRef` hash failures
423/// (e.g. `acdp::client::VerifiedContext::fetch_report`) run the
424/// structural checks via this helper, then call [`verify_embedded_hash`]
425/// themselves and record the outcome instead of short-circuiting.
426pub fn validate_data_ref_structural(dr: &DataRef) -> Result<(), AcdpError> {
427    // oneOf: exactly one of location / embedded
428    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    // Scheme: ^[a-z][a-z0-9+.-]*:
478    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    // userinfo rejection: ^[a-z][a-z0-9+.-]*://[^/?#@]+@
495    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
540/// Validate `Body::anchors` / `PublishRequest::anchors`
541/// (RFC-ACDP-0016 §4). Called only when the field is `Some` — an
542/// absent field needs no validation, and the caller is responsible for
543/// the absent-vs-null distinction (`de_present` on the field itself).
544fn 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    // utf8 / base64: content MUST be a JSON string
568    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    // Decoded size cap
580    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
591/// Decode an [`EmbeddedContent`] to its canonical byte form per
592/// `acdp-data-ref.schema.json` `content_hash` semantics:
593/// - `json`   → JCS-canonicalized bytes
594/// - `utf8`   → raw UTF-8 bytes of the string
595/// - `base64` → base64-decoded bytes of the string
596pub 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
616/// Compute the SHA-256 [`ContentHash`] of decoded embedded content.
617pub 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
623/// Verify a [`DataRef`]'s declared content hash(es) against its embedded
624/// payload. Does nothing if the ref has no `embedded`.
625///
626/// RFC-ACDP-0002 §6.6 ("Check 8") scopes the publish-time integrity
627/// obligation to `embedded.content_hash`: when present, it MUST match the
628/// SHA-256 of the decoded `embedded.content` bytes. The DataRef-root
629/// `content_hash` (§6.1) carries no obligation for embedded refs — a
630/// registry "MAY additionally verify" it, but MUST NOT reject a publish
631/// merely because both fields are present. This function checks **both
632/// fields when both are present**: that satisfies §6.6's two hard
633/// requirements (verify `embedded.content_hash`; don't reject on
634/// co-presence alone) while also preserving the pre-existing root-hash
635/// check that consumer-side `location`-form fetch verification
636/// (`acdp-client`) depends on — dropping the root check for embedded refs
637/// would silently regress that coverage. A mismatch on *either* field is a
638/// *data-reference-level* integrity failure ([`AcdpError::DataRefHashMismatch`],
639/// wire code `data_ref_hash_mismatch`) — the embedded bytes diverged from a
640/// producer-declared hash, but the body's own `content_hash` / signature are
641/// unaffected. It is NOT the body-level [`AcdpError::HashMismatch`]
642/// (RFC-ACDP-0007 §5, data-ref-007).
643pub fn verify_embedded_hash(dr: &DataRef) -> Result<(), AcdpError> {
644    let Some(emb) = &dr.embedded else {
645        return Ok(());
646    };
647    if dr.content_hash.is_none() && emb.content_hash.is_none() {
648        return Ok(());
649    }
650    let recomputed = compute_embedded_hash(emb)?;
651    if let Some(embedded_hash) = &emb.content_hash {
652        if &recomputed != embedded_hash {
653            return Err(AcdpError::DataRefHashMismatch(format!(
654                "embedded.content_hash mismatch: declared {}, computed {}",
655                embedded_hash.as_str(),
656                recomputed.as_str()
657            )));
658        }
659    }
660    if let Some(root_hash) = &dr.content_hash {
661        if &recomputed != root_hash {
662            return Err(AcdpError::DataRefHashMismatch(format!(
663                "content_hash mismatch: declared {}, computed {}",
664                root_hash.as_str(),
665                recomputed.as_str()
666            )));
667        }
668    }
669    Ok(())
670}
671
672// ── Metadata ─────────────────────────────────────────────────────────────────
673
674/// Validate `metadata`'s runtime invariants per RFC-ACDP-0002 §3.3:
675/// max 100 top-level properties, max 8 nesting levels, max 64 KB JCS size.
676pub fn validate_metadata(value: &serde_json::Value) -> Result<(), AcdpError> {
677    validate_json_object_limits(value, "metadata")
678}
679
680/// Shared object-limit check for any producer-controlled free-form JSON
681/// object (`metadata` and the flattened `extensions`): max 100 top-level
682/// properties, max 8 nesting levels, max 64 KB JCS size. Without this,
683/// `extensions` (P1-3) would carry unbounded keys/values into JCS+SHA-256.
684fn validate_json_object_limits(value: &serde_json::Value, field: &str) -> Result<(), AcdpError> {
685    let obj = value
686        .as_object()
687        .ok_or_else(|| AcdpError::SchemaViolation(format!("{field} must be a JSON object")))?;
688    if obj.len() > MAX_METADATA_PROPERTIES {
689        return Err(AcdpError::SchemaViolation(format!(
690            "{field} has {} top-level properties, exceeds {} limit",
691            obj.len(),
692            MAX_METADATA_PROPERTIES
693        )));
694    }
695    let depth = json_depth(value);
696    if depth > MAX_METADATA_DEPTH {
697        return Err(AcdpError::SchemaViolation(format!(
698            "{field} nesting depth {depth} exceeds {MAX_METADATA_DEPTH}"
699        )));
700    }
701    let canonical_size = try_canonicalize_value(value)?.len();
702    if canonical_size > MAX_METADATA_JCS_BYTES {
703        return Err(AcdpError::SchemaViolation(format!(
704            "{field} JCS-canonical size {canonical_size} bytes exceeds {MAX_METADATA_JCS_BYTES}"
705        )));
706    }
707    Ok(())
708}
709
710/// Validate the flattened forward-compatibility `extensions` object with
711/// the same property-count / depth / JCS-size caps as `metadata`.
712pub fn validate_extensions(
713    extensions: &serde_json::Map<String, serde_json::Value>,
714) -> Result<(), AcdpError> {
715    if extensions.is_empty() {
716        return Ok(());
717    }
718    // Wrap in a `Value::Object` (clones the map) so the shared
719    // object-limit walker can scan it; `extensions` is small and capped,
720    // so the clone is negligible.
721    let value = serde_json::Value::Object(extensions.clone());
722    validate_json_object_limits(&value, "extensions")
723}
724
725/// Depth measured per RFC-ACDP-0002 §3.3: nested-object/array count,
726/// not counting leaf scalars. The cap of 8 is inclusive (`≤ 8`).
727/// `meta-003` pins this boundary.
728///
729/// Recursion is bounded by `MAX_JSON_DEPTH_SCAN` (well above the §3.3 cap
730/// of 8 and serde_json's 128-level parse limit) so a pathologically deep
731/// programmatically-built `Value` cannot blow the stack here. Any value
732/// that reaches the budget already exceeds `MAX_METADATA_DEPTH`, so the
733/// caller rejects it regardless of the exact (clamped) count.
734fn json_depth(v: &serde_json::Value) -> usize {
735    /// Above §3.3's cap of 8 and serde's 128 parse limit; bounds stack use.
736    const MAX_JSON_DEPTH_SCAN: usize = 256;
737    fn go(v: &serde_json::Value, budget: usize) -> usize {
738        if budget == 0 {
739            return 1; // stop descending; already far over MAX_METADATA_DEPTH
740        }
741        match v {
742            serde_json::Value::Object(map) => {
743                1 + map.values().map(|x| go(x, budget - 1)).max().unwrap_or(0)
744            }
745            serde_json::Value::Array(arr) => {
746                1 + arr.iter().map(|x| go(x, budget - 1)).max().unwrap_or(0)
747            }
748            _ => 0,
749        }
750    }
751    go(v, MAX_JSON_DEPTH_SCAN)
752}
753
754// ── Visibility ───────────────────────────────────────────────────────────────
755
756fn validate_visibility_audience(
757    vis: &Visibility,
758    audience: Option<&[AgentDid]>,
759) -> Result<(), AcdpError> {
760    match vis {
761        Visibility::Restricted => {
762            if audience.is_none_or(|a| a.is_empty()) {
763                return Err(AcdpError::SchemaViolation(
764                    "visibility:restricted requires a non-empty audience".into(),
765                ));
766            }
767        }
768        Visibility::Public => {
769            if audience.is_some_and(|a| !a.is_empty()) {
770                return Err(AcdpError::SchemaViolation(
771                    "visibility:public MUST NOT include audience".into(),
772                ));
773            }
774        }
775        Visibility::Private => {}
776    }
777    Ok(())
778}
779
780// ── Strings & arrays ─────────────────────────────────────────────────────────
781
782fn validate_title(title: &str) -> Result<(), AcdpError> {
783    if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
784        return Err(AcdpError::SchemaViolation(format!(
785            "title length {} not in 1..={}",
786            title.chars().count(),
787            MAX_TITLE_LEN
788        )));
789    }
790    Ok(())
791}
792
793fn validate_optional_string(s: Option<&str>, name: &str, max_len: usize) -> Result<(), AcdpError> {
794    if let Some(value) = s {
795        if value.chars().count() > max_len {
796            return Err(AcdpError::SchemaViolation(format!(
797                "{name} length {} exceeds {max_len}",
798                value.chars().count()
799            )));
800        }
801    }
802    Ok(())
803}
804
805fn validate_unique_array<T: PartialEq + std::fmt::Debug>(
806    name: &str,
807    items: &[T],
808    max: usize,
809) -> Result<(), AcdpError> {
810    if items.len() > max {
811        return Err(AcdpError::SchemaViolation(format!(
812            "{name} has {} items, exceeds {max}",
813            items.len()
814        )));
815    }
816    for (i, item) in items.iter().enumerate() {
817        if items[i + 1..].iter().any(|other| other == item) {
818            return Err(AcdpError::SchemaViolation(format!(
819                "{name} contains duplicate entry: {item:?}"
820            )));
821        }
822    }
823    Ok(())
824}
825
826fn validate_tags(tags: &[String]) -> Result<(), AcdpError> {
827    if tags.len() > MAX_TAGS {
828        return Err(AcdpError::SchemaViolation(format!(
829            "tags has {} entries, exceeds {}",
830            tags.len(),
831            MAX_TAGS
832        )));
833    }
834    for tag in tags {
835        validate_tag(tag)?;
836    }
837    // Uniqueness
838    for (i, tag) in tags.iter().enumerate() {
839        if tags[i + 1..].iter().any(|t| t == tag) {
840            return Err(AcdpError::SchemaViolation(format!(
841                "tags contains duplicate entry: {tag}"
842            )));
843        }
844    }
845    Ok(())
846}
847
848fn validate_tag(tag: &str) -> Result<(), AcdpError> {
849    if tag.is_empty() || tag.len() > MAX_TAG_LEN {
850        return Err(AcdpError::SchemaViolation(format!(
851            "tag '{tag}' length not in 1..={MAX_TAG_LEN}"
852        )));
853    }
854    let mut chars = tag.chars();
855    let first = chars.next().unwrap();
856    if !first.is_ascii_alphanumeric() {
857        return Err(AcdpError::SchemaViolation(format!(
858            "tag '{tag}' first char must be alphanumeric"
859        )));
860    }
861    if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) {
862        return Err(AcdpError::SchemaViolation(format!(
863            "tag '{tag}' must match [A-Za-z0-9][A-Za-z0-9_.-]*"
864        )));
865    }
866    Ok(())
867}
868
869// ── DID / agent_id ───────────────────────────────────────────────────────────
870
871/// Validate a DID used as `agent_id`.
872///
873/// Producers MUST use a resolvable method: `did:web` (RFC-ACDP-0001
874/// §5.4, the v0.1.0 baseline) or `did:key` (ACDP 0.2 — pure offline
875/// resolution; the DID is the key). For did:key the embedded key
876/// material is decoded here so a garbage identifier fails at schema
877/// validation rather than at signature verification. Whether a given
878/// *registry* accepts did:key producers is a capabilities decision
879/// (`supported_did_methods`), enforced by
880/// `registry::PublishValidator` — this function checks protocol-level
881/// well-formedness only.
882/// Validate the `signature.key_id` form when it is a `did:key` URL
883/// (ACDP 0.2). The only verification method a did:key document has is
884/// the key itself, so the fragment MUST equal the method-specific
885/// identifier and the key material MUST decode. No-op for other
886/// methods — their key_id resolves against a DID document at
887/// verification time.
888fn validate_did_key_key_id_form(key_id: &str) -> Result<(), AcdpError> {
889    if !key_id.starts_with("did:key:") {
890        return Ok(());
891    }
892    // RFC-ACDP-0001 §5.11.1: any did:key resolver fault (steps 1-4) is
893    // REQUIRED to surface as `key_resolution_failed`. `schema_violation` is
894    // only a spec-tolerated MAY-level alternative for a registry's own
895    // stricter grammar on steps 1-2 (dk-002's own
896    // `expected.alternative_applies_to_cases: [1, 2]` proves the tolerance
897    // is narrower than "all did:key resolver faults") — never a
898    // requirement, so propagating the resolver's own `AcdpError::KeyResolution`
899    // unconditionally is conformant for all did:key fixtures and needs no
900    // step-level distinction the resolver doesn't expose anyway.
901    acdp_did::key::resolve_did_key_url(key_id)?;
902    Ok(())
903}
904
905fn validate_agent_did(did: &AgentDid) -> Result<(), AcdpError> {
906    if did.as_str().starts_with("did:key:") {
907        AgentDid::parse(did.as_str())?;
908        // See validate_did_key_key_id_form above: propagate the resolver's
909        // own AcdpError::KeyResolution rather than downgrading to
910        // SchemaViolation.
911        acdp_did::key::resolve_did_key(did.as_str())?;
912        return Ok(());
913    }
914    AgentDid::parse_web(did.as_str())?;
915    Ok(())
916}
917
918/// Validate `body.origin_registry` per `acdp-context-body.schema.json`
919/// (RFC-ACDP-0002 §3.1, fixture body-001/body-002).
920///
921/// MUST be a bare DNS hostname — NOT a `did:web:` URI, NOT a URL.
922/// `capabilities.registry_did` carries the `did:web` encoding; the
923/// stored body carries the hostname encoding. Storing either form in
924/// the other field is a conformance violation.
925fn validate_origin_registry(s: &str) -> Result<(), AcdpError> {
926    if s.is_empty() {
927        return Err(AcdpError::SchemaViolation(
928            "origin_registry must be a non-empty DNS hostname".into(),
929        ));
930    }
931    if s.starts_with("did:") {
932        return Err(AcdpError::SchemaViolation(format!(
933            "origin_registry must be a DNS hostname, not a DID URI (got '{s}'); \
934             use the bare authority — capabilities.registry_did carries the did:web form"
935        )));
936    }
937    if s.contains("://") {
938        return Err(AcdpError::SchemaViolation(format!(
939            "origin_registry must be a DNS hostname, not a URL (got '{s}')"
940        )));
941    }
942    if s.ends_with('.') || s.starts_with('.') {
943        return Err(AcdpError::SchemaViolation(format!(
944            "origin_registry must be a syntactically valid DNS hostname (got '{s}')"
945        )));
946    }
947    // BUG-02: delegate the full hostname grammar to the same validator
948    // `CtxId::parse` uses for its authority. Enforces lowercase-only,
949    // no underscore, no port, and valid label structure — values like
950    // `REGISTRY.EXAMPLE.COM`, `registry_example.com`, or `registry-.com`
951    // pass the coarse checks above but are not schema-valid hostnames.
952    if !acdp_types::primitives::is_valid_dns_authority(s) {
953        return Err(AcdpError::SchemaViolation(format!(
954            "origin_registry '{s}' is not a valid DNS hostname (must be lowercase \
955             labels of [a-z0-9-] separated by dots, e.g. 'registry.example.com')"
956        )));
957    }
958    Ok(())
959}
960
961/// Validate a DID used in `contributors[]` or `audience[]`.
962///
963/// Per the spec plan's RFC-FIX-11 method-scope table:
964/// - contributors[] SHOULD be `did:web` (attribution; no key resolution),
965/// - audience[] MAY be any DID method (authorization list; not resolved
966///   in v0.1.0).
967///
968/// This helper enforces only the loose `did:` syntax (no method
969/// constraint) so other-method contributors are accepted.
970fn validate_loose_did(did: &AgentDid) -> Result<(), AcdpError> {
971    AgentDid::parse(did.as_str())?;
972    Ok(())
973}
974
975// ── Context type ─────────────────────────────────────────────────────────────
976
977fn validate_namespaced_context_type(value: &str) -> Result<(), AcdpError> {
978    // Schema pattern: ^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$
979    let (ns, name) = value.split_once(':').ok_or_else(|| {
980        AcdpError::SchemaViolation(format!(
981            "context_type '{value}' missing namespace separator"
982        ))
983    })?;
984    if ns.is_empty()
985        || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
986        || !ns
987            .chars()
988            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
989    {
990        return Err(AcdpError::SchemaViolation(format!(
991            "context_type namespace '{ns}' must match [a-z][a-z0-9_]*"
992        )));
993    }
994    if name.is_empty()
995        || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
996        || !name
997            .chars()
998            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
999    {
1000        return Err(AcdpError::SchemaViolation(format!(
1001            "context_type name '{name}' must match [a-z][a-z0-9_-]*"
1002        )));
1003    }
1004    Ok(())
1005}
1006
1007trait ContextTypeExt {
1008    fn namespaced_form(&self) -> Option<&str>;
1009}
1010
1011impl ContextTypeExt for ContextType {
1012    fn namespaced_form(&self) -> Option<&str> {
1013        match self {
1014            ContextType::Custom(s) => Some(s.as_str()),
1015            _ => None,
1016        }
1017    }
1018}
1019
1020// ── Signatures ───────────────────────────────────────────────────────────────
1021
1022fn validate_semver_pattern(name: &str, value: &str) -> Result<(), AcdpError> {
1023    let parts: Vec<&str> = value.split('.').collect();
1024    let ok = parts.len() == 3
1025        && parts
1026            .iter()
1027            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
1028    if !ok {
1029        return Err(AcdpError::SchemaViolation(format!(
1030            "{name} '{value}' must match the semver pattern ^\\d+\\.\\d+\\.\\d+$"
1031        )));
1032    }
1033    Ok(())
1034}
1035
1036fn validate_signature_length(algorithm: &str, value_b64: &str) -> Result<(), AcdpError> {
1037    let expected = match algorithm {
1038        "ed25519" => Some(ED25519_SIG_B64_LEN),
1039        "ecdsa-p256" => Some(ECDSA_P256_SIG_B64_LEN),
1040        _ => None,
1041    };
1042    if let Some(n) = expected {
1043        if value_b64.len() != n {
1044            return Err(AcdpError::InvalidSignature(format!(
1045                "signature.value for '{algorithm}' must be {n} base64 chars, got {}",
1046                value_b64.len()
1047            )));
1048        }
1049    }
1050    Ok(())
1051}
1052
1053// ── Tests ─────────────────────────────────────────────────────────────────────
1054
1055#[cfg(test)]
1056mod tests {
1057    use super::*;
1058    use acdp_types::data_ref::DataRefType;
1059    use serde_json::json;
1060
1061    fn embedded_json(v: serde_json::Value) -> EmbeddedContent {
1062        EmbeddedContent {
1063            encoding: EmbeddedEncoding::Json,
1064            content: v,
1065            content_hash: None,
1066        }
1067    }
1068
1069    fn embedded_json_with_hash(v: serde_json::Value, hash: ContentHash) -> EmbeddedContent {
1070        EmbeddedContent {
1071            encoding: EmbeddedEncoding::Json,
1072            content: v,
1073            content_hash: Some(hash),
1074        }
1075    }
1076
1077    // ── origin_registry (BUG-02) ─────────────────────────────────────────────
1078
1079    #[test]
1080    fn origin_registry_accepts_valid_hostname() {
1081        validate_origin_registry("registry.example.com").unwrap();
1082        validate_origin_registry("reg.example").unwrap();
1083        validate_origin_registry("a-b-c.io").unwrap();
1084    }
1085
1086    #[test]
1087    fn origin_registry_rejects_uppercase() {
1088        assert!(matches!(
1089            validate_origin_registry("REGISTRY.EXAMPLE.COM"),
1090            Err(AcdpError::SchemaViolation(_))
1091        ));
1092    }
1093
1094    #[test]
1095    fn origin_registry_rejects_underscore() {
1096        assert!(matches!(
1097            validate_origin_registry("registry_example.com"),
1098            Err(AcdpError::SchemaViolation(_))
1099        ));
1100    }
1101
1102    #[test]
1103    fn origin_registry_rejects_hyphen_label_edges() {
1104        assert!(matches!(
1105            validate_origin_registry("registry-.com"),
1106            Err(AcdpError::SchemaViolation(_))
1107        ));
1108        assert!(matches!(
1109            validate_origin_registry("-registry.example.com"),
1110            Err(AcdpError::SchemaViolation(_))
1111        ));
1112    }
1113
1114    // ── DataRef.oneOf ────────────────────────────────────────────────────────
1115
1116    #[test]
1117    fn data_ref_neither_location_nor_embedded_rejected() {
1118        let dr = DataRef {
1119            ref_type: DataRefType::PrimaryResult,
1120            description: None,
1121            size_bytes: None,
1122            format: None,
1123            schema_version: None,
1124            content_hash: None,
1125            location: None,
1126            embedded: None,
1127            extensions: serde_json::Map::new(),
1128        };
1129        assert!(matches!(
1130            validate_data_ref(&dr),
1131            Err(AcdpError::SchemaViolation(_))
1132        ));
1133    }
1134
1135    #[test]
1136    fn data_ref_both_location_and_embedded_rejected() {
1137        let dr = DataRef {
1138            ref_type: DataRefType::PrimaryResult,
1139            description: None,
1140            size_bytes: None,
1141            format: None,
1142            schema_version: None,
1143            content_hash: None,
1144            location: Some(Location::Uri("https://x/y".into())),
1145            embedded: Some(embedded_json(json!({"a": 1}))),
1146            extensions: serde_json::Map::new(),
1147        };
1148        assert!(matches!(
1149            validate_data_ref(&dr),
1150            Err(AcdpError::SchemaViolation(_))
1151        ));
1152    }
1153
1154    // ── DataRef.location URI ─────────────────────────────────────────────────
1155
1156    #[test]
1157    fn uri_credentials_rejected() {
1158        let dr = DataRef::uri(DataRefType::RawData, "https://user:pass@example.com/data");
1159        assert!(matches!(
1160            validate_data_ref(&dr),
1161            Err(AcdpError::SchemaViolation(_))
1162        ));
1163    }
1164
1165    #[test]
1166    fn uri_without_scheme_rejected() {
1167        let dr = DataRef::uri(DataRefType::RawData, "no-scheme");
1168        assert!(matches!(
1169            validate_data_ref(&dr),
1170            Err(AcdpError::SchemaViolation(_))
1171        ));
1172    }
1173
1174    #[test]
1175    fn uri_too_long_rejected() {
1176        let long_uri = format!("https://x.com/{}", "a".repeat(MAX_URI_LEN));
1177        let dr = DataRef::uri(DataRefType::RawData, long_uri);
1178        assert!(matches!(
1179            validate_data_ref(&dr),
1180            Err(AcdpError::SchemaViolation(_))
1181        ));
1182    }
1183
1184    // ── DataRef.location structured ──────────────────────────────────────────
1185
1186    #[test]
1187    fn structured_locator_missing_scheme_rejected() {
1188        let mut map = serde_json::Map::new();
1189        map.insert("offset".into(), json!(42));
1190        let dr = DataRef {
1191            ref_type: DataRefType::RawData,
1192            description: None,
1193            size_bytes: None,
1194            format: None,
1195            schema_version: None,
1196            content_hash: None,
1197            location: Some(Location::Structured(map)),
1198            embedded: None,
1199            extensions: serde_json::Map::new(),
1200        };
1201        assert!(matches!(
1202            validate_data_ref(&dr),
1203            Err(AcdpError::SchemaViolation(_))
1204        ));
1205    }
1206
1207    #[test]
1208    fn structured_locator_bad_scheme_rejected() {
1209        // try_structured rejects at construction time; structured() panics
1210        // in debug builds. The validate_data_ref guard catches anyone who
1211        // assembles a `DataRef` literal with a bad scheme directly.
1212        let err =
1213            DataRef::try_structured(DataRefType::RawData, "not_dotted", serde_json::Map::new())
1214                .unwrap_err();
1215        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1216
1217        // Direct literal construction (skipping the constructor): must
1218        // also be caught by validate_data_ref.
1219        let mut bad = serde_json::Map::new();
1220        bad.insert(
1221            "scheme".into(),
1222            serde_json::Value::String("not_dotted".into()),
1223        );
1224        let dr = DataRef {
1225            ref_type: DataRefType::RawData,
1226            description: None,
1227            size_bytes: None,
1228            format: None,
1229            schema_version: None,
1230            content_hash: None,
1231            location: Some(Location::Structured(bad)),
1232            embedded: None,
1233            extensions: serde_json::Map::new(),
1234        };
1235        assert!(matches!(
1236            validate_data_ref(&dr),
1237            Err(AcdpError::SchemaViolation(_))
1238        ));
1239    }
1240
1241    #[test]
1242    fn structured_locator_valid() {
1243        let mut extra = serde_json::Map::new();
1244        extra.insert("topic".into(), json!("events"));
1245        let dr = DataRef::structured(DataRefType::RawData, "kafka.offset", extra);
1246        validate_data_ref(&dr).unwrap();
1247    }
1248
1249    // ── DataRef.embedded ─────────────────────────────────────────────────────
1250
1251    #[test]
1252    fn embedded_utf8_must_be_string() {
1253        let dr = DataRef {
1254            ref_type: DataRefType::PrimaryResult,
1255            description: None,
1256            size_bytes: None,
1257            format: None,
1258            schema_version: None,
1259            content_hash: None,
1260            location: None,
1261            embedded: Some(EmbeddedContent {
1262                encoding: EmbeddedEncoding::Utf8,
1263                content: json!(42),
1264                content_hash: None,
1265            }),
1266            extensions: serde_json::Map::new(),
1267        };
1268        assert!(matches!(
1269            validate_data_ref(&dr),
1270            Err(AcdpError::SchemaViolation(_))
1271        ));
1272    }
1273
1274    #[test]
1275    fn embedded_too_large_rejected() {
1276        // 70 KB of UTF-8 content
1277        let big = "a".repeat(70 * 1024);
1278        let dr = DataRef::embedded_utf8(DataRefType::PrimaryResult, big);
1279        assert!(matches!(
1280            validate_data_ref(&dr),
1281            Err(AcdpError::EmbeddedTooLarge(_))
1282        ));
1283    }
1284
1285    // ── Embedded hash ────────────────────────────────────────────────────────
1286
1287    #[test]
1288    fn embedded_hash_json_round_trip() {
1289        let emb = embedded_json(json!({"b": 2, "a": 1}));
1290        let h = compute_embedded_hash(&emb).unwrap();
1291        // JCS sorts keys → {"a":1,"b":2}, hash is deterministic
1292        let expected = {
1293            let bytes = b"{\"a\":1,\"b\":2}";
1294            format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
1295        };
1296        assert_eq!(h.as_str(), expected);
1297    }
1298
1299    #[test]
1300    fn embedded_hash_utf8() {
1301        let emb = EmbeddedContent {
1302            encoding: EmbeddedEncoding::Utf8,
1303            content: json!("hello"),
1304            content_hash: None,
1305        };
1306        let h = compute_embedded_hash(&emb).unwrap();
1307        let expected = format!("sha256:{}", hex::encode(Sha256::digest(b"hello")));
1308        assert_eq!(h.as_str(), expected);
1309    }
1310
1311    #[test]
1312    fn embedded_hash_base64() {
1313        let raw = b"binary data";
1314        let b64 = STANDARD.encode(raw);
1315        let emb = EmbeddedContent {
1316            encoding: EmbeddedEncoding::Base64,
1317            content: json!(b64),
1318            content_hash: None,
1319        };
1320        let h = compute_embedded_hash(&emb).unwrap();
1321        let expected = format!("sha256:{}", hex::encode(Sha256::digest(raw)));
1322        assert_eq!(h.as_str(), expected);
1323    }
1324
1325    #[test]
1326    fn verify_embedded_hash_mismatch_detected() {
1327        let emb = embedded_json(json!({"x": 1}));
1328        let dr = DataRef {
1329            ref_type: DataRefType::PrimaryResult,
1330            description: None,
1331            size_bytes: None,
1332            format: None,
1333            schema_version: None,
1334            content_hash: Some(ContentHash("sha256:0000".into())),
1335            location: None,
1336            embedded: Some(emb),
1337            extensions: serde_json::Map::new(),
1338        };
1339        assert!(matches!(
1340            verify_embedded_hash(&dr),
1341            Err(AcdpError::DataRefHashMismatch(_))
1342        ));
1343    }
1344
1345    // ── Metadata ─────────────────────────────────────────────────────────────
1346
1347    #[test]
1348    fn metadata_too_many_properties_rejected() {
1349        let mut obj = serde_json::Map::new();
1350        for i in 0..101 {
1351            obj.insert(format!("k{i}"), json!(i));
1352        }
1353        assert!(matches!(
1354            validate_metadata(&serde_json::Value::Object(obj)),
1355            Err(AcdpError::SchemaViolation(_))
1356        ));
1357    }
1358
1359    #[test]
1360    fn metadata_too_deep_rejected() {
1361        // Build an object nested 10 levels deep
1362        let mut v = json!("leaf");
1363        for _ in 0..10 {
1364            let mut o = serde_json::Map::new();
1365            o.insert("a".into(), v);
1366            v = serde_json::Value::Object(o);
1367        }
1368        assert!(matches!(
1369            validate_metadata(&v),
1370            Err(AcdpError::SchemaViolation(_))
1371        ));
1372    }
1373
1374    #[test]
1375    fn metadata_too_large_rejected() {
1376        let big = "a".repeat(70 * 1024);
1377        let v = json!({"big": big});
1378        assert!(matches!(
1379            validate_metadata(&v),
1380            Err(AcdpError::SchemaViolation(_))
1381        ));
1382    }
1383
1384    #[test]
1385    fn metadata_must_be_object() {
1386        assert!(matches!(
1387            validate_metadata(&json!([1, 2, 3])),
1388            Err(AcdpError::SchemaViolation(_))
1389        ));
1390    }
1391
1392    // ── Visibility / audience ────────────────────────────────────────────────
1393
1394    #[test]
1395    fn public_with_audience_rejected() {
1396        let aud = vec![AgentDid::new("did:web:x")];
1397        assert!(matches!(
1398            validate_visibility_audience(&Visibility::Public, Some(&aud)),
1399            Err(AcdpError::SchemaViolation(_))
1400        ));
1401    }
1402
1403    #[test]
1404    fn public_with_empty_audience_ok() {
1405        validate_visibility_audience(&Visibility::Public, Some(&[])).unwrap();
1406        validate_visibility_audience(&Visibility::Public, None).unwrap();
1407    }
1408
1409    #[test]
1410    fn restricted_without_audience_rejected() {
1411        assert!(matches!(
1412            validate_visibility_audience(&Visibility::Restricted, None),
1413            Err(AcdpError::SchemaViolation(_))
1414        ));
1415    }
1416
1417    // ── data_period ──────────────────────────────────────────────────────────
1418
1419    #[test]
1420    fn data_period_start_after_end_rejected_via_builder() {
1421        use acdp_crypto::SigningKey;
1422        use acdp_producer::Producer;
1423        use acdp_types::body::DataPeriod;
1424        use chrono::TimeZone;
1425
1426        let p = Producer::new(
1427            SigningKey::from_bytes(&[0u8; 32]),
1428            AgentDid::new("did:web:agents.example.com:test"),
1429            "did:web:agents.example.com:test#key-1",
1430        );
1431        let err = p
1432            .publish_request()
1433            .title("t")
1434            .context_type(ContextType::DataSnapshot)
1435            .data_period(DataPeriod {
1436                start: chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
1437                end: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1438            })
1439            .build()
1440            .unwrap_err();
1441        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1442    }
1443
1444    // ── Tags ─────────────────────────────────────────────────────────────────
1445
1446    #[test]
1447    fn tag_pattern_validation() {
1448        validate_tag("hello").unwrap();
1449        validate_tag("Q1-2026").unwrap();
1450        validate_tag("a_b.c").unwrap();
1451        // Cannot start with non-alphanumeric
1452        assert!(validate_tag("-bad").is_err());
1453        // Disallowed chars
1454        assert!(validate_tag("space here").is_err());
1455        // Empty
1456        assert!(validate_tag("").is_err());
1457    }
1458
1459    #[test]
1460    fn duplicate_tags_rejected() {
1461        let tags = vec!["a".to_string(), "b".to_string(), "a".to_string()];
1462        assert!(validate_tags(&tags).is_err());
1463    }
1464
1465    // ── Signature length ─────────────────────────────────────────────────────
1466
1467    #[test]
1468    fn ed25519_sig_must_be_88_chars() {
1469        assert!(validate_signature_length("ed25519", "AAAA").is_err());
1470        validate_signature_length("ed25519", &"A".repeat(88)).unwrap();
1471        // Unknown algorithm: skipped
1472        validate_signature_length("future-alg", "any").unwrap();
1473    }
1474
1475    // ── context_type custom ──────────────────────────────────────────────────
1476
1477    #[test]
1478    fn namespaced_context_type_pattern() {
1479        validate_namespaced_context_type("finance:portfolio_snapshot").unwrap();
1480        assert!(validate_namespaced_context_type("Finance:portfolio").is_err());
1481        assert!(validate_namespaced_context_type("finance:Portfolio").is_err());
1482        assert!(validate_namespaced_context_type("no-colon").is_err());
1483    }
1484
1485    // ── R2 audit test-coverage matrix ────────────────────────────────────────
1486
1487    /// T8 — `acdp_version` semver pattern is enforced.
1488    #[test]
1489    fn acdp_version_pattern_rejects_non_semver() {
1490        validate_semver_pattern("acdp_version", "0.1.0").unwrap();
1491        validate_semver_pattern("acdp_version", "10.20.30").unwrap();
1492        assert!(validate_semver_pattern("acdp_version", "0.1.0-rc.1").is_err());
1493        assert!(validate_semver_pattern("acdp_version", "0.0").is_err());
1494        assert!(validate_semver_pattern("acdp_version", "vee.zero.zero").is_err());
1495    }
1496
1497    /// T7 — `derived_from` containing a malformed ctx_id is rejected by
1498    /// `validate_publish_request`.
1499    #[test]
1500    fn derived_from_malformed_ctx_id_rejected() {
1501        use acdp_crypto::SigningKey;
1502        use acdp_producer::Producer;
1503
1504        let p = Producer::new(
1505            SigningKey::from_bytes(&[0u8; 32]),
1506            AgentDid::new("did:web:agents.example.com:test"),
1507            "did:web:agents.example.com:test#key-1",
1508        );
1509        let err = p
1510            .publish_request()
1511            .title("t")
1512            .context_type(ContextType::DataSnapshot)
1513            .derived_from(vec![CtxId("not-a-ctx-id".into())])
1514            .build()
1515            .unwrap_err();
1516        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1517    }
1518
1519    /// T2 — Embedded `content_hash` mismatch caught by
1520    /// `verify_embedded_hash`.
1521    #[test]
1522    fn embedded_content_hash_mismatch_caught() {
1523        use acdp_types::data_ref::DataRefType;
1524        let dr = DataRef {
1525            ref_type: DataRefType::PrimaryResult,
1526            description: None,
1527            size_bytes: None,
1528            format: None,
1529            schema_version: None,
1530            content_hash: Some(ContentHash("sha256:0000".into())),
1531            location: None,
1532            embedded: Some(EmbeddedContent {
1533                encoding: EmbeddedEncoding::Json,
1534                content: json!({"x": 1}),
1535                content_hash: None,
1536            }),
1537            extensions: serde_json::Map::new(),
1538        };
1539        assert!(matches!(
1540            verify_embedded_hash(&dr),
1541            Err(AcdpError::DataRefHashMismatch(_))
1542        ));
1543    }
1544
1545    /// RFC-ACDP-0002 §6.6: a DataRef carrying both root `content_hash` and
1546    /// `embedded.content_hash` over the same, *consistent* decoded bytes
1547    /// MUST be accepted.
1548    #[test]
1549    fn both_content_hashes_consistent_accepted() {
1550        use acdp_types::data_ref::DataRefType;
1551        let emb = embedded_json(json!({"a": 1, "b": 2}));
1552        let hash = compute_embedded_hash(&emb).unwrap();
1553        let dr = DataRef {
1554            ref_type: DataRefType::PrimaryResult,
1555            description: None,
1556            size_bytes: None,
1557            format: None,
1558            schema_version: None,
1559            content_hash: Some(hash.clone()),
1560            location: None,
1561            embedded: Some(embedded_json_with_hash(json!({"a": 1, "b": 2}), hash)),
1562            extensions: serde_json::Map::new(),
1563        };
1564        verify_embedded_hash(&dr).unwrap();
1565    }
1566
1567    /// RFC-ACDP-0002 §6.6: when root `content_hash` and
1568    /// `embedded.content_hash` disagree, the ref MUST be rejected with
1569    /// `DataRefHashMismatch` — checking both fields is the natural
1570    /// consequence of Check 8, not a new rule.
1571    #[test]
1572    fn both_content_hashes_disagree_rejected() {
1573        use acdp_types::data_ref::DataRefType;
1574        let emb = embedded_json(json!({"a": 1, "b": 2}));
1575        let correct_hash = compute_embedded_hash(&emb).unwrap();
1576        let wrong_hash = ContentHash(
1577            "sha256:0000000000000000000000000000000000000000000000000000000000000000".into(),
1578        );
1579        // embedded.content_hash is correct, root content_hash disagrees with the
1580        // actual decoded bytes — Check 8 passes, but the (still-checked) root
1581        // comparison catches the disagreement.
1582        let dr = DataRef {
1583            ref_type: DataRefType::PrimaryResult,
1584            description: None,
1585            size_bytes: None,
1586            format: None,
1587            schema_version: None,
1588            content_hash: Some(wrong_hash),
1589            location: None,
1590            embedded: Some(embedded_json_with_hash(
1591                json!({"a": 1, "b": 2}),
1592                correct_hash,
1593            )),
1594            extensions: serde_json::Map::new(),
1595        };
1596        assert!(matches!(
1597            verify_embedded_hash(&dr),
1598            Err(AcdpError::DataRefHashMismatch(_))
1599        ));
1600    }
1601
1602    /// T14 — duplicate audience entries rejected (uniqueItems: true).
1603    #[test]
1604    fn audience_uniqueness_rejected() {
1605        let dup = vec![
1606            AgentDid::new("did:web:a.example.com"),
1607            AgentDid::new("did:web:a.example.com"),
1608        ];
1609        let err = validate_unique_array("audience", &dup, MAX_AUDIENCE).unwrap_err();
1610        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1611    }
1612
1613    // ── P1-3: extensions caps + bounded walkers ──────────────────────────────
1614
1615    #[test]
1616    fn extensions_empty_ok() {
1617        validate_extensions(&serde_json::Map::new()).unwrap();
1618    }
1619
1620    #[test]
1621    fn extensions_small_forward_compat_accepted() {
1622        // The can-008/can-009 shape: a couple of unknown producer fields.
1623        let mut ext = serde_json::Map::new();
1624        ext.insert("priority".into(), json!("high"));
1625        ext.insert("custom".into(), json!({"k": [1, 2, 3]}));
1626        validate_extensions(&ext).unwrap();
1627    }
1628
1629    #[test]
1630    fn extensions_too_many_properties_rejected() {
1631        let mut ext = serde_json::Map::new();
1632        for i in 0..(MAX_METADATA_PROPERTIES + 1) {
1633            ext.insert(format!("k{i}"), json!(i));
1634        }
1635        let err = validate_extensions(&ext).unwrap_err();
1636        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1637    }
1638
1639    #[test]
1640    fn extensions_oversized_jcs_rejected() {
1641        let mut ext = serde_json::Map::new();
1642        ext.insert("blob".into(), json!("x".repeat(MAX_METADATA_JCS_BYTES + 1)));
1643        let err = validate_extensions(&ext).unwrap_err();
1644        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1645    }
1646
1647    #[test]
1648    fn extensions_too_deep_rejected() {
1649        // Build a value nested past MAX_METADATA_DEPTH.
1650        let mut v = json!(0);
1651        for _ in 0..(MAX_METADATA_DEPTH + 2) {
1652            v = json!({ "n": v });
1653        }
1654        let mut ext = serde_json::Map::new();
1655        ext.insert("deep".into(), v);
1656        let err = validate_extensions(&ext).unwrap_err();
1657        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1658    }
1659
1660    #[test]
1661    fn json_depth_clamps_past_scan_budget() {
1662        // Deeper than the 256-frame scan budget but shallow enough that
1663        // building/dropping the Value is itself safe. `json_depth` must
1664        // bound its own recursion and still report a value over the §3.3
1665        // cap, and the canonicalizer must refuse it rather than overflow.
1666        let mut v = json!(0);
1667        for _ in 0..400 {
1668            v = json!([v]);
1669        }
1670        assert!(json_depth(&v) > MAX_METADATA_DEPTH);
1671        assert!(acdp_crypto::try_canonicalize_value(&v).is_err());
1672    }
1673}
1674
1675#[cfg(test)]
1676mod capabilities_0_3_0_tests {
1677    use super::*;
1678    use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1679
1680    fn caps(version: &str, supports_idem: bool, mppm: Option<u64>) -> CapabilitiesDocument {
1681        CapabilitiesDocument {
1682            acdp_version: version.into(),
1683            registry_did: "did:web:registry.example.com".into(),
1684            supported_signature_algorithms: vec!["ed25519".into()],
1685            supported_did_methods: vec!["did:web".into()],
1686            profiles: vec!["acdp-registry-core".into()],
1687            limits: Limits {
1688                max_payload_bytes: 1_048_576,
1689                max_embedded_bytes: 65_536,
1690                idempotency_key_ttl_seconds: if supports_idem { Some(86_400) } else { None },
1691                max_publish_per_minute: mppm,
1692            },
1693            read_authentication_methods: vec![],
1694            anonymous_public_reads: false,
1695            supports_idempotency_key: supports_idem,
1696            extensions: Default::default(),
1697        }
1698    }
1699
1700    /// RFC-ACDP-0007 §3.5 item 11 *(0.3.0)* — caps-007's reject variants:
1701    /// zero is rejected at validation; the ≥1 accept case passes.
1702    #[test]
1703    fn max_publish_per_minute_bounds() {
1704        assert!(validate_capabilities(&caps("0.1.0", false, Some(600))).is_ok());
1705        let err = validate_capabilities(&caps("0.1.0", false, Some(0)))
1706            .expect_err("zero MUST be rejected");
1707        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1708    }
1709
1710    /// RFC-ACDP-0003 §6.4 / RFC-ACDP-0007 §3.5 item 10 *(0.3.0)* — the
1711    /// idem-007 rule: acdp_version ≥ 0.3.0 without idempotency support
1712    /// is self-contradictory and MUST be rejected; 0.1.0/0.2.0 without
1713    /// support stay valid; 0.3.0 with support is valid.
1714    #[test]
1715    fn idempotency_required_at_0_3_0() {
1716        assert!(validate_capabilities(&caps("0.1.0", false, None)).is_ok());
1717        assert!(validate_capabilities(&caps("0.2.0", false, None)).is_ok());
1718        assert!(validate_capabilities(&caps("0.3.0", true, None)).is_ok());
1719        assert!(validate_capabilities(&caps("0.4.0", true, None)).is_ok());
1720        for v in ["0.3.0", "0.4.0", "1.0.0"] {
1721            let err = validate_capabilities(&caps(v, false, None))
1722                .expect_err("version >= 0.3.0 without idempotency MUST be rejected");
1723            assert!(matches!(err, AcdpError::SchemaViolation(_)), "{v}: {err:?}");
1724        }
1725    }
1726}