Skip to main content

golem_schema/schema/validation/
value.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Structural validation that a [`SchemaValue`] matches a given
16//! [`SchemaType`] inside a [`SchemaGraph`].
17
18use crate::schema::graph::{GraphIndex, SchemaGraph};
19use crate::schema::metadata::TypeId;
20use crate::schema::schema_type::{
21    BinaryRestrictions, DiscriminatorRule, NumericBound, NumericRepr, NumericRestrictionError,
22    NumericRestrictions, PathSpec, QuantitySpec, QuantityValue, SchemaType, TextRestrictions,
23    UnionBranch, UrlRestrictions, numeric_bound_cmp,
24};
25#[cfg(not(all(feature = "guest", not(feature = "host"))))]
26use crate::schema::schema_value::SecretValuePayload;
27use crate::schema::schema_value::{
28    BinaryValuePayload, ResultValuePayload, SchemaValue, TextValuePayload,
29};
30// Only the host (and feature-neutral) build inspects a quota-token's snapshot
31// fields; on a guest the value is an opaque owned handle.
32#[cfg(not(all(feature = "guest", not(feature = "host"))))]
33use crate::schema::schema_type::{PermissionCardSpec, QuotaTokenSpec, SecretSpec};
34#[cfg(not(all(feature = "guest", not(feature = "host"))))]
35use crate::schema::schema_value::{PermissionCardValuePayload, QuotaTokenValuePayload};
36use std::cmp::Ordering;
37use std::error::Error;
38use std::fmt::{self, Display, Formatter, Write};
39
40/// A path segment inside a [`SchemaValue`] tree.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum ValuePathSegment {
43    Field(String),
44    Index(usize),
45    VariantPayload,
46    OptionInner,
47    ResultOk,
48    ResultErr,
49    UnionBody,
50    MapKey(usize),
51    MapValue(usize),
52}
53
54impl Display for ValuePathSegment {
55    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
56        match self {
57            ValuePathSegment::Field(n) => write!(f, ".field({n:?})"),
58            ValuePathSegment::Index(i) => write!(f, ".index({i})"),
59            ValuePathSegment::VariantPayload => f.write_str(".variant_payload"),
60            ValuePathSegment::OptionInner => f.write_str(".option_inner"),
61            ValuePathSegment::ResultOk => f.write_str(".ok"),
62            ValuePathSegment::ResultErr => f.write_str(".err"),
63            ValuePathSegment::UnionBody => f.write_str(".union_body"),
64            ValuePathSegment::MapKey(i) => write!(f, ".map_key({i})"),
65            ValuePathSegment::MapValue(i) => write!(f, ".map_value({i})"),
66        }
67    }
68}
69
70/// Path through a [`SchemaValue`] tree to a specific failing node.
71#[derive(Clone, Debug, Default, PartialEq, Eq)]
72pub struct ValuePath {
73    segments: Vec<ValuePathSegment>,
74}
75
76impl ValuePath {
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    pub fn segments(&self) -> &[ValuePathSegment] {
82        &self.segments
83    }
84
85    fn push(&mut self, segment: ValuePathSegment) {
86        self.segments.push(segment);
87    }
88
89    fn pop(&mut self) {
90        self.segments.pop();
91    }
92
93    fn snapshot(&self) -> Self {
94        Self {
95            segments: self.segments.clone(),
96        }
97    }
98}
99
100impl Display for ValuePath {
101    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
102        for s in &self.segments {
103            write!(f, "{s}")?;
104        }
105        Ok(())
106    }
107}
108
109/// All errors raised by [`validate_value`].
110#[derive(Clone, Debug, PartialEq)]
111pub enum ValueError {
112    /// Validation requires an optional Cargo feature.
113    UnsupportedFeature {
114        path: ValuePath,
115        feature: &'static str,
116    },
117    ShapeMismatch {
118        path: ValuePath,
119        expected: String,
120        found: String,
121    },
122    VariantCaseOutOfRange {
123        path: ValuePath,
124        case: u32,
125        case_count: usize,
126    },
127    EnumCaseOutOfRange {
128        path: ValuePath,
129        case: u32,
130        case_count: usize,
131    },
132    RecordArityMismatch {
133        path: ValuePath,
134        expected: usize,
135        found: usize,
136    },
137    TupleArityMismatch {
138        path: ValuePath,
139        expected: usize,
140        found: usize,
141    },
142    FlagsArityMismatch {
143        path: ValuePath,
144        expected: usize,
145        found: usize,
146    },
147    FixedListLengthMismatch {
148        path: ValuePath,
149        expected: u32,
150        found: usize,
151    },
152    DanglingRef {
153        path: ValuePath,
154        type_id: TypeId,
155    },
156    /// A chain of named references at one value position resolves back
157    /// to itself (e.g. `A = Ref<A>` or a mutual cycle `A → B → A`) before
158    /// reaching a structural shape, so no finite value can ever satisfy
159    /// it.
160    RecursiveRef {
161        path: ValuePath,
162        type_id: TypeId,
163    },
164    UnionUnknownTag {
165        path: ValuePath,
166        tag: String,
167    },
168    UnionDiscriminatorMismatch {
169        path: ValuePath,
170        tag: String,
171    },
172    VariantPayloadPresenceMismatch {
173        path: ValuePath,
174        expected_some: bool,
175    },
176    ResultPayloadPresenceMismatch {
177        path: ValuePath,
178        expected_some: bool,
179        side: ResultSide,
180    },
181    OptionInnerPresenceMismatch {
182        path: ValuePath,
183    },
184    TextLanguageNotAllowed {
185        path: ValuePath,
186        language: String,
187    },
188    TextTooShort {
189        path: ValuePath,
190        min: u32,
191        found: usize,
192    },
193    TextTooLong {
194        path: ValuePath,
195        max: u32,
196        found: usize,
197    },
198    TextRegexMismatch {
199        path: ValuePath,
200        regex: String,
201    },
202    BinaryMimeNotAllowed {
203        path: ValuePath,
204        mime_type: String,
205    },
206    BinaryTooSmall {
207        path: ValuePath,
208        min: u32,
209        found: usize,
210    },
211    BinaryTooLarge {
212        path: ValuePath,
213        max: u32,
214        found: usize,
215    },
216    PathEmpty {
217        path: ValuePath,
218    },
219    PathExtensionNotAllowed {
220        path: ValuePath,
221        extension: String,
222    },
223    UrlEmpty {
224        path: ValuePath,
225    },
226    UrlInvalid {
227        path: ValuePath,
228        url: String,
229        reason: String,
230    },
231    UrlSchemeNotAllowed {
232        path: ValuePath,
233        scheme: String,
234    },
235    UrlHostNotAllowed {
236        path: ValuePath,
237        host: String,
238    },
239    UrlHostMissing {
240        path: ValuePath,
241    },
242    QuantityUnitNotAllowed {
243        path: ValuePath,
244        unit: String,
245    },
246    QuantityOutOfRange {
247        path: ValuePath,
248        reason: String,
249    },
250    NumericOutOfRange {
251        path: ValuePath,
252        reason: String,
253    },
254    SecretCategoryMismatch {
255        path: ValuePath,
256        expected: String,
257        found: Option<String>,
258    },
259    QuotaTokenResourceMismatch {
260        path: ValuePath,
261        expected: String,
262        found: String,
263    },
264    PermissionCardPolymorphicMismatch {
265        path: ValuePath,
266        expected: bool,
267        found: bool,
268    },
269}
270
271#[derive(Copy, Clone, Debug, PartialEq, Eq)]
272pub enum ResultSide {
273    Ok,
274    Err,
275}
276
277impl Display for ValueError {
278    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
279        match self {
280            ValueError::UnsupportedFeature { path, feature } => {
281                write!(f, "validation at {path} requires feature `{feature}`")
282            }
283            ValueError::ShapeMismatch {
284                path,
285                expected,
286                found,
287            } => write!(
288                f,
289                "shape mismatch at {path}: expected {expected}, found {found}"
290            ),
291            ValueError::VariantCaseOutOfRange {
292                path,
293                case,
294                case_count,
295            } => write!(
296                f,
297                "variant case index {case} at {path} is out of range (case count: {case_count})"
298            ),
299            ValueError::EnumCaseOutOfRange {
300                path,
301                case,
302                case_count,
303            } => write!(
304                f,
305                "enum case index {case} at {path} is out of range (case count: {case_count})"
306            ),
307            ValueError::RecordArityMismatch {
308                path,
309                expected,
310                found,
311            } => write!(
312                f,
313                "record at {path} has {found} field(s), expected {expected}"
314            ),
315            ValueError::TupleArityMismatch {
316                path,
317                expected,
318                found,
319            } => write!(
320                f,
321                "tuple at {path} has {found} element(s), expected {expected}"
322            ),
323            ValueError::FlagsArityMismatch {
324                path,
325                expected,
326                found,
327            } => write!(
328                f,
329                "flags value at {path} has {found} bit(s), expected {expected}"
330            ),
331            ValueError::FixedListLengthMismatch {
332                path,
333                expected,
334                found,
335            } => write!(
336                f,
337                "fixed-list at {path} has {found} element(s), expected {expected}"
338            ),
339            ValueError::DanglingRef { path, type_id } => write!(
340                f,
341                "dangling ref `{type_id}` at {path} (no such named definition)"
342            ),
343            ValueError::RecursiveRef { path, type_id } => write!(
344                f,
345                "ref chain at {path} loops back to `{type_id}` without reaching a structural shape"
346            ),
347            ValueError::UnionUnknownTag { path, tag } => write!(
348                f,
349                "union value at {path} carries tag `{tag}` that does not match any branch"
350            ),
351            ValueError::UnionDiscriminatorMismatch { path, tag } => write!(
352                f,
353                "union value at {path} does not satisfy branch `{tag}` discriminator"
354            ),
355            ValueError::VariantPayloadPresenceMismatch {
356                path,
357                expected_some,
358            } => {
359                let mut s = String::new();
360                let _ = write!(s, "variant payload presence mismatch at {path}: ");
361                if *expected_some {
362                    let _ = write!(s, "schema expects a payload, value carries none");
363                } else {
364                    let _ = write!(s, "schema expects no payload, value carries one");
365                }
366                f.write_str(&s)
367            }
368            ValueError::ResultPayloadPresenceMismatch {
369                path,
370                expected_some,
371                side,
372            } => {
373                let side = match side {
374                    ResultSide::Ok => "ok",
375                    ResultSide::Err => "err",
376                };
377                let mut s = String::new();
378                let _ = write!(s, "result {side} payload presence mismatch at {path}: ");
379                if *expected_some {
380                    let _ = write!(s, "schema expects a payload, value carries none");
381                } else {
382                    let _ = write!(s, "schema expects no payload, value carries one");
383                }
384                f.write_str(&s)
385            }
386            ValueError::OptionInnerPresenceMismatch { path } => {
387                write!(f, "option-value presence inconsistent at {path}")
388            }
389            ValueError::TextLanguageNotAllowed { path, language } => write!(
390                f,
391                "text value at {path} carries language `{language}` not in the allow-list"
392            ),
393            ValueError::TextTooShort { path, min, found } => write!(
394                f,
395                "text value at {path} has {found} char(s), below min-length {min}"
396            ),
397            ValueError::TextTooLong { path, max, found } => write!(
398                f,
399                "text value at {path} has {found} char(s), above max-length {max}"
400            ),
401            ValueError::TextRegexMismatch { path, regex } => write!(
402                f,
403                "text value at {path} does not match required regex `{regex}`"
404            ),
405            ValueError::BinaryMimeNotAllowed { path, mime_type } => write!(
406                f,
407                "binary value at {path} carries mime-type `{mime_type}` not in the allow-list"
408            ),
409            ValueError::BinaryTooSmall { path, min, found } => write!(
410                f,
411                "binary value at {path} has {found} byte(s), below min-bytes {min}"
412            ),
413            ValueError::BinaryTooLarge { path, max, found } => write!(
414                f,
415                "binary value at {path} has {found} byte(s), above max-bytes {max}"
416            ),
417            ValueError::PathEmpty { path } => write!(f, "path value at {path} is empty"),
418            ValueError::PathExtensionNotAllowed { path, extension } => write!(
419                f,
420                "path value at {path} has extension `{extension}` not in the allow-list"
421            ),
422            ValueError::UrlEmpty { path } => write!(f, "url value at {path} is empty"),
423            ValueError::UrlInvalid { path, url, reason } => write!(
424                f,
425                "url value at {path} (`{url}`) is not a valid URL: {reason}"
426            ),
427            ValueError::UrlSchemeNotAllowed { path, scheme } => write!(
428                f,
429                "url value at {path} has scheme `{scheme}` not in the allow-list"
430            ),
431            ValueError::UrlHostNotAllowed { path, host } => write!(
432                f,
433                "url value at {path} has host `{host}` not in the allow-list"
434            ),
435            ValueError::UrlHostMissing { path } => write!(
436                f,
437                "url value at {path} has no host (allow-list requires one)"
438            ),
439            ValueError::QuantityUnitNotAllowed { path, unit } => write!(
440                f,
441                "quantity value at {path} has unit `{unit}` which is not allowed"
442            ),
443            ValueError::QuantityOutOfRange { path, reason } => {
444                write!(f, "quantity value at {path} is out of range ({reason})")
445            }
446            ValueError::NumericOutOfRange { path, reason } => {
447                write!(f, "numeric value at {path} is out of range ({reason})")
448            }
449            ValueError::SecretCategoryMismatch {
450                path,
451                expected,
452                found,
453            } => write!(
454                f,
455                "secret value at {path} expected category `{expected}`, found `{}`",
456                found.as_deref().unwrap_or("<none>")
457            ),
458            ValueError::QuotaTokenResourceMismatch {
459                path,
460                expected,
461                found,
462            } => write!(
463                f,
464                "quota-token value at {path} expected resource `{expected}`, found `{found}`"
465            ),
466            ValueError::PermissionCardPolymorphicMismatch {
467                path,
468                expected,
469                found,
470            } => write!(
471                f,
472                "permission-card value at {path} expected polymorphic `{expected}`, found `{found}`"
473            ),
474        }
475    }
476}
477
478impl Error for ValueError {}
479
480/// Validate that `value` structurally conforms to `ty` (in the context of
481/// `graph`).
482pub fn validate_value(
483    graph: &SchemaGraph,
484    ty: &SchemaType,
485    value: &SchemaValue,
486) -> Result<(), Vec<ValueError>> {
487    let mut errors = Vec::new();
488    let mut path = ValuePath::new();
489    let index = GraphIndex::new(graph);
490    check(&index, ty, value, &mut path, &mut errors);
491    if errors.is_empty() {
492        Ok(())
493    } else {
494        Err(errors)
495    }
496}
497
498/// Validate the field values of a record against a set of named field types
499/// defined in `graph`, **without** materialising a temporary record
500/// [`SchemaType`].
501///
502/// This is the allocation-light counterpart of validating a `Record` value
503/// against `SchemaType::record(fields)`: each `(name, body)` pair is borrowed
504/// from the caller's schema, so only the field name is cloned (for the error
505/// path segment) — never the (potentially large) field `SchemaType` or its
506/// metadata. The produced [`ValueError`]s carry the same `.field("name")…`
507/// [`ValuePath`] context as validating the equivalent record type would.
508///
509/// `fields` and `values` must be in matching parameter order; a length
510/// mismatch is reported as a single [`ValueError::RecordArityMismatch`] at the
511/// root, exactly as the record-validation path does.
512pub fn validate_record_fields<'g, 'f, I>(
513    graph: &'g SchemaGraph,
514    fields: I,
515    values: &[SchemaValue],
516) -> Result<(), Vec<ValueError>>
517where
518    I: ExactSizeIterator<Item = (&'f str, &'f SchemaType)>,
519{
520    let mut errors = Vec::new();
521    let mut path = ValuePath::new();
522    let index = GraphIndex::new(graph);
523
524    if fields.len() != values.len() {
525        errors.push(ValueError::RecordArityMismatch {
526            path: path.snapshot(),
527            expected: fields.len(),
528            found: values.len(),
529        });
530        return Err(errors);
531    }
532
533    for ((name, body), v) in fields.zip(values.iter()) {
534        path.push(ValuePathSegment::Field(name.to_string()));
535        check(&index, body, v, &mut path, &mut errors);
536        path.pop();
537    }
538
539    if errors.is_empty() {
540        Ok(())
541    } else {
542        Err(errors)
543    }
544}
545
546fn shape_name(value: &SchemaValue) -> &'static str {
547    match value {
548        SchemaValue::Bool(_) => "bool",
549        SchemaValue::S8(_) => "s8",
550        SchemaValue::S16(_) => "s16",
551        SchemaValue::S32(_) => "s32",
552        SchemaValue::S64(_) => "s64",
553        SchemaValue::U8(_) => "u8",
554        SchemaValue::U16(_) => "u16",
555        SchemaValue::U32(_) => "u32",
556        SchemaValue::U64(_) => "u64",
557        SchemaValue::F32(_) => "f32",
558        SchemaValue::F64(_) => "f64",
559        SchemaValue::Char(_) => "char",
560        SchemaValue::String(_) => "string",
561        SchemaValue::Record { .. } => "record",
562        SchemaValue::Variant(_) => "variant",
563        SchemaValue::Enum { .. } => "enum",
564        SchemaValue::Flags { .. } => "flags",
565        SchemaValue::Tuple { .. } => "tuple",
566        SchemaValue::List { .. } => "list",
567        SchemaValue::FixedList { .. } => "fixed-list",
568        SchemaValue::Map { .. } => "map",
569        SchemaValue::Option { .. } => "option",
570        SchemaValue::Result(_) => "result",
571        SchemaValue::Text(_) => "text",
572        SchemaValue::Binary(_) => "binary",
573        SchemaValue::Path { .. } => "path",
574        SchemaValue::Url { .. } => "url",
575        SchemaValue::Uuid(_) => "uuid",
576        SchemaValue::Datetime { .. } => "datetime",
577        SchemaValue::Duration(_) => "duration",
578        SchemaValue::Quantity(_) => "quantity",
579        SchemaValue::Union(_) => "union",
580        SchemaValue::Secret(_) => "secret",
581        SchemaValue::QuotaToken(_) => "quota-token",
582        SchemaValue::Stream(_) => "stream",
583        SchemaValue::PermissionCard(_) => "permission-card",
584    }
585}
586
587fn type_name(ty: &SchemaType) -> &'static str {
588    match ty {
589        SchemaType::Ref { .. } => "ref",
590        SchemaType::Bool { .. } => "bool",
591        SchemaType::S8 { .. } => "s8",
592        SchemaType::S16 { .. } => "s16",
593        SchemaType::S32 { .. } => "s32",
594        SchemaType::S64 { .. } => "s64",
595        SchemaType::U8 { .. } => "u8",
596        SchemaType::U16 { .. } => "u16",
597        SchemaType::U32 { .. } => "u32",
598        SchemaType::U64 { .. } => "u64",
599        SchemaType::F32 { .. } => "f32",
600        SchemaType::F64 { .. } => "f64",
601        SchemaType::Char { .. } => "char",
602        SchemaType::String { .. } => "string",
603        SchemaType::Record { .. } => "record",
604        SchemaType::Variant { .. } => "variant",
605        SchemaType::Enum { .. } => "enum",
606        SchemaType::Flags { .. } => "flags",
607        SchemaType::Tuple { .. } => "tuple",
608        SchemaType::List { .. } => "list",
609        SchemaType::FixedList { .. } => "fixed-list",
610        SchemaType::Map { .. } => "map",
611        SchemaType::Option { .. } => "option",
612        SchemaType::Result { .. } => "result",
613        SchemaType::Text { .. } => "text",
614        SchemaType::Binary { .. } => "binary",
615        SchemaType::Path { .. } => "path",
616        SchemaType::Url { .. } => "url",
617        SchemaType::Uuid { .. } => "uuid",
618        SchemaType::Datetime { .. } => "datetime",
619        SchemaType::Duration { .. } => "duration",
620        SchemaType::Quantity { .. } => "quantity",
621        SchemaType::Union { .. } => "union",
622        SchemaType::Secret { .. } => "secret",
623        SchemaType::QuotaToken { .. } => "quota-token",
624        SchemaType::PermissionCard { .. } => "permission-card",
625        SchemaType::Future { .. } => "future",
626        SchemaType::Stream { .. } => "stream",
627    }
628}
629
630fn shape_mismatch(
631    path: &ValuePath,
632    ty: &SchemaType,
633    value: &SchemaValue,
634    errors: &mut Vec<ValueError>,
635) {
636    errors.push(ValueError::ShapeMismatch {
637        path: path.snapshot(),
638        expected: type_name(ty).to_string(),
639        found: shape_name(value).to_string(),
640    });
641}
642
643/// Resolve a chain of [`SchemaType::Ref`]s at the *current* value node into
644/// a non-`Ref` schema. Cycle protection is local to this resolution loop
645/// so it only guards pure alias cycles like `A = Ref<A>` or
646/// `A = Ref<B>, B = Ref<A>` that can never reach a structural shape.
647/// Recursive types that go through a value-shrinking constructor (record,
648/// list, option, …) are fully validated at every level because each
649/// value-tree step gets a fresh resolution scope.
650///
651/// Returns `Some(ty)` with a non-`Ref` schema on success, or `None` after
652/// pushing a [`ValueError::DanglingRef`] or [`ValueError::RecursiveRef`]
653/// on failure.
654fn resolve_refs_at_value_node<'a>(
655    index: &GraphIndex<'a>,
656    mut ty: &'a SchemaType,
657    path: &ValuePath,
658    errors: &mut Vec<ValueError>,
659) -> Option<&'a SchemaType> {
660    // A pure alias chain (`Ref -> Ref -> …`) at a single value position can
661    // visit each named definition at most once before it must revisit one and
662    // therefore cycle. Bounding the hop count by the number of definitions lets
663    // us detect such cycles without allocating a `HashSet` or cloning the
664    // (`String`-backed) `TypeId` on the common, allocation-free happy path.
665    let max_hops = index.defs_len();
666    let mut hops = 0usize;
667    loop {
668        match ty {
669            SchemaType::Ref { id, .. } => {
670                if hops > max_hops {
671                    errors.push(ValueError::RecursiveRef {
672                        path: path.snapshot(),
673                        type_id: id.clone(),
674                    });
675                    return None;
676                }
677                hops += 1;
678                match index.lookup(id) {
679                    Some(def) => {
680                        ty = &def.body;
681                    }
682                    None => {
683                        errors.push(ValueError::DanglingRef {
684                            path: path.snapshot(),
685                            type_id: id.clone(),
686                        });
687                        return None;
688                    }
689                }
690            }
691            other => return Some(other),
692        }
693    }
694}
695
696fn check<'a>(
697    index: &GraphIndex<'a>,
698    ty: &'a SchemaType,
699    value: &SchemaValue,
700    path: &mut ValuePath,
701    errors: &mut Vec<ValueError>,
702) {
703    // Resolve any chain of named refs at this exact value position. The
704    // resolution scope is local to this call: as soon as we descend into
705    // a value-shrinking constructor (record field, list element, etc.)
706    // a fresh scope is established, so recursive types backed by finite
707    // values are validated at every level.
708    let Some(ty) = resolve_refs_at_value_node(index, ty, path, errors) else {
709        return;
710    };
711
712    match (ty, value) {
713        (SchemaType::Bool { .. }, SchemaValue::Bool(_)) => {}
714        (SchemaType::S8 { restrictions, .. }, SchemaValue::S8(v)) => {
715            check_numeric(
716                NumericRepr::S8,
717                restrictions,
718                NumericBound::Signed(*v as i64),
719                path,
720                errors,
721            );
722        }
723        (SchemaType::S16 { restrictions, .. }, SchemaValue::S16(v)) => {
724            check_numeric(
725                NumericRepr::S16,
726                restrictions,
727                NumericBound::Signed(*v as i64),
728                path,
729                errors,
730            );
731        }
732        (SchemaType::S32 { restrictions, .. }, SchemaValue::S32(v)) => {
733            check_numeric(
734                NumericRepr::S32,
735                restrictions,
736                NumericBound::Signed(*v as i64),
737                path,
738                errors,
739            );
740        }
741        (SchemaType::S64 { restrictions, .. }, SchemaValue::S64(v)) => {
742            check_numeric(
743                NumericRepr::S64,
744                restrictions,
745                NumericBound::Signed(*v),
746                path,
747                errors,
748            );
749        }
750        (SchemaType::U8 { restrictions, .. }, SchemaValue::U8(v)) => {
751            check_numeric(
752                NumericRepr::U8,
753                restrictions,
754                NumericBound::Unsigned(*v as u64),
755                path,
756                errors,
757            );
758        }
759        (SchemaType::U16 { restrictions, .. }, SchemaValue::U16(v)) => {
760            check_numeric(
761                NumericRepr::U16,
762                restrictions,
763                NumericBound::Unsigned(*v as u64),
764                path,
765                errors,
766            );
767        }
768        (SchemaType::U32 { restrictions, .. }, SchemaValue::U32(v)) => {
769            check_numeric(
770                NumericRepr::U32,
771                restrictions,
772                NumericBound::Unsigned(*v as u64),
773                path,
774                errors,
775            );
776        }
777        (SchemaType::U64 { restrictions, .. }, SchemaValue::U64(v)) => {
778            check_numeric(
779                NumericRepr::U64,
780                restrictions,
781                NumericBound::Unsigned(*v),
782                path,
783                errors,
784            );
785        }
786        (SchemaType::F32 { restrictions, .. }, SchemaValue::F32(v)) => {
787            check_numeric(
788                NumericRepr::F32,
789                restrictions,
790                NumericBound::FloatBits((*v as f64).to_bits()),
791                path,
792                errors,
793            );
794        }
795        (SchemaType::F64 { restrictions, .. }, SchemaValue::F64(v)) => {
796            check_numeric(
797                NumericRepr::F64,
798                restrictions,
799                NumericBound::FloatBits(v.to_bits()),
800                path,
801                errors,
802            );
803        }
804        (SchemaType::Char { .. }, SchemaValue::Char(_)) => {}
805        (SchemaType::String { .. }, SchemaValue::String(_)) => {}
806
807        (SchemaType::Text { restrictions, .. }, SchemaValue::Text(payload)) => {
808            check_text(restrictions, payload, path, errors);
809        }
810        (SchemaType::Binary { restrictions, .. }, SchemaValue::Binary(payload)) => {
811            check_binary(restrictions, payload, path, errors);
812        }
813        (SchemaType::Path { spec, .. }, SchemaValue::Path { path: p }) => {
814            check_path(spec, p.as_str(), path, errors);
815        }
816        (SchemaType::Url { restrictions, .. }, SchemaValue::Url { url }) => {
817            check_url(restrictions, url.as_str(), path, errors);
818        }
819        (SchemaType::Uuid { .. }, SchemaValue::Uuid(_)) => {}
820        (SchemaType::Datetime { .. }, SchemaValue::Datetime { .. }) => {}
821        (SchemaType::Duration { .. }, SchemaValue::Duration(_)) => {}
822        (SchemaType::Quantity { spec, .. }, SchemaValue::Quantity(value)) => {
823            check_quantity(spec, value, path, errors);
824        }
825        (SchemaType::Secret { spec, .. }, SchemaValue::Secret(payload)) => {
826            #[cfg(not(all(feature = "guest", not(feature = "host"))))]
827            check_secret(spec, payload, path, errors);
828            // On a guest the secret value is an opaque owned handle with no
829            // readable fields; category constraints are enforced host-side when
830            // the handle is lifted to its trusted snapshot.
831            #[cfg(all(feature = "guest", not(feature = "host")))]
832            let _ = (spec, payload);
833        }
834        (SchemaType::QuotaToken { spec, .. }, SchemaValue::QuotaToken(payload)) => {
835            #[cfg(not(all(feature = "guest", not(feature = "host"))))]
836            check_quota_token(spec, payload, path, errors);
837            // On a guest the quota-token value is an opaque owned handle with no
838            // readable fields; the resource-name constraint is enforced
839            // host-side when the handle is lifted to its trusted snapshot.
840            #[cfg(all(feature = "guest", not(feature = "host")))]
841            let _ = (spec, payload);
842        }
843        (SchemaType::Stream { .. }, SchemaValue::Stream(_)) => {}
844        (SchemaType::PermissionCard { spec, .. }, SchemaValue::PermissionCard(payload)) => {
845            #[cfg(not(all(feature = "guest", not(feature = "host"))))]
846            check_permission_card(spec, payload, path, errors);
847            // On a guest the permission-card value is an opaque owned handle
848            // with no readable fields; the polymorphic constraint is enforced
849            // host-side when the handle is lifted to its trusted snapshot.
850            #[cfg(all(feature = "guest", not(feature = "host")))]
851            let _ = (spec, payload);
852        }
853
854        (SchemaType::Record { fields, .. }, SchemaValue::Record { fields: vs }) => {
855            if fields.len() != vs.len() {
856                errors.push(ValueError::RecordArityMismatch {
857                    path: path.snapshot(),
858                    expected: fields.len(),
859                    found: vs.len(),
860                });
861                return;
862            }
863            for (field, v) in fields.iter().zip(vs.iter()) {
864                path.push(ValuePathSegment::Field(field.name.clone()));
865                check(index, &field.body, v, path, errors);
866                path.pop();
867            }
868        }
869
870        (SchemaType::Variant { cases, .. }, SchemaValue::Variant(vp)) => {
871            let idx = vp.case as usize;
872            if idx >= cases.len() {
873                errors.push(ValueError::VariantCaseOutOfRange {
874                    path: path.snapshot(),
875                    case: vp.case,
876                    case_count: cases.len(),
877                });
878                return;
879            }
880            let case = &cases[idx];
881            match (&case.payload, &vp.payload) {
882                (Some(case_ty), Some(payload)) => {
883                    path.push(ValuePathSegment::VariantPayload);
884                    check(index, case_ty, payload, path, errors);
885                    path.pop();
886                }
887                (None, None) => {}
888                (Some(_), None) => errors.push(ValueError::VariantPayloadPresenceMismatch {
889                    path: path.snapshot(),
890                    expected_some: true,
891                }),
892                (None, Some(_)) => errors.push(ValueError::VariantPayloadPresenceMismatch {
893                    path: path.snapshot(),
894                    expected_some: false,
895                }),
896            }
897        }
898
899        (SchemaType::Enum { cases, .. }, SchemaValue::Enum { case }) => {
900            if (*case as usize) >= cases.len() {
901                errors.push(ValueError::EnumCaseOutOfRange {
902                    path: path.snapshot(),
903                    case: *case,
904                    case_count: cases.len(),
905                });
906            }
907        }
908
909        (SchemaType::Flags { flags, .. }, SchemaValue::Flags { bits }) => {
910            if flags.len() != bits.len() {
911                errors.push(ValueError::FlagsArityMismatch {
912                    path: path.snapshot(),
913                    expected: flags.len(),
914                    found: bits.len(),
915                });
916            }
917        }
918
919        (SchemaType::Tuple { elements, .. }, SchemaValue::Tuple { elements: vs }) => {
920            if elements.len() != vs.len() {
921                errors.push(ValueError::TupleArityMismatch {
922                    path: path.snapshot(),
923                    expected: elements.len(),
924                    found: vs.len(),
925                });
926                return;
927            }
928            for (i, (t, v)) in elements.iter().zip(vs.iter()).enumerate() {
929                path.push(ValuePathSegment::Index(i));
930                check(index, t, v, path, errors);
931                path.pop();
932            }
933        }
934
935        (SchemaType::List { element, .. }, SchemaValue::List { elements }) => {
936            for (i, v) in elements.iter().enumerate() {
937                path.push(ValuePathSegment::Index(i));
938                check(index, element, v, path, errors);
939                path.pop();
940            }
941        }
942
943        (
944            SchemaType::FixedList {
945                element, length, ..
946            },
947            SchemaValue::FixedList { elements },
948        ) => {
949            if elements.len() != *length as usize {
950                errors.push(ValueError::FixedListLengthMismatch {
951                    path: path.snapshot(),
952                    expected: *length,
953                    found: elements.len(),
954                });
955                return;
956            }
957            for (i, v) in elements.iter().enumerate() {
958                path.push(ValuePathSegment::Index(i));
959                check(index, element, v, path, errors);
960                path.pop();
961            }
962        }
963
964        (
965            SchemaType::Map {
966                key, value: vty, ..
967            },
968            SchemaValue::Map { entries },
969        ) => {
970            for (i, (k, v)) in entries.iter().enumerate() {
971                path.push(ValuePathSegment::MapKey(i));
972                check(index, key, k, path, errors);
973                path.pop();
974                path.push(ValuePathSegment::MapValue(i));
975                check(index, vty, v, path, errors);
976                path.pop();
977            }
978        }
979
980        (SchemaType::Option { inner, .. }, SchemaValue::Option { inner: v }) => {
981            if let Some(v) = v {
982                path.push(ValuePathSegment::OptionInner);
983                check(index, inner, v, path, errors);
984                path.pop();
985            }
986        }
987
988        (SchemaType::Result { spec, .. }, SchemaValue::Result(vp)) => match vp {
989            ResultValuePayload::Ok { value: v } => match (&spec.ok, v) {
990                (Some(t), Some(v)) => {
991                    path.push(ValuePathSegment::ResultOk);
992                    check(index, t, v, path, errors);
993                    path.pop();
994                }
995                (None, None) => {}
996                (Some(_), None) => errors.push(ValueError::ResultPayloadPresenceMismatch {
997                    path: path.snapshot(),
998                    expected_some: true,
999                    side: ResultSide::Ok,
1000                }),
1001                (None, Some(_)) => errors.push(ValueError::ResultPayloadPresenceMismatch {
1002                    path: path.snapshot(),
1003                    expected_some: false,
1004                    side: ResultSide::Ok,
1005                }),
1006            },
1007            ResultValuePayload::Err { value: v } => match (&spec.err, v) {
1008                (Some(t), Some(v)) => {
1009                    path.push(ValuePathSegment::ResultErr);
1010                    check(index, t, v, path, errors);
1011                    path.pop();
1012                }
1013                (None, None) => {}
1014                (Some(_), None) => errors.push(ValueError::ResultPayloadPresenceMismatch {
1015                    path: path.snapshot(),
1016                    expected_some: true,
1017                    side: ResultSide::Err,
1018                }),
1019                (None, Some(_)) => errors.push(ValueError::ResultPayloadPresenceMismatch {
1020                    path: path.snapshot(),
1021                    expected_some: false,
1022                    side: ResultSide::Err,
1023                }),
1024            },
1025        },
1026
1027        (SchemaType::Union { spec, .. }, SchemaValue::Union(vp)) => {
1028            let branch = spec.branches.iter().find(|b| b.tag == vp.tag);
1029            match branch {
1030                None => errors.push(ValueError::UnionUnknownTag {
1031                    path: path.snapshot(),
1032                    tag: vp.tag.clone(),
1033                }),
1034                Some(branch) => {
1035                    path.push(ValuePathSegment::UnionBody);
1036                    #[cfg(not(feature = "regex"))]
1037                    if matches!(branch.discriminator, DiscriminatorRule::Regex { .. }) {
1038                        errors.push(ValueError::UnsupportedFeature {
1039                            path: path.snapshot(),
1040                            feature: "regex",
1041                        });
1042                        path.pop();
1043                        return;
1044                    }
1045                    let mut sub_errors = Vec::new();
1046                    check(index, &branch.body, &vp.body, path, &mut sub_errors);
1047                    errors.extend(sub_errors);
1048                    if !discriminator_matches(index, branch, &vp.body) {
1049                        errors.push(ValueError::UnionDiscriminatorMismatch {
1050                            path: path.snapshot(),
1051                            tag: vp.tag.clone(),
1052                        });
1053                    }
1054                    path.pop();
1055                }
1056            }
1057        }
1058
1059        (ty, v) => shape_mismatch(path, ty, v, errors),
1060    }
1061}
1062
1063/// Validate a numeric value against the node's optional restrictions.
1064///
1065/// Only the value range is checked here (the `unit` is schema-level metadata,
1066/// never carried by a `SchemaValue`). A malformed restriction set is a
1067/// well-formedness concern reported elsewhere, so when the stored restrictions
1068/// are not well-formed for the repr this check is skipped rather than producing
1069/// a misleading value error. `value` is the numeric value reinterpreted as a
1070/// same-family `NumericBound` (floats widened to `f64` bits).
1071fn check_numeric(
1072    repr: NumericRepr,
1073    restrictions: &Option<NumericRestrictions>,
1074    value: NumericBound,
1075    path: &mut ValuePath,
1076    errors: &mut Vec<ValueError>,
1077) {
1078    let Some(restrictions) = restrictions else {
1079        return;
1080    };
1081    // Skip value-level range checking only when the bounds are incomparable to
1082    // this repr (family/range/float mismatches), since comparing against them
1083    // would produce misleading errors. An inverted `min > max` is still a pair
1084    // of comparable bounds, so it is enforced here (the constraint is simply
1085    // unsatisfiable), consistent with the text/binary range validators.
1086    if let Err(err) = restrictions.validate_for_repr(repr)
1087        && !matches!(err, NumericRestrictionError::MinGreaterThanMax)
1088    {
1089        return;
1090    }
1091    if let Some(min) = restrictions.min
1092        && !matches!(
1093            numeric_bound_cmp(value, min),
1094            Some(Ordering::Greater | Ordering::Equal)
1095        )
1096    {
1097        errors.push(ValueError::NumericOutOfRange {
1098            path: path.snapshot(),
1099            reason: format!("below minimum {}", describe_numeric_bound(min)),
1100        });
1101    }
1102    if let Some(max) = restrictions.max
1103        && !matches!(
1104            numeric_bound_cmp(value, max),
1105            Some(Ordering::Less | Ordering::Equal)
1106        )
1107    {
1108        errors.push(ValueError::NumericOutOfRange {
1109            path: path.snapshot(),
1110            reason: format!("above maximum {}", describe_numeric_bound(max)),
1111        });
1112    }
1113}
1114
1115fn describe_numeric_bound(bound: NumericBound) -> String {
1116    match bound {
1117        NumericBound::Signed(v) => v.to_string(),
1118        NumericBound::Unsigned(v) => v.to_string(),
1119        NumericBound::FloatBits(bits) => f64::from_bits(bits).to_string(),
1120    }
1121}
1122
1123fn check_text(
1124    restrictions: &TextRestrictions,
1125    payload: &TextValuePayload,
1126    path: &mut ValuePath,
1127    errors: &mut Vec<ValueError>,
1128) {
1129    if let (Some(allowed), Some(lang)) = (&restrictions.languages, &payload.language)
1130        && !allowed.iter().any(|a| a == lang)
1131    {
1132        errors.push(ValueError::TextLanguageNotAllowed {
1133            path: path.snapshot(),
1134            language: lang.clone(),
1135        });
1136    }
1137    let char_len = payload.text.chars().count();
1138    if let Some(min) = restrictions.min_length
1139        && (char_len as u64) < (min as u64)
1140    {
1141        errors.push(ValueError::TextTooShort {
1142            path: path.snapshot(),
1143            min,
1144            found: char_len,
1145        });
1146    }
1147    if let Some(max) = restrictions.max_length
1148        && (char_len as u64) > (max as u64)
1149    {
1150        errors.push(ValueError::TextTooLong {
1151            path: path.snapshot(),
1152            max,
1153            found: char_len,
1154        });
1155    }
1156    #[cfg(not(feature = "regex"))]
1157    if restrictions.regex.is_some() {
1158        errors.push(ValueError::UnsupportedFeature {
1159            path: path.snapshot(),
1160            feature: "regex",
1161        });
1162    }
1163    #[cfg(feature = "regex")]
1164    if let Some(regex) = &restrictions.regex
1165        && let Ok(compiled) = regex::Regex::new(regex.as_str())
1166        && !compiled.is_match(payload.text.as_str())
1167    {
1168        errors.push(ValueError::TextRegexMismatch {
1169            path: path.snapshot(),
1170            regex: regex.clone(),
1171        });
1172    }
1173}
1174
1175fn check_binary(
1176    restrictions: &BinaryRestrictions,
1177    payload: &BinaryValuePayload,
1178    path: &mut ValuePath,
1179    errors: &mut Vec<ValueError>,
1180) {
1181    if let (Some(allowed), Some(mime)) = (&restrictions.mime_types, &payload.mime_type)
1182        && !allowed.iter().any(|a| a == mime)
1183    {
1184        errors.push(ValueError::BinaryMimeNotAllowed {
1185            path: path.snapshot(),
1186            mime_type: mime.clone(),
1187        });
1188    }
1189    let len = payload.bytes.len();
1190    if let Some(min) = restrictions.min_bytes
1191        && (len as u64) < (min as u64)
1192    {
1193        errors.push(ValueError::BinaryTooSmall {
1194            path: path.snapshot(),
1195            min,
1196            found: len,
1197        });
1198    }
1199    if let Some(max) = restrictions.max_bytes
1200        && (len as u64) > (max as u64)
1201    {
1202        errors.push(ValueError::BinaryTooLarge {
1203            path: path.snapshot(),
1204            max,
1205            found: len,
1206        });
1207    }
1208}
1209
1210fn check_path(spec: &PathSpec, p: &str, path: &mut ValuePath, errors: &mut Vec<ValueError>) {
1211    if p.is_empty() {
1212        errors.push(ValueError::PathEmpty {
1213            path: path.snapshot(),
1214        });
1215        return;
1216    }
1217    if let Some(allowed_exts) = &spec.allowed_extensions
1218        && let Some(ext) = file_extension(p)
1219        && !allowed_exts.iter().any(|a| a == ext)
1220    {
1221        errors.push(ValueError::PathExtensionNotAllowed {
1222            path: path.snapshot(),
1223            extension: ext.to_string(),
1224        });
1225    }
1226    // `allowed_mime_types` on `Path` is not enforceable by value validation
1227    // because `SchemaValue::Path` carries only a path string. MIME
1228    // restrictions must be enforced at protocol/adaptation layers that
1229    // carry or derive MIME metadata (e.g. an HTTP `Content-Type` header,
1230    // a multimodal envelope, a content-typed canonical-encoding layer).
1231    // The validator must not read the filesystem or sniff content.
1232}
1233
1234#[cfg(not(feature = "url"))]
1235fn check_url(
1236    _spec: &UrlRestrictions,
1237    _url: &str,
1238    path: &mut ValuePath,
1239    errors: &mut Vec<ValueError>,
1240) {
1241    errors.push(ValueError::UnsupportedFeature {
1242        path: path.snapshot(),
1243        feature: "url",
1244    });
1245}
1246
1247#[cfg(feature = "url")]
1248fn check_url(
1249    spec: &UrlRestrictions,
1250    url: &str,
1251    path: &mut ValuePath,
1252    errors: &mut Vec<ValueError>,
1253) {
1254    if url.is_empty() {
1255        errors.push(ValueError::UrlEmpty {
1256            path: path.snapshot(),
1257        });
1258        return;
1259    }
1260    let parsed = match url::Url::parse(url) {
1261        Ok(parsed) => parsed,
1262        Err(err) => {
1263            errors.push(ValueError::UrlInvalid {
1264                path: path.snapshot(),
1265                url: url.to_string(),
1266                reason: err.to_string(),
1267            });
1268            return;
1269        }
1270    };
1271    if let Some(allowed) = &spec.allowed_schemes
1272        && !allowed
1273            .iter()
1274            .any(|scheme| scheme.eq_ignore_ascii_case(parsed.scheme()))
1275    {
1276        errors.push(ValueError::UrlSchemeNotAllowed {
1277            path: path.snapshot(),
1278            scheme: parsed.scheme().to_string(),
1279        });
1280    }
1281    if let Some(allowed) = &spec.allowed_hosts {
1282        match parsed.host_str() {
1283            Some(host)
1284                if allowed
1285                    .iter()
1286                    .any(|allowed_host| allowed_host.eq_ignore_ascii_case(host)) => {}
1287            Some(host) => errors.push(ValueError::UrlHostNotAllowed {
1288                path: path.snapshot(),
1289                host: host.to_string(),
1290            }),
1291            None => errors.push(ValueError::UrlHostMissing {
1292                path: path.snapshot(),
1293            }),
1294        }
1295    }
1296}
1297
1298fn check_quantity(
1299    spec: &QuantitySpec,
1300    value: &QuantityValue,
1301    path: &mut ValuePath,
1302    errors: &mut Vec<ValueError>,
1303) {
1304    let unit_ok = if spec.allowed_suffixes.is_empty() {
1305        value.unit == spec.base_unit
1306    } else {
1307        spec.allowed_suffixes.iter().any(|s| s == &value.unit)
1308    };
1309    if !unit_ok {
1310        errors.push(ValueError::QuantityUnitNotAllowed {
1311            path: path.snapshot(),
1312            unit: value.unit.clone(),
1313        });
1314        // Range checks below assume canonical unit comparison; bail when the
1315        // unit is unrecognised.
1316        return;
1317    }
1318
1319    if let Some(min) = &spec.min {
1320        match quantity_le_checked(min, value) {
1321            Some(true) => {}
1322            Some(false) => errors.push(ValueError::QuantityOutOfRange {
1323                path: path.snapshot(),
1324                reason: format!("value is below min `{}`", render_quantity(min)),
1325            }),
1326            None => errors.push(ValueError::QuantityOutOfRange {
1327                path: path.snapshot(),
1328                reason: "overflow".to_string(),
1329            }),
1330        }
1331    }
1332    if let Some(max) = &spec.max {
1333        match quantity_le_checked(value, max) {
1334            Some(true) => {}
1335            Some(false) => errors.push(ValueError::QuantityOutOfRange {
1336                path: path.snapshot(),
1337                reason: format!("value is above max `{}`", render_quantity(max)),
1338            }),
1339            None => errors.push(ValueError::QuantityOutOfRange {
1340                path: path.snapshot(),
1341                reason: "overflow".to_string(),
1342            }),
1343        }
1344    }
1345}
1346
1347#[cfg(not(all(feature = "guest", not(feature = "host"))))]
1348fn check_secret(
1349    spec: &SecretSpec,
1350    payload: &SecretValuePayload,
1351    path: &mut ValuePath,
1352    errors: &mut Vec<ValueError>,
1353) {
1354    if let Some(expected) = &spec.category
1355        && payload.category.as_ref() != Some(expected)
1356    {
1357        errors.push(ValueError::SecretCategoryMismatch {
1358            path: path.snapshot(),
1359            expected: expected.clone(),
1360            found: payload.category.clone(),
1361        });
1362    }
1363}
1364
1365#[cfg(not(all(feature = "guest", not(feature = "host"))))]
1366fn check_quota_token(
1367    spec: &QuotaTokenSpec,
1368    payload: &QuotaTokenValuePayload,
1369    path: &mut ValuePath,
1370    errors: &mut Vec<ValueError>,
1371) {
1372    if let Some(expected) = &spec.resource_name
1373        && expected != &payload.resource_name
1374    {
1375        errors.push(ValueError::QuotaTokenResourceMismatch {
1376            path: path.snapshot(),
1377            expected: expected.clone(),
1378            found: payload.resource_name.clone(),
1379        });
1380    }
1381}
1382
1383#[cfg(not(all(feature = "guest", not(feature = "host"))))]
1384fn check_permission_card(
1385    spec: &PermissionCardSpec,
1386    payload: &PermissionCardValuePayload,
1387    path: &mut ValuePath,
1388    errors: &mut Vec<ValueError>,
1389) {
1390    if spec.polymorphic != payload.polymorphic {
1391        errors.push(ValueError::PermissionCardPolymorphicMismatch {
1392            path: path.snapshot(),
1393            expected: spec.polymorphic,
1394            found: payload.polymorphic,
1395        });
1396    }
1397}
1398
1399fn discriminator_matches(index: &GraphIndex, branch: &UnionBranch, body: &SchemaValue) -> bool {
1400    match &branch.discriminator {
1401        DiscriminatorRule::Prefix { prefix } => string_view(index, &branch.body, body)
1402            .map(|s| s.starts_with(prefix.as_str()))
1403            .unwrap_or(false),
1404        DiscriminatorRule::Suffix { suffix } => string_view(index, &branch.body, body)
1405            .map(|s| s.ends_with(suffix.as_str()))
1406            .unwrap_or(false),
1407        DiscriminatorRule::Contains { substring } => string_view(index, &branch.body, body)
1408            .map(|s| s.contains(substring.as_str()))
1409            .unwrap_or(false),
1410        #[cfg(not(feature = "regex"))]
1411        DiscriminatorRule::Regex { .. } => false,
1412        #[cfg(feature = "regex")]
1413        DiscriminatorRule::Regex { regex } => {
1414            let Some(s) = string_view(index, &branch.body, body) else {
1415                return false;
1416            };
1417            match regex::Regex::new(regex.as_str()) {
1418                Ok(compiled) => compiled.is_match(s),
1419                Err(_) => false,
1420            }
1421        }
1422        DiscriminatorRule::FieldEquals(field_disc) => {
1423            let Some(record) = record_view(index, &branch.body, body) else {
1424                return false;
1425            };
1426            let pos = record
1427                .field_names
1428                .iter()
1429                .position(|n| n == &field_disc.field_name);
1430            let Some(pos) = pos else {
1431                return false;
1432            };
1433            match &field_disc.literal {
1434                None => true,
1435                Some(lit) => {
1436                    let v = &record.values[pos];
1437                    matches!(v, SchemaValue::String(s) if s == lit)
1438                        || matches!(v, SchemaValue::Text(t) if &t.text == lit)
1439                        || matches!(v, SchemaValue::Url { url } if url == lit)
1440                        || matches!(v, SchemaValue::Path { path } if path == lit)
1441                }
1442            }
1443        }
1444        DiscriminatorRule::FieldAbsent { field_name } => {
1445            let Some(record) = record_view(index, &branch.body, body) else {
1446                return false;
1447            };
1448            !record.field_names.iter().any(|n| n == field_name)
1449        }
1450    }
1451}
1452
1453fn string_view<'a>(index: &GraphIndex, ty: &SchemaType, value: &'a SchemaValue) -> Option<&'a str> {
1454    match (resolve(index, ty), value) {
1455        (Some(_), SchemaValue::String(s)) => Some(s.as_str()),
1456        (Some(_), SchemaValue::Text(t)) => Some(t.text.as_str()),
1457        (Some(_), SchemaValue::Url { url }) => Some(url.as_str()),
1458        (Some(_), SchemaValue::Path { path }) => Some(path.as_str()),
1459        _ => None,
1460    }
1461}
1462
1463struct RecordView<'a> {
1464    field_names: Vec<String>,
1465    values: &'a [SchemaValue],
1466}
1467
1468fn record_view<'a>(
1469    index: &GraphIndex,
1470    ty: &SchemaType,
1471    value: &'a SchemaValue,
1472) -> Option<RecordView<'a>> {
1473    let resolved = resolve_record(index, ty)?;
1474    match value {
1475        SchemaValue::Record { fields } if fields.len() == resolved.len() => Some(RecordView {
1476            field_names: resolved,
1477            values: fields.as_slice(),
1478        }),
1479        _ => None,
1480    }
1481}
1482
1483fn resolve<'a>(index: &GraphIndex<'a>, ty: &'a SchemaType) -> Option<&'a SchemaType> {
1484    let mut current = ty;
1485    // Bounded by the number of named defs: a pure alias chain can visit each
1486    // def at most once before it must revisit one and cycle (see
1487    // `resolve_refs_at_value_node`). This avoids per-node allocation/cloning.
1488    let max_hops = index.defs_len();
1489    let mut hops = 0usize;
1490    loop {
1491        match current {
1492            SchemaType::Ref { id, .. } => {
1493                if hops > max_hops {
1494                    return None;
1495                }
1496                hops += 1;
1497                {
1498                    let def = index.lookup(id)?;
1499                    current = &def.body
1500                }
1501            }
1502            other => return Some(other),
1503        }
1504    }
1505}
1506
1507fn resolve_record(index: &GraphIndex, ty: &SchemaType) -> Option<Vec<String>> {
1508    let resolved = resolve(index, ty)?;
1509    match resolved {
1510        SchemaType::Record { fields, .. } => Some(fields.iter().map(|f| f.name.clone()).collect()),
1511        _ => None,
1512    }
1513}
1514
1515fn file_extension(p: &str) -> Option<&str> {
1516    let name = p.rsplit('/').next()?;
1517    let (_, ext) = name.rsplit_once('.')?;
1518    if ext.is_empty() { None } else { Some(ext) }
1519}
1520
1521fn render_quantity(q: &QuantityValue) -> String {
1522    format!("{}*10^(-{}) {}", q.mantissa, q.scale, q.unit)
1523}
1524
1525fn quantity_le_checked(a: &QuantityValue, b: &QuantityValue) -> Option<bool> {
1526    let common = a.scale.max(b.scale);
1527    let a_shift = (common - a.scale).max(0) as u32;
1528    let b_shift = (common - b.scale).max(0) as u32;
1529    let ten: i128 = 10;
1530    let a_factor = ten.checked_pow(a_shift)?;
1531    let b_factor = ten.checked_pow(b_shift)?;
1532    let a_canon = (a.mantissa as i128).checked_mul(a_factor)?;
1533    let b_canon = (b.mantissa as i128).checked_mul(b_factor)?;
1534    Some(a_canon <= b_canon)
1535}