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::body::Body;
25use acdp_types::data_ref::{DataRef, EmbeddedContent, EmbeddedEncoding, Location};
26use acdp_types::primitives::{
27    AgentDid, ContentHash, ContextType, CtxId, LineageId, Status, Visibility,
28};
29use acdp_types::publish::PublishRequest;
30use base64::{engine::general_purpose::STANDARD, Engine};
31use sha2::{Digest, Sha256};
32
33// ── Constants from the schemas ────────────────────────────────────────────────
34
35const MAX_TITLE_LEN: usize = 500;
36const MAX_DESCRIPTION_LEN: usize = 5000;
37const MAX_SUMMARY_LEN: usize = 1000;
38const MAX_DOMAIN_LEN: usize = 200;
39const MAX_DATA_REF_DESCRIPTION_LEN: usize = 1000;
40const MAX_TAG_LEN: usize = 100;
41const MAX_CONTRIBUTORS: usize = 100;
42const MAX_TAGS: usize = 200;
43const MAX_DERIVED_FROM: usize = 1000;
44const MAX_AUDIENCE: usize = 1000;
45const MAX_METADATA_PROPERTIES: usize = 100;
46const MAX_METADATA_DEPTH: usize = 8;
47const MAX_METADATA_JCS_BYTES: usize = 65_536;
48const MAX_URI_LEN: usize = 4096;
49const MAX_EMBEDDED_BYTES: usize = 65_536;
50const ED25519_SIG_B64_LEN: usize = 88;
51const ECDSA_P256_SIG_B64_LEN: usize = 88;
52
53// ── Capabilities ─────────────────────────────────────────────────────────────
54
55/// True when a `major.minor.patch` version string is >= the given
56/// major.minor (version-conditional capability checks; malformed
57/// versions are rejected by `validate_semver_pattern` first).
58fn version_at_least(v: &str, major: u64, minor: u64) -> bool {
59    let mut it = v.split('.').filter_map(|p| p.parse::<u64>().ok());
60    match (it.next(), it.next()) {
61        (Some(ma), Some(mi)) => ma > major || (ma == major && mi >= minor),
62        _ => false,
63    }
64}
65
66/// Validate a [`acdp_types::CapabilitiesDocument`] against the
67/// runtime constraints listed in RFC-ACDP-0007 §3.
68///
69/// The JSON schema enforces *types*; this validator enforces the
70/// constraints the schema cannot express:
71///
72/// 1. `acdp_version` matches `^\d+\.\d+\.\d+$`.
73/// 2. `registry_did` is a v0.1.0 `did:web` DID.
74/// 3. `supported_signature_algorithms` MUST contain `"ed25519"`.
75/// 4. `supported_did_methods` MUST contain `"did:web"`.
76/// 5. `profiles` MUST contain `"acdp-registry-core"`.
77/// 6. `limits.max_embedded_bytes` MUST equal exactly 65536.
78/// 7. If `supports_idempotency_key` is `true`,
79///    `limits.idempotency_key_ttl_seconds` MUST be present and in
80///    `86400..=604800`.
81/// 8. `limits.max_payload_bytes` MUST be at least 1024 bytes.
82///
83/// Wired into `acdp::client::RegistryClient::capabilities` and
84/// `acdp::client::CrossRegistryResolver::resolve`.
85pub fn validate_capabilities(caps: &acdp_types::CapabilitiesDocument) -> Result<(), AcdpError> {
86    validate_semver_pattern("acdp_version", &caps.acdp_version)?;
87
88    // §3.5 item 11 *(0.3.0)*: limits.max_publish_per_minute, when
89    // present, MUST be an integer >= 1 (schema minimum).
90    if let Some(mppm) = caps.limits.max_publish_per_minute {
91        if mppm < 1 {
92            return Err(AcdpError::SchemaViolation(
93                "capabilities.limits.max_publish_per_minute MUST be >= 1 \
94                 (RFC-ACDP-0007 \u{a7}3.5 item 11)"
95                    .into(),
96            ));
97        }
98    }
99
100    // §3.5 item 10 *(0.3.0)* / RFC-ACDP-0003 §6.4: a registry
101    // advertising acdp_version >= 0.3.0 MUST support Idempotency-Key;
102    // supports_idempotency_key absent-or-false alongside such a version
103    // makes the document self-contradictory (fixture idem-007).
104    if version_at_least(&caps.acdp_version, 0, 3) && !caps.supports_idempotency_key {
105        return Err(AcdpError::SchemaViolation(
106            "capabilities advertising acdp_version >= 0.3.0 MUST set \
107             supports_idempotency_key: true (RFC-ACDP-0003 \u{a7}6.4, \
108             RFC-ACDP-0007 \u{a7}3.5 item 10)"
109                .into(),
110        ));
111    }
112
113    AgentDid::parse_web(caps.registry_did.as_str()).map_err(|e| {
114        AcdpError::SchemaViolation(format!(
115            "capabilities.registry_did must be did:web for v0.1.0: {e}"
116        ))
117    })?;
118
119    if !caps
120        .supported_signature_algorithms
121        .iter()
122        .any(|a| a == "ed25519")
123    {
124        return Err(AcdpError::SchemaViolation(
125            "capabilities.supported_signature_algorithms MUST contain 'ed25519' \
126             (RFC-ACDP-0001 §5.10)"
127                .into(),
128        ));
129    }
130
131    if !caps.supported_did_methods.iter().any(|m| m == "did:web") {
132        return Err(AcdpError::SchemaViolation(
133            "capabilities.supported_did_methods MUST contain 'did:web' \
134             (RFC-ACDP-0001 §5.4)"
135                .into(),
136        ));
137    }
138
139    if !caps.profiles.iter().any(|p| p == "acdp-registry-core") {
140        return Err(AcdpError::SchemaViolation(
141            "capabilities.profiles MUST contain 'acdp-registry-core' \
142             (RFC-ACDP-0001 §9.1)"
143                .into(),
144        ));
145    }
146
147    if caps.limits.max_embedded_bytes != 65_536 {
148        return Err(AcdpError::SchemaViolation(format!(
149            "capabilities.limits.max_embedded_bytes must be 65536 (fixed by \
150             RFC-ACDP-0007 §3.1), got {}",
151            caps.limits.max_embedded_bytes
152        )));
153    }
154
155    if caps.limits.max_payload_bytes < 1024 {
156        return Err(AcdpError::SchemaViolation(format!(
157            "capabilities.limits.max_payload_bytes must be ≥ 1024, got {}",
158            caps.limits.max_payload_bytes
159        )));
160    }
161
162    if caps.supports_idempotency_key {
163        let ttl = caps.limits.idempotency_key_ttl_seconds.ok_or_else(|| {
164            AcdpError::SchemaViolation(
165                "limits.idempotency_key_ttl_seconds is required when \
166                 supports_idempotency_key is true (RFC-ACDP-0007 §3.2)"
167                    .into(),
168            )
169        })?;
170        if !(86_400..=604_800).contains(&ttl) {
171            return Err(AcdpError::SchemaViolation(format!(
172                "limits.idempotency_key_ttl_seconds must be in 86400..=604800, got {ttl}"
173            )));
174        }
175    }
176
177    Ok(())
178}
179
180// ── Top-level entry points ───────────────────────────────────────────────────
181
182/// Validate a complete [`PublishRequest`] against every schema constraint
183/// and runtime invariant.
184pub fn validate_publish_request(req: &PublishRequest) -> Result<(), AcdpError> {
185    validate_title(&req.title)?;
186    validate_optional_string(
187        req.description.as_deref(),
188        "description",
189        MAX_DESCRIPTION_LEN,
190    )?;
191    validate_optional_string(req.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
192    validate_optional_string(req.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
193
194    validate_agent_did(&req.agent_id)?;
195    for c in &req.contributors {
196        validate_loose_did(c)?;
197    }
198    validate_unique_array("contributors", &req.contributors, MAX_CONTRIBUTORS)?;
199    validate_unique_array("derived_from", &req.derived_from, MAX_DERIVED_FROM)?;
200
201    if let Some(tags) = &req.tags {
202        validate_tags(tags)?;
203    }
204    if let Some(audience) = &req.audience {
205        validate_unique_array("audience", audience, MAX_AUDIENCE)?;
206        for did in audience {
207            validate_loose_did(did)?;
208        }
209    }
210
211    validate_visibility_audience(&req.visibility, req.audience.as_deref())?;
212
213    if let Some(dp) = &req.data_period {
214        if dp.start > dp.end {
215            return Err(AcdpError::SchemaViolation(
216                "data_period.start must not be after data_period.end".into(),
217            ));
218        }
219    }
220
221    if let Some(ct) = &req.context_type.namespaced_form() {
222        validate_namespaced_context_type(ct)?;
223    }
224
225    if let Some(meta) = &req.metadata {
226        validate_metadata(meta)?;
227    }
228
229    for dr in &req.data_refs {
230        validate_data_ref(dr)?;
231    }
232
233    validate_signature_length(&req.signature.algorithm, &req.signature.value)?;
234    validate_did_key_key_id_form(&req.signature.key_id)?;
235    ContentHash::parse(req.content_hash.as_str())?;
236
237    // Identifier patterns on every supplied ctx_id
238    if let Some(prev) = &req.supersedes {
239        CtxId::parse(prev.as_str())?;
240    }
241    for ancestor in &req.derived_from {
242        CtxId::parse(ancestor.as_str())?;
243    }
244    if let Some(lineage) = &req.lineage_id {
245        acdp_types::primitives::LineageId::parse(lineage.as_str())?;
246    }
247
248    // acdp_version pattern (semver `^\d+\.\d+\.\d+$`)
249    if let Some(v) = &req.acdp_version {
250        validate_semver_pattern("acdp_version", v)?;
251    }
252
253    // Version coherence (also enforced by the builder)
254    match (&req.supersedes, req.version) {
255        (None, 1) => {}
256        (None, v) => {
257            return Err(AcdpError::SchemaViolation(format!(
258                "first-version publish requires version=1, got {v}"
259            )));
260        }
261        (Some(_), v) if v >= 2 => {}
262        (Some(_), v) => {
263            return Err(AcdpError::SchemaViolation(format!(
264                "supersession publish requires version >= 2, got {v}"
265            )));
266        }
267    }
268
269    // RFC-ACDP-0003 §2.2 / `acdp-publish-request.schema.json` allOf:
270    // v1 publications MUST NOT include lineage_id (the value would
271    // necessarily be wrong because the formula depends on the
272    // registry-assigned ctx_id). The builder enforces this too, but
273    // applying it here lets the validator stand alone for callers that
274    // do not go through `RequestBuilder` (e.g. the conformance harness,
275    // server-side validators).
276    if req.version == 1 && req.lineage_id.is_some() {
277        return Err(AcdpError::SchemaViolation(
278            "lineage_id MUST NOT be set on v1 publish requests (RFC-ACDP-0003 §2.2)".into(),
279        ));
280    }
281
282    Ok(())
283}
284
285/// Validate a stored [`Body`] (retrieval-side check).
286pub fn validate_body(body: &Body) -> Result<(), AcdpError> {
287    validate_body_inner(body, /* check_embedded_hashes = */ true)
288}
289
290/// Same as [`validate_body`] but skips the embedded-`content_hash` recomputation.
291///
292/// Used by `acdp::client::VerifiedContext::fetch_report` so per-`DataRef`
293/// embedded-hash outcomes can be recorded individually rather than
294/// short-circuiting the whole verification. Callers that want the
295/// embedded-hash check MUST run [`verify_embedded_hash`] themselves —
296/// `fetch_report`'s recording loop is one such caller.
297///
298/// Production code that doesn't need partial-failure reporting should
299/// prefer [`validate_body`].
300pub fn validate_body_structural(body: &Body) -> Result<(), AcdpError> {
301    validate_body_inner(body, /* check_embedded_hashes = */ false)
302}
303
304fn validate_body_inner(body: &Body, check_embedded_hashes: bool) -> Result<(), AcdpError> {
305    validate_title(&body.title)?;
306    validate_optional_string(
307        body.description.as_deref(),
308        "description",
309        MAX_DESCRIPTION_LEN,
310    )?;
311    validate_optional_string(body.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
312    validate_optional_string(body.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
313
314    validate_agent_did(&body.agent_id)?;
315    for c in &body.contributors {
316        validate_loose_did(c)?;
317    }
318    validate_unique_array("contributors", &body.contributors, MAX_CONTRIBUTORS)?;
319    validate_unique_array("derived_from", &body.derived_from, MAX_DERIVED_FROM)?;
320
321    if let Some(tags) = &body.tags {
322        validate_tags(tags)?;
323    }
324    if let Some(audience) = &body.audience {
325        validate_unique_array("audience", audience, MAX_AUDIENCE)?;
326        for did in audience {
327            validate_loose_did(did)?;
328        }
329    }
330    validate_visibility_audience(&body.visibility, body.audience.as_deref())?;
331
332    if let Some(dp) = &body.data_period {
333        if dp.start > dp.end {
334            return Err(AcdpError::SchemaViolation(
335                "data_period.start must not be after data_period.end".into(),
336            ));
337        }
338    }
339
340    if let Some(meta) = &body.metadata {
341        validate_metadata(meta)?;
342    }
343
344    // Forward-compat `extensions` (`#[serde(flatten)]`) are producer-
345    // controlled and flow into JCS + content_hash; cap them like metadata
346    // so they cannot bypass the §3.3 size/count/depth limits (P1-3).
347    validate_extensions(&body.extensions)?;
348
349    for dr in &body.data_refs {
350        if check_embedded_hashes {
351            validate_data_ref(dr)?;
352        } else {
353            validate_data_ref_structural(dr)?;
354        }
355    }
356
357    validate_signature_length(&body.signature.algorithm, &body.signature.value)?;
358    validate_did_key_key_id_form(&body.signature.key_id)?;
359    validate_identifiers(&body.ctx_id, &body.lineage_id, &body.content_hash)?;
360
361    // Every entry in supersedes / derived_from MUST be a valid ctx_id.
362    if let Some(prev) = &body.supersedes {
363        CtxId::parse(prev.as_str())?;
364    }
365    for ancestor in &body.derived_from {
366        CtxId::parse(ancestor.as_str())?;
367    }
368
369    if let Some(v) = &body.acdp_version {
370        validate_semver_pattern("acdp_version", v)?;
371    }
372
373    let _ = &body.created_at; // schema-derived; serde already enforces RFC 3339
374    validate_origin_registry(&body.origin_registry)?;
375
376    // Avoid unused-import warnings on Status / Visibility
377    let _ = std::any::type_name::<Status>();
378    let _: &Visibility = &body.visibility;
379
380    Ok(())
381}
382
383/// Validate an identifier triple — convenient for retrieval-side use.
384pub fn validate_identifiers(
385    ctx_id: &CtxId,
386    lineage_id: &LineageId,
387    content_hash: &ContentHash,
388) -> Result<(), AcdpError> {
389    CtxId::parse(ctx_id.as_str())?;
390    LineageId::parse(lineage_id.as_str())?;
391    ContentHash::parse(content_hash.as_str())?;
392    Ok(())
393}
394
395// ── DataRef ──────────────────────────────────────────────────────────────────
396
397/// Validate a single [`DataRef`] against `acdp-data-ref.schema.json` and the
398/// runtime invariants the schema delegates.
399pub fn validate_data_ref(dr: &DataRef) -> Result<(), AcdpError> {
400    validate_data_ref_structural(dr)?;
401    // BUG-02: verify the declared content_hash against the decoded bytes
402    // (RFC-ACDP-0002 §6.6 #8). A producer-supplied wrong hash is a
403    // signed commitment to a misleading integrity claim, so we catch
404    // it at validate time, not just inside `PublishValidator`.
405    if dr.embedded.is_some() {
406        verify_embedded_hash(dr)?;
407    }
408    Ok(())
409}
410
411/// Same as [`validate_data_ref`] but skips the embedded-`content_hash`
412/// recomputation. Callers that want to report per-`DataRef` hash failures
413/// (e.g. `acdp::client::VerifiedContext::fetch_report`) run the
414/// structural checks via this helper, then call [`verify_embedded_hash`]
415/// themselves and record the outcome instead of short-circuiting.
416pub fn validate_data_ref_structural(dr: &DataRef) -> Result<(), AcdpError> {
417    // oneOf: exactly one of location / embedded
418    match (&dr.location, &dr.embedded) {
419        (None, None) => {
420            return Err(AcdpError::SchemaViolation(
421                "DataRef requires exactly one of 'location' or 'embedded' (got neither)".into(),
422            ));
423        }
424        (Some(_), Some(_)) => {
425            return Err(AcdpError::SchemaViolation(
426                "DataRef requires exactly one of 'location' or 'embedded' (got both)".into(),
427            ));
428        }
429        _ => {}
430    }
431
432    if let Some(desc) = &dr.description {
433        if desc.len() > MAX_DATA_REF_DESCRIPTION_LEN {
434            return Err(AcdpError::SchemaViolation(format!(
435                "DataRef.description {} chars exceeds {} limit",
436                desc.len(),
437                MAX_DATA_REF_DESCRIPTION_LEN
438            )));
439        }
440    }
441
442    if let Some(loc) = &dr.location {
443        validate_location(loc)?;
444    }
445    if let Some(emb) = &dr.embedded {
446        validate_embedded(emb)?;
447    }
448
449    Ok(())
450}
451
452fn validate_location(loc: &Location) -> Result<(), AcdpError> {
453    match loc {
454        Location::Uri(uri) => validate_uri_location(uri),
455        Location::Structured(map) => validate_structured_locator(map),
456    }
457}
458
459fn validate_uri_location(uri: &str) -> Result<(), AcdpError> {
460    if uri.len() < 3 || uri.len() > MAX_URI_LEN {
461        return Err(AcdpError::SchemaViolation(format!(
462            "DataRef.location URI length {} not in 3..={}",
463            uri.len(),
464            MAX_URI_LEN
465        )));
466    }
467    // Scheme: ^[a-z][a-z0-9+.-]*:
468    let (scheme, rest) = uri
469        .split_once(':')
470        .ok_or_else(|| AcdpError::SchemaViolation(format!("URI missing scheme: {uri}")))?;
471    if scheme.is_empty()
472        || !scheme
473            .chars()
474            .next()
475            .is_some_and(|c| c.is_ascii_lowercase())
476        || !scheme
477            .chars()
478            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '.' | '-'))
479    {
480        return Err(AcdpError::SchemaViolation(format!(
481            "URI scheme '{scheme}' invalid; must match [a-z][a-z0-9+.-]*"
482        )));
483    }
484    // userinfo rejection: ^[a-z][a-z0-9+.-]*://[^/?#@]+@
485    if let Some(after_slashes) = rest.strip_prefix("//") {
486        if let Some(authority_end) = after_slashes.find(['/', '?', '#']) {
487            let authority = &after_slashes[..authority_end];
488            if authority.contains('@') {
489                return Err(AcdpError::SchemaViolation(format!(
490                    "URI MUST NOT contain credentials in userinfo: {uri}"
491                )));
492            }
493        } else if after_slashes.contains('@') {
494            return Err(AcdpError::SchemaViolation(format!(
495                "URI MUST NOT contain credentials in userinfo: {uri}"
496            )));
497        }
498    }
499    Ok(())
500}
501
502fn validate_structured_locator(
503    map: &serde_json::Map<String, serde_json::Value>,
504) -> Result<(), AcdpError> {
505    let scheme = map.get("scheme").and_then(|v| v.as_str()).ok_or_else(|| {
506        AcdpError::SchemaViolation("structured locator missing required 'scheme'".into())
507    })?;
508    if !is_dotted_namespace_scheme(scheme) {
509        return Err(AcdpError::SchemaViolation(format!(
510            "structured locator scheme '{scheme}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
511        )));
512    }
513    Ok(())
514}
515
516fn is_dotted_namespace_scheme(s: &str) -> bool {
517    let parts: Vec<&str> = s.split('.').collect();
518    if parts.len() < 2 {
519        return false;
520    }
521    parts.iter().all(|part| {
522        !part.is_empty()
523            && part.chars().next().is_some_and(|c| c.is_ascii_lowercase())
524            && part
525                .chars()
526                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
527    })
528}
529
530fn validate_embedded(emb: &EmbeddedContent) -> Result<(), AcdpError> {
531    // utf8 / base64: content MUST be a JSON string
532    match emb.encoding {
533        EmbeddedEncoding::Utf8 | EmbeddedEncoding::Base64 => {
534            if !emb.content.is_string() {
535                return Err(AcdpError::SchemaViolation(format!(
536                    "embedded {:?} content MUST be a JSON string",
537                    emb.encoding
538                )));
539            }
540        }
541        EmbeddedEncoding::Json => {}
542    }
543    // Decoded size cap
544    let decoded = embedded_decoded_bytes(emb)?;
545    if decoded.len() > MAX_EMBEDDED_BYTES {
546        return Err(AcdpError::EmbeddedTooLarge(format!(
547            "embedded decoded size {} bytes exceeds {} limit",
548            decoded.len(),
549            MAX_EMBEDDED_BYTES
550        )));
551    }
552    Ok(())
553}
554
555/// Decode an [`EmbeddedContent`] to its canonical byte form per
556/// `acdp-data-ref.schema.json` `content_hash` semantics:
557/// - `json`   → JCS-canonicalized bytes
558/// - `utf8`   → raw UTF-8 bytes of the string
559/// - `base64` → base64-decoded bytes of the string
560pub fn embedded_decoded_bytes(emb: &EmbeddedContent) -> Result<Vec<u8>, AcdpError> {
561    Ok(match emb.encoding {
562        EmbeddedEncoding::Json => try_canonicalize_value(&emb.content)?,
563        EmbeddedEncoding::Utf8 => {
564            let s = emb.content.as_str().ok_or_else(|| {
565                AcdpError::SchemaViolation("utf8 embedded content must be a JSON string".into())
566            })?;
567            s.as_bytes().to_vec()
568        }
569        EmbeddedEncoding::Base64 => {
570            let s = emb.content.as_str().ok_or_else(|| {
571                AcdpError::SchemaViolation("base64 embedded content must be a JSON string".into())
572            })?;
573            STANDARD
574                .decode(s)
575                .map_err(|e| AcdpError::SchemaViolation(format!("base64 decode failed: {e}")))?
576        }
577    })
578}
579
580/// Compute the SHA-256 [`ContentHash`] of decoded embedded content.
581pub fn compute_embedded_hash(emb: &EmbeddedContent) -> Result<ContentHash, AcdpError> {
582    let bytes = embedded_decoded_bytes(emb)?;
583    let digest = Sha256::digest(&bytes);
584    Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
585}
586
587/// Verify a [`DataRef`]'s declared `content_hash` against its embedded payload.
588/// Does nothing if the ref has no `content_hash` or no `embedded`.
589///
590/// BUG-02: a mismatch is a *data-reference-level* integrity failure
591/// ([`AcdpError::DataRefHashMismatch`], wire code `data_ref_hash_mismatch`)
592/// — the embedded bytes diverged from the producer-declared hash, but the
593/// body's own `content_hash` / signature are unaffected. It is NOT the
594/// body-level [`AcdpError::HashMismatch`] (RFC-ACDP-0007 §5, data-ref-007).
595pub fn verify_embedded_hash(dr: &DataRef) -> Result<(), AcdpError> {
596    let (Some(emb), Some(stored)) = (&dr.embedded, &dr.content_hash) else {
597        return Ok(());
598    };
599    let recomputed = compute_embedded_hash(emb)?;
600    if &recomputed != stored {
601        return Err(AcdpError::DataRefHashMismatch(format!(
602            "embedded content_hash mismatch: declared {}, computed {}",
603            stored.as_str(),
604            recomputed.as_str()
605        )));
606    }
607    Ok(())
608}
609
610// ── Metadata ─────────────────────────────────────────────────────────────────
611
612/// Validate `metadata`'s runtime invariants per RFC-ACDP-0002 §3.3:
613/// max 100 top-level properties, max 8 nesting levels, max 64 KB JCS size.
614pub fn validate_metadata(value: &serde_json::Value) -> Result<(), AcdpError> {
615    validate_json_object_limits(value, "metadata")
616}
617
618/// Shared object-limit check for any producer-controlled free-form JSON
619/// object (`metadata` and the flattened `extensions`): max 100 top-level
620/// properties, max 8 nesting levels, max 64 KB JCS size. Without this,
621/// `extensions` (P1-3) would carry unbounded keys/values into JCS+SHA-256.
622fn validate_json_object_limits(value: &serde_json::Value, field: &str) -> Result<(), AcdpError> {
623    let obj = value
624        .as_object()
625        .ok_or_else(|| AcdpError::SchemaViolation(format!("{field} must be a JSON object")))?;
626    if obj.len() > MAX_METADATA_PROPERTIES {
627        return Err(AcdpError::SchemaViolation(format!(
628            "{field} has {} top-level properties, exceeds {} limit",
629            obj.len(),
630            MAX_METADATA_PROPERTIES
631        )));
632    }
633    let depth = json_depth(value);
634    if depth > MAX_METADATA_DEPTH {
635        return Err(AcdpError::SchemaViolation(format!(
636            "{field} nesting depth {depth} exceeds {MAX_METADATA_DEPTH}"
637        )));
638    }
639    let canonical_size = try_canonicalize_value(value)?.len();
640    if canonical_size > MAX_METADATA_JCS_BYTES {
641        return Err(AcdpError::SchemaViolation(format!(
642            "{field} JCS-canonical size {canonical_size} bytes exceeds {MAX_METADATA_JCS_BYTES}"
643        )));
644    }
645    Ok(())
646}
647
648/// Validate the flattened forward-compatibility `extensions` object with
649/// the same property-count / depth / JCS-size caps as `metadata`.
650pub fn validate_extensions(
651    extensions: &serde_json::Map<String, serde_json::Value>,
652) -> Result<(), AcdpError> {
653    if extensions.is_empty() {
654        return Ok(());
655    }
656    // Wrap in a `Value::Object` (clones the map) so the shared
657    // object-limit walker can scan it; `extensions` is small and capped,
658    // so the clone is negligible.
659    let value = serde_json::Value::Object(extensions.clone());
660    validate_json_object_limits(&value, "extensions")
661}
662
663/// Depth measured per RFC-ACDP-0002 §3.3: nested-object/array count,
664/// not counting leaf scalars. The cap of 8 is inclusive (`≤ 8`).
665/// `meta-003` pins this boundary.
666///
667/// Recursion is bounded by `MAX_JSON_DEPTH_SCAN` (well above the §3.3 cap
668/// of 8 and serde_json's 128-level parse limit) so a pathologically deep
669/// programmatically-built `Value` cannot blow the stack here. Any value
670/// that reaches the budget already exceeds `MAX_METADATA_DEPTH`, so the
671/// caller rejects it regardless of the exact (clamped) count.
672fn json_depth(v: &serde_json::Value) -> usize {
673    /// Above §3.3's cap of 8 and serde's 128 parse limit; bounds stack use.
674    const MAX_JSON_DEPTH_SCAN: usize = 256;
675    fn go(v: &serde_json::Value, budget: usize) -> usize {
676        if budget == 0 {
677            return 1; // stop descending; already far over MAX_METADATA_DEPTH
678        }
679        match v {
680            serde_json::Value::Object(map) => {
681                1 + map.values().map(|x| go(x, budget - 1)).max().unwrap_or(0)
682            }
683            serde_json::Value::Array(arr) => {
684                1 + arr.iter().map(|x| go(x, budget - 1)).max().unwrap_or(0)
685            }
686            _ => 0,
687        }
688    }
689    go(v, MAX_JSON_DEPTH_SCAN)
690}
691
692// ── Visibility ───────────────────────────────────────────────────────────────
693
694fn validate_visibility_audience(
695    vis: &Visibility,
696    audience: Option<&[AgentDid]>,
697) -> Result<(), AcdpError> {
698    match vis {
699        Visibility::Restricted => {
700            if audience.is_none_or(|a| a.is_empty()) {
701                return Err(AcdpError::SchemaViolation(
702                    "visibility:restricted requires a non-empty audience".into(),
703                ));
704            }
705        }
706        Visibility::Public => {
707            if audience.is_some_and(|a| !a.is_empty()) {
708                return Err(AcdpError::SchemaViolation(
709                    "visibility:public MUST NOT include audience".into(),
710                ));
711            }
712        }
713        Visibility::Private => {}
714    }
715    Ok(())
716}
717
718// ── Strings & arrays ─────────────────────────────────────────────────────────
719
720fn validate_title(title: &str) -> Result<(), AcdpError> {
721    if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
722        return Err(AcdpError::SchemaViolation(format!(
723            "title length {} not in 1..={}",
724            title.chars().count(),
725            MAX_TITLE_LEN
726        )));
727    }
728    Ok(())
729}
730
731fn validate_optional_string(s: Option<&str>, name: &str, max_len: usize) -> Result<(), AcdpError> {
732    if let Some(value) = s {
733        if value.chars().count() > max_len {
734            return Err(AcdpError::SchemaViolation(format!(
735                "{name} length {} exceeds {max_len}",
736                value.chars().count()
737            )));
738        }
739    }
740    Ok(())
741}
742
743fn validate_unique_array<T: PartialEq + std::fmt::Debug>(
744    name: &str,
745    items: &[T],
746    max: usize,
747) -> Result<(), AcdpError> {
748    if items.len() > max {
749        return Err(AcdpError::SchemaViolation(format!(
750            "{name} has {} items, exceeds {max}",
751            items.len()
752        )));
753    }
754    for (i, item) in items.iter().enumerate() {
755        if items[i + 1..].iter().any(|other| other == item) {
756            return Err(AcdpError::SchemaViolation(format!(
757                "{name} contains duplicate entry: {item:?}"
758            )));
759        }
760    }
761    Ok(())
762}
763
764fn validate_tags(tags: &[String]) -> Result<(), AcdpError> {
765    if tags.len() > MAX_TAGS {
766        return Err(AcdpError::SchemaViolation(format!(
767            "tags has {} entries, exceeds {}",
768            tags.len(),
769            MAX_TAGS
770        )));
771    }
772    for tag in tags {
773        validate_tag(tag)?;
774    }
775    // Uniqueness
776    for (i, tag) in tags.iter().enumerate() {
777        if tags[i + 1..].iter().any(|t| t == tag) {
778            return Err(AcdpError::SchemaViolation(format!(
779                "tags contains duplicate entry: {tag}"
780            )));
781        }
782    }
783    Ok(())
784}
785
786fn validate_tag(tag: &str) -> Result<(), AcdpError> {
787    if tag.is_empty() || tag.len() > MAX_TAG_LEN {
788        return Err(AcdpError::SchemaViolation(format!(
789            "tag '{tag}' length not in 1..={MAX_TAG_LEN}"
790        )));
791    }
792    let mut chars = tag.chars();
793    let first = chars.next().unwrap();
794    if !first.is_ascii_alphanumeric() {
795        return Err(AcdpError::SchemaViolation(format!(
796            "tag '{tag}' first char must be alphanumeric"
797        )));
798    }
799    if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) {
800        return Err(AcdpError::SchemaViolation(format!(
801            "tag '{tag}' must match [A-Za-z0-9][A-Za-z0-9_.-]*"
802        )));
803    }
804    Ok(())
805}
806
807// ── DID / agent_id ───────────────────────────────────────────────────────────
808
809/// Validate a DID used as `agent_id`.
810///
811/// Producers MUST use a resolvable method: `did:web` (RFC-ACDP-0001
812/// §5.4, the v0.1.0 baseline) or `did:key` (ACDP 0.2 — pure offline
813/// resolution; the DID is the key). For did:key the embedded key
814/// material is decoded here so a garbage identifier fails at schema
815/// validation rather than at signature verification. Whether a given
816/// *registry* accepts did:key producers is a capabilities decision
817/// (`supported_did_methods`), enforced by
818/// `registry::PublishValidator` — this function checks protocol-level
819/// well-formedness only.
820/// Validate the `signature.key_id` form when it is a `did:key` URL
821/// (ACDP 0.2). The only verification method a did:key document has is
822/// the key itself, so the fragment MUST equal the method-specific
823/// identifier and the key material MUST decode. No-op for other
824/// methods — their key_id resolves against a DID document at
825/// verification time.
826fn validate_did_key_key_id_form(key_id: &str) -> Result<(), AcdpError> {
827    if !key_id.starts_with("did:key:") {
828        return Ok(());
829    }
830    acdp_did::key::resolve_did_key_url(key_id).map_err(|e| {
831        AcdpError::SchemaViolation(format!(
832            "signature.key_id is not a well-formed did:key URL: {e}"
833        ))
834    })?;
835    Ok(())
836}
837
838fn validate_agent_did(did: &AgentDid) -> Result<(), AcdpError> {
839    if did.as_str().starts_with("did:key:") {
840        AgentDid::parse(did.as_str())?;
841        acdp_did::key::resolve_did_key(did.as_str()).map_err(|e| {
842            AcdpError::SchemaViolation(format!("agent_id is not a well-formed did:key: {e}"))
843        })?;
844        return Ok(());
845    }
846    AgentDid::parse_web(did.as_str())?;
847    Ok(())
848}
849
850/// Validate `body.origin_registry` per `acdp-context-body.schema.json`
851/// (RFC-ACDP-0002 §3.1, fixture body-001/body-002).
852///
853/// MUST be a bare DNS hostname — NOT a `did:web:` URI, NOT a URL.
854/// `capabilities.registry_did` carries the `did:web` encoding; the
855/// stored body carries the hostname encoding. Storing either form in
856/// the other field is a conformance violation.
857fn validate_origin_registry(s: &str) -> Result<(), AcdpError> {
858    if s.is_empty() {
859        return Err(AcdpError::SchemaViolation(
860            "origin_registry must be a non-empty DNS hostname".into(),
861        ));
862    }
863    if s.starts_with("did:") {
864        return Err(AcdpError::SchemaViolation(format!(
865            "origin_registry must be a DNS hostname, not a DID URI (got '{s}'); \
866             use the bare authority — capabilities.registry_did carries the did:web form"
867        )));
868    }
869    if s.contains("://") {
870        return Err(AcdpError::SchemaViolation(format!(
871            "origin_registry must be a DNS hostname, not a URL (got '{s}')"
872        )));
873    }
874    if s.ends_with('.') || s.starts_with('.') {
875        return Err(AcdpError::SchemaViolation(format!(
876            "origin_registry must be a syntactically valid DNS hostname (got '{s}')"
877        )));
878    }
879    // BUG-02: delegate the full hostname grammar to the same validator
880    // `CtxId::parse` uses for its authority. Enforces lowercase-only,
881    // no underscore, no port, and valid label structure — values like
882    // `REGISTRY.EXAMPLE.COM`, `registry_example.com`, or `registry-.com`
883    // pass the coarse checks above but are not schema-valid hostnames.
884    if !acdp_types::primitives::is_valid_dns_authority(s) {
885        return Err(AcdpError::SchemaViolation(format!(
886            "origin_registry '{s}' is not a valid DNS hostname (must be lowercase \
887             labels of [a-z0-9-] separated by dots, e.g. 'registry.example.com')"
888        )));
889    }
890    Ok(())
891}
892
893/// Validate a DID used in `contributors[]` or `audience[]`.
894///
895/// Per the spec plan's RFC-FIX-11 method-scope table:
896/// - contributors[] SHOULD be `did:web` (attribution; no key resolution),
897/// - audience[] MAY be any DID method (authorization list; not resolved
898///   in v0.1.0).
899///
900/// This helper enforces only the loose `did:` syntax (no method
901/// constraint) so other-method contributors are accepted.
902fn validate_loose_did(did: &AgentDid) -> Result<(), AcdpError> {
903    AgentDid::parse(did.as_str())?;
904    Ok(())
905}
906
907// ── Context type ─────────────────────────────────────────────────────────────
908
909fn validate_namespaced_context_type(value: &str) -> Result<(), AcdpError> {
910    // Schema pattern: ^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$
911    let (ns, name) = value.split_once(':').ok_or_else(|| {
912        AcdpError::SchemaViolation(format!(
913            "context_type '{value}' missing namespace separator"
914        ))
915    })?;
916    if ns.is_empty()
917        || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
918        || !ns
919            .chars()
920            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
921    {
922        return Err(AcdpError::SchemaViolation(format!(
923            "context_type namespace '{ns}' must match [a-z][a-z0-9_]*"
924        )));
925    }
926    if name.is_empty()
927        || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
928        || !name
929            .chars()
930            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
931    {
932        return Err(AcdpError::SchemaViolation(format!(
933            "context_type name '{name}' must match [a-z][a-z0-9_-]*"
934        )));
935    }
936    Ok(())
937}
938
939trait ContextTypeExt {
940    fn namespaced_form(&self) -> Option<&str>;
941}
942
943impl ContextTypeExt for ContextType {
944    fn namespaced_form(&self) -> Option<&str> {
945        match self {
946            ContextType::Custom(s) => Some(s.as_str()),
947            _ => None,
948        }
949    }
950}
951
952// ── Signatures ───────────────────────────────────────────────────────────────
953
954fn validate_semver_pattern(name: &str, value: &str) -> Result<(), AcdpError> {
955    let parts: Vec<&str> = value.split('.').collect();
956    let ok = parts.len() == 3
957        && parts
958            .iter()
959            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
960    if !ok {
961        return Err(AcdpError::SchemaViolation(format!(
962            "{name} '{value}' must match the semver pattern ^\\d+\\.\\d+\\.\\d+$"
963        )));
964    }
965    Ok(())
966}
967
968fn validate_signature_length(algorithm: &str, value_b64: &str) -> Result<(), AcdpError> {
969    let expected = match algorithm {
970        "ed25519" => Some(ED25519_SIG_B64_LEN),
971        "ecdsa-p256" => Some(ECDSA_P256_SIG_B64_LEN),
972        _ => None,
973    };
974    if let Some(n) = expected {
975        if value_b64.len() != n {
976            return Err(AcdpError::InvalidSignature(format!(
977                "signature.value for '{algorithm}' must be {n} base64 chars, got {}",
978                value_b64.len()
979            )));
980        }
981    }
982    Ok(())
983}
984
985// ── Tests ─────────────────────────────────────────────────────────────────────
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990    use acdp_types::data_ref::DataRefType;
991    use serde_json::json;
992
993    fn embedded_json(v: serde_json::Value) -> EmbeddedContent {
994        EmbeddedContent {
995            encoding: EmbeddedEncoding::Json,
996            content: v,
997        }
998    }
999
1000    // ── origin_registry (BUG-02) ─────────────────────────────────────────────
1001
1002    #[test]
1003    fn origin_registry_accepts_valid_hostname() {
1004        validate_origin_registry("registry.example.com").unwrap();
1005        validate_origin_registry("reg.example").unwrap();
1006        validate_origin_registry("a-b-c.io").unwrap();
1007    }
1008
1009    #[test]
1010    fn origin_registry_rejects_uppercase() {
1011        assert!(matches!(
1012            validate_origin_registry("REGISTRY.EXAMPLE.COM"),
1013            Err(AcdpError::SchemaViolation(_))
1014        ));
1015    }
1016
1017    #[test]
1018    fn origin_registry_rejects_underscore() {
1019        assert!(matches!(
1020            validate_origin_registry("registry_example.com"),
1021            Err(AcdpError::SchemaViolation(_))
1022        ));
1023    }
1024
1025    #[test]
1026    fn origin_registry_rejects_hyphen_label_edges() {
1027        assert!(matches!(
1028            validate_origin_registry("registry-.com"),
1029            Err(AcdpError::SchemaViolation(_))
1030        ));
1031        assert!(matches!(
1032            validate_origin_registry("-registry.example.com"),
1033            Err(AcdpError::SchemaViolation(_))
1034        ));
1035    }
1036
1037    // ── DataRef.oneOf ────────────────────────────────────────────────────────
1038
1039    #[test]
1040    fn data_ref_neither_location_nor_embedded_rejected() {
1041        let dr = DataRef {
1042            ref_type: DataRefType::PrimaryResult,
1043            description: None,
1044            size_bytes: None,
1045            format: None,
1046            schema_version: None,
1047            content_hash: None,
1048            location: None,
1049            embedded: None,
1050            extensions: serde_json::Map::new(),
1051        };
1052        assert!(matches!(
1053            validate_data_ref(&dr),
1054            Err(AcdpError::SchemaViolation(_))
1055        ));
1056    }
1057
1058    #[test]
1059    fn data_ref_both_location_and_embedded_rejected() {
1060        let dr = DataRef {
1061            ref_type: DataRefType::PrimaryResult,
1062            description: None,
1063            size_bytes: None,
1064            format: None,
1065            schema_version: None,
1066            content_hash: None,
1067            location: Some(Location::Uri("https://x/y".into())),
1068            embedded: Some(embedded_json(json!({"a": 1}))),
1069            extensions: serde_json::Map::new(),
1070        };
1071        assert!(matches!(
1072            validate_data_ref(&dr),
1073            Err(AcdpError::SchemaViolation(_))
1074        ));
1075    }
1076
1077    // ── DataRef.location URI ─────────────────────────────────────────────────
1078
1079    #[test]
1080    fn uri_credentials_rejected() {
1081        let dr = DataRef::uri(DataRefType::RawData, "https://user:pass@example.com/data");
1082        assert!(matches!(
1083            validate_data_ref(&dr),
1084            Err(AcdpError::SchemaViolation(_))
1085        ));
1086    }
1087
1088    #[test]
1089    fn uri_without_scheme_rejected() {
1090        let dr = DataRef::uri(DataRefType::RawData, "no-scheme");
1091        assert!(matches!(
1092            validate_data_ref(&dr),
1093            Err(AcdpError::SchemaViolation(_))
1094        ));
1095    }
1096
1097    #[test]
1098    fn uri_too_long_rejected() {
1099        let long_uri = format!("https://x.com/{}", "a".repeat(MAX_URI_LEN));
1100        let dr = DataRef::uri(DataRefType::RawData, long_uri);
1101        assert!(matches!(
1102            validate_data_ref(&dr),
1103            Err(AcdpError::SchemaViolation(_))
1104        ));
1105    }
1106
1107    // ── DataRef.location structured ──────────────────────────────────────────
1108
1109    #[test]
1110    fn structured_locator_missing_scheme_rejected() {
1111        let mut map = serde_json::Map::new();
1112        map.insert("offset".into(), json!(42));
1113        let dr = DataRef {
1114            ref_type: DataRefType::RawData,
1115            description: None,
1116            size_bytes: None,
1117            format: None,
1118            schema_version: None,
1119            content_hash: None,
1120            location: Some(Location::Structured(map)),
1121            embedded: None,
1122            extensions: serde_json::Map::new(),
1123        };
1124        assert!(matches!(
1125            validate_data_ref(&dr),
1126            Err(AcdpError::SchemaViolation(_))
1127        ));
1128    }
1129
1130    #[test]
1131    fn structured_locator_bad_scheme_rejected() {
1132        // try_structured rejects at construction time; structured() panics
1133        // in debug builds. The validate_data_ref guard catches anyone who
1134        // assembles a `DataRef` literal with a bad scheme directly.
1135        let err =
1136            DataRef::try_structured(DataRefType::RawData, "not_dotted", serde_json::Map::new())
1137                .unwrap_err();
1138        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1139
1140        // Direct literal construction (skipping the constructor): must
1141        // also be caught by validate_data_ref.
1142        let mut bad = serde_json::Map::new();
1143        bad.insert(
1144            "scheme".into(),
1145            serde_json::Value::String("not_dotted".into()),
1146        );
1147        let dr = DataRef {
1148            ref_type: DataRefType::RawData,
1149            description: None,
1150            size_bytes: None,
1151            format: None,
1152            schema_version: None,
1153            content_hash: None,
1154            location: Some(Location::Structured(bad)),
1155            embedded: None,
1156            extensions: serde_json::Map::new(),
1157        };
1158        assert!(matches!(
1159            validate_data_ref(&dr),
1160            Err(AcdpError::SchemaViolation(_))
1161        ));
1162    }
1163
1164    #[test]
1165    fn structured_locator_valid() {
1166        let mut extra = serde_json::Map::new();
1167        extra.insert("topic".into(), json!("events"));
1168        let dr = DataRef::structured(DataRefType::RawData, "kafka.offset", extra);
1169        validate_data_ref(&dr).unwrap();
1170    }
1171
1172    // ── DataRef.embedded ─────────────────────────────────────────────────────
1173
1174    #[test]
1175    fn embedded_utf8_must_be_string() {
1176        let dr = DataRef {
1177            ref_type: DataRefType::PrimaryResult,
1178            description: None,
1179            size_bytes: None,
1180            format: None,
1181            schema_version: None,
1182            content_hash: None,
1183            location: None,
1184            embedded: Some(EmbeddedContent {
1185                encoding: EmbeddedEncoding::Utf8,
1186                content: json!(42),
1187            }),
1188            extensions: serde_json::Map::new(),
1189        };
1190        assert!(matches!(
1191            validate_data_ref(&dr),
1192            Err(AcdpError::SchemaViolation(_))
1193        ));
1194    }
1195
1196    #[test]
1197    fn embedded_too_large_rejected() {
1198        // 70 KB of UTF-8 content
1199        let big = "a".repeat(70 * 1024);
1200        let dr = DataRef::embedded_utf8(DataRefType::PrimaryResult, big);
1201        assert!(matches!(
1202            validate_data_ref(&dr),
1203            Err(AcdpError::EmbeddedTooLarge(_))
1204        ));
1205    }
1206
1207    // ── Embedded hash ────────────────────────────────────────────────────────
1208
1209    #[test]
1210    fn embedded_hash_json_round_trip() {
1211        let emb = embedded_json(json!({"b": 2, "a": 1}));
1212        let h = compute_embedded_hash(&emb).unwrap();
1213        // JCS sorts keys → {"a":1,"b":2}, hash is deterministic
1214        let expected = {
1215            let bytes = b"{\"a\":1,\"b\":2}";
1216            format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
1217        };
1218        assert_eq!(h.as_str(), expected);
1219    }
1220
1221    #[test]
1222    fn embedded_hash_utf8() {
1223        let emb = EmbeddedContent {
1224            encoding: EmbeddedEncoding::Utf8,
1225            content: json!("hello"),
1226        };
1227        let h = compute_embedded_hash(&emb).unwrap();
1228        let expected = format!("sha256:{}", hex::encode(Sha256::digest(b"hello")));
1229        assert_eq!(h.as_str(), expected);
1230    }
1231
1232    #[test]
1233    fn embedded_hash_base64() {
1234        let raw = b"binary data";
1235        let b64 = STANDARD.encode(raw);
1236        let emb = EmbeddedContent {
1237            encoding: EmbeddedEncoding::Base64,
1238            content: json!(b64),
1239        };
1240        let h = compute_embedded_hash(&emb).unwrap();
1241        let expected = format!("sha256:{}", hex::encode(Sha256::digest(raw)));
1242        assert_eq!(h.as_str(), expected);
1243    }
1244
1245    #[test]
1246    fn verify_embedded_hash_mismatch_detected() {
1247        let emb = embedded_json(json!({"x": 1}));
1248        let dr = DataRef {
1249            ref_type: DataRefType::PrimaryResult,
1250            description: None,
1251            size_bytes: None,
1252            format: None,
1253            schema_version: None,
1254            content_hash: Some(ContentHash("sha256:0000".into())),
1255            location: None,
1256            embedded: Some(emb),
1257            extensions: serde_json::Map::new(),
1258        };
1259        assert!(matches!(
1260            verify_embedded_hash(&dr),
1261            Err(AcdpError::DataRefHashMismatch(_))
1262        ));
1263    }
1264
1265    // ── Metadata ─────────────────────────────────────────────────────────────
1266
1267    #[test]
1268    fn metadata_too_many_properties_rejected() {
1269        let mut obj = serde_json::Map::new();
1270        for i in 0..101 {
1271            obj.insert(format!("k{i}"), json!(i));
1272        }
1273        assert!(matches!(
1274            validate_metadata(&serde_json::Value::Object(obj)),
1275            Err(AcdpError::SchemaViolation(_))
1276        ));
1277    }
1278
1279    #[test]
1280    fn metadata_too_deep_rejected() {
1281        // Build an object nested 10 levels deep
1282        let mut v = json!("leaf");
1283        for _ in 0..10 {
1284            let mut o = serde_json::Map::new();
1285            o.insert("a".into(), v);
1286            v = serde_json::Value::Object(o);
1287        }
1288        assert!(matches!(
1289            validate_metadata(&v),
1290            Err(AcdpError::SchemaViolation(_))
1291        ));
1292    }
1293
1294    #[test]
1295    fn metadata_too_large_rejected() {
1296        let big = "a".repeat(70 * 1024);
1297        let v = json!({"big": big});
1298        assert!(matches!(
1299            validate_metadata(&v),
1300            Err(AcdpError::SchemaViolation(_))
1301        ));
1302    }
1303
1304    #[test]
1305    fn metadata_must_be_object() {
1306        assert!(matches!(
1307            validate_metadata(&json!([1, 2, 3])),
1308            Err(AcdpError::SchemaViolation(_))
1309        ));
1310    }
1311
1312    // ── Visibility / audience ────────────────────────────────────────────────
1313
1314    #[test]
1315    fn public_with_audience_rejected() {
1316        let aud = vec![AgentDid::new("did:web:x")];
1317        assert!(matches!(
1318            validate_visibility_audience(&Visibility::Public, Some(&aud)),
1319            Err(AcdpError::SchemaViolation(_))
1320        ));
1321    }
1322
1323    #[test]
1324    fn public_with_empty_audience_ok() {
1325        validate_visibility_audience(&Visibility::Public, Some(&[])).unwrap();
1326        validate_visibility_audience(&Visibility::Public, None).unwrap();
1327    }
1328
1329    #[test]
1330    fn restricted_without_audience_rejected() {
1331        assert!(matches!(
1332            validate_visibility_audience(&Visibility::Restricted, None),
1333            Err(AcdpError::SchemaViolation(_))
1334        ));
1335    }
1336
1337    // ── data_period ──────────────────────────────────────────────────────────
1338
1339    #[test]
1340    fn data_period_start_after_end_rejected_via_builder() {
1341        use acdp_crypto::SigningKey;
1342        use acdp_producer::Producer;
1343        use acdp_types::body::DataPeriod;
1344        use chrono::TimeZone;
1345
1346        let p = Producer::new(
1347            SigningKey::from_bytes(&[0u8; 32]),
1348            AgentDid::new("did:web:agents.example.com:test"),
1349            "did:web:agents.example.com:test#key-1",
1350        );
1351        let err = p
1352            .publish_request()
1353            .title("t")
1354            .context_type(ContextType::DataSnapshot)
1355            .data_period(DataPeriod {
1356                start: chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
1357                end: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1358            })
1359            .build()
1360            .unwrap_err();
1361        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1362    }
1363
1364    // ── Tags ─────────────────────────────────────────────────────────────────
1365
1366    #[test]
1367    fn tag_pattern_validation() {
1368        validate_tag("hello").unwrap();
1369        validate_tag("Q1-2026").unwrap();
1370        validate_tag("a_b.c").unwrap();
1371        // Cannot start with non-alphanumeric
1372        assert!(validate_tag("-bad").is_err());
1373        // Disallowed chars
1374        assert!(validate_tag("space here").is_err());
1375        // Empty
1376        assert!(validate_tag("").is_err());
1377    }
1378
1379    #[test]
1380    fn duplicate_tags_rejected() {
1381        let tags = vec!["a".to_string(), "b".to_string(), "a".to_string()];
1382        assert!(validate_tags(&tags).is_err());
1383    }
1384
1385    // ── Signature length ─────────────────────────────────────────────────────
1386
1387    #[test]
1388    fn ed25519_sig_must_be_88_chars() {
1389        assert!(validate_signature_length("ed25519", "AAAA").is_err());
1390        validate_signature_length("ed25519", &"A".repeat(88)).unwrap();
1391        // Unknown algorithm: skipped
1392        validate_signature_length("future-alg", "any").unwrap();
1393    }
1394
1395    // ── context_type custom ──────────────────────────────────────────────────
1396
1397    #[test]
1398    fn namespaced_context_type_pattern() {
1399        validate_namespaced_context_type("finance:portfolio_snapshot").unwrap();
1400        assert!(validate_namespaced_context_type("Finance:portfolio").is_err());
1401        assert!(validate_namespaced_context_type("finance:Portfolio").is_err());
1402        assert!(validate_namespaced_context_type("no-colon").is_err());
1403    }
1404
1405    // ── R2 audit test-coverage matrix ────────────────────────────────────────
1406
1407    /// T8 — `acdp_version` semver pattern is enforced.
1408    #[test]
1409    fn acdp_version_pattern_rejects_non_semver() {
1410        validate_semver_pattern("acdp_version", "0.1.0").unwrap();
1411        validate_semver_pattern("acdp_version", "10.20.30").unwrap();
1412        assert!(validate_semver_pattern("acdp_version", "0.1.0-rc.1").is_err());
1413        assert!(validate_semver_pattern("acdp_version", "0.0").is_err());
1414        assert!(validate_semver_pattern("acdp_version", "vee.zero.zero").is_err());
1415    }
1416
1417    /// T7 — `derived_from` containing a malformed ctx_id is rejected by
1418    /// `validate_publish_request`.
1419    #[test]
1420    fn derived_from_malformed_ctx_id_rejected() {
1421        use acdp_crypto::SigningKey;
1422        use acdp_producer::Producer;
1423
1424        let p = Producer::new(
1425            SigningKey::from_bytes(&[0u8; 32]),
1426            AgentDid::new("did:web:agents.example.com:test"),
1427            "did:web:agents.example.com:test#key-1",
1428        );
1429        let err = p
1430            .publish_request()
1431            .title("t")
1432            .context_type(ContextType::DataSnapshot)
1433            .derived_from(vec![CtxId("not-a-ctx-id".into())])
1434            .build()
1435            .unwrap_err();
1436        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1437    }
1438
1439    /// T2 — Embedded `content_hash` mismatch caught by
1440    /// `verify_embedded_hash`.
1441    #[test]
1442    fn embedded_content_hash_mismatch_caught() {
1443        use acdp_types::data_ref::DataRefType;
1444        let dr = DataRef {
1445            ref_type: DataRefType::PrimaryResult,
1446            description: None,
1447            size_bytes: None,
1448            format: None,
1449            schema_version: None,
1450            content_hash: Some(ContentHash("sha256:0000".into())),
1451            location: None,
1452            embedded: Some(EmbeddedContent {
1453                encoding: EmbeddedEncoding::Json,
1454                content: json!({"x": 1}),
1455            }),
1456            extensions: serde_json::Map::new(),
1457        };
1458        assert!(matches!(
1459            verify_embedded_hash(&dr),
1460            Err(AcdpError::DataRefHashMismatch(_))
1461        ));
1462    }
1463
1464    /// T14 — duplicate audience entries rejected (uniqueItems: true).
1465    #[test]
1466    fn audience_uniqueness_rejected() {
1467        let dup = vec![
1468            AgentDid::new("did:web:a.example.com"),
1469            AgentDid::new("did:web:a.example.com"),
1470        ];
1471        let err = validate_unique_array("audience", &dup, MAX_AUDIENCE).unwrap_err();
1472        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1473    }
1474
1475    // ── P1-3: extensions caps + bounded walkers ──────────────────────────────
1476
1477    #[test]
1478    fn extensions_empty_ok() {
1479        validate_extensions(&serde_json::Map::new()).unwrap();
1480    }
1481
1482    #[test]
1483    fn extensions_small_forward_compat_accepted() {
1484        // The can-008/can-009 shape: a couple of unknown producer fields.
1485        let mut ext = serde_json::Map::new();
1486        ext.insert("priority".into(), json!("high"));
1487        ext.insert("custom".into(), json!({"k": [1, 2, 3]}));
1488        validate_extensions(&ext).unwrap();
1489    }
1490
1491    #[test]
1492    fn extensions_too_many_properties_rejected() {
1493        let mut ext = serde_json::Map::new();
1494        for i in 0..(MAX_METADATA_PROPERTIES + 1) {
1495            ext.insert(format!("k{i}"), json!(i));
1496        }
1497        let err = validate_extensions(&ext).unwrap_err();
1498        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1499    }
1500
1501    #[test]
1502    fn extensions_oversized_jcs_rejected() {
1503        let mut ext = serde_json::Map::new();
1504        ext.insert("blob".into(), json!("x".repeat(MAX_METADATA_JCS_BYTES + 1)));
1505        let err = validate_extensions(&ext).unwrap_err();
1506        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1507    }
1508
1509    #[test]
1510    fn extensions_too_deep_rejected() {
1511        // Build a value nested past MAX_METADATA_DEPTH.
1512        let mut v = json!(0);
1513        for _ in 0..(MAX_METADATA_DEPTH + 2) {
1514            v = json!({ "n": v });
1515        }
1516        let mut ext = serde_json::Map::new();
1517        ext.insert("deep".into(), v);
1518        let err = validate_extensions(&ext).unwrap_err();
1519        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1520    }
1521
1522    #[test]
1523    fn json_depth_clamps_past_scan_budget() {
1524        // Deeper than the 256-frame scan budget but shallow enough that
1525        // building/dropping the Value is itself safe. `json_depth` must
1526        // bound its own recursion and still report a value over the §3.3
1527        // cap, and the canonicalizer must refuse it rather than overflow.
1528        let mut v = json!(0);
1529        for _ in 0..400 {
1530            v = json!([v]);
1531        }
1532        assert!(json_depth(&v) > MAX_METADATA_DEPTH);
1533        assert!(acdp_crypto::try_canonicalize_value(&v).is_err());
1534    }
1535}
1536
1537#[cfg(test)]
1538mod capabilities_0_3_0_tests {
1539    use super::*;
1540    use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1541
1542    fn caps(version: &str, supports_idem: bool, mppm: Option<u64>) -> CapabilitiesDocument {
1543        CapabilitiesDocument {
1544            acdp_version: version.into(),
1545            registry_did: "did:web:registry.example.com".into(),
1546            supported_signature_algorithms: vec!["ed25519".into()],
1547            supported_did_methods: vec!["did:web".into()],
1548            profiles: vec!["acdp-registry-core".into()],
1549            limits: Limits {
1550                max_payload_bytes: 1_048_576,
1551                max_embedded_bytes: 65_536,
1552                idempotency_key_ttl_seconds: if supports_idem { Some(86_400) } else { None },
1553                max_publish_per_minute: mppm,
1554            },
1555            read_authentication_methods: vec![],
1556            anonymous_public_reads: false,
1557            supports_idempotency_key: supports_idem,
1558            extensions: Default::default(),
1559        }
1560    }
1561
1562    /// RFC-ACDP-0007 §3.5 item 11 *(0.3.0)* — caps-007's reject variants:
1563    /// zero is rejected at validation; the ≥1 accept case passes.
1564    #[test]
1565    fn max_publish_per_minute_bounds() {
1566        assert!(validate_capabilities(&caps("0.1.0", false, Some(600))).is_ok());
1567        let err = validate_capabilities(&caps("0.1.0", false, Some(0)))
1568            .expect_err("zero MUST be rejected");
1569        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1570    }
1571
1572    /// RFC-ACDP-0003 §6.4 / RFC-ACDP-0007 §3.5 item 10 *(0.3.0)* — the
1573    /// idem-007 rule: acdp_version ≥ 0.3.0 without idempotency support
1574    /// is self-contradictory and MUST be rejected; 0.1.0/0.2.0 without
1575    /// support stay valid; 0.3.0 with support is valid.
1576    #[test]
1577    fn idempotency_required_at_0_3_0() {
1578        assert!(validate_capabilities(&caps("0.1.0", false, None)).is_ok());
1579        assert!(validate_capabilities(&caps("0.2.0", false, None)).is_ok());
1580        assert!(validate_capabilities(&caps("0.3.0", true, None)).is_ok());
1581        assert!(validate_capabilities(&caps("0.4.0", true, None)).is_ok());
1582        for v in ["0.3.0", "0.4.0", "1.0.0"] {
1583            let err = validate_capabilities(&caps(v, false, None))
1584                .expect_err("version >= 0.3.0 without idempotency MUST be rejected");
1585            assert!(matches!(err, AcdpError::SchemaViolation(_)), "{v}: {err:?}");
1586        }
1587    }
1588}