Skip to main content

fastmcp_protocol/
schema.rs

1//! JSON Schema validation for MCP tool inputs.
2//!
3//! This module provides a bounded JSON Schema Draft 2020-12 validator for the
4//! supported final-core vocabulary used by MCP tool input validation:
5//!
6//! - Type checking (string, number, integer, boolean, object, array, null)
7//! - Required field validation
8//! - Enum validation
9//! - Property, pattern-property, dependency, property-name, and
10//!   unevaluated-property validation
11//! - Items, tuple, contains, and unevaluated-items validation for arrays
12//! - Local `$id` resources, `$defs`/`$ref`/anchor/dynamic-reference
13//!   resolution, composition, and conditional applicators
14//! - Declared Draft 2020-12 vocabularies and bounded content annotations
15//!
16//! External references are never resolved through network or filesystem I/O.
17
18use fastmcp_core::AbsoluteUri;
19use regex::Regex;
20use serde_json::Value;
21use std::{cmp::Ordering, collections::HashSet, fmt};
22
23/// The sole JSON Schema dialect accepted by the final core schema-admission
24/// boundary.
25pub const FINAL_JSON_SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
26
27const CORE_VOCABULARY_URI: &str = "https://json-schema.org/draft/2020-12/vocab/core";
28const APPLICATOR_VOCABULARY_URI: &str = "https://json-schema.org/draft/2020-12/vocab/applicator";
29const UNEVALUATED_VOCABULARY_URI: &str = "https://json-schema.org/draft/2020-12/vocab/unevaluated";
30const VALIDATION_VOCABULARY_URI: &str = "https://json-schema.org/draft/2020-12/vocab/validation";
31const META_DATA_VOCABULARY_URI: &str = "https://json-schema.org/draft/2020-12/vocab/meta-data";
32const FORMAT_ANNOTATION_VOCABULARY_URI: &str =
33    "https://json-schema.org/draft/2020-12/vocab/format-annotation";
34const FORMAT_ASSERTION_VOCABULARY_URI: &str =
35    "https://json-schema.org/draft/2020-12/vocab/format-assertion";
36const CONTENT_VOCABULARY_URI: &str = "https://json-schema.org/draft/2020-12/vocab/content";
37
38/// Maximum nested schema applications on a single validation path.
39pub const MAX_SCHEMA_VALIDATION_DEPTH: usize = 64;
40
41/// Maximum schema nodes admitted for one final-dialect schema document.
42pub const MAX_SCHEMA_ADMISSION_NODES: usize = 4_096;
43
44/// Maximum JSON instance nodes traversed by one validation call.
45pub const MAX_SCHEMA_INSTANCE_NODES: usize = 4_096;
46
47/// Maximum JSON instance nesting depth accepted by one validation call.
48pub const MAX_SCHEMA_INSTANCE_DEPTH: usize = 64;
49
50/// Maximum UTF-8 bytes in one instance string or object member name.
51pub const MAX_SCHEMA_INSTANCE_STRING_BYTES: usize = 64 * 1024;
52
53/// Maximum work units performed by one validation call, including schema
54/// applications, local-resource traversal, branch probes, regular-expression
55/// compilation and matching, and object-property annotation bookkeeping.
56pub const MAX_SCHEMA_VALIDATION_WORK: usize = 4_096;
57
58/// Maximum local `$ref` hops on a single validation path.
59pub const MAX_LOCAL_REFERENCE_DEPTH: usize = 32;
60
61/// Maximum schemas evaluated by one composition keyword.
62pub const MAX_COMPOSITION_BRANCHES: usize = 64;
63
64/// Maximum `patternProperties` entries compiled for one object schema.
65pub const MAX_PATTERN_PROPERTIES: usize = 64;
66
67/// Maximum UTF-8 bytes in one locally compiled pattern-property expression.
68pub const MAX_PATTERN_PROPERTY_BYTES: usize = 4 * 1024;
69
70/// Maximum UTF-8 bytes in one locally compiled `pattern` expression.
71pub const MAX_PATTERN_BYTES: usize = 4 * 1024;
72
73/// Maximum entries accepted by one final-schema assertion payload.
74pub const MAX_SCHEMA_ASSERTION_ENTRIES: usize = 64;
75
76/// Maximum UTF-8 bytes in one final-schema assertion string.
77pub const MAX_SCHEMA_ASSERTION_STRING_BYTES: usize = 4 * 1024;
78
79/// Maximum validation errors retained for one public `validate` call.
80pub const MAX_VALIDATION_ERRORS: usize = 64;
81
82/// Maximum decimal digits retained by one exact numeric comparison.
83///
84/// `serde_json::Number` keeps the textual spelling available to this module,
85/// but final-schema validation still bounds the local representation before it
86/// participates in comparison or divisibility work.
87const MAX_EXACT_DECIMAL_DIGITS: usize = 4 * 1024;
88
89/// Error returned when JSON Schema validation fails.
90#[derive(Debug, Clone)]
91pub struct ValidationError {
92    /// Path to the invalid value (e.g., `root.foo.bar` or `root[0]`).
93    pub path: String,
94    /// Description of what went wrong.
95    pub message: String,
96}
97
98impl fmt::Display for ValidationError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "{}: {}", self.path, self.message)
101    }
102}
103
104impl std::error::Error for ValidationError {}
105
106/// Result of JSON Schema validation.
107pub type ValidationResult = Result<(), Vec<ValidationError>>;
108
109/// A stable refusal emitted before an untrusted schema reaches validation.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct SchemaAdmissionError {
112    path: String,
113    reason: &'static str,
114}
115
116impl SchemaAdmissionError {
117    fn new(path: impl Into<String>, reason: &'static str) -> Self {
118        Self {
119            path: path.into(),
120            reason,
121        }
122    }
123
124    /// JSON path of the malformed schema member.
125    #[must_use]
126    pub fn path(&self) -> &str {
127        &self.path
128    }
129
130    /// Stable refusal category for the malformed schema member.
131    #[must_use]
132    pub const fn reason(&self) -> &'static str {
133        self.reason
134    }
135}
136
137impl fmt::Display for SchemaAdmissionError {
138    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139        write!(formatter, "{}: {}", self.path, self.reason)
140    }
141}
142
143impl std::error::Error for SchemaAdmissionError {}
144
145/// A final-dialect schema that passed structural admission.
146///
147/// Construction is intentionally restricted to [`admit_final_schema`] so a
148/// caller cannot present malformed schema syntax as a validated schema.
149#[derive(Debug, Clone)]
150pub struct AdmittedSchema {
151    schema: Value,
152}
153
154impl AdmittedSchema {
155    /// Returns the admitted schema without altering its wire representation.
156    #[must_use]
157    pub const fn schema(&self) -> &Value {
158        &self.schema
159    }
160
161    /// Validates an instance using this admitted schema.
162    pub fn validate(&self, value: &Value) -> ValidationResult {
163        validate_admitted_final_schema(&self.schema, value)
164    }
165}
166
167/// A final form elicitation schema that passed both shared Draft 2020-12
168/// admission and the form surface's flat primitive-field restrictions.
169///
170/// This is intentionally distinct from [`AdmittedSchema`]: the general
171/// final-schema service permits composed and nested schemas that a client form
172/// renderer must never be asked to interpret.
173#[derive(Debug, Clone)]
174pub struct AdmittedFinalFormSchema {
175    schema: AdmittedSchema,
176}
177
178impl AdmittedFinalFormSchema {
179    /// Returns the admitted form schema without altering its wire form.
180    #[must_use]
181    pub const fn schema(&self) -> &Value {
182        self.schema.schema()
183    }
184}
185
186impl serde::Serialize for AdmittedFinalFormSchema {
187    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
188    where
189        S: serde::Serializer,
190    {
191        serde::Serialize::serialize(self.schema(), serializer)
192    }
193}
194
195impl<'de> serde::Deserialize<'de> for AdmittedFinalFormSchema {
196    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
197    where
198        D: serde::Deserializer<'de>,
199    {
200        Self::admit(<Value as serde::Deserialize>::deserialize(deserializer)?)
201            .map_err(serde::de::Error::custom)
202    }
203}
204
205impl AdmittedFinalFormSchema {
206    /// Admits one final form schema from an already-decoded wire value.
207    pub fn admit(schema: Value) -> Result<Self, SchemaAdmissionError> {
208        admit_final_form_schema(schema)
209    }
210}
211
212/// A final core result discriminator admitted by this protocol surface.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum FinalCoreResultType {
215    /// An ordinary method result.
216    Complete,
217    /// A result asking the client to supply an input before retrying.
218    InputRequired,
219}
220
221impl FinalCoreResultType {
222    /// Exact final wire spelling of the discriminator.
223    #[must_use]
224    pub const fn as_str(self) -> &'static str {
225        match self {
226            Self::Complete => "complete",
227            Self::InputRequired => "input_required",
228        }
229    }
230}
231
232/// Validates a final-dialect schema before any caller uses it for tool input,
233/// tool output, or another final core schema-bearing field.
234///
235/// The final wire boundary accepts only JSON Schema booleans or objects. A
236/// present `$schema` must identify the canonical Draft 2020-12 dialect, and
237/// every structural keyword consumed by this validator is checked before the
238/// schema can be retained. External references are refused rather than being
239/// interpreted as I/O authority.
240pub fn admit_final_schema(schema: Value) -> Result<AdmittedSchema, SchemaAdmissionError> {
241    let mut node_count = 0;
242    validate_final_schema_node(&schema, &schema, "$", true, 0, &mut node_count)?;
243    validate_unique_local_anchors(&schema, "$", 0, &mut HashSet::new())?;
244    validate_unique_local_resource_ids(&schema, "$", 0, None, &mut HashSet::new())?;
245    Ok(AdmittedSchema { schema })
246}
247
248/// Admits a final form elicitation schema before a client or server interprets
249/// it as a form contract.
250///
251/// The shared service performs bounded Draft 2020-12 admission first. The
252/// form layer then requires an object root with only flat, primitive-typed
253/// fields; nested schemas and structural applicators are not renderable form
254/// controls and are refused.
255pub fn admit_final_form_schema(
256    schema: Value,
257) -> Result<AdmittedFinalFormSchema, SchemaAdmissionError> {
258    let schema = admit_final_schema(schema)?;
259    validate_final_form_schema(schema.schema())?;
260    Ok(AdmittedFinalFormSchema { schema })
261}
262
263fn validate_final_form_schema(schema: &Value) -> Result<(), SchemaAdmissionError> {
264    let root = schema
265        .as_object()
266        .ok_or_else(|| SchemaAdmissionError::new("$", "final form schema must be an object"))?;
267    if root.get("type").and_then(Value::as_str) != Some("object") {
268        return Err(SchemaAdmissionError::new(
269            "$.type",
270            "final form schema type must be object",
271        ));
272    }
273    for keyword in [
274        "$ref",
275        "$dynamicRef",
276        "$defs",
277        "items",
278        "prefixItems",
279        "contains",
280        "allOf",
281        "anyOf",
282        "oneOf",
283        "not",
284        "if",
285        "then",
286        "else",
287        "dependentSchemas",
288        "patternProperties",
289        "propertyNames",
290        "unevaluatedProperties",
291        "unevaluatedItems",
292        "contentSchema",
293    ] {
294        if root.contains_key(keyword) {
295            return Err(SchemaAdmissionError::new(
296                format!("$.{keyword}"),
297                "final form schema cannot contain nested or composed schemas",
298            ));
299        }
300    }
301    let properties = root.get("properties").map_or(Ok(None), |value| {
302        value.as_object().map(Some).ok_or_else(|| {
303            SchemaAdmissionError::new("$.properties", "final form properties must be an object")
304        })
305    })?;
306    if root
307        .get("additionalProperties")
308        .is_some_and(|value| value != &Value::Bool(false))
309    {
310        return Err(SchemaAdmissionError::new(
311            "$.additionalProperties",
312            "final form additionalProperties must be false when present",
313        ));
314    }
315    if let Some(required) = root.get("required") {
316        let Some(required) = required.as_array() else {
317            return Err(SchemaAdmissionError::new(
318                "$.required",
319                "final form required must be an array",
320            ));
321        };
322        for (index, name) in required.iter().enumerate() {
323            let Some(name) = name.as_str() else {
324                return Err(SchemaAdmissionError::new(
325                    format!("$.required[{index}]"),
326                    "final form required entries must be property names",
327                ));
328            };
329            if !properties.is_some_and(|properties| properties.contains_key(name)) {
330                return Err(SchemaAdmissionError::new(
331                    format!("$.required[{index}]"),
332                    "final form required entries must name declared properties",
333                ));
334            }
335        }
336    }
337    let Some(properties) = properties else {
338        return Ok(());
339    };
340    for (name, property) in properties {
341        validate_final_form_property_schema(name, property)?;
342    }
343    Ok(())
344}
345
346fn validate_final_form_property_schema(
347    name: &str,
348    property: &Value,
349) -> Result<(), SchemaAdmissionError> {
350    let path = format!("$.properties.{name}");
351    let property = property.as_object().ok_or_else(|| {
352        SchemaAdmissionError::new(&path, "final form property schema must be an object")
353    })?;
354    match property.get("type").and_then(Value::as_str) {
355        Some("string" | "number" | "integer" | "boolean") => {}
356        _ => {
357            return Err(SchemaAdmissionError::new(
358                format!("{path}.type"),
359                "final form property type must be a primitive",
360            ));
361        }
362    }
363    for keyword in [
364        "$ref",
365        "$dynamicRef",
366        "$defs",
367        "properties",
368        "items",
369        "prefixItems",
370        "contains",
371        "allOf",
372        "anyOf",
373        "oneOf",
374        "not",
375        "if",
376        "then",
377        "else",
378        "dependentSchemas",
379        "patternProperties",
380        "propertyNames",
381        "unevaluatedProperties",
382        "unevaluatedItems",
383        "contentSchema",
384    ] {
385        if property.contains_key(keyword) {
386            return Err(SchemaAdmissionError::new(
387                format!("{path}.{keyword}"),
388                "final form property cannot contain nested or composed schemas",
389            ));
390        }
391    }
392    Ok(())
393}
394
395/// Validates a final core result against an admitted schema and its expected
396/// discriminator.
397///
398/// This is intentionally stricter than peer-result compatibility decoding:
399/// safe final emission must carry an explicit core `resultType`; absent,
400/// non-string, extension, and cross-branch values are rejected here.
401pub fn validate_final_core_result(
402    schema: &AdmittedSchema,
403    value: &Value,
404    expected_result_type: FinalCoreResultType,
405) -> ValidationResult {
406    let mut errors = Vec::new();
407    let Some(result) = value.as_object() else {
408        push_error(&mut errors, "root", "final result must be an object");
409        return Err(errors);
410    };
411
412    match result.get("resultType") {
413        Some(Value::String(result_type)) if result_type == expected_result_type.as_str() => {}
414        Some(Value::String(_)) => push_error(
415            &mut errors,
416            "root.resultType",
417            "resultType does not match the selected final core result branch",
418        ),
419        Some(_) => push_error(
420            &mut errors,
421            "root.resultType",
422            "resultType must be a final core result discriminator string",
423        ),
424        None => push_error(&mut errors, "root", "final result requires resultType"),
425    }
426
427    if let Err(schema_errors) = schema.validate(value) {
428        for error in schema_errors {
429            if errors.len() == MAX_VALIDATION_ERRORS {
430                break;
431            }
432            errors.push(error);
433        }
434    }
435
436    if errors.is_empty() {
437        Ok(())
438    } else {
439        Err(errors)
440    }
441}
442
443fn validate_final_schema_node(
444    schema: &Value,
445    root_schema: &Value,
446    path: &str,
447    root: bool,
448    depth: usize,
449    node_count: &mut usize,
450) -> Result<(), SchemaAdmissionError> {
451    if depth >= MAX_SCHEMA_VALIDATION_DEPTH {
452        return Err(SchemaAdmissionError::new(
453            path,
454            "schema admission nesting limit exceeded",
455        ));
456    }
457    *node_count += 1;
458    if *node_count > MAX_SCHEMA_ADMISSION_NODES {
459        return Err(SchemaAdmissionError::new(
460            path,
461            "schema admission node limit exceeded",
462        ));
463    }
464    if schema.is_boolean() {
465        return Ok(());
466    }
467    let object = schema
468        .as_object()
469        .ok_or_else(|| SchemaAdmissionError::new(path, "schema must be an object or boolean"))?;
470
471    validate_supported_schema_keywords(object, path, root)?;
472    validate_schema_id_keyword(object, path)?;
473    validate_schema_dialect_keyword(object, root_schema, path)?;
474
475    validate_local_reference_keyword(object, "$ref", root_schema, path)?;
476    validate_local_reference_keyword(object, "$dynamicRef", root_schema, path)?;
477    validate_anchor_keyword(object, "$anchor", path)?;
478    validate_anchor_keyword(object, "$dynamicAnchor", path)?;
479
480    if let Some(type_value) = object.get("type") {
481        validate_schema_type(type_value, &format!("{path}.type"))?;
482    }
483    validate_string_array_keyword(object, "required", path)?;
484    validate_string_array_keyword(object, "dependentRequired", path)?;
485    validate_nonnegative_integer_keywords(
486        object,
487        path,
488        &[
489            "minProperties",
490            "maxProperties",
491            "minItems",
492            "maxItems",
493            "minContains",
494            "maxContains",
495            "minLength",
496            "maxLength",
497        ],
498    )?;
499    validate_number_keywords(
500        object,
501        path,
502        &[
503            "minimum",
504            "maximum",
505            "exclusiveMinimum",
506            "exclusiveMaximum",
507            "multipleOf",
508        ],
509    )?;
510    if object.get("multipleOf").is_some_and(|value| {
511        ExactDecimal::from_value(value).is_none_or(|multiple| !multiple.is_positive())
512    }) {
513        return Err(SchemaAdmissionError::new(
514            format!("{path}.multipleOf"),
515            "multipleOf must be a positive number",
516        ));
517    }
518    validate_boolean_keywords(object, path, &["uniqueItems"])?;
519    validate_enum_keyword(object, path)?;
520    if let Some(value) = object.get("const") {
521        validate_exact_equality_value(value, &format!("{path}.const"), depth + 1, node_count)?;
522    }
523    if let Some(values) = object.get("enum").and_then(Value::as_array) {
524        for (index, value) in values.iter().enumerate() {
525            validate_exact_equality_value(
526                value,
527                &format!("{path}.enum[{index}]"),
528                depth + 1,
529                node_count,
530            )?;
531        }
532    }
533    validate_pattern_keyword(object, path)?;
534    validate_format_keyword(object, path)?;
535    validate_content_annotation_keywords(object, path)?;
536
537    for keyword in [
538        "properties",
539        "patternProperties",
540        "$defs",
541        "dependentSchemas",
542    ] {
543        if let Some(subschemas) = object.get(keyword) {
544            let subschemas = subschemas.as_object().ok_or_else(|| {
545                SchemaAdmissionError::new(
546                    format!("{path}.{keyword}"),
547                    "schema map keyword must be an object",
548                )
549            })?;
550            if keyword == "patternProperties" {
551                if subschemas.len() > MAX_PATTERN_PROPERTIES {
552                    return Err(SchemaAdmissionError::new(
553                        format!("{path}.{keyword}"),
554                        "patternProperties exceeds entry limit",
555                    ));
556                }
557                for pattern in subschemas.keys() {
558                    if pattern.len() > MAX_PATTERN_PROPERTY_BYTES {
559                        return Err(SchemaAdmissionError::new(
560                            format!("{path}.{keyword}"),
561                            "patternProperties pattern exceeds byte limit",
562                        ));
563                    }
564                    if Regex::new(pattern).is_err() {
565                        return Err(SchemaAdmissionError::new(
566                            format!("{path}.{keyword}"),
567                            "invalid patternProperties pattern",
568                        ));
569                    }
570                }
571            }
572            for (name, subschema) in subschemas {
573                validate_final_schema_node(
574                    subschema,
575                    root_schema,
576                    &format!("{path}.{keyword}.{name}"),
577                    false,
578                    depth + 1,
579                    node_count,
580                )?;
581            }
582        }
583    }
584
585    for keyword in [
586        "additionalProperties",
587        "unevaluatedProperties",
588        "unevaluatedItems",
589        "items",
590        "contains",
591        "not",
592        "if",
593        "then",
594        "else",
595        "propertyNames",
596        "contentSchema",
597    ] {
598        if let Some(subschema) = object.get(keyword) {
599            validate_final_schema_node(
600                subschema,
601                root_schema,
602                &format!("{path}.{keyword}"),
603                false,
604                depth + 1,
605                node_count,
606            )?;
607        }
608    }
609
610    for keyword in ["prefixItems", "allOf", "anyOf", "oneOf"] {
611        if let Some(subschemas) = object.get(keyword) {
612            let subschemas = subschemas.as_array().ok_or_else(|| {
613                SchemaAdmissionError::new(
614                    format!("{path}.{keyword}"),
615                    "schema array keyword must be an array",
616                )
617            })?;
618            if matches!(keyword, "allOf" | "anyOf" | "oneOf")
619                && subschemas.len() > MAX_COMPOSITION_BRANCHES
620            {
621                return Err(SchemaAdmissionError::new(
622                    format!("{path}.{keyword}"),
623                    "composition keyword exceeds branch limit",
624                ));
625            }
626            for (index, subschema) in subschemas.iter().enumerate() {
627                validate_final_schema_node(
628                    subschema,
629                    root_schema,
630                    &format!("{path}.{keyword}[{index}]"),
631                    false,
632                    depth + 1,
633                    node_count,
634                )?;
635            }
636        }
637    }
638
639    Ok(())
640}
641
642fn validate_supported_schema_keywords(
643    object: &serde_json::Map<String, Value>,
644    path: &str,
645    root: bool,
646) -> Result<(), SchemaAdmissionError> {
647    const SUPPORTED: &[&str] = &[
648        "$anchor",
649        "$comment",
650        "$defs",
651        "$dynamicAnchor",
652        "$dynamicRef",
653        "$id",
654        "$ref",
655        "$schema",
656        "$vocabulary",
657        "additionalProperties",
658        "allOf",
659        "anyOf",
660        "const",
661        "contains",
662        "contentEncoding",
663        "contentMediaType",
664        "contentSchema",
665        "default",
666        "dependentRequired",
667        "dependentSchemas",
668        "deprecated",
669        "description",
670        "else",
671        "enum",
672        "examples",
673        "exclusiveMaximum",
674        "exclusiveMinimum",
675        "format",
676        "if",
677        "items",
678        "maxContains",
679        "maxItems",
680        "maxLength",
681        "maxProperties",
682        "maximum",
683        "minContains",
684        "minItems",
685        "minLength",
686        "minProperties",
687        "minimum",
688        "multipleOf",
689        "not",
690        "oneOf",
691        "pattern",
692        "patternProperties",
693        "prefixItems",
694        "properties",
695        "propertyNames",
696        "readOnly",
697        "required",
698        "then",
699        "title",
700        "type",
701        "unevaluatedItems",
702        "unevaluatedProperties",
703        "uniqueItems",
704        "writeOnly",
705    ];
706
707    for keyword in object.keys() {
708        if !SUPPORTED.contains(&keyword.as_str()) {
709            return Err(SchemaAdmissionError::new(
710                format!("{path}.{keyword}"),
711                "unsupported Draft 2020-12 vocabulary keyword",
712            ));
713        }
714    }
715    if !root && object.contains_key("$schema") && !object.contains_key("$id") {
716        return Err(SchemaAdmissionError::new(
717            format!("{path}.$schema"),
718            "nested $schema is unsupported without local resource identifiers",
719        ));
720    }
721    for keyword in ["$comment", "title", "description"] {
722        if object.get(keyword).is_some_and(|value| !value.is_string()) {
723            return Err(SchemaAdmissionError::new(
724                format!("{path}.{keyword}"),
725                "schema annotation keyword must be a string",
726            ));
727        }
728    }
729    if object
730        .get("examples")
731        .is_some_and(|value| !value.is_array())
732    {
733        return Err(SchemaAdmissionError::new(
734            format!("{path}.examples"),
735            "examples must be an array",
736        ));
737    }
738    validate_boolean_keywords(object, path, &["deprecated", "readOnly", "writeOnly"])
739}
740
741fn validate_schema_id_keyword(
742    object: &serde_json::Map<String, Value>,
743    path: &str,
744) -> Result<(), SchemaAdmissionError> {
745    let Some(identifier) = object.get("$id") else {
746        return Ok(());
747    };
748    let identifier = identifier.as_str().ok_or_else(|| {
749        SchemaAdmissionError::new(format!("{path}.$id"), "schema $id must be a string")
750    })?;
751    let (identifier, fragment) = split_uri_reference_fragment(identifier);
752    if fragment.is_some_and(|fragment| !fragment.is_empty()) {
753        return Err(SchemaAdmissionError::new(
754            format!("{path}.$id"),
755            "schema $id must not contain a fragment",
756        ));
757    }
758    if !is_bounded_uri_reference(identifier) {
759        return Err(SchemaAdmissionError::new(
760            format!("{path}.$id"),
761            "schema $id must be a bounded URI reference",
762        ));
763    }
764    Ok(())
765}
766
767fn validate_schema_dialect_keyword(
768    object: &serde_json::Map<String, Value>,
769    root_schema: &Value,
770    path: &str,
771) -> Result<(), SchemaAdmissionError> {
772    let Some(dialect) = object.get("$schema") else {
773        return Ok(());
774    };
775    let dialect = dialect.as_str().ok_or_else(|| {
776        SchemaAdmissionError::new(format!("{path}.$schema"), "unsupported schema dialect")
777    })?;
778    let dialect = AbsoluteUri::parse_with_max_bytes(dialect, MAX_SCHEMA_ASSERTION_STRING_BYTES)
779        .ok()
780        .filter(|dialect| dialect.fragment().is_none())
781        .ok_or_else(|| {
782            SchemaAdmissionError::new(
783                format!("{path}.$schema"),
784                "schema dialect must be a bounded absolute URI",
785            )
786        })?;
787    if dialect.as_str() == FINAL_JSON_SCHEMA_DIALECT {
788        return Ok(());
789    }
790    let meta_schema =
791        find_local_schema_resource(root_schema, dialect.as_str()).ok_or_else(|| {
792            SchemaAdmissionError::new(format!("{path}.$schema"), "unsupported schema dialect")
793        })?;
794    validate_meta_schema_vocabulary(meta_schema, path)?;
795    Ok(())
796}
797
798fn validate_meta_schema_vocabulary(
799    meta_schema: &Value,
800    path: &str,
801) -> Result<(), SchemaAdmissionError> {
802    let Some(vocabularies) = meta_schema
803        .as_object()
804        .and_then(|object| object.get("$vocabulary"))
805    else {
806        return Ok(());
807    };
808    let vocabularies = vocabularies.as_object().ok_or_else(|| {
809        SchemaAdmissionError::new(
810            format!("{path}.$vocabulary"),
811            "meta-schema $vocabulary must be an object",
812        )
813    })?;
814    if vocabularies.len() > MAX_SCHEMA_ASSERTION_ENTRIES {
815        return Err(SchemaAdmissionError::new(
816            format!("{path}.$vocabulary"),
817            "meta-schema $vocabulary exceeds entry limit",
818        ));
819    }
820    if vocabularies.get(CORE_VOCABULARY_URI) == Some(&Value::Bool(false)) {
821        return Err(SchemaAdmissionError::new(
822            format!("{path}.$vocabulary"),
823            "meta-schema $vocabulary cannot disable the Draft 2020-12 core vocabulary",
824        ));
825    }
826    for (vocabulary, required) in vocabularies {
827        let vocabulary_uri =
828            AbsoluteUri::parse_with_max_bytes(vocabulary, MAX_SCHEMA_ASSERTION_STRING_BYTES)
829                .map_err(|_| {
830                    SchemaAdmissionError::new(
831                        format!("{path}.$vocabulary"),
832                        "meta-schema $vocabulary keys must be bounded absolute URIs",
833                    )
834                })?;
835        if vocabulary_uri.fragment().is_some() {
836            return Err(SchemaAdmissionError::new(
837                format!("{path}.$vocabulary"),
838                "meta-schema $vocabulary keys must not contain fragments",
839            ));
840        }
841        let required = required.as_bool().ok_or_else(|| {
842            SchemaAdmissionError::new(
843                format!("{path}.$vocabulary.{vocabulary}"),
844                "meta-schema $vocabulary values must be booleans",
845            )
846        })?;
847        if required && vocabulary == FORMAT_ASSERTION_VOCABULARY_URI {
848            return Err(SchemaAdmissionError::new(
849                format!("{path}.$vocabulary.{vocabulary}"),
850                "format assertion vocabulary is not supported",
851            ));
852        }
853        if required && !is_supported_vocabulary_uri(vocabulary) {
854            return Err(SchemaAdmissionError::new(
855                format!("{path}.$vocabulary.{vocabulary}"),
856                "required schema vocabulary is not supported",
857            ));
858        }
859    }
860    if vocabularies.get(CORE_VOCABULARY_URI) != Some(&Value::Bool(true)) {
861        return Err(SchemaAdmissionError::new(
862            format!("{path}.$vocabulary"),
863            "meta-schema $vocabulary must require the Draft 2020-12 core vocabulary",
864        ));
865    }
866    Ok(())
867}
868
869fn is_supported_vocabulary_uri(vocabulary: &str) -> bool {
870    matches!(
871        vocabulary,
872        CORE_VOCABULARY_URI
873            | APPLICATOR_VOCABULARY_URI
874            | UNEVALUATED_VOCABULARY_URI
875            | VALIDATION_VOCABULARY_URI
876            | META_DATA_VOCABULARY_URI
877            | FORMAT_ANNOTATION_VOCABULARY_URI
878            | CONTENT_VOCABULARY_URI
879    )
880}
881
882fn validate_content_annotation_keywords(
883    object: &serde_json::Map<String, Value>,
884    path: &str,
885) -> Result<(), SchemaAdmissionError> {
886    for keyword in ["contentEncoding", "contentMediaType"] {
887        if object.get(keyword).is_some_and(|value| {
888            value.as_str().is_none_or(|value| {
889                value.is_empty() || value.len() > MAX_SCHEMA_ASSERTION_STRING_BYTES
890            })
891        }) {
892            return Err(SchemaAdmissionError::new(
893                format!("{path}.{keyword}"),
894                "content annotation keyword must be a bounded non-empty string",
895            ));
896        }
897    }
898    Ok(())
899}
900
901fn validate_local_reference_keyword(
902    object: &serde_json::Map<String, Value>,
903    keyword: &str,
904    root_schema: &Value,
905    path: &str,
906) -> Result<(), SchemaAdmissionError> {
907    let Some(reference) = object.get(keyword) else {
908        return Ok(());
909    };
910    let reference = reference.as_str().ok_or_else(|| {
911        SchemaAdmissionError::new(
912            format!("{path}.{keyword}"),
913            "schema reference must be a string",
914        )
915    })?;
916    match resolve_local_reference(root_schema, object, reference) {
917        Ok(target) if target.is_boolean() || target.is_object() => {
918            if !is_admitted_schema_node(root_schema, target) {
919                return Err(SchemaAdmissionError::new(
920                    format!("{path}.{keyword}"),
921                    "local schema reference target is not an admitted schema node",
922                ));
923            }
924        }
925        Ok(_) => {
926            return Err(SchemaAdmissionError::new(
927                format!("{path}.{keyword}"),
928                "local schema reference target must be an object or boolean",
929            ));
930        }
931        Err("external schema reference is not allowed") => {
932            return Err(SchemaAdmissionError::new(
933                format!("{path}.{keyword}"),
934                "external schema reference is not allowed",
935            ));
936        }
937        Err("invalid local schema reference") => {
938            return Err(SchemaAdmissionError::new(
939                format!("{path}.{keyword}"),
940                "invalid local schema reference",
941            ));
942        }
943        Err(_) => {
944            return Err(SchemaAdmissionError::new(
945                format!("{path}.{keyword}"),
946                "unresolved local schema reference",
947            ));
948        }
949    }
950    Ok(())
951}
952
953/// Returns whether `target` occupies a schema-valued location in the final
954/// schema document. Annotation values can be objects too, but are never
955/// schemas merely by shape.
956fn is_admitted_schema_node(schema: &Value, target: &Value) -> bool {
957    if std::ptr::eq(schema, target) {
958        return true;
959    }
960    let Some(object) = schema.as_object() else {
961        return false;
962    };
963
964    for keyword in [
965        "properties",
966        "patternProperties",
967        "$defs",
968        "dependentSchemas",
969    ] {
970        if object
971            .get(keyword)
972            .and_then(Value::as_object)
973            .is_some_and(|subschemas| {
974                subschemas
975                    .values()
976                    .any(|subschema| is_admitted_schema_node(subschema, target))
977            })
978        {
979            return true;
980        }
981    }
982
983    for keyword in [
984        "additionalProperties",
985        "unevaluatedProperties",
986        "unevaluatedItems",
987        "items",
988        "contains",
989        "not",
990        "if",
991        "then",
992        "else",
993        "propertyNames",
994        "contentSchema",
995    ] {
996        if object
997            .get(keyword)
998            .is_some_and(|subschema| is_admitted_schema_node(subschema, target))
999        {
1000            return true;
1001        }
1002    }
1003
1004    for keyword in ["prefixItems", "allOf", "anyOf", "oneOf"] {
1005        if object
1006            .get(keyword)
1007            .and_then(Value::as_array)
1008            .is_some_and(|subschemas| {
1009                subschemas
1010                    .iter()
1011                    .any(|subschema| is_admitted_schema_node(subschema, target))
1012            })
1013        {
1014            return true;
1015        }
1016    }
1017
1018    false
1019}
1020
1021fn validate_anchor_keyword(
1022    object: &serde_json::Map<String, Value>,
1023    keyword: &str,
1024    path: &str,
1025) -> Result<(), SchemaAdmissionError> {
1026    let Some(anchor) = object.get(keyword) else {
1027        return Ok(());
1028    };
1029    let anchor = anchor.as_str().ok_or_else(|| {
1030        SchemaAdmissionError::new(
1031            format!("{path}.{keyword}"),
1032            "schema anchor must be a string",
1033        )
1034    })?;
1035    if valid_anchor_name(anchor) {
1036        Ok(())
1037    } else {
1038        Err(SchemaAdmissionError::new(
1039            format!("{path}.{keyword}"),
1040            "schema anchor has an invalid name",
1041        ))
1042    }
1043}
1044
1045fn valid_anchor_name(anchor: &str) -> bool {
1046    let mut characters = anchor.chars();
1047    matches!(characters.next(), Some(character) if character.is_ascii_alphabetic() || character == '_')
1048        && characters.all(|character| {
1049            character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.')
1050        })
1051}
1052
1053fn validate_unique_local_anchors(
1054    schema: &Value,
1055    path: &str,
1056    depth: usize,
1057    anchors: &mut HashSet<String>,
1058) -> Result<(), SchemaAdmissionError> {
1059    if depth >= MAX_SCHEMA_VALIDATION_DEPTH {
1060        return Err(SchemaAdmissionError::new(
1061            path,
1062            "schema admission nesting limit exceeded",
1063        ));
1064    }
1065    let Some(object) = schema.as_object() else {
1066        return Ok(());
1067    };
1068    for keyword in ["$anchor", "$dynamicAnchor"] {
1069        if let Some(anchor) = object.get(keyword).and_then(Value::as_str)
1070            && !anchors.insert(anchor.to_owned())
1071        {
1072            return Err(SchemaAdmissionError::new(
1073                format!("{path}.{keyword}"),
1074                "duplicate local schema anchor",
1075            ));
1076        }
1077    }
1078    for keyword in [
1079        "properties",
1080        "patternProperties",
1081        "$defs",
1082        "dependentSchemas",
1083    ] {
1084        if let Some(subschemas) = object.get(keyword).and_then(Value::as_object) {
1085            for (name, subschema) in subschemas {
1086                validate_unique_local_anchor_child(
1087                    subschema,
1088                    &format!("{path}.{keyword}.{name}"),
1089                    depth + 1,
1090                    anchors,
1091                )?;
1092            }
1093        }
1094    }
1095    for keyword in [
1096        "additionalProperties",
1097        "unevaluatedProperties",
1098        "unevaluatedItems",
1099        "items",
1100        "contains",
1101        "not",
1102        "if",
1103        "then",
1104        "else",
1105        "propertyNames",
1106        "contentSchema",
1107    ] {
1108        if let Some(subschema) = object.get(keyword) {
1109            validate_unique_local_anchor_child(
1110                subschema,
1111                &format!("{path}.{keyword}"),
1112                depth + 1,
1113                anchors,
1114            )?;
1115        }
1116    }
1117    for keyword in ["prefixItems", "allOf", "anyOf", "oneOf"] {
1118        if let Some(subschemas) = object.get(keyword).and_then(Value::as_array) {
1119            for (index, subschema) in subschemas.iter().enumerate() {
1120                validate_unique_local_anchor_child(
1121                    subschema,
1122                    &format!("{path}.{keyword}[{index}]"),
1123                    depth + 1,
1124                    anchors,
1125                )?;
1126            }
1127        }
1128    }
1129    Ok(())
1130}
1131
1132fn validate_unique_local_anchor_child(
1133    schema: &Value,
1134    path: &str,
1135    depth: usize,
1136    anchors: &mut HashSet<String>,
1137) -> Result<(), SchemaAdmissionError> {
1138    if schema
1139        .as_object()
1140        .is_some_and(|object| object.contains_key("$id"))
1141    {
1142        validate_unique_local_anchors(schema, path, depth, &mut HashSet::new())
1143    } else {
1144        validate_unique_local_anchors(schema, path, depth, anchors)
1145    }
1146}
1147
1148fn validate_unique_local_resource_ids(
1149    schema: &Value,
1150    path: &str,
1151    depth: usize,
1152    parent_base: Option<&str>,
1153    identifiers: &mut HashSet<String>,
1154) -> Result<(), SchemaAdmissionError> {
1155    if depth >= MAX_SCHEMA_VALIDATION_DEPTH {
1156        return Err(SchemaAdmissionError::new(
1157            path,
1158            "schema admission nesting limit exceeded",
1159        ));
1160    }
1161    let Some(object) = schema.as_object() else {
1162        return Ok(());
1163    };
1164    let base = object
1165        .get("$id")
1166        .and_then(Value::as_str)
1167        .map(|identifier| {
1168            resolve_resource_identifier(parent_base, identifier).ok_or_else(|| {
1169                SchemaAdmissionError::new(
1170                    format!("{path}.$id"),
1171                    "schema $id must resolve to a bounded absolute URI",
1172                )
1173            })
1174        })
1175        .transpose()?;
1176    if let Some(identifier) = &base
1177        && !identifiers.insert(identifier.clone())
1178    {
1179        return Err(SchemaAdmissionError::new(
1180            format!("{path}.$id"),
1181            "duplicate local schema resource identifier",
1182        ));
1183    }
1184    let current_base = base.as_deref().or(parent_base);
1185    for keyword in [
1186        "properties",
1187        "patternProperties",
1188        "$defs",
1189        "dependentSchemas",
1190    ] {
1191        if let Some(subschemas) = object.get(keyword).and_then(Value::as_object) {
1192            for (name, subschema) in subschemas {
1193                validate_unique_local_resource_ids(
1194                    subschema,
1195                    &format!("{path}.{keyword}.{name}"),
1196                    depth + 1,
1197                    current_base,
1198                    identifiers,
1199                )?;
1200            }
1201        }
1202    }
1203    for keyword in [
1204        "additionalProperties",
1205        "unevaluatedProperties",
1206        "unevaluatedItems",
1207        "items",
1208        "contains",
1209        "not",
1210        "if",
1211        "then",
1212        "else",
1213        "propertyNames",
1214        "contentSchema",
1215    ] {
1216        if let Some(subschema) = object.get(keyword) {
1217            validate_unique_local_resource_ids(
1218                subschema,
1219                &format!("{path}.{keyword}"),
1220                depth + 1,
1221                current_base,
1222                identifiers,
1223            )?;
1224        }
1225    }
1226    for keyword in ["prefixItems", "allOf", "anyOf", "oneOf"] {
1227        if let Some(subschemas) = object.get(keyword).and_then(Value::as_array) {
1228            for (index, subschema) in subschemas.iter().enumerate() {
1229                validate_unique_local_resource_ids(
1230                    subschema,
1231                    &format!("{path}.{keyword}[{index}]"),
1232                    depth + 1,
1233                    current_base,
1234                    identifiers,
1235                )?;
1236            }
1237        }
1238    }
1239    Ok(())
1240}
1241
1242fn validate_schema_type(value: &Value, path: &str) -> Result<(), SchemaAdmissionError> {
1243    let valid_type = |type_name: &str| {
1244        matches!(
1245            type_name,
1246            "array" | "boolean" | "integer" | "null" | "number" | "object" | "string"
1247        )
1248    };
1249    match value {
1250        Value::String(type_name) if valid_type(type_name) => Ok(()),
1251        Value::Array(types)
1252            if !types.is_empty()
1253                && types
1254                    .iter()
1255                    .all(|type_name| type_name.as_str().is_some_and(valid_type)) =>
1256        {
1257            Ok(())
1258        }
1259        _ => Err(SchemaAdmissionError::new(
1260            path,
1261            "type must contain only JSON Schema primitive type names",
1262        )),
1263    }
1264}
1265
1266fn validate_string_array_keyword(
1267    object: &serde_json::Map<String, Value>,
1268    keyword: &str,
1269    path: &str,
1270) -> Result<(), SchemaAdmissionError> {
1271    let Some(value) = object.get(keyword) else {
1272        return Ok(());
1273    };
1274    if keyword == "dependentRequired" {
1275        let dependencies = value.as_object().ok_or_else(|| {
1276            SchemaAdmissionError::new(
1277                format!("{path}.{keyword}"),
1278                "dependentRequired must be an object",
1279            )
1280        })?;
1281        if dependencies.len() > MAX_SCHEMA_ASSERTION_ENTRIES {
1282            return Err(SchemaAdmissionError::new(
1283                format!("{path}.{keyword}"),
1284                "dependentRequired exceeds entry limit",
1285            ));
1286        }
1287        let mut validation_work = dependencies.len();
1288        for (trigger, required) in dependencies {
1289            if trigger.len() > MAX_SCHEMA_ASSERTION_STRING_BYTES {
1290                return Err(SchemaAdmissionError::new(
1291                    format!("{path}.{keyword}"),
1292                    "dependentRequired string exceeds byte limit",
1293                ));
1294            }
1295            let members = required.as_array().ok_or_else(|| {
1296                SchemaAdmissionError::new(
1297                    format!("{path}.{keyword}"),
1298                    "dependentRequired values must be arrays of strings",
1299                )
1300            })?;
1301            if members.len() > MAX_SCHEMA_ASSERTION_ENTRIES {
1302                return Err(SchemaAdmissionError::new(
1303                    format!("{path}.{keyword}"),
1304                    "dependentRequired values exceed entry limit",
1305                ));
1306            }
1307            validation_work = validation_work.checked_add(members.len()).ok_or_else(|| {
1308                SchemaAdmissionError::new(
1309                    format!("{path}.{keyword}"),
1310                    "dependentRequired exceeds validation work budget",
1311                )
1312            })?;
1313            if validation_work >= MAX_SCHEMA_VALIDATION_WORK {
1314                return Err(SchemaAdmissionError::new(
1315                    format!("{path}.{keyword}"),
1316                    "dependentRequired exceeds validation work budget",
1317                ));
1318            }
1319            if members.iter().any(|member| {
1320                member
1321                    .as_str()
1322                    .is_none_or(|member| member.len() > MAX_SCHEMA_ASSERTION_STRING_BYTES)
1323            }) {
1324                return Err(SchemaAdmissionError::new(
1325                    format!("{path}.{keyword}"),
1326                    "dependentRequired values must be bounded strings",
1327                ));
1328            }
1329        }
1330        return Ok(());
1331    }
1332    let members = value.as_array().ok_or_else(|| {
1333        SchemaAdmissionError::new(
1334            format!("{path}.{keyword}"),
1335            "schema string-array keyword must be an array of strings",
1336        )
1337    })?;
1338    if members.len() > MAX_SCHEMA_ASSERTION_ENTRIES {
1339        return Err(SchemaAdmissionError::new(
1340            format!("{path}.{keyword}"),
1341            "required exceeds entry limit",
1342        ));
1343    }
1344    if members.iter().any(|member| {
1345        member
1346            .as_str()
1347            .is_none_or(|member| member.len() > MAX_SCHEMA_ASSERTION_STRING_BYTES)
1348    }) {
1349        return Err(SchemaAdmissionError::new(
1350            format!("{path}.{keyword}"),
1351            "schema string-array keyword must contain bounded strings",
1352        ));
1353    }
1354    Ok(())
1355}
1356
1357fn validate_nonnegative_integer_keywords(
1358    object: &serde_json::Map<String, Value>,
1359    path: &str,
1360    keywords: &[&str],
1361) -> Result<(), SchemaAdmissionError> {
1362    for keyword in keywords {
1363        if object.get(*keyword).is_some_and(|value| {
1364            ExactDecimal::from_value(value)
1365                .is_none_or(|number| number.negative || !number.is_integer())
1366        }) {
1367            return Err(SchemaAdmissionError::new(
1368                format!("{path}.{keyword}"),
1369                "schema count keyword must be a nonnegative integer",
1370            ));
1371        }
1372    }
1373    Ok(())
1374}
1375
1376fn validate_number_keywords(
1377    object: &serde_json::Map<String, Value>,
1378    path: &str,
1379    keywords: &[&str],
1380) -> Result<(), SchemaAdmissionError> {
1381    for keyword in keywords {
1382        if object
1383            .get(*keyword)
1384            .is_some_and(|value| ExactDecimal::from_value(value).is_none())
1385        {
1386            return Err(SchemaAdmissionError::new(
1387                format!("{path}.{keyword}"),
1388                "schema numeric keyword must be a bounded exact number",
1389            ));
1390        }
1391    }
1392    Ok(())
1393}
1394
1395fn validate_boolean_keywords(
1396    object: &serde_json::Map<String, Value>,
1397    path: &str,
1398    keywords: &[&str],
1399) -> Result<(), SchemaAdmissionError> {
1400    for keyword in keywords {
1401        if object
1402            .get(*keyword)
1403            .is_some_and(|value| !value.is_boolean())
1404        {
1405            return Err(SchemaAdmissionError::new(
1406                format!("{path}.{keyword}"),
1407                "schema boolean keyword must be a boolean",
1408            ));
1409        }
1410    }
1411    Ok(())
1412}
1413
1414fn validate_enum_keyword(
1415    object: &serde_json::Map<String, Value>,
1416    path: &str,
1417) -> Result<(), SchemaAdmissionError> {
1418    if let Some(value) = object.get("enum") {
1419        let values = value.as_array().ok_or_else(|| {
1420            SchemaAdmissionError::new(format!("{path}.enum"), "enum must be a nonempty array")
1421        })?;
1422        if values.is_empty() {
1423            return Err(SchemaAdmissionError::new(
1424                format!("{path}.enum"),
1425                "enum must be a nonempty array",
1426            ));
1427        }
1428        if values.len() > MAX_SCHEMA_ASSERTION_ENTRIES {
1429            return Err(SchemaAdmissionError::new(
1430                format!("{path}.enum"),
1431                "enum exceeds entry limit",
1432            ));
1433        }
1434    }
1435    Ok(())
1436}
1437
1438/// Refuses an admitted equality assertion that the bounded final validator
1439/// could not compare reflexively. This applies to `const` and every `enum`
1440/// member, including numbers nested inside arrays or objects.
1441fn validate_exact_equality_value(
1442    value: &Value,
1443    path: &str,
1444    depth: usize,
1445    node_count: &mut usize,
1446) -> Result<(), SchemaAdmissionError> {
1447    if depth >= MAX_SCHEMA_VALIDATION_DEPTH {
1448        return Err(SchemaAdmissionError::new(
1449            path,
1450            "schema admission nesting limit exceeded",
1451        ));
1452    }
1453    *node_count += 1;
1454    if *node_count > MAX_SCHEMA_ADMISSION_NODES {
1455        return Err(SchemaAdmissionError::new(
1456            path,
1457            "schema admission node limit exceeded",
1458        ));
1459    }
1460    match value {
1461        Value::Number(number) if ExactDecimal::from_number(number).is_none() => {
1462            Err(SchemaAdmissionError::new(
1463                path,
1464                "const or enum value exceeds exact numeric equality bound",
1465            ))
1466        }
1467        Value::String(string) if string.len() > MAX_SCHEMA_ASSERTION_STRING_BYTES => Err(
1468            SchemaAdmissionError::new(path, "schema assertion string exceeds byte limit"),
1469        ),
1470        Value::Array(values) => {
1471            for (index, value) in values.iter().enumerate() {
1472                validate_exact_equality_value(
1473                    value,
1474                    &format!("{path}[{index}]"),
1475                    depth + 1,
1476                    node_count,
1477                )?;
1478            }
1479            Ok(())
1480        }
1481        Value::Object(values) => {
1482            for (name, value) in values {
1483                if name.len() > MAX_SCHEMA_ASSERTION_STRING_BYTES {
1484                    return Err(SchemaAdmissionError::new(
1485                        path,
1486                        "schema assertion string exceeds byte limit",
1487                    ));
1488                }
1489                validate_exact_equality_value(
1490                    value,
1491                    &format!("{path}.{name}"),
1492                    depth + 1,
1493                    node_count,
1494                )?;
1495            }
1496            Ok(())
1497        }
1498        _ => Ok(()),
1499    }
1500}
1501
1502fn validate_pattern_keyword(
1503    object: &serde_json::Map<String, Value>,
1504    path: &str,
1505) -> Result<(), SchemaAdmissionError> {
1506    let Some(pattern) = object.get("pattern") else {
1507        return Ok(());
1508    };
1509    let pattern = pattern.as_str().ok_or_else(|| {
1510        SchemaAdmissionError::new(format!("{path}.pattern"), "pattern must be a string")
1511    })?;
1512    if pattern.len() > MAX_PATTERN_BYTES {
1513        return Err(SchemaAdmissionError::new(
1514            format!("{path}.pattern"),
1515            "pattern exceeds byte limit",
1516        ));
1517    }
1518    if Regex::new(pattern).is_ok() {
1519        Ok(())
1520    } else {
1521        Err(SchemaAdmissionError::new(
1522            format!("{path}.pattern"),
1523            "invalid schema pattern",
1524        ))
1525    }
1526}
1527
1528fn validate_format_keyword(
1529    object: &serde_json::Map<String, Value>,
1530    path: &str,
1531) -> Result<(), SchemaAdmissionError> {
1532    if object.get("format").is_some_and(|value| !value.is_string()) {
1533        Err(SchemaAdmissionError::new(
1534            format!("{path}.format"),
1535            "format must be a string",
1536        ))
1537    } else {
1538        Ok(())
1539    }
1540}
1541
1542/// Validates a JSON value against a JSON Schema.
1543///
1544/// # Arguments
1545///
1546/// * `schema` - The JSON Schema to validate against
1547/// * `value` - The value to validate
1548///
1549/// # Returns
1550///
1551/// `Ok(())` if the value is valid, or `Err(Vec<ValidationError>)` with all
1552/// validation errors found.
1553///
1554/// # Example
1555///
1556/// ```
1557/// use fastmcp_protocol::schema::validate;
1558/// use serde_json::json;
1559///
1560/// let schema = json!({
1561///     "type": "object",
1562///     "properties": {
1563///         "name": { "type": "string" },
1564///         "age": { "type": "integer" }
1565///     },
1566///     "required": ["name"]
1567/// });
1568///
1569/// let valid = json!({ "name": "Alice", "age": 30 });
1570/// assert!(validate(&schema, &valid).is_ok());
1571///
1572/// let invalid = json!({ "age": 30 });
1573/// assert!(validate(&schema, &invalid).is_err());
1574/// ```
1575pub fn validate(schema: &Value, value: &Value) -> ValidationResult {
1576    validate_with_schema_features(schema, value, false)
1577}
1578
1579/// Validates an instance with the final-schema vocabulary accepted by
1580/// [`admit_final_schema`].
1581fn validate_admitted_final_schema(schema: &Value, value: &Value) -> ValidationResult {
1582    validate_with_schema_features(schema, value, true)
1583}
1584
1585fn validate_with_schema_features(
1586    schema: &Value,
1587    value: &Value,
1588    enforce_unevaluated_properties: bool,
1589) -> ValidationResult {
1590    let mut errors = Vec::new();
1591    let mut context = ValidationContext::new(schema, enforce_unevaluated_properties);
1592    let mut instance_nodes = 0;
1593    if !validate_instance_bounds(value, "root", 0, &mut instance_nodes, &mut errors) {
1594        return Err(errors);
1595    }
1596    if enforce_unevaluated_properties
1597        && !consume_instance_preflight_work(&mut context, instance_nodes, "root", &mut errors)
1598    {
1599        return Err(errors);
1600    }
1601    validate_internal(schema, value, "root", &mut errors, &mut context);
1602    if context.work_exhausted
1603        && !errors
1604            .iter()
1605            .any(|error| error.message == "schema validation work limit exceeded")
1606    {
1607        push_error(&mut errors, "root", "schema validation work limit exceeded");
1608    }
1609
1610    if errors.is_empty() {
1611        Ok(())
1612    } else {
1613        Err(errors)
1614    }
1615}
1616
1617/// Validates a JSON value against a JSON Schema in strict mode.
1618///
1619/// Strict mode enforces `additionalProperties: false` on all object schemas,
1620/// rejecting any properties not explicitly defined in the schema.
1621///
1622/// # Arguments
1623///
1624/// * `schema` - The JSON Schema to validate against
1625/// * `value` - The value to validate
1626///
1627/// # Returns
1628///
1629/// `Ok(())` if the value is valid, or `Err(Vec<ValidationError>)` with all
1630/// validation errors found.
1631///
1632/// # Example
1633///
1634/// ```
1635/// use fastmcp_protocol::schema::validate_strict;
1636/// use serde_json::json;
1637///
1638/// let schema = json!({
1639///     "type": "object",
1640///     "properties": {
1641///         "name": { "type": "string" }
1642///     }
1643/// });
1644///
1645/// // Extra property "age" is rejected in strict mode
1646/// let with_extra = json!({ "name": "Alice", "age": 30 });
1647/// assert!(validate_strict(&schema, &with_extra).is_err());
1648///
1649/// // Only defined properties pass
1650/// let valid = json!({ "name": "Alice" });
1651/// assert!(validate_strict(&schema, &valid).is_ok());
1652/// ```
1653pub fn validate_strict(schema: &Value, value: &Value) -> ValidationResult {
1654    // Clone and modify the schema to enforce additionalProperties: false
1655    let strict_schema = make_strict_schema(schema);
1656    validate(&strict_schema, value)
1657}
1658
1659/// Recursively adds `additionalProperties: false` to all object schemas.
1660fn make_strict_schema(schema: &Value) -> Value {
1661    match schema {
1662        Value::Object(obj) => {
1663            let mut new_obj = obj.clone();
1664
1665            // Add additionalProperties: false if this is an object type schema
1666            // and doesn't already have additionalProperties defined
1667            if let Some(type_val) = obj.get("type") {
1668                let is_object_type = type_val == "object"
1669                    || type_val
1670                        .as_array()
1671                        .is_some_and(|arr| arr.iter().any(|t| t == "object"));
1672
1673                if is_object_type && !obj.contains_key("additionalProperties") {
1674                    new_obj.insert("additionalProperties".to_string(), Value::Bool(false));
1675                }
1676            }
1677
1678            for keyword in [
1679                "properties",
1680                "patternProperties",
1681                "dependentSchemas",
1682                "$defs",
1683            ] {
1684                if let Some(Value::Object(subschemas)) = obj.get(keyword) {
1685                    let strict_subschemas: serde_json::Map<String, Value> = subschemas
1686                        .iter()
1687                        .map(|(key, subschema)| (key.clone(), make_strict_schema(subschema)))
1688                        .collect();
1689                    new_obj.insert(keyword.to_owned(), Value::Object(strict_subschemas));
1690                }
1691            }
1692
1693            for keyword in [
1694                "additionalProperties",
1695                "unevaluatedProperties",
1696                "unevaluatedItems",
1697                "items",
1698                "contains",
1699                "not",
1700                "if",
1701                "then",
1702                "else",
1703                "propertyNames",
1704                "contentSchema",
1705            ] {
1706                if let Some(subschema) = obj.get(keyword) {
1707                    new_obj.insert(keyword.to_owned(), make_strict_schema(subschema));
1708                }
1709            }
1710
1711            for keyword in ["prefixItems", "allOf", "anyOf", "oneOf"] {
1712                if let Some(Value::Array(subschemas)) = obj.get(keyword) {
1713                    let strict_subschemas = subschemas.iter().map(make_strict_schema).collect();
1714                    new_obj.insert(keyword.to_owned(), Value::Array(strict_subschemas));
1715                }
1716            }
1717
1718            Value::Object(new_obj)
1719        }
1720        Value::Array(arr) => {
1721            // Handle array schemas (union types in older drafts)
1722            Value::Array(arr.iter().map(make_strict_schema).collect())
1723        }
1724        _ => schema.clone(),
1725    }
1726}
1727
1728struct ValidationContext<'a> {
1729    root_schema: &'a Value,
1730    schema_depth: usize,
1731    reference_depth: usize,
1732    dynamic_anchors: Vec<(String, Value)>,
1733    remaining_work: usize,
1734    work_exhausted: bool,
1735    enforce_unevaluated_properties: bool,
1736}
1737
1738impl<'a> ValidationContext<'a> {
1739    const fn new(root_schema: &'a Value, enforce_unevaluated_properties: bool) -> Self {
1740        Self {
1741            root_schema,
1742            schema_depth: 0,
1743            reference_depth: 0,
1744            dynamic_anchors: Vec::new(),
1745            remaining_work: MAX_SCHEMA_VALIDATION_WORK,
1746            work_exhausted: false,
1747            enforce_unevaluated_properties,
1748        }
1749    }
1750
1751    fn consume_work(&mut self) -> bool {
1752        let Some(remaining_work) = self.remaining_work.checked_sub(1) else {
1753            self.work_exhausted = true;
1754            return false;
1755        };
1756        self.remaining_work = remaining_work;
1757        true
1758    }
1759
1760    fn enter_schema(&mut self) -> bool {
1761        if self.schema_depth >= MAX_SCHEMA_VALIDATION_DEPTH || !self.consume_work() {
1762            return false;
1763        }
1764        self.schema_depth += 1;
1765        true
1766    }
1767
1768    fn leave_schema(&mut self) {
1769        self.schema_depth -= 1;
1770    }
1771
1772    fn enter_reference(&mut self) -> bool {
1773        if self.reference_depth >= MAX_LOCAL_REFERENCE_DEPTH {
1774            return false;
1775        }
1776        self.reference_depth += 1;
1777        true
1778    }
1779
1780    fn leave_reference(&mut self) {
1781        self.reference_depth -= 1;
1782    }
1783
1784    fn push_dynamic_anchor(&mut self, name: &str, schema: &Value) {
1785        self.dynamic_anchors.push((name.to_owned(), schema.clone()));
1786    }
1787
1788    fn pop_dynamic_anchor(&mut self) {
1789        let _ = self.dynamic_anchors.pop();
1790    }
1791
1792    fn dynamic_anchor_target(&self, name: &str) -> Option<Value> {
1793        self.dynamic_anchors
1794            .iter()
1795            .rev()
1796            .find(|(candidate, _)| candidate == name)
1797            .map(|(_, schema)| schema.clone())
1798    }
1799}
1800
1801fn consume_instance_preflight_work(
1802    context: &mut ValidationContext<'_>,
1803    nodes: usize,
1804    path: &str,
1805    errors: &mut Vec<ValidationError>,
1806) -> bool {
1807    for _ in 0..nodes {
1808        if !context.consume_work() {
1809            push_error(errors, path, "schema validation work limit exceeded");
1810            return false;
1811        }
1812    }
1813    true
1814}
1815
1816fn consume_validation_work(
1817    context: &mut ValidationContext<'_>,
1818    path: &str,
1819    errors: &mut Vec<ValidationError>,
1820) -> bool {
1821    if !context.enforce_unevaluated_properties {
1822        return true;
1823    }
1824    if context.consume_work() {
1825        true
1826    } else {
1827        push_error(errors, path, "schema validation work limit exceeded");
1828        false
1829    }
1830}
1831
1832fn charged_regex_is_match(
1833    pattern: &Regex,
1834    candidate: &str,
1835    path: &str,
1836    errors: &mut Vec<ValidationError>,
1837    context: &mut ValidationContext<'_>,
1838) -> Option<bool> {
1839    consume_validation_work(context, path, errors).then(|| pattern.is_match(candidate))
1840}
1841
1842fn validate_instance_bounds(
1843    value: &Value,
1844    path: &str,
1845    depth: usize,
1846    node_count: &mut usize,
1847    errors: &mut Vec<ValidationError>,
1848) -> bool {
1849    if depth >= MAX_SCHEMA_INSTANCE_DEPTH {
1850        push_error(errors, path, "instance nesting limit exceeded");
1851        return false;
1852    }
1853    *node_count += 1;
1854    if *node_count > MAX_SCHEMA_INSTANCE_NODES {
1855        push_error(errors, path, "instance node limit exceeded");
1856        return false;
1857    }
1858
1859    match value {
1860        Value::String(string) => {
1861            if string.len() > MAX_SCHEMA_INSTANCE_STRING_BYTES {
1862                push_error(errors, path, "instance string byte limit exceeded");
1863                return false;
1864            }
1865        }
1866        Value::Array(items) => {
1867            for (index, item) in items.iter().enumerate() {
1868                if !validate_instance_bounds(
1869                    item,
1870                    &format!("{path}[{index}]"),
1871                    depth + 1,
1872                    node_count,
1873                    errors,
1874                ) {
1875                    return false;
1876                }
1877            }
1878        }
1879        Value::Object(members) => {
1880            for (name, member) in members {
1881                if name.len() > MAX_SCHEMA_INSTANCE_STRING_BYTES {
1882                    push_error(
1883                        errors,
1884                        path,
1885                        "instance object member-name byte limit exceeded",
1886                    );
1887                    return false;
1888                }
1889                if !validate_instance_bounds(
1890                    member,
1891                    &format!("{path}.{name}"),
1892                    depth + 1,
1893                    node_count,
1894                    errors,
1895                ) {
1896                    return false;
1897                }
1898            }
1899        }
1900        Value::Null | Value::Bool(_) | Value::Number(_) => {}
1901    }
1902    true
1903}
1904
1905fn push_error(errors: &mut Vec<ValidationError>, path: &str, message: impl Into<String>) {
1906    if errors.len() < MAX_VALIDATION_ERRORS {
1907        errors.push(ValidationError {
1908            path: path.to_owned(),
1909            message: message.into(),
1910        });
1911    }
1912}
1913
1914/// Internal recursive validation function.
1915fn validate_internal(
1916    schema: &Value,
1917    value: &Value,
1918    path: &str,
1919    errors: &mut Vec<ValidationError>,
1920    context: &mut ValidationContext<'_>,
1921) {
1922    if !context.enter_schema() {
1923        let reason = if context.remaining_work == 0 {
1924            "schema validation work limit exceeded"
1925        } else {
1926            "schema validation nesting limit exceeded"
1927        };
1928        push_error(errors, path, reason);
1929        return;
1930    }
1931
1932    // Handle boolean schemas (true = accept all, false = reject all)
1933    if let Some(b) = schema.as_bool() {
1934        if !b {
1935            push_error(errors, path, "schema rejects all values");
1936        }
1937        context.leave_schema();
1938        return;
1939    }
1940
1941    // Schema must be an object
1942    let Some(schema_obj) = schema.as_object() else {
1943        context.leave_schema();
1944        return; // Invalid schema, skip validation
1945    };
1946
1947    // Check type constraint
1948    if let Some(type_val) = schema_obj.get("type") {
1949        if !validate_type(type_val, value, context.enforce_unevaluated_properties) {
1950            let expected = type_val
1951                .as_str()
1952                .map(String::from)
1953                .or_else(|| type_val.as_array().map(|arr| format!("{arr:?}")))
1954                .unwrap_or_else(|| "unknown".to_string());
1955            push_error(
1956                errors,
1957                path,
1958                format!(
1959                    "expected type {expected}, got {}",
1960                    json_type_name_with_final_semantics(
1961                        value,
1962                        context.enforce_unevaluated_properties
1963                    )
1964                ),
1965            );
1966            context.leave_schema();
1967            return; // Type mismatch, skip further validation
1968        }
1969    }
1970
1971    let has_dynamic_anchor = context.enforce_unevaluated_properties
1972        && schema_obj
1973            .get("$dynamicAnchor")
1974            .and_then(Value::as_str)
1975            .map(|name| {
1976                context.push_dynamic_anchor(name, schema);
1977            })
1978            .is_some();
1979
1980    validate_local_reference(schema_obj, value, path, errors, context);
1981    validate_dynamic_reference(schema_obj, value, path, errors, context);
1982    validate_composition(schema_obj, value, path, errors, context);
1983
1984    // Check enum constraint
1985    if let Some(enum_val) = schema_obj.get("enum") {
1986        if let Some(enum_arr) = enum_val.as_array() {
1987            let matches = if context.enforce_unevaluated_properties {
1988                let mut matches = false;
1989                for candidate in enum_arr {
1990                    match json_schema_equal_with_work(candidate, value, path, errors, context) {
1991                        Some(true) => {
1992                            matches = true;
1993                            break;
1994                        }
1995                        Some(false) => {}
1996                        None => break,
1997                    }
1998                }
1999                matches
2000            } else {
2001                enum_arr.contains(value)
2002            };
2003            if !matches && !context.work_exhausted {
2004                push_error(errors, path, format!("value must be one of: {enum_arr:?}"));
2005            }
2006        }
2007    }
2008
2009    // Check const constraint
2010    if let Some(const_val) = schema_obj.get("const") {
2011        let matches = if context.enforce_unevaluated_properties {
2012            json_schema_equal_with_work(value, const_val, path, errors, context)
2013        } else {
2014            Some(value == const_val)
2015        };
2016        if matches == Some(false) {
2017            push_error(errors, path, format!("value must equal {const_val}"));
2018        }
2019    }
2020
2021    // Type-specific validation
2022    match value {
2023        Value::Object(obj) => {
2024            validate_object(schema_obj, value, obj, path, errors, context);
2025        }
2026        Value::Array(arr) => {
2027            validate_array(schema_obj, arr, path, errors, context);
2028        }
2029        Value::String(s) => {
2030            validate_string(schema_obj, s, path, errors, context);
2031        }
2032        Value::Number(n) => {
2033            validate_number(
2034                schema_obj,
2035                n,
2036                path,
2037                errors,
2038                context.enforce_unevaluated_properties,
2039                context,
2040            );
2041        }
2042        _ => {}
2043    }
2044    if has_dynamic_anchor {
2045        context.pop_dynamic_anchor();
2046    }
2047    context.leave_schema();
2048}
2049
2050fn validate_local_reference(
2051    schema: &serde_json::Map<String, Value>,
2052    value: &Value,
2053    path: &str,
2054    errors: &mut Vec<ValidationError>,
2055    context: &mut ValidationContext<'_>,
2056) {
2057    let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
2058        return;
2059    };
2060    if !context.enter_reference() {
2061        push_error(errors, path, "local schema reference depth limit exceeded");
2062        return;
2063    }
2064    let root_schema = context.root_schema;
2065    let resolution = if context.enforce_unevaluated_properties {
2066        resolve_local_reference_with_work(root_schema, schema, reference, context)
2067    } else {
2068        resolve_legacy_local_reference(root_schema, reference)
2069    };
2070    match resolution {
2071        Ok(target) => validate_internal(target, value, path, errors, context),
2072        Err(message) => push_error(errors, path, message),
2073    }
2074    context.leave_reference();
2075}
2076
2077fn validate_dynamic_reference(
2078    schema: &serde_json::Map<String, Value>,
2079    value: &Value,
2080    path: &str,
2081    errors: &mut Vec<ValidationError>,
2082    context: &mut ValidationContext<'_>,
2083) {
2084    if !context.enforce_unevaluated_properties {
2085        return;
2086    }
2087    let Some(reference) = schema.get("$dynamicRef").and_then(Value::as_str) else {
2088        return;
2089    };
2090    if !context.enter_reference() {
2091        push_error(errors, path, "local schema reference depth limit exceeded");
2092        return;
2093    }
2094    let root_schema = context.root_schema;
2095    match resolve_local_reference_with_work(root_schema, schema, reference, context) {
2096        Ok(target) => {
2097            let dynamic_anchor = target
2098                .as_object()
2099                .and_then(|object| object.get("$dynamicAnchor"))
2100                .and_then(Value::as_str);
2101            let selected = dynamic_anchor
2102                .and_then(|name| context.dynamic_anchor_target(name))
2103                .unwrap_or_else(|| target.clone());
2104            validate_internal(&selected, value, path, errors, context);
2105        }
2106        Err(message) => push_error(errors, path, message),
2107    }
2108    context.leave_reference();
2109}
2110
2111struct SchemaResourceScope<'a> {
2112    resource: &'a Value,
2113    base: Option<String>,
2114}
2115
2116fn resolve_local_reference<'a>(
2117    root_schema: &'a Value,
2118    source: &serde_json::Map<String, Value>,
2119    reference: &str,
2120) -> Result<&'a Value, &'static str> {
2121    let mut uncharged = || Ok(());
2122    resolve_local_reference_with_charge(root_schema, source, reference, &mut uncharged)
2123}
2124
2125fn resolve_local_reference_with_work<'a>(
2126    root_schema: &'a Value,
2127    source: &serde_json::Map<String, Value>,
2128    reference: &str,
2129    context: &mut ValidationContext<'_>,
2130) -> Result<&'a Value, &'static str> {
2131    let mut charge = || {
2132        if context.consume_work() {
2133            Ok(())
2134        } else {
2135            Err("schema validation work limit exceeded")
2136        }
2137    };
2138    resolve_local_reference_with_charge(root_schema, source, reference, &mut charge)
2139}
2140
2141fn resolve_local_reference_with_charge<'a>(
2142    root_schema: &'a Value,
2143    source: &serde_json::Map<String, Value>,
2144    reference: &str,
2145    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2146) -> Result<&'a Value, &'static str> {
2147    if reference.len() > MAX_SCHEMA_ASSERTION_STRING_BYTES {
2148        return Err("schema reference exceeds byte limit");
2149    }
2150    if !is_bounded_uri_reference(reference) {
2151        return Err("invalid local schema reference");
2152    }
2153    let source_scope = find_schema_resource_scope(root_schema, source, charge)?
2154        .ok_or("unresolved local schema reference")?;
2155    let (identifier, fragment) = split_uri_reference_fragment(reference);
2156    let fragment = match fragment {
2157        Some(fragment) => {
2158            Some(decode_uri_fragment(fragment).ok_or("invalid local schema reference")?)
2159        }
2160        None => None,
2161    };
2162    let resource = if identifier.is_empty() {
2163        source_scope.resource
2164    } else {
2165        let identifier = resolve_uri_reference(source_scope.base.as_deref(), identifier)
2166            .ok_or("external schema reference is not allowed")?;
2167        find_local_schema_resource_with_charge(root_schema, &identifier, charge)?
2168            .ok_or("external schema reference is not allowed")?
2169    };
2170    resolve_local_reference_fragment_with_charge(
2171        resource,
2172        fragment.as_deref().unwrap_or(""),
2173        charge,
2174    )
2175}
2176
2177fn resolve_local_reference_fragment<'a>(
2178    resource: &'a Value,
2179    fragment: &str,
2180) -> Result<&'a Value, &'static str> {
2181    if !fragment.is_empty() && !fragment.starts_with('/') {
2182        return find_resource_anchor(resource, fragment).ok_or("unresolved local schema reference");
2183    }
2184    let mut uncharged = || Ok(());
2185    resolve_local_reference_fragment_with_charge(resource, fragment, &mut uncharged)
2186}
2187
2188fn resolve_local_reference_fragment_with_charge<'a>(
2189    resource: &'a Value,
2190    fragment: &str,
2191    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2192) -> Result<&'a Value, &'static str> {
2193    if fragment.is_empty() {
2194        return Ok(resource);
2195    }
2196    if let Some(pointer) = fragment.strip_prefix('/') {
2197        let mut target = resource;
2198        for encoded_segment in pointer.split('/') {
2199            charge()?;
2200            let segment = unescape_json_pointer_segment(encoded_segment)
2201                .ok_or("invalid local schema reference")?;
2202            target = match target {
2203                Value::Object(object) => object.get(&segment),
2204                Value::Array(array) => segment
2205                    .parse::<usize>()
2206                    .ok()
2207                    .and_then(|index| array.get(index)),
2208                _ => None,
2209            }
2210            .ok_or("unresolved local schema reference")?;
2211        }
2212        return Ok(target);
2213    }
2214    find_resource_anchor_with_charge(resource, fragment, charge)?
2215        .ok_or("unresolved local schema reference")
2216}
2217
2218/// The public raw validator predates anchor fragments. Keep its local-ref
2219/// boundary intact while the admitted final-dialect path uses full anchors.
2220fn resolve_legacy_local_reference<'a>(
2221    root_schema: &'a Value,
2222    reference: &str,
2223) -> Result<&'a Value, &'static str> {
2224    if reference == "#" || reference.starts_with("#/") {
2225        resolve_local_reference_fragment(root_schema, &reference[1..])
2226    } else {
2227        Err("external schema reference is not allowed")
2228    }
2229}
2230
2231fn find_schema_resource_scope<'a>(
2232    root_schema: &'a Value,
2233    target: &serde_json::Map<String, Value>,
2234    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2235) -> Result<Option<SchemaResourceScope<'a>>, &'static str> {
2236    find_schema_resource_scope_inner(root_schema, target, None, root_schema, None, charge)
2237}
2238
2239fn find_schema_resource_scope_inner<'a>(
2240    schema: &'a Value,
2241    target: &serde_json::Map<String, Value>,
2242    parent_base: Option<&str>,
2243    inherited_resource: &'a Value,
2244    inherited_base: Option<String>,
2245    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2246) -> Result<Option<SchemaResourceScope<'a>>, &'static str> {
2247    charge()?;
2248    let Some(object) = schema.as_object() else {
2249        return Ok(None);
2250    };
2251    let identifier = object.get("$id").and_then(Value::as_str);
2252    let base = if let Some(identifier) = identifier {
2253        Some(
2254            resolve_resource_identifier(parent_base, identifier)
2255                .ok_or("unresolved local schema reference")?,
2256        )
2257    } else {
2258        inherited_base
2259    };
2260    let resource = if identifier.is_some() {
2261        schema
2262    } else {
2263        inherited_resource
2264    };
2265    if std::ptr::eq(object, target) {
2266        return Ok(Some(SchemaResourceScope { resource, base }));
2267    }
2268    find_schema_child_scope(
2269        schema,
2270        target,
2271        base.as_deref(),
2272        resource,
2273        base.clone(),
2274        charge,
2275    )
2276}
2277
2278fn find_schema_child_scope<'a>(
2279    schema: &'a Value,
2280    target: &serde_json::Map<String, Value>,
2281    base: Option<&str>,
2282    resource: &'a Value,
2283    inherited_base: Option<String>,
2284    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2285) -> Result<Option<SchemaResourceScope<'a>>, &'static str> {
2286    let Some(object) = schema.as_object() else {
2287        return Ok(None);
2288    };
2289    for keyword in [
2290        "properties",
2291        "patternProperties",
2292        "$defs",
2293        "dependentSchemas",
2294    ] {
2295        if let Some(subschemas) = object.get(keyword).and_then(Value::as_object) {
2296            for subschema in subschemas.values() {
2297                if let Some(scope) = find_schema_resource_scope_inner(
2298                    subschema,
2299                    target,
2300                    base,
2301                    resource,
2302                    inherited_base.clone(),
2303                    charge,
2304                )? {
2305                    return Ok(Some(scope));
2306                }
2307            }
2308        }
2309    }
2310    for keyword in [
2311        "additionalProperties",
2312        "unevaluatedProperties",
2313        "unevaluatedItems",
2314        "items",
2315        "contains",
2316        "not",
2317        "if",
2318        "then",
2319        "else",
2320        "propertyNames",
2321        "contentSchema",
2322    ] {
2323        if let Some(subschema) = object.get(keyword)
2324            && let Some(scope) = find_schema_resource_scope_inner(
2325                subschema,
2326                target,
2327                base,
2328                resource,
2329                inherited_base.clone(),
2330                charge,
2331            )?
2332        {
2333            return Ok(Some(scope));
2334        }
2335    }
2336    for keyword in ["prefixItems", "allOf", "anyOf", "oneOf"] {
2337        if let Some(subschemas) = object.get(keyword).and_then(Value::as_array) {
2338            for subschema in subschemas {
2339                if let Some(scope) = find_schema_resource_scope_inner(
2340                    subschema,
2341                    target,
2342                    base,
2343                    resource,
2344                    inherited_base.clone(),
2345                    charge,
2346                )? {
2347                    return Ok(Some(scope));
2348                }
2349            }
2350        }
2351    }
2352    Ok(None)
2353}
2354
2355fn find_local_schema_resource<'a>(schema: &'a Value, identifier: &str) -> Option<&'a Value> {
2356    let mut uncharged = || Ok(());
2357    find_local_schema_resource_with_charge(schema, identifier, &mut uncharged)
2358        .ok()
2359        .flatten()
2360}
2361
2362fn find_local_schema_resource_with_charge<'a>(
2363    schema: &'a Value,
2364    identifier: &str,
2365    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2366) -> Result<Option<&'a Value>, &'static str> {
2367    find_local_schema_resource_inner(schema, identifier, None, charge)
2368}
2369
2370fn find_local_schema_resource_inner<'a>(
2371    schema: &'a Value,
2372    identifier: &str,
2373    parent_base: Option<&str>,
2374    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2375) -> Result<Option<&'a Value>, &'static str> {
2376    charge()?;
2377    let Some(object) = schema.as_object() else {
2378        return Ok(None);
2379    };
2380    let base = if let Some(candidate) = object.get("$id").and_then(Value::as_str) {
2381        Some(
2382            resolve_resource_identifier(parent_base, candidate)
2383                .ok_or("unresolved local schema reference")?,
2384        )
2385    } else {
2386        None
2387    };
2388    if base.as_deref() == Some(identifier) {
2389        return Ok(Some(schema));
2390    }
2391    let inherited_base = base.as_deref().or(parent_base);
2392    find_local_schema_resource_child(schema, identifier, inherited_base, charge)
2393}
2394
2395fn find_local_schema_resource_child<'a>(
2396    schema: &'a Value,
2397    identifier: &str,
2398    parent_base: Option<&str>,
2399    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2400) -> Result<Option<&'a Value>, &'static str> {
2401    let Some(object) = schema.as_object() else {
2402        return Ok(None);
2403    };
2404    for keyword in [
2405        "properties",
2406        "patternProperties",
2407        "$defs",
2408        "dependentSchemas",
2409    ] {
2410        if let Some(subschemas) = object.get(keyword).and_then(Value::as_object) {
2411            for subschema in subschemas.values() {
2412                if let Some(found) =
2413                    find_local_schema_resource_inner(subschema, identifier, parent_base, charge)?
2414                {
2415                    return Ok(Some(found));
2416                }
2417            }
2418        }
2419    }
2420    for keyword in [
2421        "additionalProperties",
2422        "unevaluatedProperties",
2423        "unevaluatedItems",
2424        "items",
2425        "contains",
2426        "not",
2427        "if",
2428        "then",
2429        "else",
2430        "propertyNames",
2431        "contentSchema",
2432    ] {
2433        if let Some(subschema) = object.get(keyword)
2434            && let Some(found) =
2435                find_local_schema_resource_inner(subschema, identifier, parent_base, charge)?
2436        {
2437            return Ok(Some(found));
2438        }
2439    }
2440    for keyword in ["prefixItems", "allOf", "anyOf", "oneOf"] {
2441        if let Some(subschemas) = object.get(keyword).and_then(Value::as_array) {
2442            for subschema in subschemas {
2443                if let Some(found) =
2444                    find_local_schema_resource_inner(subschema, identifier, parent_base, charge)?
2445                {
2446                    return Ok(Some(found));
2447                }
2448            }
2449        }
2450    }
2451    Ok(None)
2452}
2453
2454fn split_uri_reference_fragment(reference: &str) -> (&str, Option<&str>) {
2455    reference
2456        .split_once('#')
2457        .map_or((reference, None), |(identifier, fragment)| {
2458            (identifier, Some(fragment))
2459        })
2460}
2461
2462fn decode_uri_fragment(fragment: &str) -> Option<String> {
2463    let mut decoded = Vec::with_capacity(fragment.len());
2464    let bytes = fragment.as_bytes();
2465    let mut index = 0;
2466    while index < bytes.len() {
2467        if bytes[index] != b'%' {
2468            decoded.push(bytes[index]);
2469            index += 1;
2470            continue;
2471        }
2472        let high = *bytes.get(index + 1)?;
2473        let low = *bytes.get(index + 2)?;
2474        decoded.push((hex_value(high)? << 4) | hex_value(low)?);
2475        index += 3;
2476    }
2477    String::from_utf8(decoded).ok()
2478}
2479
2480fn hex_value(byte: u8) -> Option<u8> {
2481    match byte {
2482        b'0'..=b'9' => Some(byte - b'0'),
2483        b'a'..=b'f' => Some(byte - b'a' + 10),
2484        b'A'..=b'F' => Some(byte - b'A' + 10),
2485        _ => None,
2486    }
2487}
2488
2489fn resolve_resource_identifier(parent_base: Option<&str>, identifier: &str) -> Option<String> {
2490    let (identifier, fragment) = split_uri_reference_fragment(identifier);
2491    if fragment.is_some_and(|fragment| !fragment.is_empty()) {
2492        return None;
2493    }
2494    resolve_uri_reference(parent_base, identifier)
2495}
2496
2497fn resolve_uri_reference(base: Option<&str>, reference: &str) -> Option<String> {
2498    if !is_bounded_uri_reference(reference) {
2499        return None;
2500    }
2501    if has_uri_scheme(reference) {
2502        return normalize_absolute_uri_reference(reference);
2503    }
2504    let base = base?;
2505    let base_uri =
2506        AbsoluteUri::parse_with_max_bytes(base, MAX_SCHEMA_ASSERTION_STRING_BYTES).ok()?;
2507    let scheme = base_uri.scheme().as_str();
2508    let (base_without_query, base_query) = base
2509        .split_once('?')
2510        .map_or((base, None), |(value, query)| (value, Some(query)));
2511    let (reference_path, reference_query) = reference
2512        .split_once('?')
2513        .map_or((reference, None), |(path, query)| (path, Some(query)));
2514    if reference_path.starts_with("//") {
2515        let resolved = format!("{scheme}:{reference}");
2516        return normalize_absolute_uri_reference(&resolved);
2517    }
2518    let hierarchy = base_without_query
2519        .strip_prefix(&format!("{scheme}:"))
2520        .filter(|value| value.starts_with("//"))?;
2521    let authority_end = hierarchy[2..]
2522        .find('/')
2523        .map_or(hierarchy.len(), |index| index + 2);
2524    let origin = format!("{scheme}:{}", &hierarchy[..authority_end]);
2525    let base_path = &hierarchy[authority_end..];
2526    let path = if reference_path.is_empty() {
2527        base_path.to_owned()
2528    } else if reference_path.starts_with('/') {
2529        remove_uri_dot_segments(reference_path)
2530    } else {
2531        let directory_end = base_path.rfind('/').map_or(0, |index| index + 1);
2532        let directory = if base_path.is_empty() {
2533            "/"
2534        } else {
2535            &base_path[..directory_end]
2536        };
2537        remove_uri_dot_segments(&format!("{}{}", directory, reference_path))
2538    };
2539    // RFC 3986 section 5.2.2 inherits the base query only when the reference
2540    // has neither a path nor a query. A query-only reference replaces it, and
2541    // every non-empty path starts with no query unless it declares one.
2542    let query = match (reference_path.is_empty(), reference_query, base_query) {
2543        (_, Some(query), _) => format!("?{query}"),
2544        (true, None, Some(query)) => format!("?{query}"),
2545        (true, None, None) | (false, None, _) => String::new(),
2546    };
2547    let resolved = format!("{origin}{path}{query}");
2548    AbsoluteUri::parse_with_max_bytes(&resolved, MAX_SCHEMA_ASSERTION_STRING_BYTES)
2549        .ok()
2550        .map(|uri| uri.as_str().to_owned())
2551}
2552
2553fn normalize_absolute_uri_reference(reference: &str) -> Option<String> {
2554    let uri = AbsoluteUri::parse_with_max_bytes(reference, MAX_SCHEMA_ASSERTION_STRING_BYTES)
2555        .ok()
2556        .filter(|uri| uri.fragment().is_none())?;
2557    let scheme = uri.scheme().as_str();
2558    let hierarchy = reference.strip_prefix(&format!("{scheme}:"))?;
2559    let (hierarchy, query) = hierarchy
2560        .split_once('?')
2561        .map_or((hierarchy, None), |(path, query)| (path, Some(query)));
2562    let normalized = if let Some(authority_and_path) = hierarchy.strip_prefix("//") {
2563        let authority_end = authority_and_path
2564            .find('/')
2565            .map_or(hierarchy.len(), |index| index + 2);
2566        let authority = &hierarchy[..authority_end];
2567        let path = remove_uri_dot_segments(&hierarchy[authority_end..]);
2568        format!("{authority}{path}")
2569    } else {
2570        remove_uri_dot_segments(hierarchy)
2571    };
2572    let query = query.map_or_else(String::new, |query| format!("?{query}"));
2573    let normalized = format!("{scheme}:{normalized}{query}");
2574    AbsoluteUri::parse_with_max_bytes(&normalized, MAX_SCHEMA_ASSERTION_STRING_BYTES)
2575        .ok()
2576        .filter(|uri| uri.fragment().is_none())
2577        .map(|uri| uri.as_str().to_owned())
2578}
2579
2580fn has_uri_scheme(reference: &str) -> bool {
2581    reference.find(':').is_some_and(|index| {
2582        !reference[..index].contains(['/', '?']) && is_valid_uri_scheme(&reference[..index])
2583    })
2584}
2585
2586fn is_bounded_uri_reference(reference: &str) -> bool {
2587    reference.len() <= MAX_SCHEMA_ASSERTION_STRING_BYTES
2588        && reference.bytes().enumerate().all(|(index, byte)| {
2589            if byte == b'%' {
2590                return reference
2591                    .as_bytes()
2592                    .get(index + 1..=index + 2)
2593                    .is_some_and(|digits| digits.iter().all(u8::is_ascii_hexdigit));
2594            }
2595            byte.is_ascii()
2596                && byte > b' '
2597                && byte != 0x7f
2598                && (byte.is_ascii_alphanumeric()
2599                    || matches!(
2600                        byte,
2601                        b'-' | b'.'
2602                            | b'_'
2603                            | b'~'
2604                            | b':'
2605                            | b'/'
2606                            | b'?'
2607                            | b'#'
2608                            | b'['
2609                            | b']'
2610                            | b'@'
2611                            | b'!'
2612                            | b'$'
2613                            | b'&'
2614                            | b'\''
2615                            | b'('
2616                            | b')'
2617                            | b'*'
2618                            | b'+'
2619                            | b','
2620                            | b';'
2621                            | b'='
2622                            | b'%'
2623                    ))
2624        })
2625}
2626
2627/// Removes dot segments according to RFC 3986 section 5.2.4.
2628///
2629/// This is intentionally a buffer algorithm rather than a split-and-join
2630/// shortcut. Empty path segments are meaningful (`/a//b` differs from
2631/// `/a/b`), and the final `/.` and `/..` inputs each leave a trailing slash.
2632fn remove_uri_dot_segments(path: &str) -> String {
2633    let mut input = path.to_owned();
2634    let mut output = String::with_capacity(path.len());
2635
2636    while !input.is_empty() {
2637        if input.starts_with("../") {
2638            input.drain(..3);
2639        } else if input.starts_with("./") {
2640            input.drain(..2);
2641        } else if input.starts_with("/./") {
2642            input.replace_range(..3, "/");
2643        } else if input == "/." {
2644            input.truncate(1);
2645        } else if input.starts_with("/../") {
2646            input.replace_range(..4, "/");
2647            remove_last_uri_path_segment(&mut output);
2648        } else if input == "/.." {
2649            input.replace_range(..3, "/");
2650            remove_last_uri_path_segment(&mut output);
2651        } else if input == "." || input == ".." {
2652            input.clear();
2653        } else {
2654            // Move the first input path segment, including its leading slash
2655            // when present, from the input buffer to the output buffer.
2656            let segment_end = if let Some(path_after_initial_slash) = input.strip_prefix('/') {
2657                path_after_initial_slash
2658                    .find('/')
2659                    .map_or(input.len(), |index| index + 1)
2660            } else {
2661                input.find('/').unwrap_or(input.len())
2662            };
2663            output.push_str(&input[..segment_end]);
2664            input.drain(..segment_end);
2665        }
2666    }
2667
2668    output
2669}
2670
2671/// Removes the last output path segment as defined by RFC 3986 section 5.2.4.
2672fn remove_last_uri_path_segment(output: &mut String) {
2673    if let Some(separator) = output.rfind('/') {
2674        output.truncate(separator);
2675    } else {
2676        output.clear();
2677    }
2678}
2679
2680fn find_resource_anchor<'a>(schema: &'a Value, anchor: &str) -> Option<&'a Value> {
2681    let mut uncharged = || Ok(());
2682    find_resource_anchor_with_charge(schema, anchor, &mut uncharged)
2683        .ok()
2684        .flatten()
2685}
2686
2687fn find_resource_anchor_with_charge<'a>(
2688    schema: &'a Value,
2689    anchor: &str,
2690    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2691) -> Result<Option<&'a Value>, &'static str> {
2692    find_resource_anchor_inner(schema, anchor, true, charge)
2693}
2694
2695fn find_resource_anchor_inner<'a>(
2696    schema: &'a Value,
2697    anchor: &str,
2698    resource_root: bool,
2699    charge: &mut dyn FnMut() -> Result<(), &'static str>,
2700) -> Result<Option<&'a Value>, &'static str> {
2701    charge()?;
2702    let Some(object) = schema.as_object() else {
2703        return Ok(None);
2704    };
2705    if !resource_root && object.contains_key("$id") {
2706        return Ok(None);
2707    }
2708    if ["$anchor", "$dynamicAnchor"].iter().any(|keyword| {
2709        object
2710            .get(*keyword)
2711            .and_then(Value::as_str)
2712            .is_some_and(|candidate| candidate == anchor)
2713    }) {
2714        return Ok(Some(schema));
2715    }
2716    for keyword in [
2717        "properties",
2718        "patternProperties",
2719        "$defs",
2720        "dependentSchemas",
2721    ] {
2722        if let Some(subschemas) = object.get(keyword).and_then(Value::as_object) {
2723            for subschema in subschemas.values() {
2724                if let Some(found) = find_resource_anchor_inner(subschema, anchor, false, charge)? {
2725                    return Ok(Some(found));
2726                }
2727            }
2728        }
2729    }
2730    for keyword in [
2731        "additionalProperties",
2732        "unevaluatedProperties",
2733        "unevaluatedItems",
2734        "items",
2735        "contains",
2736        "not",
2737        "if",
2738        "then",
2739        "else",
2740        "propertyNames",
2741        "contentSchema",
2742    ] {
2743        if let Some(subschema) = object.get(keyword)
2744            && let Some(found) = find_resource_anchor_inner(subschema, anchor, false, charge)?
2745        {
2746            return Ok(Some(found));
2747        }
2748    }
2749    for keyword in ["prefixItems", "allOf", "anyOf", "oneOf"] {
2750        if let Some(subschemas) = object.get(keyword).and_then(Value::as_array) {
2751            for subschema in subschemas {
2752                if let Some(found) = find_resource_anchor_inner(subschema, anchor, false, charge)? {
2753                    return Ok(Some(found));
2754                }
2755            }
2756        }
2757    }
2758    Ok(None)
2759}
2760
2761fn unescape_json_pointer_segment(segment: &str) -> Option<String> {
2762    let mut decoded = String::with_capacity(segment.len());
2763    let mut characters = segment.chars();
2764    while let Some(character) = characters.next() {
2765        if character != '~' {
2766            decoded.push(character);
2767            continue;
2768        }
2769        match characters.next()? {
2770            '0' => decoded.push('~'),
2771            '1' => decoded.push('/'),
2772            _ => return None,
2773        }
2774    }
2775    Some(decoded)
2776}
2777
2778fn validate_composition(
2779    schema: &serde_json::Map<String, Value>,
2780    value: &Value,
2781    path: &str,
2782    errors: &mut Vec<ValidationError>,
2783    context: &mut ValidationContext<'_>,
2784) {
2785    validate_all_of(schema, value, path, errors, context);
2786    validate_any_of(schema, value, path, errors, context);
2787    validate_one_of(schema, value, path, errors, context);
2788    validate_not(schema, value, path, errors, context);
2789    validate_conditional(schema, value, path, errors, context);
2790}
2791
2792fn bounded_subschemas<'a>(
2793    schema: &'a serde_json::Map<String, Value>,
2794    keyword: &str,
2795    path: &str,
2796    errors: &mut Vec<ValidationError>,
2797) -> Option<&'a [Value]> {
2798    let subschemas = schema.get(keyword)?.as_array()?;
2799    if subschemas.len() > MAX_COMPOSITION_BRANCHES {
2800        push_error(
2801            errors,
2802            path,
2803            format!("{keyword} exceeds composition branch limit"),
2804        );
2805        return None;
2806    }
2807    Some(subschemas)
2808}
2809
2810fn validate_all_of(
2811    schema: &serde_json::Map<String, Value>,
2812    value: &Value,
2813    path: &str,
2814    errors: &mut Vec<ValidationError>,
2815    context: &mut ValidationContext<'_>,
2816) {
2817    if let Some(subschemas) = bounded_subschemas(schema, "allOf", path, errors) {
2818        for subschema in subschemas {
2819            validate_internal(subschema, value, path, errors, context);
2820        }
2821    }
2822}
2823
2824fn validate_any_of(
2825    schema: &serde_json::Map<String, Value>,
2826    value: &Value,
2827    path: &str,
2828    errors: &mut Vec<ValidationError>,
2829    context: &mut ValidationContext<'_>,
2830) {
2831    let Some(subschemas) = bounded_subschemas(schema, "anyOf", path, errors) else {
2832        return;
2833    };
2834    let mut matched = false;
2835    for subschema in subschemas {
2836        if branch_is_valid(subschema, value, path, context) {
2837            matched = true;
2838            break;
2839        }
2840    }
2841    if !matched {
2842        push_error(errors, path, "no subschema in anyOf matched");
2843    }
2844}
2845
2846fn validate_one_of(
2847    schema: &serde_json::Map<String, Value>,
2848    value: &Value,
2849    path: &str,
2850    errors: &mut Vec<ValidationError>,
2851    context: &mut ValidationContext<'_>,
2852) {
2853    let Some(subschemas) = bounded_subschemas(schema, "oneOf", path, errors) else {
2854        return;
2855    };
2856    let mut matches = 0;
2857    for subschema in subschemas {
2858        if branch_is_valid(subschema, value, path, context) {
2859            matches += 1;
2860        }
2861    }
2862    if matches != 1 {
2863        push_error(errors, path, "exactly one subschema in oneOf must match");
2864    }
2865}
2866
2867fn validate_not(
2868    schema: &serde_json::Map<String, Value>,
2869    value: &Value,
2870    path: &str,
2871    errors: &mut Vec<ValidationError>,
2872    context: &mut ValidationContext<'_>,
2873) {
2874    if let Some(subschema) = schema.get("not") {
2875        if branch_is_valid(subschema, value, path, context) {
2876            push_error(errors, path, "value must not match the not subschema");
2877        }
2878    }
2879}
2880
2881fn validate_conditional(
2882    schema: &serde_json::Map<String, Value>,
2883    value: &Value,
2884    path: &str,
2885    errors: &mut Vec<ValidationError>,
2886    context: &mut ValidationContext<'_>,
2887) {
2888    let Some(condition) = schema.get("if") else {
2889        return;
2890    };
2891    let branch_keyword = if branch_is_valid(condition, value, path, context) {
2892        "then"
2893    } else {
2894        "else"
2895    };
2896    if let Some(subschema) = schema.get(branch_keyword) {
2897        validate_internal(subschema, value, path, errors, context);
2898    }
2899}
2900
2901fn branch_is_valid(
2902    schema: &Value,
2903    value: &Value,
2904    path: &str,
2905    context: &mut ValidationContext<'_>,
2906) -> bool {
2907    if context.enforce_unevaluated_properties && !context.consume_work() {
2908        return false;
2909    }
2910    let mut branch_errors = Vec::new();
2911    validate_internal(schema, value, path, &mut branch_errors, context);
2912    branch_errors.is_empty()
2913}
2914
2915/// Validates type constraint.
2916fn validate_type(type_val: &Value, value: &Value, final_semantics: bool) -> bool {
2917    match type_val {
2918        Value::String(t) => matches_type(t, value, final_semantics),
2919        Value::Array(types) => types.iter().any(|t| {
2920            t.as_str()
2921                .is_some_and(|type_str| matches_type(type_str, value, final_semantics))
2922        }),
2923        _ => true, // Invalid type constraint, skip
2924    }
2925}
2926
2927/// Checks if a value matches a single type name.
2928fn matches_type(type_name: &str, value: &Value, final_semantics: bool) -> bool {
2929    match type_name {
2930        "string" => value.is_string(),
2931        "number" => value.is_number(),
2932        "integer" if final_semantics => value
2933            .as_number()
2934            .and_then(ExactDecimal::from_number)
2935            .is_some_and(|number| number.is_integer()),
2936        "integer" => value.is_i64() || value.is_u64(),
2937        "boolean" => value.is_boolean(),
2938        "object" => value.is_object(),
2939        "array" => value.is_array(),
2940        "null" => value.is_null(),
2941        _ => true, // Unknown type, accept
2942    }
2943}
2944
2945/// Returns the JSON type name for a value.
2946fn json_type_name(value: &Value) -> &'static str {
2947    match value {
2948        Value::Null => "null",
2949        Value::Bool(_) => "boolean",
2950        Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
2951        Value::Number(_) => "number",
2952        Value::String(_) => "string",
2953        Value::Array(_) => "array",
2954        Value::Object(_) => "object",
2955    }
2956}
2957
2958fn json_type_name_with_final_semantics(value: &Value, final_semantics: bool) -> &'static str {
2959    if !final_semantics {
2960        return json_type_name(value);
2961    }
2962    match value {
2963        Value::Number(number)
2964            if ExactDecimal::from_number(number).is_some_and(|number| number.is_integer()) =>
2965        {
2966            "integer"
2967        }
2968        _ => json_type_name(value),
2969    }
2970}
2971
2972/// Validates object-specific constraints.
2973fn validate_object(
2974    schema: &serde_json::Map<String, Value>,
2975    value: &Value,
2976    obj: &serde_json::Map<String, Value>,
2977    path: &str,
2978    errors: &mut Vec<ValidationError>,
2979    context: &mut ValidationContext<'_>,
2980) {
2981    // Check required fields
2982    if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
2983        for req in required {
2984            if !consume_validation_work(context, path, errors) {
2985                return;
2986            }
2987            if let Some(req_name) = req.as_str() {
2988                if !obj.contains_key(req_name) {
2989                    push_error(errors, path, format!("missing required field: {req_name}"));
2990                }
2991            }
2992        }
2993    }
2994
2995    let properties = schema.get("properties").and_then(Value::as_object);
2996    let patterns = compile_pattern_properties(schema, path, errors, context);
2997    if context.work_exhausted {
2998        return;
2999    }
3000
3001    for (key, value) in obj {
3002        let property_path = format!("{path}.{key}");
3003        if let Some(property_schema) = properties.and_then(|properties| properties.get(key)) {
3004            validate_internal(property_schema, value, &property_path, errors, context);
3005        }
3006        for (pattern, pattern_schema) in &patterns {
3007            let Some(matches) =
3008                charged_regex_is_match(pattern, key, &property_path, errors, context)
3009            else {
3010                return;
3011            };
3012            if matches {
3013                validate_internal(pattern_schema, value, &property_path, errors, context);
3014            }
3015        }
3016    }
3017
3018    if let Some(property_name_schema) = schema.get("propertyNames") {
3019        for key in obj.keys() {
3020            let property_path = format!("{path}.{key}");
3021            validate_internal(
3022                property_name_schema,
3023                &Value::String(key.clone()),
3024                &property_path,
3025                errors,
3026                context,
3027            );
3028        }
3029    }
3030
3031    validate_dependencies(schema, value, obj, path, errors, context);
3032
3033    // Check additionalProperties after applying both named and pattern properties.
3034    if let Some(additional) = schema.get("additionalProperties") {
3035        for (key, value) in obj {
3036            let mut matches_pattern = false;
3037            for (pattern, _) in &patterns {
3038                let Some(matches) = charged_regex_is_match(pattern, key, path, errors, context)
3039                else {
3040                    return;
3041                };
3042                if matches {
3043                    matches_pattern = true;
3044                    break;
3045                }
3046            }
3047            let is_defined_property = properties
3048                .is_some_and(|properties| properties.contains_key(key))
3049                || matches_pattern;
3050            if !is_defined_property {
3051                match additional {
3052                    Value::Bool(false) => {
3053                        push_error(
3054                            errors,
3055                            path,
3056                            format!("additional property not allowed: {key}"),
3057                        );
3058                    }
3059                    Value::Object(_) => {
3060                        let prop_path = format!("{path}.{key}");
3061                        validate_internal(additional, value, &prop_path, errors, context);
3062                    }
3063                    _ => {}
3064                }
3065            }
3066        }
3067    }
3068
3069    if context.enforce_unevaluated_properties {
3070        validate_unevaluated_properties(schema, value, obj, path, errors, context);
3071    }
3072
3073    // Admitted final schemas preserve arbitrary-width count bounds; raw
3074    // validation retains its historical u64-only behavior.
3075    if let Some(min) = schema.get("minProperties")
3076        && count_compare_to_schema_bound(obj.len(), min, context.enforce_unevaluated_properties)
3077            == Some(Ordering::Less)
3078    {
3079        push_error(
3080            errors,
3081            path,
3082            format!("object must have at least {min} properties"),
3083        );
3084    }
3085    if let Some(max) = schema.get("maxProperties")
3086        && count_compare_to_schema_bound(obj.len(), max, context.enforce_unevaluated_properties)
3087            == Some(Ordering::Greater)
3088    {
3089        push_error(
3090            errors,
3091            path,
3092            format!("object must have at most {max} properties"),
3093        );
3094    }
3095}
3096
3097/// Applies the Draft 2020-12 `unevaluatedProperties` keyword after every
3098/// sibling applicator has had an opportunity to evaluate object members.
3099fn validate_unevaluated_properties(
3100    schema: &serde_json::Map<String, Value>,
3101    value: &Value,
3102    obj: &serde_json::Map<String, Value>,
3103    path: &str,
3104    errors: &mut Vec<ValidationError>,
3105    context: &mut ValidationContext<'_>,
3106) {
3107    let Some(unevaluated_schema) = schema.get("unevaluatedProperties") else {
3108        return;
3109    };
3110
3111    let mut evaluated = std::collections::HashSet::with_capacity(obj.len());
3112    mark_evaluated_object_properties(
3113        schema,
3114        value,
3115        obj,
3116        path,
3117        false,
3118        &mut evaluated,
3119        errors,
3120        context,
3121    );
3122
3123    for (key, member) in obj {
3124        if !consume_validation_work(context, path, errors) {
3125            return;
3126        }
3127        if !evaluated.contains(key) {
3128            validate_internal(
3129                unevaluated_schema,
3130                member,
3131                &format!("{path}.{key}"),
3132                errors,
3133                context,
3134            );
3135        }
3136    }
3137}
3138
3139/// Marks the object members evaluated by a successful schema application.
3140///
3141/// `unevaluatedProperties` consumes all remaining members when it belongs to
3142/// a successful nested applicator. The outer invocation leaves its own keyword
3143/// out of the annotation set so that it can validate those remaining members.
3144fn mark_evaluated_object_properties(
3145    schema: &serde_json::Map<String, Value>,
3146    value: &Value,
3147    obj: &serde_json::Map<String, Value>,
3148    path: &str,
3149    include_unevaluated_properties: bool,
3150    evaluated: &mut std::collections::HashSet<String>,
3151    errors: &mut Vec<ValidationError>,
3152    context: &mut ValidationContext<'_>,
3153) {
3154    if !context.consume_work() {
3155        push_error(errors, path, "schema validation work limit exceeded");
3156        return;
3157    }
3158
3159    let properties = schema.get("properties").and_then(Value::as_object);
3160    let patterns = compile_pattern_properties(schema, path, errors, context);
3161    if context.work_exhausted {
3162        return;
3163    }
3164    for key in obj.keys() {
3165        if !consume_validation_work(context, path, errors) {
3166            return;
3167        }
3168        let matched_property = properties.is_some_and(|properties| properties.contains_key(key));
3169        let mut matched_pattern = false;
3170        for (pattern, _) in &patterns {
3171            let Some(matches) = charged_regex_is_match(pattern, key, path, errors, context) else {
3172                return;
3173            };
3174            if matches {
3175                matched_pattern = true;
3176                break;
3177            }
3178        }
3179        if matched_property || matched_pattern || schema.contains_key("additionalProperties") {
3180            evaluated.insert(key.clone());
3181        }
3182    }
3183
3184    if include_unevaluated_properties && schema.contains_key("unevaluatedProperties") {
3185        for key in obj.keys() {
3186            if !consume_validation_work(context, path, errors) {
3187                return;
3188            }
3189            evaluated.insert(key.clone());
3190        }
3191    }
3192
3193    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
3194        if !context.enter_reference() {
3195            push_error(errors, path, "local schema reference depth limit exceeded");
3196        } else {
3197            let root_schema = context.root_schema;
3198            match resolve_local_reference_with_work(root_schema, schema, reference, context) {
3199                Ok(target) if branch_is_valid(target, value, path, context) => {
3200                    if let Some(target) = target.as_object() {
3201                        mark_evaluated_object_properties(
3202                            target, value, obj, path, true, evaluated, errors, context,
3203                        );
3204                    }
3205                }
3206                Ok(_) => {}
3207                Err(message) => push_error(errors, path, message),
3208            }
3209            context.leave_reference();
3210        }
3211    }
3212
3213    if let Some(reference) = schema.get("$dynamicRef").and_then(Value::as_str) {
3214        if !context.enter_reference() {
3215            push_error(errors, path, "local schema reference depth limit exceeded");
3216        } else {
3217            let root_schema = context.root_schema;
3218            let target =
3219                resolve_local_reference_with_work(root_schema, schema, reference, context).ok();
3220            if let Some(target) = target {
3221                let dynamic_anchor = target
3222                    .as_object()
3223                    .and_then(|object| object.get("$dynamicAnchor"))
3224                    .and_then(Value::as_str);
3225                let selected = dynamic_anchor
3226                    .and_then(|name| context.dynamic_anchor_target(name))
3227                    .unwrap_or_else(|| target.clone());
3228                if branch_is_valid(&selected, value, path, context)
3229                    && let Some(selected) = selected.as_object()
3230                {
3231                    mark_evaluated_object_properties(
3232                        selected, value, obj, path, true, evaluated, errors, context,
3233                    );
3234                }
3235            }
3236            context.leave_reference();
3237        }
3238    }
3239
3240    if let Some(dependent_schemas) = schema.get("dependentSchemas").and_then(Value::as_object) {
3241        for (trigger, dependent_schema) in dependent_schemas {
3242            if obj.contains_key(trigger)
3243                && branch_is_valid(dependent_schema, value, path, context)
3244                && let Some(dependent_schema) = dependent_schema.as_object()
3245            {
3246                mark_evaluated_object_properties(
3247                    dependent_schema,
3248                    value,
3249                    obj,
3250                    path,
3251                    true,
3252                    evaluated,
3253                    errors,
3254                    context,
3255                );
3256            }
3257        }
3258    }
3259
3260    mark_composition_evaluated_properties(
3261        schema, "allOf", value, obj, path, evaluated, errors, context,
3262    );
3263    mark_composition_evaluated_properties(
3264        schema, "anyOf", value, obj, path, evaluated, errors, context,
3265    );
3266
3267    if let Some(subschemas) = bounded_subschemas(schema, "oneOf", path, errors) {
3268        let matching: Vec<_> = subschemas
3269            .iter()
3270            .filter(|subschema| branch_is_valid(subschema, value, path, context))
3271            .collect();
3272        if matching.len() == 1
3273            && let Some(subschema) = matching[0].as_object()
3274        {
3275            mark_evaluated_object_properties(
3276                subschema, value, obj, path, true, evaluated, errors, context,
3277            );
3278        }
3279    }
3280
3281    if let Some(condition) = schema.get("if") {
3282        let condition_matched = branch_is_valid(condition, value, path, context);
3283        if condition_matched && let Some(condition) = condition.as_object() {
3284            mark_evaluated_object_properties(
3285                condition, value, obj, path, true, evaluated, errors, context,
3286            );
3287        }
3288
3289        let branch = if condition_matched { "then" } else { "else" };
3290        if let Some(subschema) = schema.get(branch)
3291            && branch_is_valid(subschema, value, path, context)
3292            && let Some(subschema) = subschema.as_object()
3293        {
3294            mark_evaluated_object_properties(
3295                subschema, value, obj, path, true, evaluated, errors, context,
3296            );
3297        }
3298    }
3299}
3300
3301/// Merges annotations from every successful `allOf` or `anyOf` branch.
3302fn mark_composition_evaluated_properties(
3303    schema: &serde_json::Map<String, Value>,
3304    keyword: &str,
3305    value: &Value,
3306    obj: &serde_json::Map<String, Value>,
3307    path: &str,
3308    evaluated: &mut std::collections::HashSet<String>,
3309    errors: &mut Vec<ValidationError>,
3310    context: &mut ValidationContext<'_>,
3311) {
3312    let Some(subschemas) = bounded_subschemas(schema, keyword, path, errors) else {
3313        return;
3314    };
3315    for subschema in subschemas {
3316        if branch_is_valid(subschema, value, path, context)
3317            && let Some(subschema) = subschema.as_object()
3318        {
3319            mark_evaluated_object_properties(
3320                subschema, value, obj, path, true, evaluated, errors, context,
3321            );
3322        }
3323    }
3324}
3325
3326fn compile_pattern_properties<'a>(
3327    schema: &'a serde_json::Map<String, Value>,
3328    path: &str,
3329    errors: &mut Vec<ValidationError>,
3330    context: &mut ValidationContext<'_>,
3331) -> Vec<(Regex, &'a Value)> {
3332    let Some(pattern_properties) = schema.get("patternProperties").and_then(Value::as_object)
3333    else {
3334        return Vec::new();
3335    };
3336    if pattern_properties.len() > MAX_PATTERN_PROPERTIES {
3337        push_error(errors, path, "patternProperties exceeds entry limit");
3338        return Vec::new();
3339    }
3340
3341    let mut patterns = Vec::with_capacity(pattern_properties.len());
3342    for (source, pattern_schema) in pattern_properties {
3343        if !consume_validation_work(context, path, errors) {
3344            break;
3345        }
3346        if source.len() > MAX_PATTERN_PROPERTY_BYTES {
3347            push_error(errors, path, "patternProperties pattern exceeds byte limit");
3348            continue;
3349        }
3350        match Regex::new(source) {
3351            Ok(pattern) => patterns.push((pattern, pattern_schema)),
3352            Err(_) => push_error(errors, path, "invalid patternProperties pattern"),
3353        }
3354    }
3355    patterns
3356}
3357
3358fn validate_dependencies(
3359    schema: &serde_json::Map<String, Value>,
3360    value: &Value,
3361    obj: &serde_json::Map<String, Value>,
3362    path: &str,
3363    errors: &mut Vec<ValidationError>,
3364    context: &mut ValidationContext<'_>,
3365) {
3366    if let Some(dependent_required) = schema.get("dependentRequired").and_then(Value::as_object) {
3367        for (trigger, required) in dependent_required {
3368            if !consume_validation_work(context, path, errors) {
3369                return;
3370            }
3371            if !obj.contains_key(trigger) {
3372                continue;
3373            }
3374            if let Some(required) = required.as_array() {
3375                for required_property in required {
3376                    if !consume_validation_work(context, path, errors) {
3377                        return;
3378                    }
3379                    if let Some(required_property) = required_property.as_str() {
3380                        if !obj.contains_key(required_property) {
3381                            push_error(
3382                                errors,
3383                                path,
3384                                format!("property {trigger} requires property {required_property}"),
3385                            );
3386                        }
3387                    }
3388                }
3389            }
3390        }
3391    }
3392
3393    if let Some(dependent_schemas) = schema.get("dependentSchemas").and_then(Value::as_object) {
3394        for (trigger, dependent_schema) in dependent_schemas {
3395            if obj.contains_key(trigger) {
3396                validate_internal(dependent_schema, value, path, errors, context);
3397            }
3398        }
3399    }
3400}
3401
3402/// Validates array-specific constraints.
3403fn validate_array(
3404    schema: &serde_json::Map<String, Value>,
3405    arr: &[Value],
3406    path: &str,
3407    errors: &mut Vec<ValidationError>,
3408    context: &mut ValidationContext<'_>,
3409) {
3410    // Validate prefixItems (tuple validation)
3411    let mut prefix_len = 0;
3412    if let Some(prefix_items) = schema.get("prefixItems").and_then(|v| v.as_array()) {
3413        prefix_len = prefix_items.len();
3414        for (i, item_schema) in prefix_items.iter().enumerate() {
3415            if let Some(item) = arr.get(i) {
3416                let item_path = format!("{path}[{i}]");
3417                validate_internal(item_schema, item, &item_path, errors, context);
3418            }
3419        }
3420    }
3421
3422    // Validate items (remaining items or all items)
3423    if let Some(items_schema) = schema.get("items") {
3424        // If items is an array (Draft 4-7 tuple), treat as prefixItems fallback if prefixItems absent
3425        if items_schema.is_array() && prefix_len == 0 {
3426            if let Some(items_arr) = items_schema.as_array() {
3427                for (i, item_schema) in items_arr.iter().enumerate() {
3428                    if let Some(item) = arr.get(i) {
3429                        let item_path = format!("{path}[{i}]");
3430                        validate_internal(item_schema, item, &item_path, errors, context);
3431                    }
3432                }
3433                // In older drafts, 'additionalItems' controls the rest. We skip that for simplicity unless needed.
3434            }
3435        } else if items_schema.is_object() || items_schema.is_boolean() {
3436            // Validate items starting from where prefixItems left off
3437            for (i, item) in arr.iter().enumerate().skip(prefix_len) {
3438                let item_path = format!("{path}[{i}]");
3439                validate_internal(items_schema, item, &item_path, errors, context);
3440            }
3441        }
3442    }
3443
3444    // Admitted final schemas preserve arbitrary-width count bounds; raw
3445    // validation retains its historical u64-only behavior.
3446    if let Some(min) = schema.get("minItems")
3447        && count_compare_to_schema_bound(arr.len(), min, context.enforce_unevaluated_properties)
3448            == Some(Ordering::Less)
3449    {
3450        push_error(
3451            errors,
3452            path,
3453            format!("array must have at least {min} items"),
3454        );
3455    }
3456    if let Some(max) = schema.get("maxItems")
3457        && count_compare_to_schema_bound(arr.len(), max, context.enforce_unevaluated_properties)
3458            == Some(Ordering::Greater)
3459    {
3460        push_error(errors, path, format!("array must have at most {max} items"));
3461    }
3462
3463    // Check uniqueItems
3464    if schema
3465        .get("uniqueItems")
3466        .and_then(serde_json::Value::as_bool)
3467        .unwrap_or(false)
3468    {
3469        if context.enforce_unevaluated_properties {
3470            // Final JSON Schema equality treats numerically equal spellings
3471            // (for example `1` and `1.0`) as one value. Charge every pairwise
3472            // comparison because recursive structural equality is not constant
3473            // time and must remain within the final validation work budget.
3474            for (index, item) in arr.iter().enumerate() {
3475                for previous in &arr[..index] {
3476                    if !consume_validation_work(context, path, errors) {
3477                        return;
3478                    }
3479                    let Some(equal) =
3480                        json_schema_equal_with_work(previous, item, path, errors, context)
3481                    else {
3482                        return;
3483                    };
3484                    if equal {
3485                        push_error(
3486                            errors,
3487                            &format!("{path}[{index}]"),
3488                            "duplicate item in array",
3489                        );
3490                        break;
3491                    }
3492                }
3493            }
3494        } else {
3495            // Preserve the historical raw-validator representation equality.
3496            let mut seen = std::collections::HashSet::with_capacity(arr.len());
3497            for (index, item) in arr.iter().enumerate() {
3498                let key = serde_json::to_string(item).unwrap_or_default();
3499                if !seen.insert(key) {
3500                    push_error(
3501                        errors,
3502                        &format!("{path}[{index}]"),
3503                        "duplicate item in array",
3504                    );
3505                }
3506            }
3507        }
3508    }
3509
3510    if let Some(contains) = schema.get("contains") {
3511        let mut matches = 0;
3512        for item in arr {
3513            if branch_is_valid(contains, item, path, context) {
3514                matches += 1;
3515            }
3516        }
3517        let below_minimum = if context.enforce_unevaluated_properties {
3518            schema.get("minContains").map_or(matches == 0, |minimum| {
3519                count_compare_to_schema_bound(matches, minimum, true) == Some(Ordering::Less)
3520            })
3521        } else {
3522            let minimum = schema
3523                .get("minContains")
3524                .and_then(Value::as_u64)
3525                .unwrap_or(1);
3526            (matches as u64) < minimum
3527        };
3528        if below_minimum {
3529            let minimum = schema
3530                .get("minContains")
3531                .map_or_else(|| "1".to_owned(), |minimum| minimum.to_string());
3532            push_error(
3533                errors,
3534                path,
3535                format!("array must contain at least {minimum} matching items"),
3536            );
3537        }
3538        if let Some(maximum) = schema.get("maxContains")
3539            && count_compare_to_schema_bound(
3540                matches,
3541                maximum,
3542                context.enforce_unevaluated_properties,
3543            ) == Some(Ordering::Greater)
3544        {
3545            push_error(
3546                errors,
3547                path,
3548                format!("array must contain at most {maximum} matching items"),
3549            );
3550        }
3551    }
3552
3553    if context.enforce_unevaluated_properties {
3554        validate_unevaluated_items(schema, arr, path, errors, context);
3555    }
3556}
3557
3558/// Applies Draft 2020-12 `unevaluatedItems` after sibling applicators have
3559/// contributed their successful item annotations.
3560fn validate_unevaluated_items(
3561    schema: &serde_json::Map<String, Value>,
3562    arr: &[Value],
3563    path: &str,
3564    errors: &mut Vec<ValidationError>,
3565    context: &mut ValidationContext<'_>,
3566) {
3567    let Some(unevaluated_schema) = schema.get("unevaluatedItems") else {
3568        return;
3569    };
3570    let mut evaluated = HashSet::with_capacity(arr.len());
3571    mark_evaluated_array_items(schema, arr, path, false, &mut evaluated, errors, context);
3572
3573    for (index, item) in arr.iter().enumerate() {
3574        if !consume_validation_work(context, path, errors) {
3575            return;
3576        }
3577        if !evaluated.contains(&index) {
3578            validate_internal(
3579                unevaluated_schema,
3580                item,
3581                &format!("{path}[{index}]"),
3582                errors,
3583                context,
3584            );
3585        }
3586    }
3587}
3588
3589fn mark_evaluated_array_items(
3590    schema: &serde_json::Map<String, Value>,
3591    arr: &[Value],
3592    path: &str,
3593    include_unevaluated_items: bool,
3594    evaluated: &mut HashSet<usize>,
3595    errors: &mut Vec<ValidationError>,
3596    context: &mut ValidationContext<'_>,
3597) {
3598    if !consume_validation_work(context, path, errors) {
3599        return;
3600    }
3601
3602    if let Some(prefix_items) = schema.get("prefixItems").and_then(Value::as_array) {
3603        for (index, item_schema) in prefix_items.iter().enumerate() {
3604            let Some(item) = arr.get(index) else {
3605                break;
3606            };
3607            if !consume_validation_work(context, path, errors) {
3608                return;
3609            }
3610            if branch_is_valid(item_schema, item, &format!("{path}[{index}]"), context) {
3611                evaluated.insert(index);
3612            }
3613        }
3614    }
3615
3616    let prefix_len = schema
3617        .get("prefixItems")
3618        .and_then(Value::as_array)
3619        .map_or(0, Vec::len);
3620    if let Some(items_schema) = schema.get("items") {
3621        for (index, item) in arr.iter().enumerate().skip(prefix_len) {
3622            if !consume_validation_work(context, path, errors) {
3623                return;
3624            }
3625            if branch_is_valid(items_schema, item, &format!("{path}[{index}]"), context) {
3626                evaluated.insert(index);
3627            }
3628        }
3629    }
3630
3631    if let Some(contains_schema) = schema.get("contains") {
3632        let matches: Vec<usize> = arr
3633            .iter()
3634            .enumerate()
3635            .filter_map(|(index, item)| {
3636                consume_validation_work(context, path, errors)
3637                    .then(|| {
3638                        branch_is_valid(contains_schema, item, &format!("{path}[{index}]"), context)
3639                    })
3640                    .filter(|matches| *matches)
3641                    .map(|_| index)
3642            })
3643            .collect();
3644        let meets_minimum = schema
3645            .get("minContains")
3646            .map_or(matches.len() >= 1, |minimum| {
3647                count_compare_to_schema_bound(matches.len(), minimum, true) != Some(Ordering::Less)
3648            });
3649        let within_maximum = schema.get("maxContains").is_none_or(|maximum| {
3650            count_compare_to_schema_bound(matches.len(), maximum, true) != Some(Ordering::Greater)
3651        });
3652        if meets_minimum && within_maximum {
3653            evaluated.extend(matches);
3654        }
3655    }
3656
3657    if include_unevaluated_items && schema.contains_key("unevaluatedItems") {
3658        for index in 0..arr.len() {
3659            if !consume_validation_work(context, path, errors) {
3660                return;
3661            }
3662            evaluated.insert(index);
3663        }
3664    }
3665
3666    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
3667        mark_reference_evaluated_array_items(
3668            schema, reference, arr, path, evaluated, errors, context,
3669        );
3670    }
3671    if let Some(reference) = schema.get("$dynamicRef").and_then(Value::as_str) {
3672        mark_dynamic_reference_evaluated_array_items(
3673            schema, reference, arr, path, evaluated, errors, context,
3674        );
3675    }
3676
3677    mark_composition_evaluated_array_items(schema, "allOf", arr, path, evaluated, errors, context);
3678    mark_composition_evaluated_array_items(schema, "anyOf", arr, path, evaluated, errors, context);
3679
3680    if let Some(subschemas) = bounded_subschemas(schema, "oneOf", path, errors) {
3681        let matching: Vec<_> = subschemas
3682            .iter()
3683            .filter(|subschema| {
3684                branch_is_valid(subschema, &Value::Array(arr.to_vec()), path, context)
3685            })
3686            .collect();
3687        if matching.len() == 1
3688            && let Some(subschema) = matching[0].as_object()
3689        {
3690            mark_evaluated_array_items(subschema, arr, path, true, evaluated, errors, context);
3691        }
3692    }
3693
3694    if let Some(condition) = schema.get("if") {
3695        let value = Value::Array(arr.to_vec());
3696        let condition_matched = branch_is_valid(condition, &value, path, context);
3697        if condition_matched && let Some(condition) = condition.as_object() {
3698            mark_evaluated_array_items(condition, arr, path, true, evaluated, errors, context);
3699        }
3700        let branch = if condition_matched { "then" } else { "else" };
3701        if let Some(subschema) = schema.get(branch)
3702            && branch_is_valid(subschema, &value, path, context)
3703            && let Some(subschema) = subschema.as_object()
3704        {
3705            mark_evaluated_array_items(subschema, arr, path, true, evaluated, errors, context);
3706        }
3707    }
3708}
3709
3710fn mark_reference_evaluated_array_items(
3711    schema: &serde_json::Map<String, Value>,
3712    reference: &str,
3713    arr: &[Value],
3714    path: &str,
3715    evaluated: &mut HashSet<usize>,
3716    errors: &mut Vec<ValidationError>,
3717    context: &mut ValidationContext<'_>,
3718) {
3719    if !context.enter_reference() {
3720        push_error(errors, path, "local schema reference depth limit exceeded");
3721        return;
3722    }
3723    let root_schema = context.root_schema;
3724    let target = resolve_local_reference_with_work(root_schema, schema, reference, context).ok();
3725    if let Some(target) = target
3726        && branch_is_valid(target, &Value::Array(arr.to_vec()), path, context)
3727        && let Some(target) = target.as_object()
3728    {
3729        mark_evaluated_array_items(target, arr, path, true, evaluated, errors, context);
3730    }
3731    context.leave_reference();
3732}
3733
3734fn mark_dynamic_reference_evaluated_array_items(
3735    schema: &serde_json::Map<String, Value>,
3736    reference: &str,
3737    arr: &[Value],
3738    path: &str,
3739    evaluated: &mut HashSet<usize>,
3740    errors: &mut Vec<ValidationError>,
3741    context: &mut ValidationContext<'_>,
3742) {
3743    if !context.enter_reference() {
3744        push_error(errors, path, "local schema reference depth limit exceeded");
3745        return;
3746    }
3747    let root_schema = context.root_schema;
3748    let target = resolve_local_reference_with_work(root_schema, schema, reference, context).ok();
3749    if let Some(target) = target {
3750        let dynamic_anchor = target
3751            .as_object()
3752            .and_then(|object| object.get("$dynamicAnchor"))
3753            .and_then(Value::as_str);
3754        let selected = dynamic_anchor
3755            .and_then(|name| context.dynamic_anchor_target(name))
3756            .unwrap_or_else(|| target.clone());
3757        if branch_is_valid(&selected, &Value::Array(arr.to_vec()), path, context)
3758            && let Some(selected) = selected.as_object()
3759        {
3760            mark_evaluated_array_items(selected, arr, path, true, evaluated, errors, context);
3761        }
3762    }
3763    context.leave_reference();
3764}
3765
3766fn mark_composition_evaluated_array_items(
3767    schema: &serde_json::Map<String, Value>,
3768    keyword: &str,
3769    arr: &[Value],
3770    path: &str,
3771    evaluated: &mut HashSet<usize>,
3772    errors: &mut Vec<ValidationError>,
3773    context: &mut ValidationContext<'_>,
3774) {
3775    let Some(subschemas) = bounded_subschemas(schema, keyword, path, errors) else {
3776        return;
3777    };
3778    let value = Value::Array(arr.to_vec());
3779    for subschema in subschemas {
3780        if branch_is_valid(subschema, &value, path, context)
3781            && let Some(subschema) = subschema.as_object()
3782        {
3783            mark_evaluated_array_items(subschema, arr, path, true, evaluated, errors, context);
3784        }
3785    }
3786}
3787
3788/// Validates string-specific constraints.
3789fn validate_string(
3790    schema: &serde_json::Map<String, Value>,
3791    s: &str,
3792    path: &str,
3793    errors: &mut Vec<ValidationError>,
3794    context: &mut ValidationContext<'_>,
3795) {
3796    // Admitted final schemas preserve arbitrary-width count bounds; raw
3797    // validation retains its historical u64-only behavior.
3798    let len = s.chars().count();
3799    if let Some(min) = schema.get("minLength")
3800        && count_compare_to_schema_bound(len, min, context.enforce_unevaluated_properties)
3801            == Some(Ordering::Less)
3802    {
3803        push_error(
3804            errors,
3805            path,
3806            format!("string must be at least {min} characters"),
3807        );
3808    }
3809    if let Some(max) = schema.get("maxLength")
3810        && count_compare_to_schema_bound(len, max, context.enforce_unevaluated_properties)
3811            == Some(Ordering::Greater)
3812    {
3813        push_error(
3814            errors,
3815            path,
3816            format!("string must be at most {max} characters"),
3817        );
3818    }
3819
3820    // Check pattern (JSON Schema semantics: pattern matches if any substring matches).
3821    if let Some(pattern) = schema.get("pattern").and_then(serde_json::Value::as_str) {
3822        if !consume_validation_work(context, path, errors) {
3823            return;
3824        }
3825        match Regex::new(pattern) {
3826            Ok(re) => {
3827                let Some(matches) = charged_regex_is_match(&re, s, path, errors, context) else {
3828                    return;
3829                };
3830                if !matches {
3831                    push_error(
3832                        errors,
3833                        path,
3834                        format!("string does not match pattern {pattern:?}"),
3835                    );
3836                }
3837            }
3838            Err(e) => {
3839                // Invalid schema: treat as a validation error rather than silently skipping.
3840                push_error(
3841                    errors,
3842                    path,
3843                    format!("invalid schema pattern {pattern:?}: {e}"),
3844                );
3845            }
3846        }
3847    }
3848}
3849
3850fn is_valid_uri_scheme(scheme: &str) -> bool {
3851    let bytes = scheme.as_bytes();
3852    bytes.first().is_some_and(u8::is_ascii_alphabetic)
3853        && bytes
3854            .iter()
3855            .skip(1)
3856            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'+' | b'-' | b'.'))
3857}
3858
3859/// A bounded, base-ten exact JSON number.
3860///
3861/// JSON Schema numeric comparisons are mathematical comparisons, not binary
3862/// floating-point comparisons. This representation retains the number's
3863/// decimal coefficient and exponent, avoiding rounding at strict boundaries.
3864#[derive(Debug, Clone, PartialEq, Eq)]
3865struct ExactDecimal {
3866    negative: bool,
3867    digits: String,
3868    exponent: i64,
3869}
3870
3871impl ExactDecimal {
3872    fn from_value(value: &Value) -> Option<Self> {
3873        value.as_number().and_then(Self::from_number)
3874    }
3875
3876    fn from_number(number: &serde_json::Number) -> Option<Self> {
3877        Self::parse(&number.to_string())
3878    }
3879
3880    fn parse(source: &str) -> Option<Self> {
3881        if source.is_empty() || source.len() > MAX_EXACT_DECIMAL_DIGITS {
3882            return None;
3883        }
3884        let (negative, unsigned) = match source.as_bytes().first() {
3885            Some(b'-') => (true, &source[1..]),
3886            _ => (false, source),
3887        };
3888        let (significand, exponent) = match unsigned.split_once(['e', 'E']) {
3889            Some((significand, exponent)) => {
3890                let exponent = exponent.parse::<i64>().ok()?;
3891                if exponent.unsigned_abs() as usize > MAX_EXACT_DECIMAL_DIGITS {
3892                    return None;
3893                }
3894                (significand, exponent)
3895            }
3896            None => (unsigned, 0),
3897        };
3898        let (whole, fraction) = match significand.split_once('.') {
3899            Some((whole, fraction)) => (whole, fraction),
3900            None => (significand, ""),
3901        };
3902        if whole.is_empty()
3903            || !whole.bytes().all(|byte| byte.is_ascii_digit())
3904            || !fraction.bytes().all(|byte| byte.is_ascii_digit())
3905        {
3906            return None;
3907        }
3908        let coefficient = format!("{whole}{fraction}");
3909        let significant = coefficient.trim_start_matches('0');
3910        if significant.is_empty() {
3911            return Some(Self {
3912                negative: false,
3913                digits: "0".to_owned(),
3914                exponent: 0,
3915            });
3916        }
3917        if significant.len() > MAX_EXACT_DECIMAL_DIGITS {
3918            return None;
3919        }
3920        let exponent = exponent.checked_sub(i64::try_from(fraction.len()).ok()?)?;
3921        if exponent.unsigned_abs() as usize > MAX_EXACT_DECIMAL_DIGITS {
3922            return None;
3923        }
3924        Some(Self {
3925            negative,
3926            digits: significant.to_owned(),
3927            exponent,
3928        })
3929    }
3930
3931    fn is_zero(&self) -> bool {
3932        self.digits.as_bytes()[0] == b'0'
3933    }
3934
3935    fn is_positive(&self) -> bool {
3936        !self.negative && !self.is_zero()
3937    }
3938
3939    fn is_integer(&self) -> bool {
3940        self.exponent >= 0
3941            || self
3942                .digits
3943                .bytes()
3944                .rev()
3945                .take(self.exponent.unsigned_abs() as usize)
3946                .all(|digit| digit == b'0')
3947    }
3948
3949    fn compare(&self, other: &Self) -> Ordering {
3950        match (self.negative, other.negative) {
3951            (true, false) => return Ordering::Less,
3952            (false, true) => return Ordering::Greater,
3953            _ => {}
3954        }
3955        let magnitude = self.compare_magnitude(other);
3956        if self.negative {
3957            magnitude.reverse()
3958        } else {
3959            magnitude
3960        }
3961    }
3962
3963    fn compare_magnitude(&self, other: &Self) -> Ordering {
3964        match (self.is_zero(), other.is_zero()) {
3965            (true, true) => return Ordering::Equal,
3966            (true, false) => return Ordering::Less,
3967            (false, true) => return Ordering::Greater,
3968            (false, false) => {}
3969        }
3970        let self_position = self.exponent + self.digits.len() as i64;
3971        let other_position = other.exponent + other.digits.len() as i64;
3972        match self_position.cmp(&other_position) {
3973            Ordering::Equal => compare_digit_strings(&self.digits, &other.digits),
3974            order => order,
3975        }
3976    }
3977
3978    fn is_multiple_of_bounded(
3979        &self,
3980        divisor: &Self,
3981        path: &str,
3982        errors: &mut Vec<ValidationError>,
3983        context: &mut ValidationContext<'_>,
3984    ) -> Option<bool> {
3985        if divisor.is_zero() {
3986            return Some(false);
3987        }
3988        if self.is_zero() {
3989            return Some(true);
3990        }
3991        let scale = self.exponent.min(divisor.exponent).min(0).unsigned_abs() as usize;
3992        let dividend_zeros = usize::try_from(self.exponent + scale as i64).ok();
3993        let divisor_zeros = usize::try_from(divisor.exponent + scale as i64).ok();
3994        let (Some(dividend_zeros), Some(divisor_zeros)) = (dividend_zeros, divisor_zeros) else {
3995            return Some(false);
3996        };
3997        if !consume_validation_work_units(
3998            context,
3999            dividend_zeros.saturating_add(divisor_zeros),
4000            path,
4001            errors,
4002        ) {
4003            return None;
4004        }
4005        let mut dividend = self.digits.clone();
4006        dividend.extend(std::iter::repeat_n('0', dividend_zeros));
4007        let mut divisor = divisor.digits.clone();
4008        divisor.extend(std::iter::repeat_n('0', divisor_zeros));
4009        decimal_integer_is_divisible(&dividend, &divisor, path, errors, context)
4010    }
4011}
4012
4013/// Compares an in-memory collection count with a schema number exactly.
4014///
4015/// Count keywords are admitted as mathematical nonnegative integers. Retain
4016/// that exact decimal representation here rather than silently dropping a
4017/// bound that does not fit in `u64`.
4018fn count_compare_to_schema_bound(
4019    count: usize,
4020    bound: &Value,
4021    admitted_final: bool,
4022) -> Option<Ordering> {
4023    if !admitted_final {
4024        return bound.as_u64().map(|bound| (count as u64).cmp(&bound));
4025    }
4026    let count = ExactDecimal::parse(&count.to_string())?;
4027    let bound = ExactDecimal::from_value(bound)?;
4028    Some(count.compare(&bound))
4029}
4030
4031fn compare_digit_strings(left: &str, right: &str) -> Ordering {
4032    let shared_length = left.len().max(right.len());
4033    for index in 0..shared_length {
4034        let left_digit = left.as_bytes().get(index).copied().unwrap_or(b'0');
4035        let right_digit = right.as_bytes().get(index).copied().unwrap_or(b'0');
4036        match left_digit.cmp(&right_digit) {
4037            Ordering::Equal => {}
4038            order => return order,
4039        }
4040    }
4041    Ordering::Equal
4042}
4043
4044fn decimal_integer_is_divisible(
4045    dividend: &str,
4046    divisor: &str,
4047    path: &str,
4048    errors: &mut Vec<ValidationError>,
4049    context: &mut ValidationContext<'_>,
4050) -> Option<bool> {
4051    let divisor = divisor.trim_start_matches('0');
4052    if divisor.is_empty() {
4053        return Some(false);
4054    }
4055    let mut remainder = String::new();
4056    for digit in dividend.bytes() {
4057        if !consume_validation_work(context, path, errors) {
4058            return None;
4059        }
4060        if digit != b'0' || !remainder.is_empty() {
4061            remainder.push(char::from(digit));
4062        }
4063        loop {
4064            if !consume_validation_work_units(
4065                context,
4066                remainder.len().min(divisor.len()),
4067                path,
4068                errors,
4069            ) {
4070                return None;
4071            }
4072            if compare_decimal_integers(&remainder, divisor) == Ordering::Less {
4073                break;
4074            }
4075            if !consume_validation_work_units(context, remainder.len(), path, errors) {
4076                return None;
4077            }
4078            subtract_decimal_integers(&mut remainder, divisor);
4079            if !consume_validation_work_units(context, remainder.len(), path, errors) {
4080                return None;
4081            }
4082            trim_decimal_integer(&mut remainder);
4083        }
4084    }
4085    Some(remainder.is_empty() || remainder == "0")
4086}
4087
4088fn consume_validation_work_units(
4089    context: &mut ValidationContext<'_>,
4090    units: usize,
4091    path: &str,
4092    errors: &mut Vec<ValidationError>,
4093) -> bool {
4094    (0..units).all(|_| consume_validation_work(context, path, errors))
4095}
4096
4097fn compare_decimal_integers(left: &str, right: &str) -> Ordering {
4098    match left.len().cmp(&right.len()) {
4099        Ordering::Equal => left.cmp(right),
4100        order => order,
4101    }
4102}
4103
4104fn subtract_decimal_integers(left: &mut String, right: &str) {
4105    let mut digits = left.bytes().collect::<Vec<_>>();
4106    let right = right.as_bytes();
4107    let mut borrow = 0_i16;
4108    for offset in 0..digits.len() {
4109        let left_index = digits.len() - 1 - offset;
4110        let right_digit = right
4111            .get(right.len().saturating_sub(offset + 1))
4112            .map_or(0, |digit| i16::from(*digit - b'0'));
4113        let mut digit = i16::from(digits[left_index] - b'0') - right_digit - borrow;
4114        if digit < 0 {
4115            digit += 10;
4116            borrow = 1;
4117        } else {
4118            borrow = 0;
4119        }
4120        digits[left_index] = digit as u8 + b'0';
4121    }
4122    // The caller charges validation work against the untrimmed result length
4123    // and trims afterwards; trimming here would erase that charge for exact
4124    // divisions and undercount the long-division budget.
4125    *left = digits.into_iter().map(char::from).collect();
4126}
4127
4128fn trim_decimal_integer(value: &mut String) {
4129    let first_nonzero = value.bytes().position(|digit| digit != b'0');
4130    match first_nonzero {
4131        Some(index) if index > 0 => {
4132            let _ = value.drain(..index);
4133        }
4134        None => value.clear(),
4135        _ => {}
4136    }
4137}
4138
4139/// Compares final-schema values while charging every recursive equality step.
4140fn json_schema_equal_with_work(
4141    left: &Value,
4142    right: &Value,
4143    path: &str,
4144    errors: &mut Vec<ValidationError>,
4145    context: &mut ValidationContext<'_>,
4146) -> Option<bool> {
4147    if !consume_validation_work(context, path, errors) {
4148        return None;
4149    }
4150    match (left, right) {
4151        (Value::Number(left), Value::Number(right)) => Some(
4152            ExactDecimal::from_number(left)
4153                .zip(ExactDecimal::from_number(right))
4154                .is_some_and(|(left, right)| left.compare(&right) == Ordering::Equal),
4155        ),
4156        (Value::Array(left), Value::Array(right)) => {
4157            if left.len() != right.len() {
4158                return Some(false);
4159            }
4160            for (left, right) in left.iter().zip(right) {
4161                if !json_schema_equal_with_work(left, right, path, errors, context)? {
4162                    return Some(false);
4163                }
4164            }
4165            Some(true)
4166        }
4167        (Value::Object(left), Value::Object(right)) => {
4168            if left.len() != right.len() {
4169                return Some(false);
4170            }
4171            for (key, left) in left {
4172                let Some(right) = right.get(key) else {
4173                    return Some(false);
4174                };
4175                if !json_schema_equal_with_work(left, right, path, errors, context)? {
4176                    return Some(false);
4177                }
4178            }
4179            Some(true)
4180        }
4181        _ => Some(left == right),
4182    }
4183}
4184
4185/// Validates number-specific constraints.
4186fn validate_number(
4187    schema: &serde_json::Map<String, Value>,
4188    n: &serde_json::Number,
4189    path: &str,
4190    errors: &mut Vec<ValidationError>,
4191    final_semantics: bool,
4192    context: &mut ValidationContext<'_>,
4193) {
4194    if final_semantics {
4195        validate_exact_number(schema, n, path, errors, context);
4196        return;
4197    }
4198    let val = n.as_f64().unwrap_or(0.0);
4199
4200    // Check minimum/maximum
4201    if let Some(min) = schema.get("minimum").and_then(serde_json::Value::as_f64) {
4202        if val < min {
4203            push_error(errors, path, format!("value must be >= {min}"));
4204        }
4205    }
4206    if let Some(max) = schema.get("maximum").and_then(serde_json::Value::as_f64) {
4207        if val > max {
4208            push_error(errors, path, format!("value must be <= {max}"));
4209        }
4210    }
4211
4212    // Check exclusiveMinimum/exclusiveMaximum
4213    if let Some(min) = schema
4214        .get("exclusiveMinimum")
4215        .and_then(serde_json::Value::as_f64)
4216    {
4217        if val <= min {
4218            push_error(errors, path, format!("value must be > {min}"));
4219        }
4220    }
4221    if let Some(max) = schema
4222        .get("exclusiveMaximum")
4223        .and_then(serde_json::Value::as_f64)
4224    {
4225        if val >= max {
4226            push_error(errors, path, format!("value must be < {max}"));
4227        }
4228    }
4229
4230    // Check multipleOf
4231    if let Some(multiple) = schema.get("multipleOf").and_then(serde_json::Value::as_f64) {
4232        if multiple != 0.0 && (val % multiple).abs() > f64::EPSILON {
4233            push_error(
4234                errors,
4235                path,
4236                format!("value must be a multiple of {multiple}"),
4237            );
4238        }
4239    }
4240}
4241
4242fn validate_exact_number(
4243    schema: &serde_json::Map<String, Value>,
4244    number: &serde_json::Number,
4245    path: &str,
4246    errors: &mut Vec<ValidationError>,
4247    context: &mut ValidationContext<'_>,
4248) {
4249    let Some(value) = ExactDecimal::from_number(number) else {
4250        push_error(
4251            errors,
4252            path,
4253            "instance number exceeds exact comparison bound",
4254        );
4255        return;
4256    };
4257    for (keyword, allowed) in [
4258        ("minimum", ">="),
4259        ("maximum", "<="),
4260        ("exclusiveMinimum", ">"),
4261        ("exclusiveMaximum", "<"),
4262    ] {
4263        let Some(bound_value) = schema.get(keyword) else {
4264            continue;
4265        };
4266        let bound_description = bound_value.to_string();
4267        let Some(bound) = ExactDecimal::from_value(bound_value) else {
4268            push_error(
4269                errors,
4270                path,
4271                "schema numeric keyword must be a bounded exact number",
4272            );
4273            continue;
4274        };
4275        let comparison = value.compare(&bound);
4276        let invalid = match keyword {
4277            "minimum" => comparison == Ordering::Less,
4278            "maximum" => comparison == Ordering::Greater,
4279            "exclusiveMinimum" => comparison != Ordering::Greater,
4280            "exclusiveMaximum" => comparison != Ordering::Less,
4281            _ => unreachable!("the numeric keyword set is fixed"),
4282        };
4283        if invalid {
4284            push_error(
4285                errors,
4286                path,
4287                format!("value must be {allowed} {bound_description}"),
4288            );
4289        }
4290    }
4291    if let Some(multiple) = schema.get("multipleOf").and_then(ExactDecimal::from_value) {
4292        if let Some(false) = value.is_multiple_of_bounded(&multiple, path, errors, context) {
4293            push_error(
4294                errors,
4295                path,
4296                "value must be a multiple of the exact schema divisor",
4297            );
4298        }
4299    }
4300}
4301
4302#[cfg(test)]
4303mod tests {
4304    use super::*;
4305    use serde_json::json;
4306
4307    fn sch_01_a_schema() -> Value {
4308        json!({
4309            "$defs": {
4310                "positive-id": {"type": "integer", "minimum": 1}
4311            },
4312            "type": "object",
4313            "properties": {
4314                "id": {"$ref": "#/$defs/positive-id"},
4315                "mode": {"enum": ["fast", "safe"]},
4316                "items": {
4317                    "type": "array",
4318                    "contains": {"type": "integer", "multipleOf": 2},
4319                    "minContains": 2,
4320                    "maxContains": 2
4321                }
4322            },
4323            "required": ["id", "mode", "items"],
4324            "patternProperties": {
4325                "^x-": {"type": "string"}
4326            },
4327            "propertyNames": {"pattern": "^[A-Za-z-]+$"},
4328            "dependentRequired": {
4329                "creditCard": ["billingAddress"]
4330            },
4331            "dependentSchemas": {
4332                "creditCard": {"required": ["billingAddress"]}
4333            },
4334            "allOf": [{"required": ["id"]}],
4335            "anyOf": [
4336                {"properties": {"mode": {"const": "fast"}}, "required": ["mode"]},
4337                {"properties": {"mode": {"const": "safe"}}, "required": ["mode"]}
4338            ],
4339            "oneOf": [
4340                {"properties": {"mode": {"const": "fast"}}, "required": ["mode"]},
4341                {"properties": {"mode": {"const": "safe"}}, "required": ["mode"]}
4342            ],
4343            "not": {
4344                "properties": {"mode": {"const": "disabled"}},
4345                "required": ["mode"]
4346            },
4347            "if": {
4348                "properties": {"mode": {"const": "fast"}},
4349                "required": ["mode"]
4350            },
4351            "then": {"required": ["fastConfig"]},
4352            "else": {"required": ["safeConfig"]}
4353        })
4354    }
4355
4356    fn sch_01_a_valid_instance() -> Value {
4357        json!({
4358            "id": 7,
4359            "mode": "fast",
4360            "fastConfig": true,
4361            "items": [2, 4, 5],
4362            "x-label": "bounded",
4363            "creditCard": "4111",
4364            "billingAddress": "42 Schema Street"
4365        })
4366    }
4367
4368    #[test]
4369    fn sch_01_a_positive() {
4370        let schema = sch_01_a_schema();
4371        let instance = sch_01_a_valid_instance();
4372
4373        assert!(validate(&schema, &instance).is_ok());
4374    }
4375
4376    #[test]
4377    fn sch_01_a_planted_negative() {
4378        let schema = sch_01_a_schema();
4379        let schema_before = schema.clone();
4380        let mut instance = sch_01_a_valid_instance();
4381        instance["items"][1] = json!(3);
4382
4383        let errors = validate(&schema, &instance)
4384            .expect_err("changing only one array item must violate minContains");
4385        assert_eq!(errors.len(), 1);
4386        assert_eq!(errors[0].path, "root.items");
4387        assert_eq!(
4388            errors[0].message,
4389            "array must contain at least 2 matching items"
4390        );
4391        assert_eq!(schema, schema_before);
4392    }
4393
4394    #[test]
4395    fn final_core_result_schema_positive() {
4396        let schema = admit_final_schema(json!({
4397            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4398            "type": "object",
4399            "properties": {
4400                "resultType": {"const": "complete"},
4401                "content": {"type": "array", "items": {"type": "string"}}
4402            },
4403            "required": ["resultType", "content"],
4404            "additionalProperties": false
4405        }))
4406        .expect("a strict final core result schema admits");
4407        let result = json!({"resultType": "complete", "content": ["ready"]});
4408
4409        validate_final_core_result(&schema, &result, FinalCoreResultType::Complete)
4410            .expect("the selected final core branch and schema both admit the result");
4411        assert_eq!(schema.schema()["$schema"], FINAL_JSON_SCHEMA_DIALECT);
4412        assert_eq!(FinalCoreResultType::Complete.as_str(), "complete");
4413    }
4414
4415    #[test]
4416    fn final_core_result_schema_cross_era_and_unknown_field_negatives() {
4417        let schema = admit_final_schema(json!({
4418            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4419            "type": "object",
4420            "properties": {
4421                "resultType": {"const": "complete"},
4422                "content": {"type": "array", "items": {"type": "string"}}
4423            },
4424            "required": ["resultType", "content"],
4425            "additionalProperties": false
4426        }))
4427        .expect("the final schema admits");
4428        let accepted = json!({"resultType": "complete", "content": ["ready"]});
4429
4430        let mut cross_era = accepted.clone();
4431        cross_era["resultType"] = json!("legacy_complete");
4432        let cross_era_errors =
4433            validate_final_core_result(&schema, &cross_era, FinalCoreResultType::Complete)
4434                .expect_err("changing only resultType to a non-final branch must reject");
4435        assert!(cross_era_errors.iter().any(|error| {
4436            error.path == "root.resultType"
4437                && error.message
4438                    == "resultType does not match the selected final core result branch"
4439        }));
4440
4441        let mut with_legacy_field = accepted.clone();
4442        with_legacy_field["protocolVersion"] = json!("2024-11-05");
4443        let unknown_field_errors =
4444            validate_final_core_result(&schema, &with_legacy_field, FinalCoreResultType::Complete)
4445                .expect_err("adding only a legacy result field must reject");
4446        assert!(unknown_field_errors.iter().any(|error| {
4447            error.path == "root"
4448                && error.message == "additional property not allowed: protocolVersion"
4449        }));
4450        assert_eq!(
4451            accepted,
4452            json!({"resultType": "complete", "content": ["ready"]})
4453        );
4454    }
4455
4456    fn bounded_draft_2020_12_schema() -> AdmittedSchema {
4457        admit_final_schema(json!({
4458            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4459            "$defs": {
4460                "positive": {
4461                    "allOf": [
4462                        {"type": "integer"},
4463                        {"minimum": 1}
4464                    ]
4465                },
4466                "entry": {
4467                    "type": "object",
4468                    "properties": {
4469                        "kind": {"enum": ["number", "label"]},
4470                        "value": {}
4471                    },
4472                    "required": ["kind", "value"],
4473                    "additionalProperties": false,
4474                    "allOf": [{
4475                        "if": {
4476                            "properties": {"kind": {"const": "number"}},
4477                            "required": ["kind"]
4478                        },
4479                        "then": {
4480                            "properties": {"value": {"$ref": "#/$defs/positive"}}
4481                        },
4482                        "else": {
4483                            "properties": {"value": {"type": "string", "minLength": 3}}
4484                        }
4485                    }]
4486                }
4487            },
4488            "type": "object",
4489            "properties": {
4490                "entries": {
4491                    "type": "array",
4492                    "items": {"$ref": "#/$defs/entry"}
4493                }
4494            },
4495            "required": ["entries"],
4496            "additionalProperties": false
4497        }))
4498        .expect("bounded local-reference schema admits")
4499    }
4500
4501    #[test]
4502    fn bounded_draft_2020_12_local_ref_composition_conditional_positive() {
4503        let schema = bounded_draft_2020_12_schema();
4504        let instance = json!({
4505            "entries": [
4506                {"kind": "number", "value": 7},
4507                {"kind": "label", "value": "ready"}
4508            ]
4509        });
4510
4511        schema
4512            .validate(&instance)
4513            .expect("local references, allOf, and the selected conditional branch validate");
4514    }
4515
4516    #[test]
4517    fn bounded_draft_2020_12_local_ref_composition_conditional_planted_negative() {
4518        let schema = bounded_draft_2020_12_schema();
4519        let accepted = json!({
4520            "entries": [
4521                {"kind": "number", "value": 7},
4522                {"kind": "label", "value": "ready"}
4523            ]
4524        });
4525        let mut planted = accepted.clone();
4526        planted["entries"][0]["value"] = json!(0);
4527
4528        let errors = schema.validate(&planted).expect_err(
4529            "changing only the local-reference value violates the selected then branch",
4530        );
4531        assert!(errors.iter().any(|error| {
4532            error.path == "root.entries[0].value" && error.message == "value must be >= 1"
4533        }));
4534        assert_eq!(
4535            accepted,
4536            json!({
4537                "entries": [
4538                    {"kind": "number", "value": 7},
4539                    {"kind": "label", "value": "ready"}
4540                ]
4541            })
4542        );
4543    }
4544
4545    fn bounded_draft_2020_12_unevaluated_properties_schema() -> AdmittedSchema {
4546        admit_final_schema(json!({
4547            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4548            "type": "object",
4549            "allOf": [{
4550                "properties": {
4551                    "label": {"type": "string"}
4552                },
4553                "required": ["label"]
4554            }],
4555            "unevaluatedProperties": false
4556        }))
4557        .expect("the bounded unevaluated-properties schema admits")
4558    }
4559
4560    #[test]
4561    fn bounded_draft_2020_12_unevaluated_properties_positive() {
4562        let schema = bounded_draft_2020_12_unevaluated_properties_schema();
4563        let accepted = json!({"label": "ready"});
4564
4565        schema
4566            .validate(&accepted)
4567            .expect("a property evaluated by a successful allOf branch remains accepted");
4568        assert_eq!(accepted, json!({"label": "ready"}));
4569    }
4570
4571    #[test]
4572    fn bounded_draft_2020_12_unevaluated_properties_planted_negative() {
4573        let schema = bounded_draft_2020_12_unevaluated_properties_schema();
4574        let accepted = json!({"label": "ready"});
4575        let mut planted = accepted.clone();
4576        planted["unexpected"] = json!(true);
4577
4578        let errors = schema
4579            .validate(&planted)
4580            .expect_err("adding only an unevaluated property must be rejected by the false schema");
4581        assert_eq!(errors.len(), 1);
4582        assert_eq!(errors[0].path, "root.unexpected");
4583        assert_eq!(errors[0].message, "schema rejects all values");
4584        assert_eq!(accepted, json!({"label": "ready"}));
4585    }
4586
4587    #[test]
4588    fn raw_validate_preserves_legacy_unevaluated_properties_behavior() {
4589        let legacy_schema = json!({
4590            "type": "object",
4591            "unevaluatedProperties": false
4592        });
4593        let legacy_instance = json!({"legacy": true});
4594
4595        assert!(validate(&legacy_schema, &legacy_instance).is_ok());
4596        assert_eq!(legacy_instance, json!({"legacy": true}));
4597    }
4598
4599    #[test]
4600    fn admitted_anchor_and_dynamic_reference_positive() {
4601        let anchored = admit_final_schema(json!({
4602            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4603            "$defs": {
4604                "positive": {"$anchor": "positive", "type": "integer", "minimum": 1}
4605            },
4606            "$ref": "#positive"
4607        }))
4608        .expect("a local named anchor admits");
4609        anchored
4610            .validate(&json!(1))
4611            .expect("the named anchor resolves without external I/O");
4612
4613        let dynamic = admit_final_schema(json!({
4614            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4615            "$defs": {
4616                "node": {
4617                    "$dynamicAnchor": "node",
4618                    "type": "object",
4619                    "properties": {
4620                        "value": {"type": "integer"},
4621                        "child": {"$dynamicRef": "#node"}
4622                    },
4623                    "required": ["value"],
4624                    "additionalProperties": false
4625                }
4626            },
4627            "$ref": "#node"
4628        }))
4629        .expect("a bounded recursive dynamic reference admits");
4630        let accepted = json!({"value": 1, "child": {"value": 2}});
4631        dynamic
4632            .validate(&accepted)
4633            .expect("a dynamic reference resolves to the active local anchor");
4634        assert_eq!(accepted, json!({"value": 1, "child": {"value": 2}}));
4635    }
4636
4637    #[test]
4638    fn admitted_anchor_and_dynamic_reference_planted_negative() {
4639        let schema = admit_final_schema(json!({
4640            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4641            "$defs": {
4642                "node": {
4643                    "$dynamicAnchor": "node",
4644                    "type": "object",
4645                    "properties": {
4646                        "value": {"type": "integer"},
4647                        "child": {"$dynamicRef": "#node"}
4648                    },
4649                    "required": ["value"],
4650                    "additionalProperties": false
4651                }
4652            },
4653            "$ref": "#node"
4654        }))
4655        .expect("the recursive dynamic schema admits");
4656        let accepted = json!({"value": 1, "child": {"value": 2}});
4657        let mut planted = accepted.clone();
4658        planted["child"]["value"] = json!("not-an-integer");
4659
4660        let errors = schema
4661            .validate(&planted)
4662            .expect_err("changing only the dynamically referenced value must reject");
4663        assert!(errors.iter().any(|error| error.path == "root.child.value"));
4664        assert_eq!(accepted, json!({"value": 1, "child": {"value": 2}}));
4665    }
4666
4667    #[test]
4668    fn admitted_unevaluated_items_positive() {
4669        let schema = admit_final_schema(json!({
4670            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4671            "type": "array",
4672            "prefixItems": [{"type": "string"}],
4673            "contains": {"type": "integer"},
4674            "unevaluatedItems": false
4675        }))
4676        .expect("the bounded unevaluated-items schema admits");
4677        let accepted = json!(["heading", 2]);
4678
4679        schema
4680            .validate(&accepted)
4681            .expect("prefixItems and contains annotations consume every item");
4682        assert_eq!(accepted, json!(["heading", 2]));
4683    }
4684
4685    #[test]
4686    fn admitted_unevaluated_items_planted_negative() {
4687        let schema = admit_final_schema(json!({
4688            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4689            "type": "array",
4690            "prefixItems": [{"type": "string"}],
4691            "contains": {"type": "integer"},
4692            "unevaluatedItems": false
4693        }))
4694        .expect("the bounded unevaluated-items schema admits");
4695        let accepted = json!(["heading", 2]);
4696        let mut planted = accepted.clone();
4697        planted
4698            .as_array_mut()
4699            .expect("fixture is an array")
4700            .push(json!(true));
4701
4702        let errors = schema
4703            .validate(&planted)
4704            .expect_err("adding only an unevaluated item must reject");
4705        assert!(errors.iter().any(|error| error.path == "root[2]"));
4706        assert_eq!(accepted, json!(["heading", 2]));
4707    }
4708
4709    #[test]
4710    fn admitted_exact_numeric_boundaries_positive() {
4711        let minimum = admit_final_schema(json!({
4712            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4713            "type": "integer",
4714            "minimum": 9007199254740993_u64
4715        }))
4716        .expect("the exact integer boundary schema admits");
4717        minimum
4718            .validate(&json!(9007199254740993_u64))
4719            .expect("the exact minimum itself is accepted");
4720
4721        let multiple = admit_final_schema(json!({
4722            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4723            "type": "number",
4724            "multipleOf": 0.1
4725        }))
4726        .expect("the exact decimal divisor schema admits");
4727        multiple
4728            .validate(&json!(0.3))
4729            .expect("0.3 is exactly divisible by the decimal divisor 0.1");
4730    }
4731
4732    #[test]
4733    fn admitted_exact_numeric_boundaries_planted_negative() {
4734        let schema = admit_final_schema(json!({
4735            "$schema": FINAL_JSON_SCHEMA_DIALECT,
4736            "type": "integer",
4737            "minimum": 9007199254740993_u64
4738        }))
4739        .expect("the exact integer boundary schema admits");
4740        let accepted = json!(9007199254740993_u64);
4741        let planted = json!(9007199254740992_u64);
4742        let errors = schema
4743            .validate(&planted)
4744            .expect_err("changing only the integer below the exact boundary must reject");
4745        assert!(errors.iter().any(|error| error.path == "root"));
4746        assert_eq!(accepted, json!(9007199254740993_u64));
4747    }
4748
4749    #[test]
4750    fn admitted_final_string_lengths_above_u64_are_lossless_positive() {
4751        const BOUND: &str = "184467440737095516160e-1";
4752        let accepted_schema: Value = serde_json::from_str(&format!(
4753            r#"{{"$schema":"{FINAL_JSON_SCHEMA_DIALECT}","type":"string","minLength":1,"maxLength":{BOUND}}}"#
4754        ))
4755        .expect("the arbitrary-precision count schema parses");
4756        let schema = admit_final_schema(accepted_schema.clone())
4757            .expect("a mathematical string-length count above u64 admits");
4758        let accepted = json!("x");
4759
4760        schema
4761            .validate(&accepted)
4762            .expect("the exact upper string-length bound accepts the smaller instance");
4763        assert_eq!(
4764            schema.schema()["maxLength"]
4765                .as_number()
4766                .expect("admitted maxLength remains numeric")
4767                .as_str(),
4768            BOUND
4769        );
4770        assert_eq!(accepted_schema["minLength"], json!(1));
4771        assert_eq!(accepted, json!("x"));
4772    }
4773
4774    #[test]
4775    fn admitted_final_string_lengths_above_u64_are_lossless_planted_negative() {
4776        const BOUND: &str = "184467440737095516160e-1";
4777        let accepted_schema: Value = serde_json::from_str(&format!(
4778            r#"{{"$schema":"{FINAL_JSON_SCHEMA_DIALECT}","type":"string","minLength":1,"maxLength":{BOUND}}}"#
4779        ))
4780        .expect("the arbitrary-precision count schema parses");
4781        let mut planted_schema = accepted_schema.clone();
4782        planted_schema["minLength"] = planted_schema["maxLength"].clone();
4783        let accepted = json!("x");
4784
4785        let errors = admit_final_schema(planted_schema)
4786            .expect("the mathematical string-length lower bound admits")
4787            .validate(&accepted)
4788            .expect_err("changing only minLength to the exact large bound must reject");
4789        assert!(errors.iter().any(|error| {
4790            error.path == "root"
4791                && error.message == format!("string must be at least {BOUND} characters")
4792        }));
4793        assert_eq!(accepted_schema["minLength"], json!(1));
4794        assert_eq!(
4795            accepted_schema["maxLength"]
4796                .as_number()
4797                .expect("baseline maxLength remains numeric")
4798                .as_str(),
4799            BOUND
4800        );
4801        assert_eq!(accepted, json!("x"));
4802    }
4803
4804    #[test]
4805    fn admitted_final_item_counts_above_u64_are_lossless_positive() {
4806        const BOUND: &str = "184467440737095516160e-1";
4807        let accepted_schema: Value = serde_json::from_str(&format!(
4808            r#"{{"$schema":"{FINAL_JSON_SCHEMA_DIALECT}","type":"array","minItems":1,"maxItems":{BOUND}}}"#
4809        ))
4810        .expect("the arbitrary-precision count schema parses");
4811        let schema = admit_final_schema(accepted_schema.clone())
4812            .expect("a mathematical item count above u64 admits");
4813        let accepted = json!([null]);
4814
4815        schema
4816            .validate(&accepted)
4817            .expect("the exact upper item-count bound accepts the smaller instance");
4818        assert_eq!(
4819            schema.schema()["maxItems"]
4820                .as_number()
4821                .expect("admitted maxItems remains numeric")
4822                .as_str(),
4823            BOUND
4824        );
4825        assert_eq!(accepted_schema["minItems"], json!(1));
4826        assert_eq!(accepted, json!([null]));
4827    }
4828
4829    #[test]
4830    fn admitted_final_item_counts_above_u64_are_lossless_planted_negative() {
4831        const BOUND: &str = "184467440737095516160e-1";
4832        let accepted_schema: Value = serde_json::from_str(&format!(
4833            r#"{{"$schema":"{FINAL_JSON_SCHEMA_DIALECT}","type":"array","minItems":1,"maxItems":{BOUND}}}"#
4834        ))
4835        .expect("the arbitrary-precision count schema parses");
4836        let mut planted_schema = accepted_schema.clone();
4837        planted_schema["minItems"] = planted_schema["maxItems"].clone();
4838        let accepted = json!([null]);
4839
4840        let errors = admit_final_schema(planted_schema)
4841            .expect("the mathematical item-count lower bound admits")
4842            .validate(&accepted)
4843            .expect_err("changing only minItems to the exact large bound must reject");
4844        assert!(errors.iter().any(|error| {
4845            error.path == "root"
4846                && error.message == format!("array must have at least {BOUND} items")
4847        }));
4848        assert_eq!(accepted_schema["minItems"], json!(1));
4849        assert_eq!(
4850            accepted_schema["maxItems"]
4851                .as_number()
4852                .expect("baseline maxItems remains numeric")
4853                .as_str(),
4854            BOUND
4855        );
4856        assert_eq!(accepted, json!([null]));
4857    }
4858
4859    #[test]
4860    fn arbitrary_width_count_comparisons_are_final_only_and_raw_remains_legacy() {
4861        const BOUND: &str = "184467440737095516160e-1";
4862        let accepted_schema: Value = serde_json::from_str(&format!(
4863            r#"{{"$schema":"{FINAL_JSON_SCHEMA_DIALECT}","type":"array","minItems":1,"maxItems":{BOUND}}}"#
4864        ))
4865        .expect("the arbitrary-precision count schema parses");
4866        let mut planted_schema = accepted_schema.clone();
4867        planted_schema["minItems"] = planted_schema["maxItems"].clone();
4868        let instance = json!([null]);
4869
4870        validate(&planted_schema, &instance)
4871            .expect("raw validation retains its legacy u64-only count behavior");
4872        validate_strict(&planted_schema, &instance)
4873            .expect("raw strict validation retains its legacy u64-only count behavior");
4874        let errors = admit_final_schema(planted_schema)
4875            .expect("the arbitrary-width final count admits")
4876            .validate(&instance)
4877            .expect_err("the same final count remains mathematically enforced");
4878        assert!(errors.iter().any(|error| {
4879            error.path == "root"
4880                && error.message == format!("array must have at least {BOUND} items")
4881        }));
4882        assert_eq!(accepted_schema["minItems"], json!(1));
4883        assert_eq!(
4884            accepted_schema["maxItems"]
4885                .as_number()
4886                .expect("baseline maxItems remains numeric")
4887                .as_str(),
4888            BOUND
4889        );
4890        assert_eq!(instance, json!([null]));
4891    }
4892
4893    #[test]
4894    fn admitted_final_property_counts_above_u64_are_lossless_positive() {
4895        const BOUND: &str = "184467440737095516160e-1";
4896        let accepted_schema: Value = serde_json::from_str(&format!(
4897            r#"{{"$schema":"{FINAL_JSON_SCHEMA_DIALECT}","type":"object","minProperties":1,"maxProperties":{BOUND}}}"#
4898        ))
4899        .expect("the arbitrary-precision count schema parses");
4900        let schema = admit_final_schema(accepted_schema.clone())
4901            .expect("a mathematical property count above u64 admits");
4902        let accepted = json!({"ready": null});
4903
4904        schema
4905            .validate(&accepted)
4906            .expect("the exact upper property-count bound accepts the smaller instance");
4907        assert_eq!(
4908            schema.schema()["maxProperties"]
4909                .as_number()
4910                .expect("admitted maxProperties remains numeric")
4911                .as_str(),
4912            BOUND
4913        );
4914        assert_eq!(accepted_schema["minProperties"], json!(1));
4915        assert_eq!(accepted, json!({"ready": null}));
4916    }
4917
4918    #[test]
4919    fn admitted_final_property_counts_above_u64_are_lossless_planted_negative() {
4920        const BOUND: &str = "184467440737095516160e-1";
4921        let accepted_schema: Value = serde_json::from_str(&format!(
4922            r#"{{"$schema":"{FINAL_JSON_SCHEMA_DIALECT}","type":"object","minProperties":1,"maxProperties":{BOUND}}}"#
4923        ))
4924        .expect("the arbitrary-precision count schema parses");
4925        let mut planted_schema = accepted_schema.clone();
4926        planted_schema["minProperties"] = planted_schema["maxProperties"].clone();
4927        let accepted = json!({"ready": null});
4928
4929        let errors = admit_final_schema(planted_schema)
4930            .expect("the mathematical property-count lower bound admits")
4931            .validate(&accepted)
4932            .expect_err("changing only minProperties to the exact large bound must reject");
4933        assert!(errors.iter().any(|error| {
4934            error.path == "root"
4935                && error.message == format!("object must have at least {BOUND} properties")
4936        }));
4937        assert_eq!(accepted_schema["minProperties"], json!(1));
4938        assert_eq!(
4939            accepted_schema["maxProperties"]
4940                .as_number()
4941                .expect("baseline maxProperties remains numeric")
4942                .as_str(),
4943            BOUND
4944        );
4945        assert_eq!(accepted, json!({"ready": null}));
4946    }
4947
4948    #[test]
4949    fn admitted_final_contains_counts_above_u64_are_lossless_positive() {
4950        const BOUND: &str = "184467440737095516160e-1";
4951        let accepted_schema: Value = serde_json::from_str(&format!(
4952            r#"{{"$schema":"{FINAL_JSON_SCHEMA_DIALECT}","type":"array","contains":{{"const":"ready"}},"minContains":1,"maxContains":{BOUND},"unevaluatedItems":false}}"#
4953        ))
4954        .expect("the arbitrary-precision count schema parses");
4955        let schema = admit_final_schema(accepted_schema.clone())
4956            .expect("a mathematical contains count above u64 admits");
4957        let accepted = json!(["ready"]);
4958
4959        schema
4960            .validate(&accepted)
4961            .expect("the exact upper contains bound retains the successful item annotation");
4962        assert_eq!(
4963            schema.schema()["maxContains"]
4964                .as_number()
4965                .expect("admitted maxContains remains numeric")
4966                .as_str(),
4967            BOUND
4968        );
4969        assert_eq!(accepted_schema["minContains"], json!(1));
4970        assert_eq!(accepted, json!(["ready"]));
4971    }
4972
4973    #[test]
4974    fn admitted_final_contains_counts_above_u64_are_lossless_planted_negative() {
4975        const BOUND: &str = "184467440737095516160e-1";
4976        let accepted_schema: Value = serde_json::from_str(&format!(
4977            r#"{{"$schema":"{FINAL_JSON_SCHEMA_DIALECT}","type":"array","contains":{{"const":"ready"}},"minContains":1,"maxContains":{BOUND},"unevaluatedItems":false}}"#
4978        ))
4979        .expect("the arbitrary-precision count schema parses");
4980        let mut planted_schema = accepted_schema.clone();
4981        planted_schema["minContains"] = planted_schema["maxContains"].clone();
4982        let accepted = json!(["ready"]);
4983
4984        let errors = admit_final_schema(planted_schema)
4985            .expect("the mathematical contains lower bound admits")
4986            .validate(&accepted)
4987            .expect_err("changing only minContains to the exact large bound must reject");
4988        assert!(errors.iter().any(|error| {
4989            error.path == "root"
4990                && error.message == format!("array must contain at least {BOUND} matching items")
4991        }));
4992        assert_eq!(accepted_schema["minContains"], json!(1));
4993        assert_eq!(
4994            accepted_schema["maxContains"]
4995                .as_number()
4996                .expect("baseline maxContains remains numeric")
4997                .as_str(),
4998            BOUND
4999        );
5000        assert_eq!(accepted, json!(["ready"]));
5001    }
5002
5003    #[test]
5004    fn admitted_underscore_anchor_positive() {
5005        let schema = admit_final_schema(json!({
5006            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5007            "$defs": {
5008                "private": {"$anchor": "_private", "const": "ready"}
5009            },
5010            "$ref": "#_private"
5011        }))
5012        .expect("an underscore-prefixed JSON Schema anchor admits");
5013        let accepted = json!("ready");
5014
5015        schema
5016            .validate(&accepted)
5017            .expect("the underscore-prefixed local anchor resolves");
5018        assert_eq!(accepted, json!("ready"));
5019    }
5020
5021    #[test]
5022    fn admitted_underscore_anchor_planted_negative() {
5023        let accepted = json!({
5024            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5025            "$defs": {
5026                "private": {"$anchor": "_private", "const": "ready"}
5027            }
5028        });
5029        admit_final_schema(accepted.clone())
5030            .expect("the underscore-prefixed anchor remains the valid baseline");
5031        let mut planted = accepted.clone();
5032        planted["$defs"]["private"]["$anchor"] = json!("-private");
5033
5034        let error = admit_final_schema(planted)
5035            .expect_err("changing only the initial anchor character to a hyphen must reject");
5036        assert_eq!(error.path(), "$.$defs.private.$anchor");
5037        assert_eq!(error.reason(), "schema anchor has an invalid name");
5038        assert_eq!(accepted["$defs"]["private"]["$anchor"], json!("_private"));
5039    }
5040
5041    #[test]
5042    fn admitted_unique_items_uses_numeric_schema_equality_positive() {
5043        let schema = admit_final_schema(json!({
5044            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5045            "type": "array",
5046            "uniqueItems": true
5047        }))
5048        .expect("the final unique-items schema admits");
5049        let accepted = json!([1, 2.0]);
5050
5051        schema
5052            .validate(&accepted)
5053            .expect("numerically distinct items remain unique");
5054        assert_eq!(accepted, json!([1, 2.0]));
5055    }
5056
5057    #[test]
5058    fn admitted_unique_items_uses_numeric_schema_equality_planted_negative() {
5059        let schema = admit_final_schema(json!({
5060            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5061            "type": "array",
5062            "uniqueItems": true
5063        }))
5064        .expect("the final unique-items schema admits");
5065        let accepted = json!([1, 2.0]);
5066        let mut planted = accepted.clone();
5067        planted[1] = json!(1.0);
5068
5069        let errors = schema
5070            .validate(&planted)
5071            .expect_err("changing only 2.0 to the numerically equal 1.0 must reject");
5072        assert!(errors.iter().any(|error| {
5073            error.path == "root[1]" && error.message == "duplicate item in array"
5074        }));
5075        assert_eq!(accepted, json!([1, 2.0]));
5076    }
5077
5078    fn arbitrary_precision_number(decimal_digits: usize) -> Value {
5079        serde_json::from_str(&"1".repeat(decimal_digits))
5080            .expect("the workspace serde_json configuration retains arbitrary-precision numbers")
5081    }
5082
5083    #[test]
5084    fn admitted_const_and_enum_exact_equality_bound_positive() {
5085        let number = arbitrary_precision_number(MAX_EXACT_DECIMAL_DIGITS);
5086        let const_schema = admit_final_schema(json!({
5087            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5088            "const": number.clone()
5089        }))
5090        .expect("a const at the exact numeric equality bound admits");
5091        const_schema
5092            .validate(&number)
5093            .expect("an admitted const remains reflexive at the exact bound");
5094
5095        let enum_schema = admit_final_schema(json!({
5096            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5097            "enum": [number.clone()]
5098        }))
5099        .expect("an enum member at the exact numeric equality bound admits");
5100        enum_schema
5101            .validate(&number)
5102            .expect("an admitted enum member remains reflexive at the exact bound");
5103    }
5104
5105    #[test]
5106    fn admitted_const_and_enum_exact_equality_bound_planted_negative() {
5107        let accepted = arbitrary_precision_number(MAX_EXACT_DECIMAL_DIGITS);
5108        let planted = arbitrary_precision_number(MAX_EXACT_DECIMAL_DIGITS + 1);
5109
5110        let const_error = admit_final_schema(json!({
5111            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5112            "const": planted.clone()
5113        }))
5114        .expect_err("adding one digit beyond the const equality bound must reject admission");
5115        assert_eq!(const_error.path(), "$.const");
5116        assert_eq!(
5117            const_error.reason(),
5118            "const or enum value exceeds exact numeric equality bound"
5119        );
5120
5121        let enum_error = admit_final_schema(json!({
5122            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5123            "enum": [planted]
5124        }))
5125        .expect_err("adding one digit beyond the enum equality bound must reject admission");
5126        assert_eq!(enum_error.path(), "$.enum[0]");
5127        assert_eq!(
5128            enum_error.reason(),
5129            "const or enum value exceeds exact numeric equality bound"
5130        );
5131        assert_eq!(accepted.to_string().len(), MAX_EXACT_DECIMAL_DIGITS);
5132    }
5133
5134    #[test]
5135    fn admitted_multiple_of_work_accounting_positive() {
5136        let schema = admit_final_schema(json!({
5137            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5138            "type": "number",
5139            "multipleOf": 1
5140        }))
5141        .expect("the exact divisor schema admits");
5142        let accepted = arbitrary_precision_number((MAX_SCHEMA_VALIDATION_WORK - 1) / 4);
5143
5144        schema
5145            .validate(&accepted)
5146            .expect("the exact long-division work budget admits its final charged digit");
5147        assert_eq!(
5148            accepted.to_string().len(),
5149            (MAX_SCHEMA_VALIDATION_WORK - 1) / 4
5150        );
5151    }
5152
5153    #[test]
5154    fn admitted_multiple_of_work_accounting_planted_negative() {
5155        let schema = admit_final_schema(json!({
5156            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5157            "type": "number",
5158            "multipleOf": 1
5159        }))
5160        .expect("the exact divisor schema admits");
5161        let accepted = arbitrary_precision_number((MAX_SCHEMA_VALIDATION_WORK - 1) / 4);
5162        let planted = arbitrary_precision_number(((MAX_SCHEMA_VALIDATION_WORK - 1) / 4) + 1);
5163
5164        let errors = schema
5165            .validate(&planted)
5166            .expect_err("adding one decimal digit beyond the charged division budget must reject");
5167        assert!(
5168            errors
5169                .iter()
5170                .any(|error| error.message == "schema validation work limit exceeded")
5171        );
5172        assert_eq!(
5173            accepted.to_string().len(),
5174            (MAX_SCHEMA_VALIDATION_WORK - 1) / 4
5175        );
5176    }
5177
5178    #[test]
5179    fn admitted_schema_refuses_unknown_vocabulary_keywords_and_raw_semantics_remain_legacy() {
5180        let error = admit_final_schema(json!({
5181            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5182            "unsupportedFinalKeyword": true
5183        }))
5184        .expect_err("unsupported vocabularies fail before final-schema validation");
5185        assert_eq!(error.path(), "$.unsupportedFinalKeyword");
5186        assert_eq!(
5187            error.reason(),
5188            "unsupported Draft 2020-12 vocabulary keyword"
5189        );
5190
5191        assert!(validate(&json!({"type": "integer"}), &json!(1.0)).is_err());
5192        assert!(validate(&json!({"$ref": "#named"}), &json!(true)).is_err());
5193    }
5194
5195    #[test]
5196    fn admitted_schema_accepts_declared_vocabulary_local_resources_and_content_annotations() {
5197        let schema = admit_final_schema(json!({
5198            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5199            "$id": "https://schemas.example.test/root",
5200            "$vocabulary": {
5201                CORE_VOCABULARY_URI: true,
5202                APPLICATOR_VOCABULARY_URI: true,
5203                VALIDATION_VOCABULARY_URI: true,
5204                CONTENT_VOCABULARY_URI: true,
5205                "urn:example:optional-extension": false
5206            },
5207            "type": "object",
5208            "properties": {
5209                "payload": {"$ref": "https://schemas.example.test/payload"}
5210            },
5211            "required": ["payload"],
5212            "$defs": {
5213                "payload": {
5214                    "$id": "https://schemas.example.test/payload",
5215                    "$schema": FINAL_JSON_SCHEMA_DIALECT,
5216                    "$vocabulary": {
5217                        CORE_VOCABULARY_URI: true,
5218                        CONTENT_VOCABULARY_URI: true
5219                    },
5220                    "type": "string",
5221                    "contentEncoding": "base64",
5222                    "contentMediaType": "application/json",
5223                    "contentSchema": {
5224                        "type": "object",
5225                        "required": ["ok"],
5226                        "properties": {"ok": {"type": "boolean"}}
5227                    }
5228                }
5229            }
5230        }))
5231        .expect("declared supported vocabularies and local resources admit");
5232
5233        schema
5234            .validate(&json!({"payload": "this is deliberately not base64"}))
5235            .expect("content annotations do not decode or validate instance strings");
5236    }
5237
5238    #[test]
5239    fn admitted_schema_rejects_duplicate_and_undeclared_local_resource_ids() {
5240        let accepted = json!({
5241            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5242            "$id": "https://schemas.example.test/root",
5243            "$defs": {
5244                "payload": {
5245                    "$id": "https://schemas.example.test/payload",
5246                    "type": "string"
5247                },
5248                "other": {
5249                    "$id": "https://schemas.example.test/other",
5250                    "type": "string"
5251                }
5252            },
5253            "$ref": "https://schemas.example.test/payload"
5254        });
5255        admit_final_schema(accepted.clone())
5256            .expect("a unique declared resource satisfies an absolute local reference");
5257
5258        let mut duplicate = accepted.clone();
5259        duplicate["$defs"]["other"]["$id"] = json!("https://schemas.example.test/root");
5260        let duplicate_error = admit_final_schema(duplicate)
5261            .expect_err("changing only the resource id to a duplicate rejects");
5262        assert_eq!(duplicate_error.path(), "$.$defs.other.$id");
5263        assert_eq!(
5264            duplicate_error.reason(),
5265            "duplicate local schema resource identifier"
5266        );
5267
5268        let mut undeclared = accepted;
5269        undeclared["$ref"] = json!("https://schemas.example.test/not-declared");
5270        let reference_error = admit_final_schema(undeclared)
5271            .expect_err("undeclared URI references cannot trigger retrieval");
5272        assert_eq!(reference_error.path(), "$.$ref");
5273        assert_eq!(
5274            reference_error.reason(),
5275            "external schema reference is not allowed"
5276        );
5277    }
5278
5279    #[test]
5280    fn admitted_schema_rejects_invalid_resource_id_with_a_near_identical_fixture() {
5281        let accepted = json!({
5282            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5283            "$id": "urn:example:bounded-schema",
5284            "type": "string"
5285        });
5286        admit_final_schema(accepted.clone()).expect("a bounded absolute resource id admits");
5287
5288        let mut planted = accepted;
5289        planted["$id"] = json!("relative-schema");
5290        let error = admit_final_schema(planted)
5291            .expect_err("changing only the id to a relative URI rejects");
5292        assert_eq!(error.path(), "$.$id");
5293        assert_eq!(
5294            error.reason(),
5295            "schema $id must resolve to a bounded absolute URI"
5296        );
5297
5298        let fragmented = json!({
5299            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5300            "$id": "urn:example:bounded-schema#fragment",
5301            "type": "string"
5302        });
5303        let fragment_error =
5304            admit_final_schema(fragmented).expect_err("changing only the id fragment rejects");
5305        assert_eq!(fragment_error.path(), "$.$id");
5306        assert_eq!(
5307            fragment_error.reason(),
5308            "schema $id must not contain a fragment"
5309        );
5310    }
5311
5312    #[test]
5313    fn ordinary_schema_vocabulary_is_ignored_and_meta_schema_vocabulary_is_a_dialect_gate() {
5314        let accepted = json!({
5315            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5316            "$vocabulary": {
5317                "not-a-uri": "not-a-boolean"
5318            },
5319            "type": "string"
5320        });
5321        admit_final_schema(accepted)
5322            .expect("ordinary-schema $vocabulary does not select or gate a dialect");
5323
5324        let meta_schema = json!({
5325            "$id": "https://schemas.example.test/meta",
5326            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5327            "$vocabulary": {
5328                CORE_VOCABULARY_URI: true,
5329                "urn:example:unsupported": true
5330            }
5331        });
5332        let schema = json!({
5333            "$id": "https://schemas.example.test/root",
5334            "$schema": "https://schemas.example.test/meta",
5335            "$defs": {"meta": meta_schema},
5336            "type": "string"
5337        });
5338        let error = admit_final_schema(schema).expect_err(
5339            "only a selected meta-schema vocabulary can reject an unsupported requirement",
5340        );
5341        assert_eq!(error.path(), "$.$vocabulary.urn:example:unsupported");
5342        assert_eq!(
5343            error.reason(),
5344            "required schema vocabulary is not supported"
5345        );
5346    }
5347
5348    #[test]
5349    fn schema_dialect_must_be_absolute_with_a_one_value_negative() {
5350        let accepted = json!({
5351            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5352            "type": "string"
5353        });
5354        admit_final_schema(accepted.clone()).expect("the canonical absolute dialect admits");
5355
5356        let mut planted = accepted.clone();
5357        planted["$schema"] = json!("meta/validation");
5358        let error = admit_final_schema(planted)
5359            .expect_err("changing only the dialect to a relative identifier must reject");
5360        assert_eq!(error.path(), "$.$schema");
5361        assert_eq!(
5362            error.reason(),
5363            "schema dialect must be a bounded absolute URI"
5364        );
5365        assert_eq!(accepted["$schema"], json!(FINAL_JSON_SCHEMA_DIALECT));
5366    }
5367
5368    #[test]
5369    fn admitted_schema_scopes_relative_ids_anchors_and_inner_local_references() {
5370        let accepted = json!({
5371            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5372            "$id": "https://schemas.example.test/catalog/root.json",
5373            "$anchor": "outer",
5374            "type": "object",
5375            "properties": {
5376                "outer": {"$ref": "resources/outer.json#outer"}
5377            },
5378            "required": ["outer"],
5379            "$defs": {
5380                "outer": {
5381                    "$id": "resources/outer.json",
5382                    "$anchor": "outer",
5383                    "type": "object",
5384                    "properties": {
5385                        "value": {"$ref": "#integer"},
5386                        "nested": {"$ref": "nested.json#nested"}
5387                    },
5388                    "required": ["value", "nested"],
5389                    "$defs": {
5390                        "integer": {"$anchor": "integer", "type": "integer"},
5391                        "nested": {
5392                            "$id": "nested.json",
5393                            "$anchor": "nested",
5394                            "type": "string"
5395                        }
5396                    }
5397                }
5398            }
5399        });
5400        let schema = admit_final_schema(accepted.clone())
5401            .expect("relative ids and resource-scoped anchors admit");
5402        schema
5403            .validate(&json!({"outer": {"value": 7, "nested": "ready"}}))
5404            .expect("inner local and relative references resolve from the owning resource");
5405
5406        let mut planted = accepted;
5407        planted["$defs"]["outer"]["properties"]["value"]["$ref"] = json!("#root-only");
5408        let error = admit_final_schema(planted)
5409            .expect_err("changing only an inner reference to an outer anchor rejects");
5410        assert_eq!(error.path(), "$.$defs.outer.properties.value.$ref");
5411        assert_eq!(error.reason(), "unresolved local schema reference");
5412    }
5413
5414    #[test]
5415    fn relative_resource_ids_resolve_from_authority_only_and_query_bases() {
5416        for base in [
5417            "https://schemas.example.test",
5418            "https://schemas.example.test?catalog=bounded",
5419        ] {
5420            let schema = admit_final_schema(json!({
5421                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5422                "$id": base,
5423                "$defs": {
5424                    "child": {
5425                        "$id": "child.json",
5426                        "type": "string",
5427                        "const": "ready"
5428                    }
5429                },
5430                "$ref": "child.json"
5431            }))
5432            .expect("a relative child resource resolves below an authority-only base");
5433            schema
5434                .validate(&json!("ready"))
5435                .expect("the authority-relative child reference reaches its local resource");
5436        }
5437    }
5438
5439    #[test]
5440    fn rfc3986_reference_resolution_table_preserves_network_query_and_dot_boundaries() {
5441        let vectors = [
5442            (
5443                "https://schemas.example.test/a/b?base=1",
5444                "https://schemas.example.test/a//b?absolute=1",
5445                "https://schemas.example.test/a//b?absolute=1",
5446            ),
5447            (
5448                "https://schemas.example.test/a/b?base=1",
5449                "/a//b",
5450                "https://schemas.example.test/a//b",
5451            ),
5452            (
5453                "https://schemas.example.test/a/b?base=1",
5454                "/a/.",
5455                "https://schemas.example.test/a/",
5456            ),
5457            (
5458                "https://schemas.example.test/a/b?base=1",
5459                "/a/..",
5460                "https://schemas.example.test/",
5461            ),
5462            (
5463                "https://schemas.example.test/a/b?base=1",
5464                "child/./leaf?relative=1",
5465                "https://schemas.example.test/a/child/leaf?relative=1",
5466            ),
5467            (
5468                "https://schemas.example.test/a/b?base=1",
5469                "child/..",
5470                "https://schemas.example.test/a/",
5471            ),
5472            (
5473                "https://schemas.example.test/a/b?base=1",
5474                "//authority.example.test/a//b?network=1",
5475                "https://authority.example.test/a//b?network=1",
5476            ),
5477            (
5478                "https://schemas.example.test/a/b?base=1",
5479                "//authority.example.test/a/.",
5480                "https://authority.example.test/a/",
5481            ),
5482            (
5483                "https://schemas.example.test/a/b?base=1",
5484                "//authority.example.test/a/..",
5485                "https://authority.example.test/",
5486            ),
5487            (
5488                "https://schemas.example.test/a/b?base=1",
5489                "?next=1",
5490                "https://schemas.example.test/a/b?next=1",
5491            ),
5492            (
5493                "https://schemas.example.test/a/b?base=1",
5494                "",
5495                "https://schemas.example.test/a/b?base=1",
5496            ),
5497        ];
5498
5499        for (base, reference, expected) in vectors {
5500            assert_eq!(
5501                resolve_uri_reference(Some(base), reference).as_deref(),
5502                Some(expected),
5503                "RFC 3986 resolution must retain the boundary semantics of {reference:?}"
5504            );
5505        }
5506    }
5507
5508    #[test]
5509    fn rfc3986_remove_dot_segments_table_preserves_empty_and_trailing_segments() {
5510        let vectors = [
5511            ("/a/b/c/./../../g", "/a/g"),
5512            ("mid/content=5/../6", "mid/6"),
5513            ("/a//b", "/a//b"),
5514            ("/a/.", "/a/"),
5515            ("/a/..", "/"),
5516            ("/../", "/"),
5517            ("../g", "g"),
5518            ("g/./h", "g/h"),
5519        ];
5520
5521        for (path, expected) in vectors {
5522            assert_eq!(
5523                remove_uri_dot_segments(path),
5524                expected,
5525                "RFC 3986 section 5.2.4 preserves the path boundary semantics of {path:?}"
5526            );
5527        }
5528    }
5529
5530    #[test]
5531    fn rfc3986_dot_segment_one_value_negatives_do_not_select_adjacent_resources() {
5532        let cases = [
5533            ("/a//b", "https://schemas.example.test/a//b", "/a/b"),
5534            ("/a/.", "https://schemas.example.test/a/", "/a"),
5535            ("/a/..", "https://schemas.example.test/", "/a"),
5536        ];
5537
5538        for (reference, resource_id, planted_reference) in cases {
5539            let schema = json!({
5540                "$id": "https://schemas.example.test/root.json",
5541                "$defs": {
5542                    "target": {"$id": resource_id, "const": "ready"}
5543                }
5544            });
5545            let root = schema
5546                .as_object()
5547                .expect("the dot-segment fixture remains a schema object");
5548
5549            let target = resolve_local_reference(&schema, root, reference)
5550                .expect("the RFC 3986 vector reaches its declared local resource");
5551            assert_eq!(target, &schema["$defs"]["target"]);
5552            assert_eq!(
5553                resolve_local_reference(&schema, root, planted_reference),
5554                Err("external schema reference is not allowed"),
5555                "changing only the path boundary must not select {resource_id:?}"
5556            );
5557        }
5558    }
5559
5560    #[test]
5561    fn final_local_reference_table_preserves_network_query_and_fragment_boundaries() {
5562        let schema = json!({
5563            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5564            "$id": "https://schemas.example.test/root?base=1",
5565            "$anchor": "root",
5566            "$defs": {
5567                "query": {
5568                    "$id": "?selected=1",
5569                    "$anchor": "selected",
5570                    "const": "ready"
5571                },
5572                "network": {
5573                    "$id": "https://authority.example.test/a//b?network=1",
5574                    "$anchor": "network",
5575                    "const": "network"
5576                }
5577            }
5578        });
5579        let root = schema
5580            .as_object()
5581            .expect("the RFC boundary fixture remains a schema object");
5582        let vectors = [
5583            ("#root", "root"),
5584            ("?selected=1#selected", "query"),
5585            ("//authority.example.test/a//b?network=1#network", "network"),
5586        ];
5587
5588        for (reference, expected_definition) in vectors {
5589            let target = resolve_local_reference(&schema, root, reference)
5590                .expect("the table vector resolves inside the local schema catalog");
5591            assert_eq!(
5592                target,
5593                if expected_definition == "root" {
5594                    &schema
5595                } else {
5596                    &schema["$defs"][expected_definition]
5597                },
5598                "the URI identifier and fragment boundaries select the expected resource for {reference:?}"
5599            );
5600        }
5601
5602        let accepted = json!({
5603            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5604            "$id": "https://schemas.example.test/root?base=1",
5605            "$defs": {"query": schema["$defs"]["query"].clone()},
5606            "$ref": "?selected=1#selected"
5607        });
5608        admit_final_schema(accepted.clone())
5609            .expect("the query and fragment boundary fixture admits")
5610            .validate(&json!("ready"))
5611            .expect("the query-selected anchored resource validates");
5612
5613        let mut planted = accepted.clone();
5614        planted["$ref"] = json!("?selected=2#selected");
5615        let error = admit_final_schema(planted)
5616            .expect_err("changing only the query value must not select the declared resource");
5617        assert_eq!(error.path(), "$.$ref");
5618        assert_eq!(error.reason(), "external schema reference is not allowed");
5619        assert_eq!(accepted["$ref"], json!("?selected=1#selected"));
5620    }
5621
5622    #[test]
5623    fn absolute_references_normalize_dot_segments_with_a_one_value_negative() {
5624        let accepted = json!({
5625            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5626            "$id": "https://schemas.example.test/catalog/root.json",
5627            "$defs": {
5628                "child": {
5629                    "$id": "../child.json",
5630                    "const": "ready"
5631                }
5632            },
5633            "$ref": "https://schemas.example.test/catalog/../child.json"
5634        });
5635        let schema = admit_final_schema(accepted.clone())
5636            .expect("an absolute reference resolves after dot-segment normalization");
5637        schema
5638            .validate(&json!("ready"))
5639            .expect("the normalized absolute reference reaches the local child resource");
5640
5641        let mut planted = accepted.clone();
5642        planted["$ref"] = json!("https://schemas.example.test/catalog/../missing.json");
5643        let error = admit_final_schema(planted)
5644            .expect_err("changing only the normalized target to an undeclared resource rejects");
5645        assert_eq!(error.path(), "$.$ref");
5646        assert_eq!(error.reason(), "external schema reference is not allowed");
5647        assert_eq!(
5648            accepted["$ref"],
5649            json!("https://schemas.example.test/catalog/../child.json")
5650        );
5651    }
5652
5653    #[test]
5654    fn final_references_percent_decode_before_json_pointer_unescaping() {
5655        let accepted = json!({
5656            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5657            "$defs": {
5658                "a/b": {"const": "ready"}
5659            },
5660            "$ref": "#/$defs/a%7E1b"
5661        });
5662        let schema = admit_final_schema(accepted.clone())
5663            .expect("percent decoding before ~1 decoding reaches the slash-containing definition");
5664        schema
5665            .validate(&json!("ready"))
5666            .expect("the percent-encoded pointer token resolves");
5667
5668        let mut invalid_percent = accepted.clone();
5669        invalid_percent["$ref"] = json!("#/$defs/a%7G1b");
5670        let invalid_percent_error = admit_final_schema(invalid_percent)
5671            .expect_err("changing only one percent-escape nibble must reject");
5672        assert_eq!(invalid_percent_error.path(), "$.$ref");
5673        assert_eq!(
5674            invalid_percent_error.reason(),
5675            "invalid local schema reference"
5676        );
5677
5678        let mut invalid_utf8 = accepted.clone();
5679        invalid_utf8["$ref"] = json!("#/$defs/a%FF1b");
5680        let invalid_utf8_error = admit_final_schema(invalid_utf8)
5681            .expect_err("changing only the pointer escape to invalid UTF-8 must reject");
5682        assert_eq!(invalid_utf8_error.path(), "$.$ref");
5683        assert_eq!(
5684            invalid_utf8_error.reason(),
5685            "invalid local schema reference"
5686        );
5687        assert_eq!(accepted["$ref"], json!("#/$defs/a%7E1b"));
5688    }
5689
5690    #[test]
5691    fn raw_validation_retains_its_legacy_local_reference_behavior() {
5692        let legacy = json!({
5693            "$defs": {
5694                "ready": {"const": "ready"},
5695                "a/b": {"const": "ready"}
5696            },
5697            "$ref": "#/$defs/ready"
5698        });
5699        validate(&legacy, &json!("ready"))
5700            .expect("the raw validator retains ordinary legacy JSON Pointer resolution");
5701
5702        let mut percent_encoded = legacy.clone();
5703        percent_encoded["$ref"] = json!("#/$defs/a%7E1b");
5704        let errors = validate(&percent_encoded, &json!("ready"))
5705            .expect_err("raw validation does not acquire final-schema percent decoding semantics");
5706        assert_eq!(errors[0].message, "unresolved local schema reference");
5707        assert_eq!(legacy["$ref"], json!("#/$defs/ready"));
5708    }
5709
5710    #[test]
5711    fn local_resource_resolution_charges_exact_shared_work_units() {
5712        let schema = json!({
5713            "$id": "https://schemas.example.test/root.json",
5714            "properties": {
5715                "source": {"$ref": "child.json"}
5716            },
5717            "$defs": {
5718                "target": {
5719                    "$id": "child.json",
5720                    "const": "ready"
5721                }
5722            }
5723        });
5724        let source = schema["properties"]["source"]
5725            .as_object()
5726            .expect("the source fixture remains a schema object");
5727
5728        let mut exact_context = ValidationContext::new(&schema, true);
5729        exact_context.remaining_work = 5;
5730        let target =
5731            resolve_local_reference_with_work(&schema, source, "child.json", &mut exact_context)
5732                .expect("two scope visits and three resource visits fit the exact work budget");
5733        assert_eq!(target, &json!({"$id": "child.json", "const": "ready"}));
5734        assert_eq!(exact_context.remaining_work, 0);
5735
5736        let mut planted_context = ValidationContext::new(&schema, true);
5737        planted_context.remaining_work = 4;
5738        let error =
5739            resolve_local_reference_with_work(&schema, source, "child.json", &mut planted_context)
5740                .expect_err("removing one lookup work unit must reject before the target is found");
5741        assert_eq!(error, "schema validation work limit exceeded");
5742        assert_eq!(planted_context.remaining_work, 0);
5743    }
5744
5745    #[test]
5746    fn admitted_schema_content_annotations_have_bounded_positive_and_negative_cases() {
5747        let accepted = json!({
5748            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5749            "type": "string",
5750            "contentEncoding": "base64",
5751            "contentMediaType": "application/json",
5752            "contentSchema": {"type": "object"}
5753        });
5754        admit_final_schema(accepted.clone()).expect("bounded content annotations admit");
5755
5756        let mut encoding = accepted.clone();
5757        encoding["contentEncoding"] = json!(true);
5758        let encoding_error =
5759            admit_final_schema(encoding).expect_err("changing only contentEncoding rejects");
5760        assert_eq!(encoding_error.path(), "$.contentEncoding");
5761        assert_eq!(
5762            encoding_error.reason(),
5763            "content annotation keyword must be a bounded non-empty string"
5764        );
5765
5766        let mut media_type = accepted.clone();
5767        media_type["contentMediaType"] = json!(true);
5768        let media_type_error =
5769            admit_final_schema(media_type).expect_err("changing only contentMediaType rejects");
5770        assert_eq!(media_type_error.path(), "$.contentMediaType");
5771        assert_eq!(
5772            media_type_error.reason(),
5773            "content annotation keyword must be a bounded non-empty string"
5774        );
5775
5776        let mut empty_encoding = accepted.clone();
5777        empty_encoding["contentEncoding"] = json!("");
5778        let empty_encoding_error = admit_final_schema(empty_encoding)
5779            .expect_err("changing only contentEncoding to empty rejects");
5780        assert_eq!(empty_encoding_error.path(), "$.contentEncoding");
5781        assert_eq!(
5782            empty_encoding_error.reason(),
5783            "content annotation keyword must be a bounded non-empty string"
5784        );
5785
5786        let mut content_schema = accepted;
5787        content_schema["contentSchema"] = json!("not-a-schema");
5788        let content_schema_error =
5789            admit_final_schema(content_schema).expect_err("changing only contentSchema rejects");
5790        assert_eq!(content_schema_error.path(), "$.contentSchema");
5791        assert_eq!(
5792            content_schema_error.reason(),
5793            "schema must be an object or boolean"
5794        );
5795    }
5796
5797    fn assert_admitted_annotations_accept_only_evaluated_members(schema: Value, accepted: Value) {
5798        let schema = admit_final_schema(schema).expect("the bounded annotation schema admits");
5799        schema
5800            .validate(&accepted)
5801            .expect("members annotated by successful applicators remain accepted");
5802
5803        let mut planted = accepted.clone();
5804        planted
5805            .as_object_mut()
5806            .expect("the annotation fixture is an object")
5807            .insert("unexpected".to_owned(), Value::Bool(true));
5808        let errors = schema
5809            .validate(&planted)
5810            .expect_err("adding only an unevaluated member must reject");
5811        assert!(errors.iter().any(|error| {
5812            error.path == "root.unexpected" && error.message == "schema rejects all values"
5813        }));
5814        assert!(accepted.get("unexpected").is_none());
5815    }
5816
5817    #[test]
5818    fn admitted_ref_annotations_reach_unevaluated_properties() {
5819        assert_admitted_annotations_accept_only_evaluated_members(
5820            json!({
5821                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5822                "$defs": {
5823                    "referenced": {
5824                        "properties": {"viaRef": {"type": "integer"}},
5825                        "required": ["viaRef"]
5826                    }
5827                },
5828                "$ref": "#/$defs/referenced",
5829                "unevaluatedProperties": false
5830            }),
5831            json!({"viaRef": 1}),
5832        );
5833    }
5834
5835    #[test]
5836    fn admitted_all_of_annotations_reach_unevaluated_properties() {
5837        assert_admitted_annotations_accept_only_evaluated_members(
5838            json!({
5839                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5840                "allOf": [{
5841                    "properties": {"viaAllOf": {"type": "integer"}},
5842                    "required": ["viaAllOf"]
5843                }],
5844                "unevaluatedProperties": false
5845            }),
5846            json!({"viaAllOf": 1}),
5847        );
5848    }
5849
5850    #[test]
5851    fn admitted_any_of_unions_all_successful_branch_annotations() {
5852        assert_admitted_annotations_accept_only_evaluated_members(
5853            json!({
5854                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5855                "anyOf": [
5856                    {
5857                        "properties": {"alpha": {"type": "integer"}},
5858                        "required": ["alpha"]
5859                    },
5860                    {
5861                        "properties": {"beta": {"type": "integer"}},
5862                        "required": ["beta"]
5863                    }
5864                ],
5865                "unevaluatedProperties": false
5866            }),
5867            json!({"alpha": 1, "beta": 2}),
5868        );
5869    }
5870
5871    #[test]
5872    fn admitted_one_of_uses_only_the_unique_successful_branch_annotations() {
5873        assert_admitted_annotations_accept_only_evaluated_members(
5874            json!({
5875                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5876                "oneOf": [
5877                    {
5878                        "properties": {"alpha": {"type": "integer"}},
5879                        "required": ["alpha"]
5880                    },
5881                    {
5882                        "properties": {"beta": {"type": "integer"}},
5883                        "required": ["beta"]
5884                    }
5885                ],
5886                "unevaluatedProperties": false
5887            }),
5888            json!({"alpha": 1}),
5889        );
5890    }
5891
5892    #[test]
5893    fn admitted_dependent_schema_annotations_reach_unevaluated_properties() {
5894        assert_admitted_annotations_accept_only_evaluated_members(
5895            json!({
5896                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5897                "properties": {"trigger": {"const": true}},
5898                "required": ["trigger"],
5899                "dependentSchemas": {
5900                    "trigger": {
5901                        "properties": {"payload": {"type": "string"}},
5902                        "required": ["payload"]
5903                    }
5904                },
5905                "unevaluatedProperties": false
5906            }),
5907            json!({"trigger": true, "payload": "ready"}),
5908        );
5909    }
5910
5911    #[test]
5912    fn admitted_if_without_then_or_else_propagates_successful_annotations() {
5913        assert_admitted_annotations_accept_only_evaluated_members(
5914            json!({
5915                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5916                "if": {
5917                    "properties": {"kind": {"const": "ready"}},
5918                    "required": ["kind"]
5919                },
5920                "unevaluatedProperties": false
5921            }),
5922            json!({"kind": "ready"}),
5923        );
5924    }
5925
5926    #[test]
5927    fn admitted_if_and_then_union_successful_annotations() {
5928        assert_admitted_annotations_accept_only_evaluated_members(
5929            json!({
5930                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5931                "if": {
5932                    "properties": {"kind": {"const": "ready"}},
5933                    "required": ["kind"]
5934                },
5935                "then": {
5936                    "properties": {"payload": {"type": "string"}},
5937                    "required": ["payload"]
5938                },
5939                "unevaluatedProperties": false
5940            }),
5941            json!({"kind": "ready", "payload": "complete"}),
5942        );
5943    }
5944
5945    #[test]
5946    fn admitted_else_propagates_only_the_selected_branch_annotations() {
5947        assert_admitted_annotations_accept_only_evaluated_members(
5948            json!({
5949                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5950                "if": {
5951                    "properties": {"kind": {"const": "primary"}},
5952                    "required": ["kind"]
5953                },
5954                "else": {
5955                    "properties": {
5956                        "kind": {"const": "fallback"},
5957                        "payload": {"type": "string"}
5958                    },
5959                    "required": ["kind", "payload"]
5960                },
5961                "unevaluatedProperties": false
5962            }),
5963            json!({"kind": "fallback", "payload": "complete"}),
5964        );
5965    }
5966
5967    #[test]
5968    fn admitted_nested_unevaluated_properties_annotations_reach_outer_schema() {
5969        assert_admitted_annotations_accept_only_evaluated_members(
5970            json!({
5971                "$schema": FINAL_JSON_SCHEMA_DIALECT,
5972                "allOf": [{
5973                    "properties": {"nested": {"type": "string"}},
5974                    "required": ["nested"],
5975                    "unevaluatedProperties": false
5976                }],
5977                "unevaluatedProperties": false
5978            }),
5979            json!({"nested": "ready"}),
5980        );
5981    }
5982
5983    fn pattern_property_work_schema() -> AdmittedSchema {
5984        let mut patterns = serde_json::Map::new();
5985        for index in 0..MAX_PATTERN_PROPERTIES {
5986            patterns.insert(format!("^never-{index}$"), Value::Bool(true));
5987        }
5988        admit_final_schema(json!({
5989            "$schema": FINAL_JSON_SCHEMA_DIALECT,
5990            "type": "object",
5991            "patternProperties": patterns
5992        }))
5993        .expect("the maximum bounded pattern family admits")
5994    }
5995
5996    fn object_with_null_members(count: usize) -> Value {
5997        let members: serde_json::Map<String, Value> = (0..count)
5998            .map(|index| (format!("field-{index}"), Value::Null))
5999            .collect();
6000        Value::Object(members)
6001    }
6002
6003    #[test]
6004    fn admitted_pattern_compilation_and_key_matching_share_work_limit() {
6005        let schema = pattern_property_work_schema();
6006        let accepted = object_with_null_members(62);
6007        schema
6008            .validate(&accepted)
6009            .expect("pattern compilation and 62 bounded key scans fit the work budget");
6010
6011        let planted = object_with_null_members(63);
6012        let errors = schema
6013            .validate(&planted)
6014            .expect_err("adding one key must exceed the shared regex work budget");
6015        assert!(
6016            errors
6017                .iter()
6018                .any(|error| error.message == "schema validation work limit exceeded")
6019        );
6020        assert_eq!(accepted.as_object().unwrap().len(), 62);
6021    }
6022
6023    #[test]
6024    fn raw_pattern_work_remains_legacy_while_admitted_final_is_bounded() {
6025        let mut patterns = serde_json::Map::new();
6026        for index in 0..MAX_PATTERN_PROPERTIES {
6027            patterns.insert(format!("^never-{index}$"), Value::Bool(true));
6028        }
6029        let schema = json!({
6030            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6031            "type": "object",
6032            "patternProperties": patterns,
6033            "additionalProperties": true
6034        });
6035        let instance = object_with_null_members(63);
6036
6037        validate(&schema, &instance)
6038            .expect("raw validation preserves its legacy pattern-work behavior");
6039        validate_strict(&schema, &instance)
6040            .expect("raw strict validation preserves its legacy pattern-work behavior");
6041
6042        let admitted = admit_final_schema(schema).expect("the bounded final schema admits");
6043        let errors = admitted
6044            .validate(&instance)
6045            .expect_err("admitted-final validation enforces the shared regex work budget");
6046        assert!(
6047            errors
6048                .iter()
6049                .any(|error| error.message == "schema validation work limit exceeded")
6050        );
6051    }
6052
6053    fn named_property_annotation_work_schema(count: usize) -> AdmittedSchema {
6054        let properties: serde_json::Map<String, Value> = (0..count)
6055            .map(|index| (format!("field-{index}"), Value::Bool(true)))
6056            .collect();
6057        admit_final_schema(json!({
6058            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6059            "type": "object",
6060            "properties": properties,
6061            "unevaluatedProperties": false
6062        }))
6063        .expect("the bounded named-property schema admits")
6064    }
6065
6066    #[test]
6067    fn admitted_key_annotation_bookkeeping_shares_work_limit() {
6068        let accepted_schema = named_property_annotation_work_schema(1_023);
6069        let accepted = object_with_null_members(1_023);
6070        accepted_schema.validate(&accepted).expect(
6071            "1,023 property validations, annotations, and preflight nodes fit the work budget",
6072        );
6073
6074        let planted_schema = named_property_annotation_work_schema(1_024);
6075        let planted = object_with_null_members(1_024);
6076        let errors = planted_schema
6077            .validate(&planted)
6078            .expect_err("adding one property must exceed the shared annotation work budget");
6079        assert!(
6080            errors
6081                .iter()
6082                .any(|error| error.message == "schema validation work limit exceeded")
6083        );
6084        assert_eq!(accepted.as_object().unwrap().len(), 1_023);
6085    }
6086
6087    fn repeated_pattern_work_schema(units: usize) -> AdmittedSchema {
6088        let unit = json!({
6089            "allOf": vec![
6090                json!({"$ref": "#/$defs/matching-pattern"});
6091                MAX_COMPOSITION_BRANCHES
6092            ]
6093        });
6094        admit_final_schema(json!({
6095            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6096            "$defs": {
6097                "matching-pattern": {"type": "string", "pattern": "^ready$"}
6098            },
6099            "allOf": vec![unit; units]
6100        }))
6101        .expect("the bounded repeated-pattern schema admits")
6102    }
6103
6104    #[test]
6105    fn admitted_string_pattern_compilation_and_matching_share_work_limit() {
6106        repeated_pattern_work_schema(1)
6107            .validate(&json!("ready"))
6108            .expect(
6109                "one repeated pattern unit and its local-reference traversal fit the work budget",
6110            );
6111
6112        let errors = repeated_pattern_work_schema(2)
6113            .validate(&json!("ready"))
6114            .expect_err("adding one repeated pattern unit must exceed the shared work budget");
6115        assert!(
6116            errors
6117                .iter()
6118                .any(|error| error.message == "schema validation work limit exceeded")
6119        );
6120    }
6121
6122    fn repeated_branch_probe_work_schema(units: usize) -> AdmittedSchema {
6123        let mut branches = vec![Value::Bool(false); MAX_COMPOSITION_BRANCHES];
6124        branches[0] = Value::Bool(true);
6125        let unit = json!({"oneOf": branches});
6126        admit_final_schema(json!({
6127            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6128            "allOf": vec![unit; units],
6129            "unevaluatedProperties": true
6130        }))
6131        .expect("the bounded repeated-branch schema admits")
6132    }
6133
6134    #[test]
6135    fn admitted_repeated_branch_probes_share_work_limit() {
6136        repeated_branch_probe_work_schema(10)
6137            .validate(&json!({}))
6138            .expect("ten repeated branch-probe units fit the work budget");
6139
6140        let errors = repeated_branch_probe_work_schema(11)
6141            .validate(&json!({}))
6142            .expect_err("adding one repeated branch unit must exceed the shared work budget");
6143        assert!(
6144            errors
6145                .iter()
6146                .any(|error| error.message == "schema validation work limit exceeded")
6147        );
6148    }
6149
6150    fn final_schema_format_annotation_schema() -> AdmittedSchema {
6151        admit_final_schema(json!({
6152            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6153            "type": "object",
6154            "properties": {
6155                "data": {"type": "string", "format": "byte"},
6156                "uri": {"type": "string", "format": "uri"},
6157                "template": {"type": "string", "format": "uri-template"}
6158            },
6159            "required": ["data", "uri", "template"],
6160            "additionalProperties": false
6161        }))
6162        .expect("the final format annotations admit")
6163    }
6164
6165    fn final_schema_format_instance() -> Value {
6166        json!({
6167            "data": "cGlubmVkIGZpbmFs",
6168            "uri": "https://example.test/resources?id=1#ready",
6169            "template": "mcp://resources/{id}{?cursor}"
6170        })
6171    }
6172
6173    #[test]
6174    fn final_schema_format_annotations_are_not_assertions_by_default() {
6175        final_schema_format_annotation_schema()
6176            .validate(&final_schema_format_instance())
6177            .expect("valid annotations do not reject an instance");
6178
6179        let mut planted = final_schema_format_instance();
6180        planted["uri"] = json!("not a uri");
6181        final_schema_format_annotation_schema()
6182            .validate(&planted)
6183            .expect("changing only an annotated URI value does not assert by default");
6184    }
6185
6186    #[test]
6187    fn format_assertion_meta_schema_is_refused_without_full_format_support() {
6188        let accepted = json!({
6189            "$id": "https://schemas.example.test/root.json",
6190            "$schema": "https://schemas.example.test/meta/format-assertion",
6191            "$defs": {
6192                "meta": {
6193                    "$id": "meta/format-assertion",
6194                    "$schema": FINAL_JSON_SCHEMA_DIALECT,
6195                    "$vocabulary": {
6196                        CORE_VOCABULARY_URI: true,
6197                        FORMAT_ANNOTATION_VOCABULARY_URI: true,
6198                        FORMAT_ASSERTION_VOCABULARY_URI: false
6199                    }
6200                }
6201            },
6202            "type": "string",
6203            "format": "uri"
6204        });
6205        let schema = admit_final_schema(accepted.clone())
6206            .expect("the annotation-only format vocabulary declaration admits");
6207        schema
6208            .validate(&json!("not a uri"))
6209            .expect("format annotations remain non-asserting");
6210
6211        let mut planted = accepted.clone();
6212        planted["$defs"]["meta"]["$vocabulary"][FORMAT_ASSERTION_VOCABULARY_URI] = json!(true);
6213        let error = admit_final_schema(planted)
6214            .expect_err("enabling only format assertion must fail closed without full support");
6215        assert_eq!(
6216            error.path(),
6217            "$.$vocabulary.https://json-schema.org/draft/2020-12/vocab/format-assertion"
6218        );
6219        assert_eq!(
6220            error.reason(),
6221            "format assertion vocabulary is not supported"
6222        );
6223        assert_eq!(
6224            accepted["$defs"]["meta"]["$vocabulary"][FORMAT_ASSERTION_VOCABULARY_URI],
6225            json!(false)
6226        );
6227    }
6228
6229    #[test]
6230    fn meta_schema_vocabulary_requires_the_core_vocabulary() {
6231        let accepted = json!({
6232            "$id": "https://schemas.example.test/root.json",
6233            "$schema": "https://schemas.example.test/meta/validation",
6234            "$defs": {
6235                "meta": {
6236                    "$id": "meta/validation",
6237                    "$schema": FINAL_JSON_SCHEMA_DIALECT,
6238                    "$vocabulary": {
6239                        CORE_VOCABULARY_URI: true,
6240                        VALIDATION_VOCABULARY_URI: true
6241                    }
6242                }
6243            },
6244            "type": "string"
6245        });
6246        admit_final_schema(accepted.clone())
6247            .expect("a meta-schema that requires Core remains admissible");
6248
6249        let mut planted = accepted.clone();
6250        planted["$defs"]["meta"]["$vocabulary"] = json!({
6251            VALIDATION_VOCABULARY_URI: true
6252        });
6253        let error = admit_final_schema(planted)
6254            .expect_err("removing only the required Core vocabulary must reject");
6255        assert_eq!(error.path(), "$.$vocabulary");
6256        assert_eq!(
6257            error.reason(),
6258            "meta-schema $vocabulary must require the Draft 2020-12 core vocabulary"
6259        );
6260        assert_eq!(
6261            accepted["$defs"]["meta"]["$vocabulary"][CORE_VOCABULARY_URI],
6262            json!(true)
6263        );
6264    }
6265
6266    #[test]
6267    fn schema_admission_node_limit_positive_and_planted_negative() {
6268        let mut properties = serde_json::Map::new();
6269        for index in 0..(MAX_SCHEMA_ADMISSION_NODES - 1) {
6270            properties.insert(format!("field-{index}"), Value::Bool(true));
6271        }
6272        let accepted = json!({"type": "object", "properties": properties});
6273        admit_final_schema(accepted.clone())
6274            .expect("the exact schema-admission node budget is accepted");
6275
6276        let mut planted = accepted.clone();
6277        planted["properties"]
6278            .as_object_mut()
6279            .expect("schema properties stay an object")
6280            .insert(
6281                format!("field-{}", MAX_SCHEMA_ADMISSION_NODES - 1),
6282                Value::Bool(true),
6283            );
6284        let error = admit_final_schema(planted)
6285            .expect_err("adding one schema node beyond the admission budget must reject");
6286        assert_eq!(error.reason(), "schema admission node limit exceeded");
6287        assert_eq!(
6288            accepted["properties"].as_object().unwrap().len(),
6289            MAX_SCHEMA_ADMISSION_NODES - 1
6290        );
6291    }
6292
6293    #[test]
6294    fn instance_node_limit_positive_and_planted_negative() {
6295        let schema = admit_final_schema(Value::Bool(true)).expect("the true schema admits");
6296        let accepted = Value::Array(vec![Value::Null; MAX_SCHEMA_INSTANCE_NODES - 1]);
6297        let accepted_errors = schema
6298            .validate(&accepted)
6299            .expect_err("the exact node budget plus schema evaluation exceeds the work budget");
6300        assert!(
6301            accepted_errors
6302                .iter()
6303                .any(|error| { error.message == "schema validation work limit exceeded" })
6304        );
6305
6306        let mut planted = accepted.clone();
6307        planted.as_array_mut().unwrap().push(Value::Null);
6308        let errors = schema
6309            .validate(&planted)
6310            .expect_err("adding one instance node beyond the budget must reject");
6311        assert_eq!(
6312            errors[0].path,
6313            format!("root[{}]", MAX_SCHEMA_INSTANCE_NODES - 1)
6314        );
6315        assert_eq!(errors[0].message, "instance node limit exceeded");
6316        assert_eq!(
6317            accepted.as_array().unwrap().len(),
6318            MAX_SCHEMA_INSTANCE_NODES - 1
6319        );
6320    }
6321
6322    #[test]
6323    fn instance_preflight_work_is_accounted_with_a_one_node_negative() {
6324        let schema = admit_final_schema(Value::Bool(true)).expect("the true schema admits");
6325        let accepted = Value::Array(vec![Value::Null; MAX_SCHEMA_INSTANCE_NODES - 2]);
6326        schema
6327            .validate(&accepted)
6328            .expect("preflight plus one schema application fits the shared work budget");
6329
6330        let mut planted = accepted.clone();
6331        planted.as_array_mut().unwrap().push(Value::Null);
6332        let errors = schema
6333            .validate(&planted)
6334            .expect_err("adding one instance node exhausts the shared work budget");
6335        assert!(
6336            errors
6337                .iter()
6338                .any(|error| { error.message == "schema validation work limit exceeded" })
6339        );
6340        assert_eq!(
6341            accepted
6342                .as_array()
6343                .expect("the baseline remains an array")
6344                .len(),
6345            MAX_SCHEMA_INSTANCE_NODES - 2
6346        );
6347    }
6348
6349    #[test]
6350    fn raw_validation_does_not_charge_admitted_final_instance_preflight_work() {
6351        let legacy_instance = Value::Array(vec![Value::Null; MAX_SCHEMA_INSTANCE_NODES - 1]);
6352        validate(&Value::Bool(true), &legacy_instance)
6353            .expect("raw validation retains its legacy non-preflight work accounting");
6354        assert_eq!(
6355            legacy_instance
6356                .as_array()
6357                .expect("the legacy fixture remains an array")
6358                .len(),
6359            MAX_SCHEMA_INSTANCE_NODES - 1
6360        );
6361    }
6362
6363    fn composition_work_schema(branches: usize) -> Value {
6364        let unit = json!({"allOf": vec![Value::Bool(true); MAX_COMPOSITION_BRANCHES]});
6365        json!({"allOf": vec![unit; branches]})
6366    }
6367
6368    #[test]
6369    fn composition_work_limit_positive_and_planted_negative() {
6370        let accepted = composition_work_schema(MAX_COMPOSITION_BRANCHES - 1);
6371        validate(&accepted, &Value::Null)
6372            .expect("the exact shared composition-work budget is accepted");
6373
6374        let planted = composition_work_schema(MAX_COMPOSITION_BRANCHES);
6375        let errors = validate(&planted, &Value::Null)
6376            .expect_err("adding one composition branch beyond the shared work budget must reject");
6377        assert!(
6378            errors
6379                .iter()
6380                .any(|error| error.message == "schema validation work limit exceeded")
6381        );
6382        assert_eq!(
6383            accepted["allOf"].as_array().unwrap().len(),
6384            MAX_COMPOSITION_BRANCHES - 1
6385        );
6386    }
6387
6388    #[test]
6389    fn admitted_composition_branch_limit_positive_and_planted_negative() {
6390        let accepted = json!({"allOf": vec![Value::Bool(true); MAX_COMPOSITION_BRANCHES]});
6391        admit_final_schema(accepted.clone()).expect("the exact composition branch limit admits");
6392
6393        let mut planted = accepted.clone();
6394        planted["allOf"]
6395            .as_array_mut()
6396            .expect("the allOf fixture remains an array")
6397            .push(Value::Bool(true));
6398        let error = admit_final_schema(planted)
6399            .expect_err("adding one composition branch beyond the bound must reject admission");
6400        assert_eq!(error.path(), "$.allOf");
6401        assert_eq!(error.reason(), "composition keyword exceeds branch limit");
6402        assert_eq!(
6403            accepted["allOf"]
6404                .as_array()
6405                .expect("the baseline remains an array")
6406                .len(),
6407            MAX_COMPOSITION_BRANCHES
6408        );
6409    }
6410
6411    #[test]
6412    fn admitted_schema_rejects_unresolved_local_reference() {
6413        let error = admit_final_schema(json!({"$ref": "#/$defs/missing"}))
6414            .expect_err("unresolved local references fail before validation");
6415        assert_eq!(error.path(), "$.$ref");
6416        assert_eq!(error.reason(), "unresolved local schema reference");
6417    }
6418
6419    #[test]
6420    fn external_references_fail_closed_without_resolution() {
6421        let errors = validate(
6422            &json!({"$ref": "https://schemas.example.test/tool.json"}),
6423            &json!({"input": "value"}),
6424        )
6425        .expect_err("external references must not acquire network or filesystem authority");
6426
6427        assert_eq!(errors.len(), 1);
6428        assert_eq!(errors[0].path, "root");
6429        assert_eq!(
6430            errors[0].message,
6431            "external schema reference is not allowed"
6432        );
6433    }
6434
6435    #[test]
6436    fn admitted_local_reference_target_membership_positive_and_planted_negative() {
6437        let accepted = json!({
6438            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6439            "$defs": {"always": true},
6440            "default": {"annotation": "object"},
6441            "$ref": "#/$defs/always"
6442        });
6443        let schema = admit_final_schema(accepted.clone())
6444            .expect("a local reference to a boolean schema admits");
6445        let instance = json!({"any": "value"});
6446
6447        schema
6448            .validate(&instance)
6449            .expect("the referenced true schema accepts the instance");
6450
6451        let mut planted = accepted.clone();
6452        planted["$ref"] = json!("#/default");
6453        let error = admit_final_schema(planted)
6454            .expect_err("changing only the target to a non-schema annotation must reject");
6455        assert_eq!(error.path(), "$.$ref");
6456        assert_eq!(
6457            error.reason(),
6458            "local schema reference target is not an admitted schema node"
6459        );
6460        assert_eq!(accepted["$ref"], json!("#/$defs/always"));
6461        assert_eq!(instance, json!({"any": "value"}));
6462    }
6463
6464    #[test]
6465    fn admitted_dynamic_reference_target_membership_positive_and_planted_negative() {
6466        let accepted = json!({
6467            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6468            "$defs": {"always": true},
6469            "default": {"annotation": "object"},
6470            "$dynamicRef": "#/$defs/always"
6471        });
6472        let schema = admit_final_schema(accepted.clone())
6473            .expect("a local dynamic reference to a boolean schema admits");
6474        let instance = json!({"any": "value"});
6475
6476        schema
6477            .validate(&instance)
6478            .expect("the dynamically referenced true schema accepts the instance");
6479
6480        let mut planted = accepted.clone();
6481        planted["$dynamicRef"] = json!("#/default");
6482        let error = admit_final_schema(planted)
6483            .expect_err("changing only the dynamic target to a non-schema annotation must reject");
6484        assert_eq!(error.path(), "$.$dynamicRef");
6485        assert_eq!(
6486            error.reason(),
6487            "local schema reference target is not an admitted schema node"
6488        );
6489        assert_eq!(accepted["$dynamicRef"], json!("#/$defs/always"));
6490        assert_eq!(instance, json!({"any": "value"}));
6491    }
6492
6493    #[test]
6494    fn admitted_enum_entry_limit_positive_and_planted_negative() {
6495        let values: Vec<Value> = (0..MAX_SCHEMA_ASSERTION_ENTRIES)
6496            .map(|index| json!(format!("value-{index}")))
6497            .collect();
6498        let accepted = json!({
6499            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6500            "enum": values
6501        });
6502        let schema =
6503            admit_final_schema(accepted.clone()).expect("the exact enum entry budget admits");
6504        let instance = json!("value-0");
6505
6506        schema
6507            .validate(&instance)
6508            .expect("the first bounded enum value validates");
6509
6510        let mut planted = accepted.clone();
6511        planted["enum"]
6512            .as_array_mut()
6513            .expect("enum remains an array")
6514            .push(json!("value-over-limit"));
6515        let error = admit_final_schema(planted)
6516            .expect_err("adding one enum member beyond the limit must reject");
6517        assert_eq!(error.path(), "$.enum");
6518        assert_eq!(error.reason(), "enum exceeds entry limit");
6519        assert_eq!(
6520            accepted["enum"]
6521                .as_array()
6522                .expect("enum remains an array")
6523                .len(),
6524            MAX_SCHEMA_ASSERTION_ENTRIES
6525        );
6526        assert_eq!(instance, json!("value-0"));
6527    }
6528
6529    #[test]
6530    fn admitted_required_entry_limit_positive_and_planted_negative() {
6531        let members: Vec<Value> = (0..MAX_SCHEMA_ASSERTION_ENTRIES)
6532            .map(|index| json!(format!("field-{index}")))
6533            .collect();
6534        let accepted = json!({
6535            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6536            "type": "object",
6537            "required": members
6538        });
6539        let schema =
6540            admit_final_schema(accepted.clone()).expect("the exact required-entry budget admits");
6541        let instance = object_with_null_members(MAX_SCHEMA_ASSERTION_ENTRIES);
6542
6543        schema
6544            .validate(&instance)
6545            .expect("every required property at the entry limit is present");
6546
6547        let mut planted = accepted.clone();
6548        planted["required"]
6549            .as_array_mut()
6550            .expect("required remains an array")
6551            .push(json!("field-over-limit"));
6552        let error = admit_final_schema(planted)
6553            .expect_err("adding one required member beyond the limit must reject");
6554        assert_eq!(error.path(), "$.required");
6555        assert_eq!(error.reason(), "required exceeds entry limit");
6556        assert_eq!(
6557            accepted["required"]
6558                .as_array()
6559                .expect("required remains an array")
6560                .len(),
6561            MAX_SCHEMA_ASSERTION_ENTRIES
6562        );
6563        assert_eq!(
6564            instance
6565                .as_object()
6566                .expect("instance remains an object")
6567                .len(),
6568            MAX_SCHEMA_ASSERTION_ENTRIES
6569        );
6570    }
6571
6572    #[test]
6573    fn admitted_dependent_required_entry_limit_positive_and_planted_negative() {
6574        let members: Vec<Value> = (0..MAX_SCHEMA_ASSERTION_ENTRIES)
6575            .map(|index| json!(format!("field-{index}")))
6576            .collect();
6577        let accepted = json!({
6578            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6579            "type": "object",
6580            "dependentRequired": {"trigger": members}
6581        });
6582        let schema = admit_final_schema(accepted.clone())
6583            .expect("the exact dependent-required entry budget admits");
6584        let mut instance = object_with_null_members(MAX_SCHEMA_ASSERTION_ENTRIES);
6585        instance["trigger"] = Value::Null;
6586
6587        schema
6588            .validate(&instance)
6589            .expect("every bounded dependent requirement is present");
6590
6591        let mut planted = accepted.clone();
6592        planted["dependentRequired"]["trigger"]
6593            .as_array_mut()
6594            .expect("dependent-required members remain an array")
6595            .push(json!("field-over-limit"));
6596        let error = admit_final_schema(planted)
6597            .expect_err("adding one dependent requirement beyond the limit must reject");
6598        assert_eq!(error.path(), "$.dependentRequired");
6599        assert_eq!(
6600            error.reason(),
6601            "dependentRequired values exceed entry limit"
6602        );
6603        assert_eq!(
6604            accepted["dependentRequired"]["trigger"]
6605                .as_array()
6606                .expect("baseline members remain an array")
6607                .len(),
6608            MAX_SCHEMA_ASSERTION_ENTRIES
6609        );
6610        assert_eq!(
6611            instance
6612                .as_object()
6613                .expect("instance remains an object")
6614                .len(),
6615            MAX_SCHEMA_ASSERTION_ENTRIES + 1
6616        );
6617    }
6618
6619    #[test]
6620    fn admitted_dependent_required_work_budget_includes_root_overhead() {
6621        let mut dependencies = serde_json::Map::new();
6622        let mut instance = serde_json::Map::new();
6623        for index in 0..MAX_SCHEMA_ASSERTION_ENTRIES {
6624            let member_count = if index + 1 == MAX_SCHEMA_ASSERTION_ENTRIES {
6625                MAX_SCHEMA_ASSERTION_ENTRIES - 2
6626            } else {
6627                MAX_SCHEMA_ASSERTION_ENTRIES - 1
6628            };
6629            let trigger = format!("trigger-{index}");
6630            let mut members = Vec::with_capacity(member_count);
6631            for member_index in 0..member_count {
6632                let member = format!("required-{index}-{member_index}");
6633                members.push(json!(member.clone()));
6634                if index == 0 {
6635                    instance.insert(member, Value::Null);
6636                }
6637            }
6638            dependencies.insert(trigger.clone(), Value::Array(members));
6639            if index == 0 {
6640                instance.insert(trigger, Value::Null);
6641            }
6642        }
6643        let accepted = json!({
6644            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6645            "type": "object",
6646            "dependentRequired": dependencies
6647        });
6648        let instance = Value::Object(instance);
6649
6650        admit_final_schema(accepted.clone())
6651            .expect("the exact dependentRequired payload plus root work budget admits")
6652            .validate(&instance)
6653            .expect("the exact dependentRequired work budget validates");
6654
6655        let mut planted = accepted.clone();
6656        planted["dependentRequired"]["trigger-0"]
6657            .as_array_mut()
6658            .expect("planted dependent requirements remain an array")
6659            .push(json!("required-over-budget"));
6660        let error = admit_final_schema(planted)
6661            .expect_err("one extra dependent requirement beyond root-inclusive work must reject");
6662        assert_eq!(error.path(), "$.dependentRequired");
6663        assert_eq!(
6664            error.reason(),
6665            "dependentRequired exceeds validation work budget"
6666        );
6667        assert_eq!(
6668            accepted["dependentRequired"]
6669                .as_object()
6670                .expect("baseline dependencies remain an object")
6671                .len(),
6672            MAX_SCHEMA_ASSERTION_ENTRIES
6673        );
6674        assert_eq!(
6675            accepted["dependentRequired"]["trigger-0"]
6676                .as_array()
6677                .expect("baseline dependency members remain an array")
6678                .len(),
6679            MAX_SCHEMA_ASSERTION_ENTRIES - 1
6680        );
6681        assert_eq!(
6682            instance
6683                .as_object()
6684                .expect("instance remains an object")
6685                .len(),
6686            MAX_SCHEMA_ASSERTION_ENTRIES
6687        );
6688    }
6689
6690    #[test]
6691    fn admitted_pattern_byte_limit_positive_and_planted_negative() {
6692        let pattern = "a".repeat(MAX_PATTERN_BYTES);
6693        let accepted = json!({
6694            "$schema": FINAL_JSON_SCHEMA_DIALECT,
6695            "type": "string",
6696            "pattern": pattern
6697        });
6698        let schema =
6699            admit_final_schema(accepted.clone()).expect("the exact pattern byte budget admits");
6700        let instance = json!("a".repeat(MAX_PATTERN_BYTES));
6701
6702        schema
6703            .validate(&instance)
6704            .expect("the bounded exact-length pattern matches the instance");
6705
6706        let mut planted = accepted.clone();
6707        planted["pattern"] = json!(format!("{}a", accepted["pattern"].as_str().unwrap()));
6708        let error = admit_final_schema(planted)
6709            .expect_err("adding one pattern byte beyond the limit must reject");
6710        assert_eq!(error.path(), "$.pattern");
6711        assert_eq!(error.reason(), "pattern exceeds byte limit");
6712        assert_eq!(
6713            accepted["pattern"]
6714                .as_str()
6715                .expect("baseline pattern remains a string")
6716                .len(),
6717            MAX_PATTERN_BYTES
6718        );
6719        assert_eq!(instance, json!("a".repeat(MAX_PATTERN_BYTES)));
6720    }
6721
6722    #[test]
6723    fn test_type_validation_string() {
6724        let schema = json!({"type": "string"});
6725        assert!(validate(&schema, &json!("hello")).is_ok());
6726        assert!(validate(&schema, &json!(123)).is_err());
6727    }
6728
6729    #[test]
6730    fn test_type_validation_number() {
6731        let schema = json!({"type": "number"});
6732        assert!(validate(&schema, &json!(123)).is_ok());
6733        assert!(validate(&schema, &json!(12.5)).is_ok());
6734        assert!(validate(&schema, &json!("hello")).is_err());
6735    }
6736
6737    #[test]
6738    fn test_type_validation_integer() {
6739        let schema = json!({"type": "integer"});
6740        assert!(validate(&schema, &json!(123)).is_ok());
6741        assert!(validate(&schema, &json!(12.5)).is_err());
6742    }
6743
6744    #[test]
6745    fn test_type_validation_boolean() {
6746        let schema = json!({"type": "boolean"});
6747        assert!(validate(&schema, &json!(true)).is_ok());
6748        assert!(validate(&schema, &json!(false)).is_ok());
6749        assert!(validate(&schema, &json!(1)).is_err());
6750    }
6751
6752    #[test]
6753    fn test_type_validation_object() {
6754        let schema = json!({"type": "object"});
6755        assert!(validate(&schema, &json!({})).is_ok());
6756        assert!(validate(&schema, &json!({"a": 1})).is_ok());
6757        assert!(validate(&schema, &json!([])).is_err());
6758    }
6759
6760    #[test]
6761    fn test_type_validation_array() {
6762        let schema = json!({"type": "array"});
6763        assert!(validate(&schema, &json!([])).is_ok());
6764        assert!(validate(&schema, &json!([1, 2, 3])).is_ok());
6765        assert!(validate(&schema, &json!({})).is_err());
6766    }
6767
6768    #[test]
6769    fn test_type_validation_null() {
6770        let schema = json!({"type": "null"});
6771        assert!(validate(&schema, &json!(null)).is_ok());
6772        assert!(validate(&schema, &json!(0)).is_err());
6773    }
6774
6775    #[test]
6776    fn test_type_validation_union() {
6777        let schema = json!({"type": ["string", "number"]});
6778        assert!(validate(&schema, &json!("hello")).is_ok());
6779        assert!(validate(&schema, &json!(123)).is_ok());
6780        assert!(validate(&schema, &json!(true)).is_err());
6781    }
6782
6783    #[test]
6784    fn test_required_fields() {
6785        let schema = json!({
6786            "type": "object",
6787            "properties": {
6788                "name": {"type": "string"},
6789                "age": {"type": "integer"}
6790            },
6791            "required": ["name"]
6792        });
6793
6794        assert!(validate(&schema, &json!({"name": "Alice"})).is_ok());
6795        assert!(validate(&schema, &json!({"name": "Alice", "age": 30})).is_ok());
6796        assert!(validate(&schema, &json!({"age": 30})).is_err());
6797        assert!(validate(&schema, &json!({})).is_err());
6798    }
6799
6800    #[test]
6801    fn test_enum_validation() {
6802        let schema = json!({"enum": ["red", "green", "blue"]});
6803        assert!(validate(&schema, &json!("red")).is_ok());
6804        assert!(validate(&schema, &json!("yellow")).is_err());
6805    }
6806
6807    #[test]
6808    fn test_const_validation() {
6809        let schema = json!({"const": "fixed"});
6810        assert!(validate(&schema, &json!("fixed")).is_ok());
6811        assert!(validate(&schema, &json!("other")).is_err());
6812    }
6813
6814    #[test]
6815    fn test_string_length() {
6816        let schema = json!({
6817            "type": "string",
6818            "minLength": 2,
6819            "maxLength": 5
6820        });
6821
6822        assert!(validate(&schema, &json!("ab")).is_ok());
6823        assert!(validate(&schema, &json!("abcde")).is_ok());
6824        assert!(validate(&schema, &json!("a")).is_err());
6825        assert!(validate(&schema, &json!("abcdef")).is_err());
6826    }
6827
6828    #[test]
6829    fn test_string_pattern() {
6830        let schema = json!({
6831            "type": "string",
6832            "pattern": "^[a-z]+$"
6833        });
6834
6835        assert!(validate(&schema, &json!("hello")).is_ok());
6836        assert!(validate(&schema, &json!("Hello")).is_err());
6837        assert!(validate(&schema, &json!("hello123")).is_err());
6838    }
6839
6840    #[test]
6841    fn test_string_pattern_invalid_regex_is_error() {
6842        let schema = json!({
6843            "type": "string",
6844            "pattern": "("
6845        });
6846
6847        assert!(validate(&schema, &json!("anything")).is_err());
6848    }
6849
6850    #[test]
6851    fn test_number_range() {
6852        let schema = json!({
6853            "type": "number",
6854            "minimum": 0,
6855            "maximum": 100
6856        });
6857
6858        assert!(validate(&schema, &json!(0)).is_ok());
6859        assert!(validate(&schema, &json!(50)).is_ok());
6860        assert!(validate(&schema, &json!(100)).is_ok());
6861        assert!(validate(&schema, &json!(-1)).is_err());
6862        assert!(validate(&schema, &json!(101)).is_err());
6863    }
6864
6865    #[test]
6866    fn test_number_exclusive_range() {
6867        let schema = json!({
6868            "type": "number",
6869            "exclusiveMinimum": 0,
6870            "exclusiveMaximum": 10
6871        });
6872
6873        assert!(validate(&schema, &json!(1)).is_ok());
6874        assert!(validate(&schema, &json!(9)).is_ok());
6875        assert!(validate(&schema, &json!(0)).is_err());
6876        assert!(validate(&schema, &json!(10)).is_err());
6877    }
6878
6879    #[test]
6880    fn test_array_items() {
6881        let schema = json!({
6882            "type": "array",
6883            "items": {"type": "integer"}
6884        });
6885
6886        assert!(validate(&schema, &json!([1, 2, 3])).is_ok());
6887        assert!(validate(&schema, &json!([])).is_ok());
6888        assert!(validate(&schema, &json!([1, "two", 3])).is_err());
6889    }
6890
6891    #[test]
6892    fn test_array_length() {
6893        let schema = json!({
6894            "type": "array",
6895            "minItems": 1,
6896            "maxItems": 3
6897        });
6898
6899        assert!(validate(&schema, &json!([1])).is_ok());
6900        assert!(validate(&schema, &json!([1, 2, 3])).is_ok());
6901        assert!(validate(&schema, &json!([])).is_err());
6902        assert!(validate(&schema, &json!([1, 2, 3, 4])).is_err());
6903    }
6904
6905    #[test]
6906    fn test_unique_items() {
6907        let schema = json!({
6908            "type": "array",
6909            "uniqueItems": true
6910        });
6911
6912        assert!(validate(&schema, &json!([1, 2, 3])).is_ok());
6913        assert!(validate(&schema, &json!([1, 1, 2])).is_err());
6914    }
6915
6916    #[test]
6917    fn test_nested_object() {
6918        let schema = json!({
6919            "type": "object",
6920            "properties": {
6921                "person": {
6922                    "type": "object",
6923                    "properties": {
6924                        "name": {"type": "string"},
6925                        "age": {"type": "integer"}
6926                    },
6927                    "required": ["name"]
6928                }
6929            }
6930        });
6931
6932        assert!(validate(&schema, &json!({"person": {"name": "Alice"}})).is_ok());
6933        assert!(validate(&schema, &json!({"person": {"name": "Alice", "age": 30}})).is_ok());
6934        assert!(validate(&schema, &json!({"person": {"age": 30}})).is_err());
6935    }
6936
6937    #[test]
6938    fn test_additional_properties_false() {
6939        let schema = json!({
6940            "type": "object",
6941            "properties": {
6942                "name": {"type": "string"}
6943            },
6944            "additionalProperties": false
6945        });
6946
6947        assert!(validate(&schema, &json!({"name": "Alice"})).is_ok());
6948        assert!(validate(&schema, &json!({})).is_ok());
6949        assert!(validate(&schema, &json!({"name": "Alice", "extra": 1})).is_err());
6950    }
6951
6952    #[test]
6953    fn test_boolean_schema() {
6954        // true schema accepts everything
6955        assert!(validate(&json!(true), &json!("anything")).is_ok());
6956        assert!(validate(&json!(true), &json!(123)).is_ok());
6957
6958        // false schema rejects everything
6959        assert!(validate(&json!(false), &json!("anything")).is_err());
6960    }
6961
6962    #[test]
6963    fn test_multiple_errors() {
6964        let schema = json!({
6965            "type": "object",
6966            "properties": {
6967                "name": {"type": "string"},
6968                "age": {"type": "integer"}
6969            },
6970            "required": ["name", "age"]
6971        });
6972
6973        let result = validate(&schema, &json!({}));
6974        assert!(result.is_err());
6975        let errors = result.unwrap_err();
6976        assert_eq!(errors.len(), 2); // Missing both name and age
6977    }
6978
6979    #[test]
6980    fn test_error_path() {
6981        let schema = json!({
6982            "type": "object",
6983            "properties": {
6984                "items": {
6985                    "type": "array",
6986                    "items": {"type": "integer"}
6987                }
6988            }
6989        });
6990
6991        let result = validate(&schema, &json!({"items": [1, "two", 3]}));
6992        assert!(result.is_err());
6993        let errors = result.unwrap_err();
6994        assert_eq!(errors.len(), 1);
6995        assert_eq!(errors[0].path, "root.items[1]");
6996    }
6997
6998    // ========================================================================
6999    // Strict Validation Tests
7000    // ========================================================================
7001
7002    #[test]
7003    fn test_validate_strict_rejects_extra_properties() {
7004        let schema = json!({
7005            "type": "object",
7006            "properties": {
7007                "name": {"type": "string"}
7008            }
7009        });
7010
7011        // Regular validate allows extra properties
7012        assert!(validate(&schema, &json!({"name": "Alice", "extra": 123})).is_ok());
7013
7014        // Strict validate rejects extra properties
7015        assert!(validate_strict(&schema, &json!({"name": "Alice", "extra": 123})).is_err());
7016
7017        // Strict validate allows only defined properties
7018        assert!(validate_strict(&schema, &json!({"name": "Alice"})).is_ok());
7019    }
7020
7021    #[test]
7022    fn test_validate_strict_nested_objects() {
7023        let schema = json!({
7024            "type": "object",
7025            "properties": {
7026                "person": {
7027                    "type": "object",
7028                    "properties": {
7029                        "name": {"type": "string"}
7030                    }
7031                }
7032            }
7033        });
7034
7035        // Regular validate allows extra properties at any level
7036        assert!(
7037            validate(
7038                &schema,
7039                &json!({
7040                    "person": {"name": "Alice", "age": 30}
7041                })
7042            )
7043            .is_ok()
7044        );
7045
7046        // Strict validate rejects extra properties at nested level
7047        assert!(
7048            validate_strict(
7049                &schema,
7050                &json!({
7051                    "person": {"name": "Alice", "age": 30}
7052                })
7053            )
7054            .is_err()
7055        );
7056
7057        // Strict validate passes with only defined properties
7058        assert!(
7059            validate_strict(
7060                &schema,
7061                &json!({
7062                    "person": {"name": "Alice"}
7063                })
7064            )
7065            .is_ok()
7066        );
7067    }
7068
7069    #[test]
7070    fn test_validate_strict_preserves_explicit_additional_properties() {
7071        // Schema explicitly allows additional properties with a specific type
7072        let schema = json!({
7073            "type": "object",
7074            "properties": {
7075                "name": {"type": "string"}
7076            },
7077            "additionalProperties": {"type": "integer"}
7078        });
7079
7080        // With explicit additionalProperties schema, strict mode should honor it
7081        assert!(
7082            validate_strict(
7083                &schema,
7084                &json!({
7085                    "name": "Alice",
7086                    "count": 42
7087                })
7088            )
7089            .is_ok()
7090        );
7091
7092        // But still validate the type of additional properties
7093        assert!(
7094            validate_strict(
7095                &schema,
7096                &json!({
7097                    "name": "Alice",
7098                    "count": "not an integer"
7099                })
7100            )
7101            .is_err()
7102        );
7103    }
7104
7105    #[test]
7106    fn test_validate_strict_array_items() {
7107        let schema = json!({
7108            "type": "array",
7109            "items": {
7110                "type": "object",
7111                "properties": {
7112                    "id": {"type": "integer"}
7113                }
7114            }
7115        });
7116
7117        // Regular validate allows extra properties in array items
7118        assert!(
7119            validate(
7120                &schema,
7121                &json!([
7122                    {"id": 1, "extra": "value"}
7123                ])
7124            )
7125            .is_ok()
7126        );
7127
7128        // Strict validate rejects extra properties in array items
7129        assert!(
7130            validate_strict(
7131                &schema,
7132                &json!([
7133                    {"id": 1, "extra": "value"}
7134                ])
7135            )
7136            .is_err()
7137        );
7138
7139        // Strict validate passes with only defined properties
7140        assert!(
7141            validate_strict(
7142                &schema,
7143                &json!([
7144                    {"id": 1}
7145                ])
7146            )
7147            .is_ok()
7148        );
7149    }
7150
7151    #[test]
7152    fn test_validate_strict_empty_schema() {
7153        // Empty schema or true accepts everything
7154        let schema = json!({});
7155
7156        // Empty schema doesn't have type: "object", so strict doesn't add additionalProperties
7157        assert!(validate_strict(&schema, &json!({"anything": "goes"})).is_ok());
7158    }
7159
7160    #[test]
7161    fn test_validate_strict_non_object_types() {
7162        // Strict mode shouldn't affect non-object types
7163        let string_schema = json!({"type": "string"});
7164        assert!(validate_strict(&string_schema, &json!("hello")).is_ok());
7165
7166        let number_schema = json!({"type": "number"});
7167        assert!(validate_strict(&number_schema, &json!(42)).is_ok());
7168
7169        let array_schema = json!({"type": "array"});
7170        assert!(validate_strict(&array_schema, &json!([1, 2, 3])).is_ok());
7171    }
7172
7173    // =========================================================================
7174    // Additional coverage tests (bd-qpwf)
7175    // =========================================================================
7176
7177    #[test]
7178    fn validation_error_display_and_error_trait() {
7179        let err = ValidationError {
7180            path: "root.name".to_string(),
7181            message: "expected string".to_string(),
7182        };
7183        assert_eq!(err.to_string(), "root.name: expected string");
7184        let _: &dyn std::error::Error = &err;
7185    }
7186
7187    #[test]
7188    fn validation_error_debug_and_clone() {
7189        let err = ValidationError {
7190            path: "root".to_string(),
7191            message: "missing".to_string(),
7192        };
7193        let debug = format!("{err:?}");
7194        assert!(debug.contains("ValidationError"));
7195
7196        let cloned = err.clone();
7197        assert_eq!(cloned.path, "root");
7198        assert_eq!(cloned.message, "missing");
7199    }
7200
7201    #[test]
7202    fn json_type_name_all_types() {
7203        assert_eq!(json_type_name(&json!(null)), "null");
7204        assert_eq!(json_type_name(&json!(true)), "boolean");
7205        assert_eq!(json_type_name(&json!(42)), "integer");
7206        assert_eq!(json_type_name(&json!(3.14)), "number");
7207        assert_eq!(json_type_name(&json!("hello")), "string");
7208        assert_eq!(json_type_name(&json!([])), "array");
7209        assert_eq!(json_type_name(&json!({})), "object");
7210    }
7211
7212    #[test]
7213    fn number_multiple_of() {
7214        let schema = json!({"type": "number", "multipleOf": 3});
7215        assert!(validate(&schema, &json!(9)).is_ok());
7216        assert!(validate(&schema, &json!(6)).is_ok());
7217        assert!(validate(&schema, &json!(0)).is_ok());
7218        assert!(validate(&schema, &json!(7)).is_err());
7219    }
7220
7221    #[test]
7222    fn object_min_max_properties() {
7223        let schema = json!({
7224            "type": "object",
7225            "minProperties": 1,
7226            "maxProperties": 2
7227        });
7228
7229        assert!(validate(&schema, &json!({"a": 1})).is_ok());
7230        assert!(validate(&schema, &json!({"a": 1, "b": 2})).is_ok());
7231        assert!(validate(&schema, &json!({})).is_err());
7232        assert!(validate(&schema, &json!({"a": 1, "b": 2, "c": 3})).is_err());
7233    }
7234
7235    #[test]
7236    fn prefix_items_tuple_validation() {
7237        let schema = json!({
7238            "type": "array",
7239            "prefixItems": [
7240                {"type": "string"},
7241                {"type": "integer"}
7242            ]
7243        });
7244
7245        assert!(validate(&schema, &json!(["hello", 42])).is_ok());
7246        assert!(validate(&schema, &json!(["hello", 42, true])).is_ok()); // extra items allowed by default
7247        assert!(validate(&schema, &json!([123, "wrong"])).is_err()); // first should be string
7248    }
7249
7250    #[test]
7251    fn prefix_items_with_additional_items_schema() {
7252        let schema = json!({
7253            "type": "array",
7254            "prefixItems": [
7255                {"type": "string"}
7256            ],
7257            "items": {"type": "integer"}
7258        });
7259
7260        // First item must be string, rest must be integers
7261        assert!(validate(&schema, &json!(["hello", 1, 2])).is_ok());
7262        assert!(validate(&schema, &json!(["hello", "bad"])).is_err());
7263    }
7264
7265    #[test]
7266    fn items_as_array_draft4_fallback() {
7267        // Draft 4-7 style: items is an array (treated as prefixItems when no prefixItems present)
7268        let schema = json!({
7269            "type": "array",
7270            "items": [
7271                {"type": "string"},
7272                {"type": "integer"}
7273            ]
7274        });
7275
7276        assert!(validate(&schema, &json!(["hello", 42])).is_ok());
7277        assert!(validate(&schema, &json!([123, "wrong"])).is_err());
7278    }
7279
7280    #[test]
7281    fn additional_properties_as_schema() {
7282        let schema = json!({
7283            "type": "object",
7284            "properties": {
7285                "name": {"type": "string"}
7286            },
7287            "additionalProperties": {"type": "integer"}
7288        });
7289
7290        assert!(validate(&schema, &json!({"name": "Alice", "count": 42})).is_ok());
7291        assert!(validate(&schema, &json!({"name": "Alice", "bad": "string"})).is_err());
7292    }
7293
7294    #[test]
7295    fn strict_schema_with_prefix_items() {
7296        let schema = json!({
7297            "type": "array",
7298            "prefixItems": [
7299                {
7300                    "type": "object",
7301                    "properties": {
7302                        "id": {"type": "integer"}
7303                    }
7304                }
7305            ]
7306        });
7307
7308        // Strict mode adds additionalProperties: false to nested objects in prefixItems
7309        assert!(validate_strict(&schema, &json!([{"id": 1}])).is_ok());
7310        assert!(validate_strict(&schema, &json!([{"id": 1, "extra": "val"}])).is_err());
7311    }
7312
7313    #[test]
7314    fn strict_schema_with_union_type() {
7315        let schema = json!({
7316            "type": ["object", "null"],
7317            "properties": {
7318                "name": {"type": "string"}
7319            }
7320        });
7321
7322        // Strict mode should add additionalProperties for union types including object
7323        assert!(validate_strict(&schema, &json!(null)).is_ok());
7324        assert!(validate_strict(&schema, &json!({"name": "Alice"})).is_ok());
7325        assert!(validate_strict(&schema, &json!({"name": "Alice", "extra": 1})).is_err());
7326    }
7327
7328    #[test]
7329    fn unknown_type_in_matches_type_is_permissive() {
7330        let schema = json!({"type": "custom_extension"});
7331        // Unknown types accept everything
7332        assert!(validate(&schema, &json!("anything")).is_ok());
7333        assert!(validate(&schema, &json!(42)).is_ok());
7334    }
7335
7336    #[test]
7337    fn invalid_schema_not_an_object() {
7338        // Non-object, non-boolean schemas are silently skipped
7339        assert!(validate(&json!(42), &json!("anything")).is_ok());
7340        assert!(validate(&json!("bad_schema"), &json!(123)).is_ok());
7341    }
7342}