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}