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
480struct ValidationContext<'a> {
481    index: GraphIndex<'a>,
482    #[cfg(feature = "regex")]
483    regexes: std::collections::HashMap<&'a str, Option<regex::Regex>>,
484}
485
486impl<'a> ValidationContext<'a> {
487    fn new(graph: &'a SchemaGraph) -> Self {
488        Self {
489            index: GraphIndex::new(graph),
490            #[cfg(feature = "regex")]
491            regexes: std::collections::HashMap::new(),
492        }
493    }
494
495    fn prepare(&mut self, ty: &'a SchemaType) {
496        #[cfg(feature = "regex")]
497        self.prepare_regexes(ty, &mut std::collections::HashSet::new());
498        #[cfg(not(feature = "regex"))]
499        let _ = ty;
500    }
501
502    #[cfg(feature = "regex")]
503    fn prepare_regexes(
504        &mut self,
505        mut ty: &'a SchemaType,
506        visited: &mut std::collections::HashSet<&'a TypeId>,
507    ) {
508        while let SchemaType::Ref { id, .. } = ty {
509            if !visited.insert(id) {
510                return;
511            }
512            let Some(def) = self.index.lookup(id) else {
513                return;
514            };
515            ty = &def.body;
516        }
517        match ty {
518            SchemaType::Record { fields, .. } => {
519                for field in fields {
520                    self.prepare_regexes(&field.body, visited);
521                }
522            }
523            SchemaType::Variant { cases, .. } => {
524                for case in cases {
525                    if let Some(ty) = &case.payload {
526                        self.prepare_regexes(ty, visited);
527                    }
528                }
529            }
530            SchemaType::Tuple { elements, .. } => {
531                for ty in elements {
532                    self.prepare_regexes(ty, visited);
533                }
534            }
535            SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => {
536                self.prepare_regexes(element, visited)
537            }
538            SchemaType::Map { key, value, .. } => {
539                self.prepare_regexes(key, visited);
540                self.prepare_regexes(value, visited);
541            }
542            SchemaType::Option { inner, .. } => self.prepare_regexes(inner, visited),
543            SchemaType::Result { spec, .. } => {
544                for ty in [spec.ok.as_deref(), spec.err.as_deref()]
545                    .into_iter()
546                    .flatten()
547                {
548                    self.prepare_regexes(ty, visited);
549                }
550            }
551            SchemaType::Text { restrictions, .. } => {
552                if let Some(pattern) = &restrictions.regex {
553                    self.prepare_regex(pattern);
554                }
555            }
556            SchemaType::Union { spec, .. } => {
557                for branch in &spec.branches {
558                    self.prepare_regexes(&branch.body, visited);
559                    if let DiscriminatorRule::Regex { regex } = &branch.discriminator {
560                        self.prepare_regex(regex);
561                    }
562                }
563            }
564            _ => {}
565        }
566    }
567
568    #[cfg(feature = "regex")]
569    fn prepare_regex(&mut self, pattern: &'a str) {
570        self.regexes
571            .entry(pattern)
572            .or_insert_with(|| regex::Regex::new(pattern).ok());
573    }
574}
575
576impl<'a> std::ops::Deref for ValidationContext<'a> {
577    type Target = GraphIndex<'a>;
578    fn deref(&self) -> &Self::Target {
579        &self.index
580    }
581}
582
583/// Immutable validation preparation bound to one borrowed graph and root type.
584/// Each validation uses fresh error and path state; no invocation state is cached.
585pub struct PreparedValueValidator<'a> {
586    context: ValidationContext<'a>,
587    ty: &'a SchemaType,
588}
589
590impl<'a> PreparedValueValidator<'a> {
591    pub fn new(graph: &'a SchemaGraph, ty: &'a SchemaType) -> Self {
592        let mut context = ValidationContext::new(graph);
593        context.prepare(ty);
594        Self { context, ty }
595    }
596
597    pub fn validate(&self, value: &SchemaValue) -> Result<(), Vec<ValueError>> {
598        let mut errors = Vec::new();
599        let mut path = ValuePath::new();
600        check(&self.context, self.ty, value, &mut path, &mut errors);
601        if errors.is_empty() {
602            Ok(())
603        } else {
604            Err(errors)
605        }
606    }
607}
608
609/// Validate that `value` structurally conforms to `ty` (in the context of
610/// `graph`).
611pub fn validate_value(
612    graph: &SchemaGraph,
613    ty: &SchemaType,
614    value: &SchemaValue,
615) -> Result<(), Vec<ValueError>> {
616    PreparedValueValidator::new(graph, ty).validate(value)
617}
618
619/// Validate the field values of a record against a set of named field types
620/// defined in `graph`, **without** materialising a temporary record
621/// [`SchemaType`].
622///
623/// This is the allocation-light counterpart of validating a `Record` value
624/// against `SchemaType::record(fields)`: each `(name, body)` pair is borrowed
625/// from the caller's schema, so only the field name is cloned (for the error
626/// path segment) — never the (potentially large) field `SchemaType` or its
627/// metadata. The produced [`ValueError`]s carry the same `.field("name")…`
628/// [`ValuePath`] context as validating the equivalent record type would.
629///
630/// `fields` and `values` must be in matching parameter order; a length
631/// mismatch is reported as a single [`ValueError::RecordArityMismatch`] at the
632/// root, exactly as the record-validation path does.
633pub fn validate_record_fields<'g, 'f, I>(
634    graph: &'g SchemaGraph,
635    fields: I,
636    values: &[SchemaValue],
637) -> Result<(), Vec<ValueError>>
638where
639    I: Iterator<Item = (&'f str, &'f SchemaType)> + Clone,
640{
641    let mut errors = Vec::new();
642    let mut path = ValuePath::new();
643    let mut index = ValidationContext::new(graph);
644
645    let field_count = fields.clone().count();
646    if field_count != values.len() {
647        errors.push(ValueError::RecordArityMismatch {
648            path: path.snapshot(),
649            expected: field_count,
650            found: values.len(),
651        });
652        return Err(errors);
653    }
654
655    for (_, body) in fields.clone() {
656        index.prepare(body);
657    }
658    for ((name, body), v) in fields.zip(values.iter()) {
659        path.push(ValuePathSegment::Field(name.to_string()));
660        check(&index, body, v, &mut path, &mut errors);
661        path.pop();
662    }
663
664    if errors.is_empty() {
665        Ok(())
666    } else {
667        Err(errors)
668    }
669}
670
671fn shape_name(value: &SchemaValue) -> &'static str {
672    match value {
673        SchemaValue::Bool(_) => "bool",
674        SchemaValue::S8(_) => "s8",
675        SchemaValue::S16(_) => "s16",
676        SchemaValue::S32(_) => "s32",
677        SchemaValue::S64(_) => "s64",
678        SchemaValue::U8(_) => "u8",
679        SchemaValue::U16(_) => "u16",
680        SchemaValue::U32(_) => "u32",
681        SchemaValue::U64(_) => "u64",
682        SchemaValue::F32(_) => "f32",
683        SchemaValue::F64(_) => "f64",
684        SchemaValue::Char(_) => "char",
685        SchemaValue::String(_) => "string",
686        SchemaValue::Record { .. } => "record",
687        SchemaValue::Variant(_) => "variant",
688        SchemaValue::Enum { .. } => "enum",
689        SchemaValue::Flags { .. } => "flags",
690        SchemaValue::Tuple { .. } => "tuple",
691        SchemaValue::List { .. } => "list",
692        SchemaValue::FixedList { .. } => "fixed-list",
693        SchemaValue::Map { .. } => "map",
694        SchemaValue::Option { .. } => "option",
695        SchemaValue::Result(_) => "result",
696        SchemaValue::Text(_) => "text",
697        SchemaValue::Binary(_) => "binary",
698        SchemaValue::Path { .. } => "path",
699        SchemaValue::Url { .. } => "url",
700        SchemaValue::Uuid(_) => "uuid",
701        SchemaValue::Datetime { .. } => "datetime",
702        SchemaValue::Duration(_) => "duration",
703        SchemaValue::Quantity(_) => "quantity",
704        SchemaValue::Union(_) => "union",
705        SchemaValue::Secret(_) => "secret",
706        SchemaValue::QuotaToken(_) => "quota-token",
707        SchemaValue::Stream(_) => "stream",
708        SchemaValue::PermissionCard(_) => "permission-card",
709    }
710}
711
712fn type_name(ty: &SchemaType) -> &'static str {
713    match ty {
714        SchemaType::Ref { .. } => "ref",
715        SchemaType::Bool { .. } => "bool",
716        SchemaType::S8 { .. } => "s8",
717        SchemaType::S16 { .. } => "s16",
718        SchemaType::S32 { .. } => "s32",
719        SchemaType::S64 { .. } => "s64",
720        SchemaType::U8 { .. } => "u8",
721        SchemaType::U16 { .. } => "u16",
722        SchemaType::U32 { .. } => "u32",
723        SchemaType::U64 { .. } => "u64",
724        SchemaType::F32 { .. } => "f32",
725        SchemaType::F64 { .. } => "f64",
726        SchemaType::Char { .. } => "char",
727        SchemaType::String { .. } => "string",
728        SchemaType::Record { .. } => "record",
729        SchemaType::Variant { .. } => "variant",
730        SchemaType::Enum { .. } => "enum",
731        SchemaType::Flags { .. } => "flags",
732        SchemaType::Tuple { .. } => "tuple",
733        SchemaType::List { .. } => "list",
734        SchemaType::FixedList { .. } => "fixed-list",
735        SchemaType::Map { .. } => "map",
736        SchemaType::Option { .. } => "option",
737        SchemaType::Result { .. } => "result",
738        SchemaType::Text { .. } => "text",
739        SchemaType::Binary { .. } => "binary",
740        SchemaType::Path { .. } => "path",
741        SchemaType::Url { .. } => "url",
742        SchemaType::Uuid { .. } => "uuid",
743        SchemaType::Datetime { .. } => "datetime",
744        SchemaType::Duration { .. } => "duration",
745        SchemaType::Quantity { .. } => "quantity",
746        SchemaType::Union { .. } => "union",
747        SchemaType::Secret { .. } => "secret",
748        SchemaType::QuotaToken { .. } => "quota-token",
749        SchemaType::PermissionCard { .. } => "permission-card",
750        SchemaType::Future { .. } => "future",
751        SchemaType::Stream { .. } => "stream",
752    }
753}
754
755fn shape_mismatch(
756    path: &ValuePath,
757    ty: &SchemaType,
758    value: &SchemaValue,
759    errors: &mut Vec<ValueError>,
760) {
761    errors.push(ValueError::ShapeMismatch {
762        path: path.snapshot(),
763        expected: type_name(ty).to_string(),
764        found: shape_name(value).to_string(),
765    });
766}
767
768/// Resolve a chain of [`SchemaType::Ref`]s at the *current* value node into
769/// a non-`Ref` schema. Cycle protection is local to this resolution loop
770/// so it only guards pure alias cycles like `A = Ref<A>` or
771/// `A = Ref<B>, B = Ref<A>` that can never reach a structural shape.
772/// Recursive types that go through a value-shrinking constructor (record,
773/// list, option, …) are fully validated at every level because each
774/// value-tree step gets a fresh resolution scope.
775///
776/// Returns `Some(ty)` with a non-`Ref` schema on success, or `None` after
777/// pushing a [`ValueError::DanglingRef`] or [`ValueError::RecursiveRef`]
778/// on failure.
779fn resolve_refs_at_value_node<'a>(
780    index: &GraphIndex<'a>,
781    mut ty: &'a SchemaType,
782    path: &ValuePath,
783    errors: &mut Vec<ValueError>,
784) -> Option<&'a SchemaType> {
785    // A pure alias chain (`Ref -> Ref -> …`) at a single value position can
786    // visit each named definition at most once before it must revisit one and
787    // therefore cycle. Bounding the hop count by the number of definitions lets
788    // us detect such cycles without allocating a `HashSet` or cloning the
789    // (`String`-backed) `TypeId` on the common, allocation-free happy path.
790    let max_hops = index.defs_len();
791    let mut hops = 0usize;
792    loop {
793        match ty {
794            SchemaType::Ref { id, .. } => {
795                if hops > max_hops {
796                    errors.push(ValueError::RecursiveRef {
797                        path: path.snapshot(),
798                        type_id: id.clone(),
799                    });
800                    return None;
801                }
802                hops += 1;
803                match index.lookup(id) {
804                    Some(def) => {
805                        ty = &def.body;
806                    }
807                    None => {
808                        errors.push(ValueError::DanglingRef {
809                            path: path.snapshot(),
810                            type_id: id.clone(),
811                        });
812                        return None;
813                    }
814                }
815            }
816            other => return Some(other),
817        }
818    }
819}
820
821fn check<'a>(
822    index: &ValidationContext<'a>,
823    ty: &'a SchemaType,
824    value: &SchemaValue,
825    path: &mut ValuePath,
826    errors: &mut Vec<ValueError>,
827) {
828    // Resolve any chain of named refs at this exact value position. The
829    // resolution scope is local to this call: as soon as we descend into
830    // a value-shrinking constructor (record field, list element, etc.)
831    // a fresh scope is established, so recursive types backed by finite
832    // values are validated at every level.
833    let Some(ty) = resolve_refs_at_value_node(index, ty, path, errors) else {
834        return;
835    };
836
837    match (ty, value) {
838        (SchemaType::Bool { .. }, SchemaValue::Bool(_)) => {}
839        (SchemaType::S8 { restrictions, .. }, SchemaValue::S8(v)) => {
840            check_numeric(
841                NumericRepr::S8,
842                restrictions,
843                NumericBound::Signed(*v as i64),
844                path,
845                errors,
846            );
847        }
848        (SchemaType::S16 { restrictions, .. }, SchemaValue::S16(v)) => {
849            check_numeric(
850                NumericRepr::S16,
851                restrictions,
852                NumericBound::Signed(*v as i64),
853                path,
854                errors,
855            );
856        }
857        (SchemaType::S32 { restrictions, .. }, SchemaValue::S32(v)) => {
858            check_numeric(
859                NumericRepr::S32,
860                restrictions,
861                NumericBound::Signed(*v as i64),
862                path,
863                errors,
864            );
865        }
866        (SchemaType::S64 { restrictions, .. }, SchemaValue::S64(v)) => {
867            check_numeric(
868                NumericRepr::S64,
869                restrictions,
870                NumericBound::Signed(*v),
871                path,
872                errors,
873            );
874        }
875        (SchemaType::U8 { restrictions, .. }, SchemaValue::U8(v)) => {
876            check_numeric(
877                NumericRepr::U8,
878                restrictions,
879                NumericBound::Unsigned(*v as u64),
880                path,
881                errors,
882            );
883        }
884        (SchemaType::U16 { restrictions, .. }, SchemaValue::U16(v)) => {
885            check_numeric(
886                NumericRepr::U16,
887                restrictions,
888                NumericBound::Unsigned(*v as u64),
889                path,
890                errors,
891            );
892        }
893        (SchemaType::U32 { restrictions, .. }, SchemaValue::U32(v)) => {
894            check_numeric(
895                NumericRepr::U32,
896                restrictions,
897                NumericBound::Unsigned(*v as u64),
898                path,
899                errors,
900            );
901        }
902        (SchemaType::U64 { restrictions, .. }, SchemaValue::U64(v)) => {
903            check_numeric(
904                NumericRepr::U64,
905                restrictions,
906                NumericBound::Unsigned(*v),
907                path,
908                errors,
909            );
910        }
911        (SchemaType::F32 { restrictions, .. }, SchemaValue::F32(v)) => {
912            check_numeric(
913                NumericRepr::F32,
914                restrictions,
915                NumericBound::FloatBits((*v as f64).to_bits()),
916                path,
917                errors,
918            );
919        }
920        (SchemaType::F64 { restrictions, .. }, SchemaValue::F64(v)) => {
921            check_numeric(
922                NumericRepr::F64,
923                restrictions,
924                NumericBound::FloatBits(v.to_bits()),
925                path,
926                errors,
927            );
928        }
929        (SchemaType::Char { .. }, SchemaValue::Char(_)) => {}
930        (SchemaType::String { .. }, SchemaValue::String(_)) => {}
931
932        (SchemaType::Text { restrictions, .. }, SchemaValue::Text(payload)) => {
933            check_text(index, restrictions, payload, path, errors);
934        }
935        (SchemaType::Binary { restrictions, .. }, SchemaValue::Binary(payload)) => {
936            check_binary(restrictions, payload, path, errors);
937        }
938        (SchemaType::Path { spec, .. }, SchemaValue::Path { path: p }) => {
939            check_path(spec, p.as_str(), path, errors);
940        }
941        (SchemaType::Url { restrictions, .. }, SchemaValue::Url { url }) => {
942            check_url(restrictions, url.as_str(), path, errors);
943        }
944        (SchemaType::Uuid { .. }, SchemaValue::Uuid(_)) => {}
945        (SchemaType::Datetime { .. }, SchemaValue::Datetime { .. }) => {}
946        (SchemaType::Duration { .. }, SchemaValue::Duration(_)) => {}
947        (SchemaType::Quantity { spec, .. }, SchemaValue::Quantity(value)) => {
948            check_quantity(spec, value, path, errors);
949        }
950        (SchemaType::Secret { spec, .. }, SchemaValue::Secret(payload)) => {
951            #[cfg(not(all(feature = "guest", not(feature = "host"))))]
952            check_secret(spec, payload, path, errors);
953            // On a guest the secret value is an opaque owned handle with no
954            // readable fields; category constraints are enforced host-side when
955            // the handle is lifted to its trusted snapshot.
956            #[cfg(all(feature = "guest", not(feature = "host")))]
957            let _ = (spec, payload);
958        }
959        (SchemaType::QuotaToken { spec, .. }, SchemaValue::QuotaToken(payload)) => {
960            #[cfg(not(all(feature = "guest", not(feature = "host"))))]
961            check_quota_token(spec, payload, path, errors);
962            // On a guest the quota-token value is an opaque owned handle with no
963            // readable fields; the resource-name constraint is enforced
964            // host-side when the handle is lifted to its trusted snapshot.
965            #[cfg(all(feature = "guest", not(feature = "host")))]
966            let _ = (spec, payload);
967        }
968        (SchemaType::Stream { .. }, SchemaValue::Stream(_)) => {}
969        (SchemaType::PermissionCard { spec, .. }, SchemaValue::PermissionCard(payload)) => {
970            #[cfg(not(all(feature = "guest", not(feature = "host"))))]
971            check_permission_card(spec, payload, path, errors);
972            // On a guest the permission-card value is an opaque owned handle
973            // with no readable fields; the polymorphic constraint is enforced
974            // host-side when the handle is lifted to its trusted snapshot.
975            #[cfg(all(feature = "guest", not(feature = "host")))]
976            let _ = (spec, payload);
977        }
978
979        (SchemaType::Record { fields, .. }, SchemaValue::Record { fields: vs }) => {
980            if fields.len() != vs.len() {
981                errors.push(ValueError::RecordArityMismatch {
982                    path: path.snapshot(),
983                    expected: fields.len(),
984                    found: vs.len(),
985                });
986                return;
987            }
988            for (field, v) in fields.iter().zip(vs.iter()) {
989                path.push(ValuePathSegment::Field(field.name.clone()));
990                check(index, &field.body, v, path, errors);
991                path.pop();
992            }
993        }
994
995        (SchemaType::Variant { cases, .. }, SchemaValue::Variant(vp)) => {
996            let idx = vp.case as usize;
997            if idx >= cases.len() {
998                errors.push(ValueError::VariantCaseOutOfRange {
999                    path: path.snapshot(),
1000                    case: vp.case,
1001                    case_count: cases.len(),
1002                });
1003                return;
1004            }
1005            let case = &cases[idx];
1006            match (&case.payload, &vp.payload) {
1007                (Some(case_ty), Some(payload)) => {
1008                    path.push(ValuePathSegment::VariantPayload);
1009                    check(index, case_ty, payload, path, errors);
1010                    path.pop();
1011                }
1012                (None, None) => {}
1013                (Some(_), None) => errors.push(ValueError::VariantPayloadPresenceMismatch {
1014                    path: path.snapshot(),
1015                    expected_some: true,
1016                }),
1017                (None, Some(_)) => errors.push(ValueError::VariantPayloadPresenceMismatch {
1018                    path: path.snapshot(),
1019                    expected_some: false,
1020                }),
1021            }
1022        }
1023
1024        (SchemaType::Enum { cases, .. }, SchemaValue::Enum { case }) => {
1025            if (*case as usize) >= cases.len() {
1026                errors.push(ValueError::EnumCaseOutOfRange {
1027                    path: path.snapshot(),
1028                    case: *case,
1029                    case_count: cases.len(),
1030                });
1031            }
1032        }
1033
1034        (SchemaType::Flags { flags, .. }, SchemaValue::Flags { bits }) => {
1035            if flags.len() != bits.len() {
1036                errors.push(ValueError::FlagsArityMismatch {
1037                    path: path.snapshot(),
1038                    expected: flags.len(),
1039                    found: bits.len(),
1040                });
1041            }
1042        }
1043
1044        (SchemaType::Tuple { elements, .. }, SchemaValue::Tuple { elements: vs }) => {
1045            if elements.len() != vs.len() {
1046                errors.push(ValueError::TupleArityMismatch {
1047                    path: path.snapshot(),
1048                    expected: elements.len(),
1049                    found: vs.len(),
1050                });
1051                return;
1052            }
1053            for (i, (t, v)) in elements.iter().zip(vs.iter()).enumerate() {
1054                path.push(ValuePathSegment::Index(i));
1055                check(index, t, v, path, errors);
1056                path.pop();
1057            }
1058        }
1059
1060        (SchemaType::List { element, .. }, SchemaValue::List { elements }) => {
1061            for (i, v) in elements.iter().enumerate() {
1062                path.push(ValuePathSegment::Index(i));
1063                check(index, element, v, path, errors);
1064                path.pop();
1065            }
1066        }
1067
1068        (
1069            SchemaType::FixedList {
1070                element, length, ..
1071            },
1072            SchemaValue::FixedList { elements },
1073        ) => {
1074            if elements.len() != *length as usize {
1075                errors.push(ValueError::FixedListLengthMismatch {
1076                    path: path.snapshot(),
1077                    expected: *length,
1078                    found: elements.len(),
1079                });
1080                return;
1081            }
1082            for (i, v) in elements.iter().enumerate() {
1083                path.push(ValuePathSegment::Index(i));
1084                check(index, element, v, path, errors);
1085                path.pop();
1086            }
1087        }
1088
1089        (
1090            SchemaType::Map {
1091                key, value: vty, ..
1092            },
1093            SchemaValue::Map { entries },
1094        ) => {
1095            for (i, (k, v)) in entries.iter().enumerate() {
1096                path.push(ValuePathSegment::MapKey(i));
1097                check(index, key, k, path, errors);
1098                path.pop();
1099                path.push(ValuePathSegment::MapValue(i));
1100                check(index, vty, v, path, errors);
1101                path.pop();
1102            }
1103        }
1104
1105        (SchemaType::Option { inner, .. }, SchemaValue::Option { inner: v }) => {
1106            if let Some(v) = v {
1107                path.push(ValuePathSegment::OptionInner);
1108                check(index, inner, v, path, errors);
1109                path.pop();
1110            }
1111        }
1112
1113        (SchemaType::Result { spec, .. }, SchemaValue::Result(vp)) => match vp {
1114            ResultValuePayload::Ok { value: v } => match (&spec.ok, v) {
1115                (Some(t), Some(v)) => {
1116                    path.push(ValuePathSegment::ResultOk);
1117                    check(index, t, v, path, errors);
1118                    path.pop();
1119                }
1120                (None, None) => {}
1121                (Some(_), None) => errors.push(ValueError::ResultPayloadPresenceMismatch {
1122                    path: path.snapshot(),
1123                    expected_some: true,
1124                    side: ResultSide::Ok,
1125                }),
1126                (None, Some(_)) => errors.push(ValueError::ResultPayloadPresenceMismatch {
1127                    path: path.snapshot(),
1128                    expected_some: false,
1129                    side: ResultSide::Ok,
1130                }),
1131            },
1132            ResultValuePayload::Err { value: v } => match (&spec.err, v) {
1133                (Some(t), Some(v)) => {
1134                    path.push(ValuePathSegment::ResultErr);
1135                    check(index, t, v, path, errors);
1136                    path.pop();
1137                }
1138                (None, None) => {}
1139                (Some(_), None) => errors.push(ValueError::ResultPayloadPresenceMismatch {
1140                    path: path.snapshot(),
1141                    expected_some: true,
1142                    side: ResultSide::Err,
1143                }),
1144                (None, Some(_)) => errors.push(ValueError::ResultPayloadPresenceMismatch {
1145                    path: path.snapshot(),
1146                    expected_some: false,
1147                    side: ResultSide::Err,
1148                }),
1149            },
1150        },
1151
1152        (SchemaType::Union { spec, .. }, SchemaValue::Union(vp)) => {
1153            let branch = spec.branches.iter().find(|b| b.tag == vp.tag);
1154            match branch {
1155                None => errors.push(ValueError::UnionUnknownTag {
1156                    path: path.snapshot(),
1157                    tag: vp.tag.clone(),
1158                }),
1159                Some(branch) => {
1160                    path.push(ValuePathSegment::UnionBody);
1161                    #[cfg(not(feature = "regex"))]
1162                    if matches!(branch.discriminator, DiscriminatorRule::Regex { .. }) {
1163                        errors.push(ValueError::UnsupportedFeature {
1164                            path: path.snapshot(),
1165                            feature: "regex",
1166                        });
1167                        path.pop();
1168                        return;
1169                    }
1170                    let mut sub_errors = Vec::new();
1171                    check(index, &branch.body, &vp.body, path, &mut sub_errors);
1172                    errors.extend(sub_errors);
1173                    if !discriminator_matches(index, branch, &vp.body) {
1174                        errors.push(ValueError::UnionDiscriminatorMismatch {
1175                            path: path.snapshot(),
1176                            tag: vp.tag.clone(),
1177                        });
1178                    }
1179                    path.pop();
1180                }
1181            }
1182        }
1183
1184        (ty, v) => shape_mismatch(path, ty, v, errors),
1185    }
1186}
1187
1188/// Validate a numeric value against the node's optional restrictions.
1189///
1190/// Only the value range is checked here (the `unit` is schema-level metadata,
1191/// never carried by a `SchemaValue`). A malformed restriction set is a
1192/// well-formedness concern reported elsewhere, so when the stored restrictions
1193/// are not well-formed for the repr this check is skipped rather than producing
1194/// a misleading value error. `value` is the numeric value reinterpreted as a
1195/// same-family `NumericBound` (floats widened to `f64` bits).
1196fn check_numeric(
1197    repr: NumericRepr,
1198    restrictions: &Option<NumericRestrictions>,
1199    value: NumericBound,
1200    path: &mut ValuePath,
1201    errors: &mut Vec<ValueError>,
1202) {
1203    let Some(restrictions) = restrictions else {
1204        return;
1205    };
1206    // Skip value-level range checking only when the bounds are incomparable to
1207    // this repr (family/range/float mismatches), since comparing against them
1208    // would produce misleading errors. An inverted `min > max` is still a pair
1209    // of comparable bounds, so it is enforced here (the constraint is simply
1210    // unsatisfiable), consistent with the text/binary range validators.
1211    if let Err(err) = restrictions.validate_for_repr(repr)
1212        && !matches!(err, NumericRestrictionError::MinGreaterThanMax)
1213    {
1214        return;
1215    }
1216    if let Some(min) = restrictions.min
1217        && !matches!(
1218            numeric_bound_cmp(value, min),
1219            Some(Ordering::Greater | Ordering::Equal)
1220        )
1221    {
1222        errors.push(ValueError::NumericOutOfRange {
1223            path: path.snapshot(),
1224            reason: format!("below minimum {}", describe_numeric_bound(min)),
1225        });
1226    }
1227    if let Some(max) = restrictions.max
1228        && !matches!(
1229            numeric_bound_cmp(value, max),
1230            Some(Ordering::Less | Ordering::Equal)
1231        )
1232    {
1233        errors.push(ValueError::NumericOutOfRange {
1234            path: path.snapshot(),
1235            reason: format!("above maximum {}", describe_numeric_bound(max)),
1236        });
1237    }
1238}
1239
1240fn describe_numeric_bound(bound: NumericBound) -> String {
1241    match bound {
1242        NumericBound::Signed(v) => v.to_string(),
1243        NumericBound::Unsigned(v) => v.to_string(),
1244        NumericBound::FloatBits(bits) => f64::from_bits(bits).to_string(),
1245    }
1246}
1247
1248fn check_text(
1249    _context: &ValidationContext,
1250    restrictions: &TextRestrictions,
1251    payload: &TextValuePayload,
1252    path: &mut ValuePath,
1253    errors: &mut Vec<ValueError>,
1254) {
1255    if let (Some(allowed), Some(lang)) = (&restrictions.languages, &payload.language)
1256        && !allowed.iter().any(|a| a == lang)
1257    {
1258        errors.push(ValueError::TextLanguageNotAllowed {
1259            path: path.snapshot(),
1260            language: lang.clone(),
1261        });
1262    }
1263    let char_len = payload.text.chars().count();
1264    if let Some(min) = restrictions.min_length
1265        && (char_len as u64) < (min as u64)
1266    {
1267        errors.push(ValueError::TextTooShort {
1268            path: path.snapshot(),
1269            min,
1270            found: char_len,
1271        });
1272    }
1273    if let Some(max) = restrictions.max_length
1274        && (char_len as u64) > (max as u64)
1275    {
1276        errors.push(ValueError::TextTooLong {
1277            path: path.snapshot(),
1278            max,
1279            found: char_len,
1280        });
1281    }
1282    #[cfg(not(feature = "regex"))]
1283    if restrictions.regex.is_some() {
1284        errors.push(ValueError::UnsupportedFeature {
1285            path: path.snapshot(),
1286            feature: "regex",
1287        });
1288    }
1289    #[cfg(feature = "regex")]
1290    if let Some(regex) = &restrictions.regex
1291        && let Some(Some(compiled)) = _context.regexes.get(regex.as_str())
1292        && !compiled.is_match(payload.text.as_str())
1293    {
1294        errors.push(ValueError::TextRegexMismatch {
1295            path: path.snapshot(),
1296            regex: regex.clone(),
1297        });
1298    }
1299}
1300
1301fn check_binary(
1302    restrictions: &BinaryRestrictions,
1303    payload: &BinaryValuePayload,
1304    path: &mut ValuePath,
1305    errors: &mut Vec<ValueError>,
1306) {
1307    if let (Some(allowed), Some(mime)) = (&restrictions.mime_types, &payload.mime_type)
1308        && !allowed.iter().any(|a| a == mime)
1309    {
1310        errors.push(ValueError::BinaryMimeNotAllowed {
1311            path: path.snapshot(),
1312            mime_type: mime.clone(),
1313        });
1314    }
1315    let len = payload.bytes.len();
1316    if let Some(min) = restrictions.min_bytes
1317        && (len as u64) < (min as u64)
1318    {
1319        errors.push(ValueError::BinaryTooSmall {
1320            path: path.snapshot(),
1321            min,
1322            found: len,
1323        });
1324    }
1325    if let Some(max) = restrictions.max_bytes
1326        && (len as u64) > (max as u64)
1327    {
1328        errors.push(ValueError::BinaryTooLarge {
1329            path: path.snapshot(),
1330            max,
1331            found: len,
1332        });
1333    }
1334}
1335
1336fn check_path(spec: &PathSpec, p: &str, path: &mut ValuePath, errors: &mut Vec<ValueError>) {
1337    if p.is_empty() {
1338        errors.push(ValueError::PathEmpty {
1339            path: path.snapshot(),
1340        });
1341        return;
1342    }
1343    if let Some(allowed_exts) = &spec.allowed_extensions
1344        && let Some(ext) = file_extension(p)
1345        && !allowed_exts.iter().any(|a| a == ext)
1346    {
1347        errors.push(ValueError::PathExtensionNotAllowed {
1348            path: path.snapshot(),
1349            extension: ext.to_string(),
1350        });
1351    }
1352    // `allowed_mime_types` on `Path` is not enforceable by value validation
1353    // because `SchemaValue::Path` carries only a path string. MIME
1354    // restrictions must be enforced at protocol/adaptation layers that
1355    // carry or derive MIME metadata (e.g. an HTTP `Content-Type` header,
1356    // a multimodal envelope, a content-typed canonical-encoding layer).
1357    // The validator must not read the filesystem or sniff content.
1358}
1359
1360#[cfg(not(feature = "url"))]
1361fn check_url(
1362    _spec: &UrlRestrictions,
1363    _url: &str,
1364    path: &mut ValuePath,
1365    errors: &mut Vec<ValueError>,
1366) {
1367    errors.push(ValueError::UnsupportedFeature {
1368        path: path.snapshot(),
1369        feature: "url",
1370    });
1371}
1372
1373#[cfg(feature = "url")]
1374fn check_url(
1375    spec: &UrlRestrictions,
1376    url: &str,
1377    path: &mut ValuePath,
1378    errors: &mut Vec<ValueError>,
1379) {
1380    if url.is_empty() {
1381        errors.push(ValueError::UrlEmpty {
1382            path: path.snapshot(),
1383        });
1384        return;
1385    }
1386    let parsed = match url::Url::parse(url) {
1387        Ok(parsed) => parsed,
1388        Err(err) => {
1389            errors.push(ValueError::UrlInvalid {
1390                path: path.snapshot(),
1391                url: url.to_string(),
1392                reason: err.to_string(),
1393            });
1394            return;
1395        }
1396    };
1397    if let Some(allowed) = &spec.allowed_schemes
1398        && !allowed
1399            .iter()
1400            .any(|scheme| scheme.eq_ignore_ascii_case(parsed.scheme()))
1401    {
1402        errors.push(ValueError::UrlSchemeNotAllowed {
1403            path: path.snapshot(),
1404            scheme: parsed.scheme().to_string(),
1405        });
1406    }
1407    if let Some(allowed) = &spec.allowed_hosts {
1408        match parsed.host_str() {
1409            Some(host)
1410                if allowed
1411                    .iter()
1412                    .any(|allowed_host| allowed_host.eq_ignore_ascii_case(host)) => {}
1413            Some(host) => errors.push(ValueError::UrlHostNotAllowed {
1414                path: path.snapshot(),
1415                host: host.to_string(),
1416            }),
1417            None => errors.push(ValueError::UrlHostMissing {
1418                path: path.snapshot(),
1419            }),
1420        }
1421    }
1422}
1423
1424fn check_quantity(
1425    spec: &QuantitySpec,
1426    value: &QuantityValue,
1427    path: &mut ValuePath,
1428    errors: &mut Vec<ValueError>,
1429) {
1430    let unit_ok = if spec.allowed_suffixes.is_empty() {
1431        value.unit == spec.base_unit
1432    } else {
1433        spec.allowed_suffixes.iter().any(|s| s == &value.unit)
1434    };
1435    if !unit_ok {
1436        errors.push(ValueError::QuantityUnitNotAllowed {
1437            path: path.snapshot(),
1438            unit: value.unit.clone(),
1439        });
1440        // Range checks below assume canonical unit comparison; bail when the
1441        // unit is unrecognised.
1442        return;
1443    }
1444
1445    if let Some(min) = &spec.min {
1446        match quantity_le_checked(min, value) {
1447            Some(true) => {}
1448            Some(false) => errors.push(ValueError::QuantityOutOfRange {
1449                path: path.snapshot(),
1450                reason: format!("value is below min `{}`", render_quantity(min)),
1451            }),
1452            None => errors.push(ValueError::QuantityOutOfRange {
1453                path: path.snapshot(),
1454                reason: "overflow".to_string(),
1455            }),
1456        }
1457    }
1458    if let Some(max) = &spec.max {
1459        match quantity_le_checked(value, max) {
1460            Some(true) => {}
1461            Some(false) => errors.push(ValueError::QuantityOutOfRange {
1462                path: path.snapshot(),
1463                reason: format!("value is above max `{}`", render_quantity(max)),
1464            }),
1465            None => errors.push(ValueError::QuantityOutOfRange {
1466                path: path.snapshot(),
1467                reason: "overflow".to_string(),
1468            }),
1469        }
1470    }
1471}
1472
1473#[cfg(not(all(feature = "guest", not(feature = "host"))))]
1474fn check_secret(
1475    spec: &SecretSpec,
1476    payload: &SecretValuePayload,
1477    path: &mut ValuePath,
1478    errors: &mut Vec<ValueError>,
1479) {
1480    if let Some(expected) = &spec.category
1481        && payload.category.as_ref() != Some(expected)
1482    {
1483        errors.push(ValueError::SecretCategoryMismatch {
1484            path: path.snapshot(),
1485            expected: expected.clone(),
1486            found: payload.category.clone(),
1487        });
1488    }
1489}
1490
1491#[cfg(not(all(feature = "guest", not(feature = "host"))))]
1492fn check_quota_token(
1493    spec: &QuotaTokenSpec,
1494    payload: &QuotaTokenValuePayload,
1495    path: &mut ValuePath,
1496    errors: &mut Vec<ValueError>,
1497) {
1498    if let Some(expected) = &spec.resource_name
1499        && expected != &payload.resource_name
1500    {
1501        errors.push(ValueError::QuotaTokenResourceMismatch {
1502            path: path.snapshot(),
1503            expected: expected.clone(),
1504            found: payload.resource_name.clone(),
1505        });
1506    }
1507}
1508
1509#[cfg(not(all(feature = "guest", not(feature = "host"))))]
1510fn check_permission_card(
1511    spec: &PermissionCardSpec,
1512    payload: &PermissionCardValuePayload,
1513    path: &mut ValuePath,
1514    errors: &mut Vec<ValueError>,
1515) {
1516    if spec.polymorphic != payload.polymorphic {
1517        errors.push(ValueError::PermissionCardPolymorphicMismatch {
1518            path: path.snapshot(),
1519            expected: spec.polymorphic,
1520            found: payload.polymorphic,
1521        });
1522    }
1523}
1524
1525fn discriminator_matches(
1526    index: &ValidationContext,
1527    branch: &UnionBranch,
1528    body: &SchemaValue,
1529) -> bool {
1530    match &branch.discriminator {
1531        DiscriminatorRule::Prefix { prefix } => string_view(index, &branch.body, body)
1532            .map(|s| s.starts_with(prefix.as_str()))
1533            .unwrap_or(false),
1534        DiscriminatorRule::Suffix { suffix } => string_view(index, &branch.body, body)
1535            .map(|s| s.ends_with(suffix.as_str()))
1536            .unwrap_or(false),
1537        DiscriminatorRule::Contains { substring } => string_view(index, &branch.body, body)
1538            .map(|s| s.contains(substring.as_str()))
1539            .unwrap_or(false),
1540        #[cfg(not(feature = "regex"))]
1541        DiscriminatorRule::Regex { .. } => false,
1542        #[cfg(feature = "regex")]
1543        DiscriminatorRule::Regex { regex } => {
1544            let Some(s) = string_view(index, &branch.body, body) else {
1545                return false;
1546            };
1547            match index.regexes.get(regex.as_str()) {
1548                Some(Some(compiled)) => compiled.is_match(s),
1549                _ => false,
1550            }
1551        }
1552        DiscriminatorRule::FieldEquals(field_disc) => {
1553            let Some(record) = record_view(index, &branch.body, body) else {
1554                return false;
1555            };
1556            let pos = record
1557                .field_names
1558                .iter()
1559                .position(|n| n == &field_disc.field_name);
1560            let Some(pos) = pos else {
1561                return false;
1562            };
1563            match &field_disc.literal {
1564                None => true,
1565                Some(lit) => {
1566                    let v = &record.values[pos];
1567                    matches!(v, SchemaValue::String(s) if s == lit)
1568                        || matches!(v, SchemaValue::Text(t) if &t.text == lit)
1569                        || matches!(v, SchemaValue::Url { url } if url == lit)
1570                        || matches!(v, SchemaValue::Path { path } if path == lit)
1571                }
1572            }
1573        }
1574        DiscriminatorRule::FieldAbsent { field_name } => {
1575            let Some(record) = record_view(index, &branch.body, body) else {
1576                return false;
1577            };
1578            !record.field_names.iter().any(|n| n == field_name)
1579        }
1580    }
1581}
1582
1583fn string_view<'a>(index: &GraphIndex, ty: &SchemaType, value: &'a SchemaValue) -> Option<&'a str> {
1584    match (resolve(index, ty), value) {
1585        (Some(_), SchemaValue::String(s)) => Some(s.as_str()),
1586        (Some(_), SchemaValue::Text(t)) => Some(t.text.as_str()),
1587        (Some(_), SchemaValue::Url { url }) => Some(url.as_str()),
1588        (Some(_), SchemaValue::Path { path }) => Some(path.as_str()),
1589        _ => None,
1590    }
1591}
1592
1593struct RecordView<'a> {
1594    field_names: Vec<String>,
1595    values: &'a [SchemaValue],
1596}
1597
1598fn record_view<'a>(
1599    index: &GraphIndex,
1600    ty: &SchemaType,
1601    value: &'a SchemaValue,
1602) -> Option<RecordView<'a>> {
1603    let resolved = resolve_record(index, ty)?;
1604    match value {
1605        SchemaValue::Record { fields } if fields.len() == resolved.len() => Some(RecordView {
1606            field_names: resolved,
1607            values: fields.as_slice(),
1608        }),
1609        _ => None,
1610    }
1611}
1612
1613fn resolve<'a>(index: &GraphIndex<'a>, ty: &'a SchemaType) -> Option<&'a SchemaType> {
1614    let mut current = ty;
1615    // Bounded by the number of named defs: a pure alias chain can visit each
1616    // def at most once before it must revisit one and cycle (see
1617    // `resolve_refs_at_value_node`). This avoids per-node allocation/cloning.
1618    let max_hops = index.defs_len();
1619    let mut hops = 0usize;
1620    loop {
1621        match current {
1622            SchemaType::Ref { id, .. } => {
1623                if hops > max_hops {
1624                    return None;
1625                }
1626                hops += 1;
1627                {
1628                    let def = index.lookup(id)?;
1629                    current = &def.body
1630                }
1631            }
1632            other => return Some(other),
1633        }
1634    }
1635}
1636
1637fn resolve_record(index: &GraphIndex, ty: &SchemaType) -> Option<Vec<String>> {
1638    let resolved = resolve(index, ty)?;
1639    match resolved {
1640        SchemaType::Record { fields, .. } => Some(fields.iter().map(|f| f.name.clone()).collect()),
1641        _ => None,
1642    }
1643}
1644
1645fn file_extension(p: &str) -> Option<&str> {
1646    let name = p.rsplit('/').next()?;
1647    let (_, ext) = name.rsplit_once('.')?;
1648    if ext.is_empty() { None } else { Some(ext) }
1649}
1650
1651fn render_quantity(q: &QuantityValue) -> String {
1652    format!("{}*10^(-{}) {}", q.mantissa, q.scale, q.unit)
1653}
1654
1655fn quantity_le_checked(a: &QuantityValue, b: &QuantityValue) -> Option<bool> {
1656    let common = a.scale.max(b.scale);
1657    let a_shift = (common - a.scale).max(0) as u32;
1658    let b_shift = (common - b.scale).max(0) as u32;
1659    let ten: i128 = 10;
1660    let a_factor = ten.checked_pow(a_shift)?;
1661    let b_factor = ten.checked_pow(b_shift)?;
1662    let a_canon = (a.mantissa as i128).checked_mul(a_factor)?;
1663    let b_canon = (b.mantissa as i128).checked_mul(b_factor)?;
1664    Some(a_canon <= b_canon)
1665}