Skip to main content

noyalib/
schema_validate.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! JSON Schema 2020-12 validation against a parsed [`crate::Value`].
5//!
6//! The schema document may itself be expressed as YAML (since JSON
7//! is a subset of YAML 1.2). Pairs naturally with the codegen
8//! surface: derive [`crate::JsonSchema`] on a Rust type, emit the
9//! schema with [`crate::schema_for_yaml`], then enforce it on
10//! inputs that arrive as YAML at runtime.
11//!
12//! Gated behind the `validate-schema` Cargo feature (which implies
13//! `schema`).
14//!
15//! # Hardening guarantees
16//!
17//! Two properties hold and are pinned by `tests/schema_hardening.rs`:
18//!
19//! - **External `$ref` is never dereferenced.** A schema referencing
20//!   anything outside the document (`http:`, `https:`, `file:`, or any
21//!   other URI) is refused, not fetched or read. Every validator is
22//!   built with an explicit retriever that refuses all external
23//!   references, so the guarantee holds even when another crate in the
24//!   build enables `jsonschema`'s `resolve-http` or `resolve-file`
25//!   features through Cargo feature unification.
26//! - **Schema recursion is bounded.** Deeply nested schemas are refused
27//!   with `recursion depth limit exceeded` rather than exhausting the
28//!   stack — a stack overflow aborts the process instead of returning an
29//!   error a caller can handle. The bound belongs to `jsonschema`.
30//!
31//! Local `$ref` and `$defs` are unaffected; they are the composition
32//! mechanism JSON Schema 2020-12 tool definitions rely on.
33//!
34//! # Resource bounds
35//!
36//! A schema may come from an untrusted party (a tool definition, an
37//! API request), so its cost is bounded as well:
38//!
39//! - **`pattern` and `patternProperties` run in linear time.** Patterns
40//!   compile with the `regex` engine, which has no backtracking.
41//!   Lookaround and backreferences are not supported by that engine, so
42//!   a schema using them fails to compile with an error naming the
43//!   pattern. [`CompiledSchemaBuilder::backtracking_patterns`] opts
44//!   back into the backtracking engine under an explicit step limit.
45//! - **Error reports are capped.** At most
46//!   [`DEFAULT_MAX_SCHEMA_ERRORS`] violations and
47//!   [`DEFAULT_MAX_SCHEMA_ERROR_BYTES`] bytes of message text are
48//!   collected, and each message is cut at
49//!   [`MAX_SCHEMA_MESSAGE_BYTES`]; the report says how many more were
50//!   found. Both caps are configurable on the builder.
51//! - **Schema size and nesting are capped** before compilation, at
52//!   [`DEFAULT_MAX_SCHEMA_NODES`] nodes and
53//!   [`DEFAULT_MAX_SCHEMA_DEPTH`] levels by default.
54//!
55//! The depth bound is inherited from the dependency and the parser
56//! rather than implemented here, which is exactly why it is tested: a
57//! feature flag or a dependency bump could remove it silently.
58//!
59//! # Examples
60//!
61//! ```
62//! use noyalib::{from_str, validate_against_schema, Value};
63//!
64//! let schema_yaml = "\
65//! type: object
66//! properties:
67//!   port:
68//!     type: integer
69//!     minimum: 0
70//!     maximum: 65535
71//! required:
72//!   - port
73//! ";
74//! let schema: Value = from_str(schema_yaml).unwrap();
75//!
76//! let good: Value = from_str("port: 8080\n").unwrap();
77//! assert!(validate_against_schema(&good, &schema).is_ok());
78//!
79//! let bad: Value = from_str("port: not-a-number\n").unwrap();
80//! assert!(validate_against_schema(&bad, &schema).is_err());
81//! ```
82
83use crate::error::{Error, Result};
84// Via the prelude, not `std`: `validate-schema` without `std` is a
85// valid combination (`jsonschema` is carried with
86// `default-features = false`), checked by the weekly feature-powerset
87// sweep.
88use crate::prelude::*;
89use crate::value::{Number, Value};
90
91/// Validate `value` against the JSON Schema 2020-12 document
92/// `schema`. Both inputs are [`Value`] trees — the schema is
93/// usually loaded from a YAML / JSON file via [`crate::from_str`],
94/// or built programmatically.
95///
96/// Multiple violations are aggregated into a single error message,
97/// each line carrying the JSON-pointer path of the offending
98/// instance. The path syntax follows RFC 6901: `/` for the root,
99/// `/port` for a top-level field, `/items/0/name` for nested
100/// data. A schema-side build failure (malformed schema document)
101/// is reported separately so callers can distinguish "your schema
102/// is broken" from "your data is broken".
103///
104/// # Errors
105///
106/// - The schema cannot be compiled (invalid JSON Schema shape).
107/// - The instance violates one or more constraints declared in
108///   the schema.
109/// - Internal JSON serialization fails for either input
110///   (vanishingly unlikely — would indicate a noyalib serializer
111///   bug).
112///
113/// # Examples
114///
115/// ```
116/// use noyalib::{from_str, validate_against_schema, Value};
117///
118/// let schema: Value = from_str(
119///     "type: object\nrequired: [port]\nproperties:\n  port:\n    type: integer\n",
120/// ).unwrap();
121/// let v: Value = from_str("port: 8080\n").unwrap();
122/// validate_against_schema(&v, &schema).unwrap();
123/// ```
124pub fn validate_against_schema(value: &Value, schema: &Value) -> Result<()> {
125    CompiledSchema::compile(schema)?.validate(value)
126}
127
128/// Default ceiling on the violations one validation collects. The
129/// total found is still counted and reported.
130pub const DEFAULT_MAX_SCHEMA_ERRORS: usize = 100;
131
132/// Default ceiling on the bytes of violation text one validation
133/// collects, summed over every message.
134pub const DEFAULT_MAX_SCHEMA_ERROR_BYTES: usize = 64 * 1024;
135
136/// Longest single violation message, in bytes. Messages quote the
137/// offending instance, so a large instance would otherwise be copied
138/// into every message that mentions it.
139pub const MAX_SCHEMA_MESSAGE_BYTES: usize = 1024;
140
141/// Default ceiling on the number of JSON nodes in a schema.
142pub const DEFAULT_MAX_SCHEMA_NODES: usize = 100_000;
143
144/// Default ceiling on how deeply a schema's arrays and objects nest.
145pub const DEFAULT_MAX_SCHEMA_DEPTH: usize = 128;
146
147/// A JSON Schema 2020-12 document compiled once, for validating many
148/// instances without re-compiling the schema on every call (#329).
149///
150/// [`validate_against_schema`] compiles its schema argument on every
151/// invocation — the right trade-off for one-off checks, and a linear
152/// waste for a config pipeline that validates thousands of documents
153/// against a handful of schemas. `CompiledSchema` front-loads the
154/// compile:
155///
156/// ```
157/// use noyalib::{CompiledSchema, Value, from_str};
158///
159/// let schema: Value = from_str(
160///     "type: object\nrequired: [port]\nproperties:\n  port:\n    type: integer\n",
161/// ).unwrap();
162/// let compiled = CompiledSchema::compile(&schema).unwrap();
163///
164/// for doc in ["port: 1\n", "port: 2\n", "port: 3\n"] {
165///     let v: Value = from_str(doc).unwrap();
166///     compiled.validate(&v).unwrap(); // no per-call schema compile
167/// }
168/// ```
169///
170/// The hardening guarantees pinned by `tests/schema_hardening.rs`
171/// hold on this path too — [`validate_against_schema`] is a
172/// compile-then-validate through this type, so the external-`$ref`
173/// refusal and the bounded recursion are exercised by the same tests.
174///
175/// Format assertion (off by default under Draft 2020-12, where
176/// `format` is an annotation) and custom formats are opt-in through
177/// [`CompiledSchema::builder`].
178pub struct CompiledSchema {
179    validator: jsonschema::Validator,
180    limits: ErrorLimits,
181}
182
183/// How many violations, and how many bytes of their text, one
184/// validation may collect.
185#[derive(Debug, Clone, Copy)]
186struct ErrorLimits {
187    max_errors: usize,
188    max_bytes: usize,
189}
190
191impl Default for ErrorLimits {
192    fn default() -> Self {
193        Self {
194            max_errors: DEFAULT_MAX_SCHEMA_ERRORS,
195            max_bytes: DEFAULT_MAX_SCHEMA_ERROR_BYTES,
196        }
197    }
198}
199
200impl fmt::Debug for CompiledSchema {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        f.debug_struct("CompiledSchema").finish_non_exhaustive()
203    }
204}
205
206impl CompiledSchema {
207    /// Compile `schema` with the same configuration
208    /// [`validate_against_schema`] uses: Draft 2020-12 semantics,
209    /// `format` as an annotation, no external `$ref` resolution.
210    ///
211    /// # Errors
212    ///
213    /// - The schema cannot be converted to JSON (YAML-only constructs
214    ///   like NaN keys).
215    /// - The schema is not a valid JSON Schema.
216    ///
217    /// # Examples
218    ///
219    /// ```
220    /// use noyalib::{CompiledSchema, Value, from_str};
221    ///
222    /// let schema: Value = from_str("type: object\n").unwrap();
223    /// let compiled = CompiledSchema::compile(&schema).unwrap();
224    /// assert!(compiled.validate(&from_str("a: 1\n").unwrap()).is_ok());
225    /// ```
226    pub fn compile(schema: &Value) -> Result<Self> {
227        Self::builder(schema).build()
228    }
229
230    /// Start a builder over `schema`, for turning on format assertion
231    /// or registering custom formats before compiling.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use noyalib::{CompiledSchema, Value, from_str};
237    ///
238    /// let schema: Value = from_str(
239    ///     "type: object\nproperties:\n  date: { type: string, format: date }\n",
240    /// ).unwrap();
241    /// let compiled = CompiledSchema::builder(&schema)
242    ///     .validate_formats(true)
243    ///     .build()
244    ///     .unwrap();
245    /// let bad: Value = from_str("date: 01/15/2024\n").unwrap();
246    /// assert!(compiled.validate(&bad).is_err());
247    /// ```
248    #[must_use]
249    pub fn builder(schema: &Value) -> CompiledSchemaBuilder {
250        CompiledSchemaBuilder {
251            schema_json: value_to_json(schema),
252            validate_formats: None,
253            formats: Vec::new(),
254            limits: ErrorLimits::default(),
255            max_schema_nodes: DEFAULT_MAX_SCHEMA_NODES,
256            max_schema_depth: DEFAULT_MAX_SCHEMA_DEPTH,
257            backtrack_limit: None,
258        }
259    }
260
261    /// Validate `value`, aggregating every violation into one error
262    /// message with RFC 6901 instance paths — the same report
263    /// [`validate_against_schema`] produces.
264    ///
265    /// # Errors
266    ///
267    /// - The instance violates one or more schema constraints.
268    /// - The instance cannot be converted to JSON.
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// use noyalib::{CompiledSchema, Value, from_str};
274    ///
275    /// let schema: Value = from_str(
276    ///     "type: object\nproperties:\n  port: { type: integer }\n",
277    /// ).unwrap();
278    /// let compiled = CompiledSchema::compile(&schema).unwrap();
279    /// let err = compiled
280    ///     .validate(&from_str("port: hello\n").unwrap())
281    ///     .unwrap_err();
282    /// assert!(err.to_string().contains("/port"));
283    /// ```
284    pub fn validate(&self, value: &Value) -> Result<()> {
285        let instance_json = value_to_json(value)
286            .map_err(|e| Error::Parse(format!("validate_against_schema: value -> JSON: {e}")))?;
287        let report = self.collect(&instance_json);
288        if report.found == 0 {
289            return Ok(());
290        }
291        Err(Error::Custom(report.summary()))
292    }
293
294    /// Every violation for `value`, structured: the RFC 6901 path of
295    /// the offending instance node, the schema keyword that raised it,
296    /// and the human-readable message. Empty when `value` conforms.
297    ///
298    /// # Errors
299    ///
300    /// The instance cannot be converted to JSON (vanishingly
301    /// unlikely — would indicate a noyalib serializer bug).
302    ///
303    /// # Examples
304    ///
305    /// ```
306    /// use noyalib::{CompiledSchema, Value, from_str};
307    ///
308    /// let schema: Value = from_str(
309    ///     "type: object\nrequired: [host]\nproperties:\n  port: { type: integer }\n",
310    /// ).unwrap();
311    /// let compiled = CompiledSchema::compile(&schema).unwrap();
312    /// let violations = compiled
313    ///     .iter_errors(&from_str("port: hello\n").unwrap())
314    ///     .unwrap();
315    /// assert_eq!(violations.len(), 2);
316    /// assert!(violations.iter().any(|v| v.instance_path == "/port"));
317    /// assert!(violations.iter().any(|v| v.keyword == "required"));
318    /// ```
319    pub fn iter_errors(&self, value: &Value) -> Result<Vec<SchemaViolation>> {
320        let instance_json = value_to_json(value)
321            .map_err(|e| Error::Parse(format!("iter_errors: value -> JSON: {e}")))?;
322        Ok(self.collect(&instance_json).violations)
323    }
324
325    /// Run the validator once and keep what fits under the limits.
326    fn collect(&self, instance: &serde_json::Value) -> Report {
327        let mut report = Report::default();
328        let mut bytes_left = self.limits.max_bytes;
329        for err in self.validator.iter_errors(instance) {
330            report.found += 1;
331            if report.violations.len() >= self.limits.max_errors || bytes_left == 0 {
332                continue;
333            }
334            let violation = SchemaViolation {
335                instance_path: bounded_display(&err.instance_path(), bytes_left),
336                keyword: keyword_of(&err),
337                message: bounded_display(&err, bytes_left.min(MAX_SCHEMA_MESSAGE_BYTES)),
338            };
339            let cost = violation.message.len() + violation.instance_path.len();
340            bytes_left = bytes_left.saturating_sub(cost);
341            report.violations.push(violation);
342        }
343        report
344    }
345}
346
347/// The last segment of a violation's schema path: the keyword.
348fn keyword_of(err: &jsonschema::ValidationError<'_>) -> String {
349    err.schema_path()
350        .to_string()
351        .rsplit('/')
352        .next()
353        .unwrap_or_default()
354        .to_string()
355}
356
357/// The violations one validation kept, and how many it found.
358#[derive(Default)]
359struct Report {
360    violations: Vec<SchemaViolation>,
361    found: usize,
362}
363
364impl Report {
365    /// The one-string form [`CompiledSchema::validate`] returns.
366    fn summary(&self) -> String {
367        let lines: Vec<String> = self
368            .violations
369            .iter()
370            .map(|v| format!("{} (at `{}`)", v.message, v.instance_path))
371            .collect();
372        if self.found == 1 && lines.len() == 1 {
373            return format!("schema violation: {}", lines[0]);
374        }
375        let mut out = format!("schema violations ({} total):", self.found);
376        for line in &lines {
377            out.push_str("\n  - ");
378            out.push_str(line);
379        }
380        let omitted = self.found - lines.len();
381        if omitted > 0 {
382            out.push_str(&format!("\n  - ... and {omitted} more not shown"));
383        }
384        out
385    }
386}
387
388/// Format `item` into at most `limit` bytes, marking a cut with `...`.
389///
390/// Writing stops as soon as the limit is reached, so a message that
391/// quotes a large instance is never rendered in full.
392fn bounded_display(item: &dyn fmt::Display, limit: usize) -> String {
393    use fmt::Write as _;
394    let mut out = BoundedWriter {
395        buf: String::new(),
396        limit,
397        cut: false,
398    };
399    let _ = write!(out, "{item}");
400    if out.cut {
401        out.buf.push_str("...");
402    }
403    out.buf
404}
405
406/// A `fmt::Write` sink that keeps at most `limit` bytes, cut on a
407/// character boundary, and fails the write once it is full.
408struct BoundedWriter {
409    buf: String,
410    limit: usize,
411    cut: bool,
412}
413
414impl fmt::Write for BoundedWriter {
415    fn write_str(&mut self, s: &str) -> fmt::Result {
416        let room = self.limit - self.buf.len();
417        if s.len() <= room {
418            self.buf.push_str(s);
419            return Ok(());
420        }
421        let mut end = room;
422        while !s.is_char_boundary(end) {
423            end -= 1;
424        }
425        self.buf.push_str(&s[..end]);
426        self.cut = true;
427        Err(fmt::Error)
428    }
429}
430
431/// Configures a [`CompiledSchema`] before compilation. Created by
432/// [`CompiledSchema::builder`].
433pub struct CompiledSchemaBuilder {
434    schema_json: core::result::Result<serde_json::Value, String>,
435    validate_formats: Option<bool>,
436    #[allow(clippy::type_complexity)]
437    formats: Vec<(String, Arc<dyn Fn(&str) -> bool + Send + Sync>)>,
438    limits: ErrorLimits,
439    max_schema_nodes: usize,
440    max_schema_depth: usize,
441    backtrack_limit: Option<usize>,
442}
443
444impl fmt::Debug for CompiledSchemaBuilder {
445    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
446        f.debug_struct("CompiledSchemaBuilder")
447            .field("validate_formats", &self.validate_formats)
448            .field("limits", &self.limits)
449            .field("max_schema_nodes", &self.max_schema_nodes)
450            .field("max_schema_depth", &self.max_schema_depth)
451            .field("backtrack_limit", &self.backtrack_limit)
452            .field(
453                "formats",
454                &self.formats.iter().map(|(n, _)| n).collect::<Vec<_>>(),
455            )
456            .finish_non_exhaustive()
457    }
458}
459
460impl CompiledSchemaBuilder {
461    /// Assert `format` keywords instead of treating them as
462    /// annotations. Under Draft 2020-12 the default is annotation-only,
463    /// so `format: date` accepts `01/15/2024` unless this is on.
464    #[must_use]
465    pub fn validate_formats(mut self, yes: bool) -> Self {
466        self.validate_formats = Some(yes);
467        self
468    }
469
470    /// Register a custom format checker for `format: <name>`. Implies
471    /// nothing about assertion — combine with
472    /// [`Self::validate_formats`] to have the checker enforced.
473    #[must_use]
474    pub fn with_format<N, F>(mut self, name: N, check: F) -> Self
475    where
476        N: Into<String>,
477        F: Fn(&str) -> bool + Send + Sync + 'static,
478    {
479        self.formats.push((name.into(), Arc::new(check)));
480        self
481    }
482
483    /// Collect at most `n` violations per validation (default
484    /// [`DEFAULT_MAX_SCHEMA_ERRORS`]). The total found is still
485    /// reported.
486    #[must_use]
487    pub fn max_errors(mut self, n: usize) -> Self {
488        self.limits.max_errors = n;
489        self
490    }
491
492    /// Collect at most `n` bytes of violation text per validation,
493    /// summed over every message (default
494    /// [`DEFAULT_MAX_SCHEMA_ERROR_BYTES`]).
495    #[must_use]
496    pub fn max_error_bytes(mut self, n: usize) -> Self {
497        self.limits.max_bytes = n;
498        self
499    }
500
501    /// Refuse schemas with more than `n` JSON nodes (default
502    /// [`DEFAULT_MAX_SCHEMA_NODES`]).
503    #[must_use]
504    pub fn max_schema_nodes(mut self, n: usize) -> Self {
505        self.max_schema_nodes = n;
506        self
507    }
508
509    /// Refuse schemas whose arrays and objects nest deeper than `n`
510    /// (default [`DEFAULT_MAX_SCHEMA_DEPTH`]).
511    #[must_use]
512    pub fn max_schema_depth(mut self, n: usize) -> Self {
513        self.max_schema_depth = n;
514        self
515    }
516
517    /// Compile `pattern` and `patternProperties` with the backtracking
518    /// engine, which supports lookaround and backreferences, and stop
519    /// any single match after `limit` backtracking steps (a match that
520    /// hits the limit is reported as a violation).
521    ///
522    /// Off by default: the default engine runs in linear time. Only
523    /// opt in for schemas from a trusted source, with a small limit.
524    ///
525    /// # Examples
526    ///
527    /// ```
528    /// use noyalib::{CompiledSchema, Value, from_str};
529    ///
530    /// let schema: Value = from_str("type: string\npattern: '^(?!tmp)'\n").unwrap();
531    /// assert!(CompiledSchema::compile(&schema).is_err());
532    /// let compiled = CompiledSchema::builder(&schema)
533    ///     .backtracking_patterns(10_000)
534    ///     .build()
535    ///     .unwrap();
536    /// assert!(compiled.validate(&from_str("tmpfile").unwrap()).is_err());
537    /// ```
538    #[must_use]
539    pub fn backtracking_patterns(mut self, limit: usize) -> Self {
540        self.backtrack_limit = Some(limit);
541        self
542    }
543
544    /// Compile the schema with the accumulated configuration.
545    ///
546    /// # Errors
547    ///
548    /// As [`CompiledSchema::compile`], plus a schema over the node or
549    /// depth limit.
550    pub fn build(self) -> Result<CompiledSchema> {
551        let schema_json = self
552            .schema_json
553            .map_err(|e| Error::Custom(format!("validate_against_schema: schema -> JSON: {e}")))?;
554        check_schema_shape(&schema_json, self.max_schema_nodes, self.max_schema_depth)?;
555        let mut options = hardened_options();
556        if let Some(limit) = self.backtrack_limit {
557            options = options.with_pattern_options(
558                jsonschema::PatternOptions::fancy_regex().backtrack_limit(limit),
559            );
560        }
561        if let Some(yes) = self.validate_formats {
562            options = options.should_validate_formats(yes);
563        }
564        for (name, check) in self.formats {
565            options = options.with_format(name, move |s: &str| check(s));
566        }
567        let validator = options.build(&schema_json).map_err(|e| {
568            Error::Custom(format!(
569                "validate_against_schema: schema is not a valid JSON Schema: {e}"
570            ))
571        })?;
572        Ok(CompiledSchema {
573            validator,
574            limits: self.limits,
575        })
576    }
577}
578
579/// One schema violation from [`CompiledSchema::iter_errors`]:
580/// where in the instance, which keyword, and the message.
581#[derive(Debug, Clone, PartialEq, Eq)]
582pub struct SchemaViolation {
583    /// RFC 6901 JSON pointer to the offending instance node
584    /// (`""` for the root, `/port`, `/items/0/name`).
585    pub instance_path: String,
586    /// The schema keyword that raised the violation (`type`,
587    /// `required`, `format`, …) — the last segment of the schema path.
588    pub keyword: String,
589    /// Human-readable description of the violation.
590    pub message: String,
591}
592
593/// Validate the YAML text in `yaml` against the JSON Schema
594/// document in `schema_yaml`. Convenience wrapper around
595/// [`validate_against_schema`] — parses both inputs and forwards.
596///
597/// # Errors
598///
599/// As [`validate_against_schema`], plus YAML parse errors for
600/// either input.
601///
602/// # Examples
603///
604/// ```
605/// use noyalib::validate_against_schema_str;
606///
607/// let schema = "type: object\nrequired: [port]\n";
608/// let yaml = "port: 8080\n";
609/// validate_against_schema_str(yaml, schema).unwrap();
610/// ```
611pub fn validate_against_schema_str(yaml: &str, schema_yaml: &str) -> Result<()> {
612    let value: Value = crate::from_str(yaml)?;
613    let schema: Value = crate::from_str(schema_yaml)?;
614    validate_against_schema(&value, &schema)
615}
616
617/// Validator options every schema in this module is compiled with.
618///
619/// The retriever is set explicitly rather than left to `jsonschema`'s
620/// default: with that crate's `resolve-file` or `resolve-http` feature
621/// switched on anywhere in the build, its default retriever reads files
622/// and fetches URLs named by `$ref`.
623///
624/// Patterns use the linear-time `regex` engine: the backtracking
625/// default can take exponential time on a hostile pattern.
626fn hardened_options() -> jsonschema::ValidationOptions<'static> {
627    jsonschema::options()
628        .with_retriever(RefuseExternalRefs)
629        .with_pattern_options(jsonschema::PatternOptions::regex())
630}
631
632/// Compile `schema` for a coercion pass: the same hardened options and
633/// default size limits as [`CompiledSchema::compile`], with errors
634/// prefixed by `context` (the calling function's name).
635pub(crate) fn compile_for_coercion(schema: &Value, context: &str) -> Result<jsonschema::Validator> {
636    let schema_json = value_to_json(schema)
637        .map_err(|e| Error::Custom(format!("{context}: schema -> JSON: {e}")))?;
638    check_schema_shape(
639        &schema_json,
640        DEFAULT_MAX_SCHEMA_NODES,
641        DEFAULT_MAX_SCHEMA_DEPTH,
642    )?;
643    hardened_options()
644        .build(&schema_json)
645        .map_err(|e| Error::Custom(format!("{context}: schema is not a valid JSON Schema: {e}")))
646}
647
648/// Refuse a schema over the node or nesting limit before compiling it.
649/// Iterative, so the walk itself cannot exhaust the stack.
650fn check_schema_shape(
651    schema: &serde_json::Value,
652    max_nodes: usize,
653    max_depth: usize,
654) -> Result<()> {
655    let mut pending: Vec<(&serde_json::Value, usize)> = vec![(schema, 0)];
656    let mut nodes = 0usize;
657    while let Some((node, depth)) = pending.pop() {
658        nodes += 1;
659        if nodes > max_nodes {
660            return Err(Error::Custom(format!(
661                "validate_against_schema: schema exceeds {max_nodes} nodes"
662            )));
663        }
664        let children: Vec<&serde_json::Value> = match node {
665            serde_json::Value::Array(items) => items.iter().collect(),
666            serde_json::Value::Object(map) => map.values().collect(),
667            _ => continue,
668        };
669        if depth + 1 > max_depth {
670            return Err(Error::Custom(format!(
671                "validate_against_schema: schema nesting exceeds the recursion depth limit of {max_depth}"
672            )));
673        }
674        pending.extend(children.into_iter().map(|c| (c, depth + 1)));
675    }
676    Ok(())
677}
678
679/// A `$ref` retriever that refuses every reference it is asked for.
680///
681/// `jsonschema` consults the retriever only for resources that are not
682/// already part of the schema document, so in-document `$ref` and
683/// `$defs` are unaffected.
684struct RefuseExternalRefs;
685
686impl jsonschema::Retrieve for RefuseExternalRefs {
687    fn retrieve(
688        &self,
689        uri: &jsonschema::Uri<String>,
690    ) -> core::result::Result<serde_json::Value, Box<dyn core::error::Error + Send + Sync>> {
691        Err(Box::new(ExternalRefRefused(uri.as_str().to_string())))
692    }
693}
694
695#[derive(Debug)]
696struct ExternalRefRefused(String);
697
698impl fmt::Display for ExternalRefRefused {
699    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
700        write!(
701            f,
702            "external $ref `{}` refused: only references inside the schema document are resolved",
703            self.0
704        )
705    }
706}
707
708impl core::error::Error for ExternalRefRefused {}
709
710/// Convert a [`Value`] tree to a [`serde_json::Value`] via the
711/// existing `Serialize` impl on `Value`. Lossless for every
712/// JSON-expressible shape; YAML-only constructs (NaN, Infinity,
713/// non-string keys) become JSON-incompatible at this boundary —
714/// JSON Schema does not have semantics for them either, so the
715/// validator would reject them downstream regardless.
716pub(crate) fn value_to_json(v: &Value) -> core::result::Result<serde_json::Value, String> {
717    serde_json::to_value(v).map_err(|e| e.to_string())
718}
719
720/// Apply schema-driven type coercions to `value` in place. Walks
721/// the validator output for type-mismatch errors, and for each
722/// case where the instance is a [`Value::String`] but the schema
723/// requires an integer / number / boolean — and the string parses
724/// cleanly into that target type — replaces the offending node
725/// with the coerced value.
726///
727/// Returns the number of coercions applied. Coercions that don't
728/// have a clean parse (e.g. `"abc"` against `type: integer`) are
729/// left in place; the caller is expected to re-run
730/// [`validate_against_schema`] afterwards and surface any
731/// remaining violations.
732///
733/// This pairs naturally with hand-written YAML where every value
734/// is a string in the typed sense (because the user did not quote
735/// vs. unquote intentionally) but the schema knows the intended
736/// types. It fits into a CI / formatter pipeline as a "fix pass"
737/// before strict validation kicks in.
738///
739/// # Errors
740///
741/// As [`validate_against_schema`].
742///
743/// # Examples
744///
745/// ```
746/// use noyalib::{coerce_to_schema, from_str, Value};
747///
748/// let schema: Value = from_str(
749///     "type: object\nproperties:\n  port:\n    type: integer\n",
750/// ).unwrap();
751/// let mut data: Value = from_str("port: \"8080\"\n").unwrap();
752/// let n = coerce_to_schema(&mut data, &schema).unwrap();
753/// assert_eq!(n, 1, "one fix expected");
754/// // The port is now an integer — re-validation succeeds.
755/// noyalib::validate_against_schema(&data, &schema).unwrap();
756/// ```
757pub fn coerce_to_schema(value: &mut Value, schema: &Value) -> Result<usize> {
758    use jsonschema::JsonType;
759    use jsonschema::error::{TypeKind, ValidationErrorKind};
760
761    let validator = compile_for_coercion(schema, "coerce_to_schema")?;
762
763    let mut applied: usize = 0;
764    // Cap the fix-loop to bound total work even on adversarial
765    // schemas where each coercion exposes a fresh error elsewhere.
766    let max_iterations = 1024;
767
768    for _ in 0..max_iterations {
769        let instance_json = value_to_json(value)
770            .map_err(|e| Error::Parse(format!("coerce_to_schema: value -> JSON: {e}")))?;
771        let mut applied_this_pass = false;
772
773        // Collect path + target type pairs first so the borrow on
774        // `value` for the subsequent mutation doesn't overlap with
775        // the iterator.
776        let mut targets: Vec<(String, JsonType)> = Vec::new();
777        for err in validator.iter_errors(&instance_json) {
778            if let ValidationErrorKind::Type {
779                kind: TypeKind::Single(target),
780            } = err.kind()
781            {
782                targets.push((err.instance_path().to_string(), *target));
783            }
784        }
785
786        for (path, target) in targets {
787            let segments = parse_json_pointer(&path);
788            if let Some(node) = navigate_mut(value, &segments)
789                && try_coerce(node, target)
790            {
791                applied += 1;
792                applied_this_pass = true;
793            }
794        }
795
796        if !applied_this_pass {
797            break;
798        }
799    }
800
801    Ok(applied)
802}
803
804/// Parse an RFC 6901 JSON pointer into segment strings.
805fn parse_json_pointer(s: &str) -> Vec<String> {
806    if s.is_empty() || s == "/" {
807        return Vec::new();
808    }
809    s.trim_start_matches('/')
810        .split('/')
811        .map(|seg| seg.replace("~1", "/").replace("~0", "~"))
812        .collect()
813}
814
815/// Walk `value` along `path` and return a mutable reference to the
816/// addressed node, or `None` if the path is unreachable.
817fn navigate_mut<'a>(value: &'a mut Value, path: &[String]) -> Option<&'a mut Value> {
818    let mut cursor = value;
819    for seg in path {
820        cursor = match cursor {
821            Value::Mapping(m) => m.get_mut(seg.as_str())?,
822            Value::Sequence(s) => {
823                let idx: usize = seg.parse().ok()?;
824                s.get_mut(idx)?
825            }
826            _ => return None,
827        };
828    }
829    Some(cursor)
830}
831
832/// Try to coerce `node` into `target`. Returns `true` when the
833/// coercion was applied. Only the safe directions are honoured:
834/// string → integer / number / boolean when the parse succeeds.
835fn try_coerce(node: &mut Value, target: jsonschema::JsonType) -> bool {
836    use jsonschema::JsonType;
837    let s = match node {
838        Value::String(s) => s.clone(),
839        _ => return false,
840    };
841    let coerced = match target {
842        JsonType::Integer => s
843            .parse::<i64>()
844            .ok()
845            .map(|n| Value::Number(Number::Integer(n))),
846        JsonType::Number => s
847            .parse::<f64>()
848            .ok()
849            .map(|f| Value::Number(Number::Float(f))),
850        JsonType::Boolean => match s.as_str() {
851            "true" => Some(Value::Bool(true)),
852            "false" => Some(Value::Bool(false)),
853            _ => None,
854        },
855        _ => None,
856    };
857    match coerced {
858        Some(new_v) => {
859            *node = new_v;
860            true
861        }
862        None => false,
863    }
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    fn parse(s: &str) -> Value {
871        crate::from_str(s).unwrap()
872    }
873
874    #[test]
875    fn valid_value_returns_ok() {
876        let schema =
877            parse("type: object\nrequired: [port]\nproperties:\n  port:\n    type: integer\n");
878        let value = parse("port: 8080\n");
879        assert!(validate_against_schema(&value, &schema).is_ok());
880    }
881
882    #[test]
883    fn type_mismatch_returns_err() {
884        let schema = parse("type: object\nproperties:\n  port:\n    type: integer\n");
885        let value = parse("port: hello\n");
886        let err = validate_against_schema(&value, &schema).unwrap_err();
887        let msg = err.to_string();
888        assert!(msg.contains("schema violation"), "got: {msg}");
889        assert!(msg.contains("/port"), "path missing: {msg}");
890    }
891
892    #[test]
893    fn missing_required_field_returns_err() {
894        let schema = parse("type: object\nrequired: [port]\n");
895        let value = parse("host: localhost\n");
896        let err = validate_against_schema(&value, &schema).unwrap_err();
897        assert!(err.to_string().contains("port"));
898    }
899
900    #[test]
901    fn multiple_violations_aggregated() {
902        let schema = parse(
903            "type: object
904required: [port, host]
905properties:
906  port:
907    type: integer
908  host:
909    type: string
910",
911        );
912        let value = parse("port: not-int\n");
913        let err = validate_against_schema(&value, &schema).unwrap_err();
914        let msg = err.to_string();
915        // Two distinct violations: type mismatch on port, missing host.
916        assert!(msg.contains("schema violations"), "got: {msg}");
917        assert!(msg.contains("port"));
918        assert!(msg.contains("host"));
919    }
920
921    #[test]
922    fn invalid_schema_distinguished_from_invalid_data() {
923        // `type` cannot be a number per JSON Schema.
924        let schema = parse("type: 42\n");
925        let value = parse("anything: 1\n");
926        let err = validate_against_schema(&value, &schema).unwrap_err();
927        let msg = err.to_string();
928        assert!(
929            msg.contains("not a valid JSON Schema"),
930            "expected schema-side error, got: {msg}"
931        );
932    }
933
934    #[test]
935    fn enum_constraint_enforced() {
936        let schema = parse(
937            "type: object
938properties:
939  level:
940    enum: [trace, debug, info, warn, error]
941",
942        );
943        assert!(validate_against_schema(&parse("level: warn\n"), &schema).is_ok());
944        assert!(validate_against_schema(&parse("level: ULTRA\n"), &schema).is_err());
945    }
946
947    #[test]
948    fn integer_bounds_enforced() {
949        let schema = parse(
950            "type: object
951properties:
952  port:
953    type: integer
954    minimum: 0
955    maximum: 65535
956",
957        );
958        assert!(validate_against_schema(&parse("port: 8080\n"), &schema).is_ok());
959        assert!(validate_against_schema(&parse("port: 70000\n"), &schema).is_err());
960        assert!(validate_against_schema(&parse("port: -1\n"), &schema).is_err());
961    }
962
963    #[test]
964    fn nested_object_validated() {
965        let schema = parse(
966            "type: object
967properties:
968  db:
969    type: object
970    required: [host]
971    properties:
972      host:
973        type: string
974",
975        );
976        let good = parse("db:\n  host: localhost\n");
977        let bad = parse("db: {}\n");
978        assert!(validate_against_schema(&good, &schema).is_ok());
979        assert!(validate_against_schema(&bad, &schema).is_err());
980    }
981
982    #[test]
983    fn validate_against_schema_str_parses_both_inputs() {
984        let schema = "type: object\nrequired: [port]\n";
985        let yaml = "port: 8080\n";
986        assert!(validate_against_schema_str(yaml, schema).is_ok());
987    }
988
989    #[test]
990    fn schema_for_codegen_round_trip_validates_self() {
991        // Phase 3.1 + 3.2 together — derive JsonSchema, emit, then
992        // validate sample data against the emitted schema.
993
994        #[derive(serde::Serialize, serde::Deserialize, crate::JsonSchema)]
995        #[allow(dead_code)]
996        struct Cfg {
997            port: u16,
998            #[serde(default)]
999            host: String,
1000        }
1001
1002        let schema = crate::schema_for::<Cfg>().unwrap();
1003        let good = parse("port: 8080\nhost: localhost\n");
1004        assert!(validate_against_schema(&good, &schema).is_ok());
1005
1006        let bad = parse("host: localhost\n"); // missing required `port`
1007        let err = validate_against_schema(&bad, &schema).unwrap_err();
1008        assert!(err.to_string().contains("port"));
1009    }
1010}