Skip to main content

scm_sdk/
validation.rs

1//! Bounded native validation of the pinned schema graph.
2//!
3//! Requests retain the source's strict/strip/passthrough policy and format checks.
4//! Both response modes preserve additional object fields and unfamiliar enum
5//! vocabulary. They deliberately differ from frozen TypeScript format refinements:
6//! string checks named `uuid`, `rfc3339`, `datetime`, `date`, `duration`, `email`,
7//! `url`, and `regex` are ignored, while string types and frozen string bounds remain
8//! enforced. Vendor-only string bounds are enforced only for requests.
9//!
10//! Existing Red Team operations select their actual frozen TypeScript response
11//! schema as the compatibility floor generated by `tools/red_team_dataplane.py`,
12//! with the shared response policy above.
13//! Selection is exclusive: a vendor-permitted document can still be rejected when
14//! the frozen baseline rejects its structure or types. Operations without a
15//! TypeScript counterpart retain vendor structure and types. Request validation
16//! continues to use the strict vendor contract where one is bound.
17//!
18//! See the [G25 compatibility report](https://git.cdot.io/cdot/scm-sdk-rs/src/branch/main/docs/G25_COMPATIBILITY.md)
19//! for the operation comparison and retained constraints. Schema expressions are
20//! data; no JavaScript runtime is involved.
21
22use crate::{Error, ErrorKind, Result, config::RequestOptions, transport::Transport};
23use serde::Deserialize;
24use serde_json::{Map, Value};
25use std::{collections::BTreeMap, sync::OnceLock};
26
27/// Request normalization or tolerant response decoding.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum Mode {
31    /// Request normalization with the selected contract's strict format checks.
32    Request,
33    /// Tolerant response decoding with frozen schema defaults applied, matching
34    /// the source SDK's default insertion. This is the default for typed responses.
35    ///
36    /// Ignores `uuid`, `rfc3339`, `datetime`, `date`, `duration`, `email`, `url`, and
37    /// `regex` string checks while retaining string types and frozen string bounds.
38    /// This format policy deliberately differs from frozen TypeScript refinements.
39    /// Existing Red Team operations use their frozen TypeScript response schemas;
40    /// vendor-only operations retain vendor structure and types. Vendor-only string
41    /// length bounds are request-only. See the
42    /// [G25 compatibility report](https://git.cdot.io/cdot/scm-sdk-rs/src/branch/main/docs/G25_COMPATIBILITY.md).
43    Response,
44    /// The same tolerant response validation as [`Mode::Response`], accepting and
45    /// rejecting exactly the same documents, but a value absent from the input is
46    /// never materialized: schema defaults are still checked and then discarded, so
47    /// the normalized output contains only the object properties actually present.
48    /// Like [`Mode::Response`], this ignores `uuid`, `rfc3339`, `datetime`, `date`,
49    /// `duration`, `email`, `url`, and `regex` string checks; vendor-only string
50    /// length bounds are request-only. See the
51    /// [G25 compatibility report](https://git.cdot.io/cdot/scm-sdk-rs/src/branch/main/docs/G25_COMPATIBILITY.md).
52    /// Typed operations select it with
53    /// [`RequestOptions::without_response_defaults`](crate::RequestOptions::without_response_defaults).
54    ResponseWithoutDefaults,
55}
56impl Mode {
57    /// Response modes preserve unknown fields and widen open vocabularies.
58    fn is_response(self) -> bool {
59        matches!(self, Self::Response | Self::ResponseWithoutDefaults)
60    }
61    /// Whether an omitted value may be replaced by its schema default in the output.
62    fn inserts_defaults(self) -> bool {
63        !matches!(self, Self::ResponseWithoutDefaults)
64    }
65}
66
67const MAX_DEPTH: usize = 64;
68
69/// Caller-selected validation resource bounds, checked before schema work.
70#[derive(Clone, Debug)]
71#[non_exhaustive]
72pub struct ValidationLimits {
73    pub max_depth: usize,
74    pub max_nodes: usize,
75    pub max_input_bytes: usize,
76    pub max_work: usize,
77    pub max_copied_bytes: usize,
78}
79impl Default for ValidationLimits {
80    fn default() -> Self {
81        Self {
82            max_depth: 64,
83            max_nodes: 100_000,
84            max_input_bytes: 8 * 1024 * 1024,
85            max_work: 1_000_000,
86            max_copied_bytes: 32 * 1024 * 1024,
87        }
88    }
89}
90impl ValidationLimits {
91    pub fn for_bytes(bytes: usize) -> Result<Self> {
92        let scale = bytes.div_ceil(8 * 1024 * 1024).max(1);
93        let value = Self {
94            max_nodes: 100_000usize.saturating_mul(scale).min(1_000_000),
95            max_work: 1_000_000usize.saturating_mul(scale).min(16_000_000),
96            max_input_bytes: bytes,
97            max_copied_bytes: bytes.saturating_mul(4).min(512 * 1024 * 1024),
98            ..Self::default()
99        };
100        value.validate()?;
101        Ok(value)
102    }
103    pub(crate) fn validate(&self) -> Result<()> {
104        if !(1..=128).contains(&self.max_depth)
105            || !(1..=1_000_000).contains(&self.max_nodes)
106            || !(1..=512 * 1024 * 1024).contains(&self.max_input_bytes)
107            || !(1..=16_000_000).contains(&self.max_work)
108            || self.max_copied_bytes < self.max_input_bytes
109            || self.max_copied_bytes > 2 * 1024 * 1024 * 1024
110        {
111            Err(Error::new(ErrorKind::Configuration))
112        } else {
113            Ok(())
114        }
115    }
116}
117
118#[derive(Deserialize)]
119struct Graph {
120    roots: BTreeMap<String, String>,
121    nodes: BTreeMap<String, Value>,
122}
123#[derive(Deserialize)]
124struct Semantic {
125    binding: String,
126    #[serde(default)]
127    default: Value,
128}
129struct Registry {
130    graph: Graph,
131    semantics: BTreeMap<String, Semantic>,
132}
133fn registry() -> Result<&'static Registry> {
134    static REGISTRY: OnceLock<std::result::Result<Registry, ()>> = OnceLock::new();
135    REGISTRY
136        .get_or_init(|| {
137            let graph: Graph = serde_json::from_str(include_str!("validation/schema_graph.json"))
138                .map_err(|_| ())?;
139            let semantics =
140                serde_json::from_str(include_str!("validation/semantics.json")).map_err(|_| ())?;
141            Ok(Registry { graph, semantics })
142        })
143        .as_ref()
144        .map_err(|_| invalid())
145}
146fn invalid() -> Error {
147    Error::new(ErrorKind::Validation)
148}
149fn limit() -> Error {
150    Error::new(ErrorKind::Limit)
151}
152fn sid<'a>(registry: &'a Registry, name: &'a str) -> Result<&'a str> {
153    if registry.graph.nodes.contains_key(name) {
154        Ok(name)
155    } else {
156        registry
157            .graph
158            .roots
159            .get(name)
160            .map(String::as_str)
161            .ok_or_else(invalid)
162    }
163}
164fn child<'a>(node: &'a Value, key: &str) -> Result<&'a str> {
165    node.get(key)
166        .and_then(|value| value.get("$ref"))
167        .and_then(Value::as_str)
168        .ok_or_else(invalid)
169}
170
171/// Validate and normalize an explicitly present JSON value.
172pub fn parse(schema: &str, input: Value, mode: Mode) -> Result<Value> {
173    parse_with_limits(schema, input, mode, &ValidationLimits::default())
174}
175
176/// Preserve the source's undefined/default behavior without representing omitted
177/// values as JSON null. This is also used by typed request builders.
178pub fn parse_optional(schema: &str, input: Option<Value>, mode: Mode) -> Result<Option<Value>> {
179    parse_optional_with_limits(schema, input, mode, &ValidationLimits::default())
180}
181/// Validate with bounds derived from the configured request/response budget.
182pub fn parse_with_limits(
183    schema: &str,
184    input: Value,
185    mode: Mode,
186    limits: &ValidationLimits,
187) -> Result<Value> {
188    parse_optional_with_limits(schema, Some(input), mode, limits)?.ok_or_else(invalid)
189}
190
191/// Ordered alternatives share one preflight, work counter and allocation budget.
192/// The first matching schema wins, including overlapping optional object shapes.
193pub fn parse_one_of_with_limits(
194    schemas: &[&str],
195    input: Value,
196    mode: Mode,
197    limits: &ValidationLimits,
198) -> Result<Value> {
199    if let Err(error) = limits.validate().and_then(|()| preflight(&input, limits)) {
200        discard(input);
201        return Err(error);
202    }
203    if schemas.is_empty() || schemas.len() > 64 {
204        return Err(invalid());
205    }
206    let registry = registry()?;
207    let mut context = Context {
208        registry,
209        limits,
210        mode,
211        work: 0,
212        copied: 0,
213    };
214    for schema in schemas {
215        let schema = sid(registry, schema)?;
216        match context.walk(schema, Some(&input), 0) {
217            Ok(Some(value)) => return Ok(value),
218            Err(error) if error.kind() == ErrorKind::Limit => return Err(error),
219            _ => {}
220        }
221    }
222    Err(invalid())
223}
224
225/// Validate the five frozen multipart objects with actual native files in a
226/// separate typed channel. JSON cannot manufacture file values. The returned
227/// normalized metadata excludes binary fields; the encoder retains their bytes.
228#[cfg(feature = "gateway")]
229pub fn parse_multipart_with_limits(
230    schema: &str,
231    metadata: Value,
232    binaries: &[(&str, &BinaryPart)],
233    limits: &ValidationLimits,
234    max_file_bytes: usize,
235) -> Result<Value> {
236    if let Err(error) = limits
237        .validate()
238        .and_then(|()| preflight(&metadata, limits))
239    {
240        discard(metadata);
241        return Err(error);
242    }
243    if max_file_bytes == 0 || max_file_bytes > 512 * 1024 * 1024 {
244        return Err(Error::new(ErrorKind::Configuration));
245    }
246    if binaries.len() > 6 {
247        return Err(limit());
248    }
249    let registry = registry()?;
250    let schema = sid(registry, schema)?;
251    if !matches!(
252        schema,
253        "s004590" | "s004620" | "s004771" | "s004833" | "s004894"
254    ) {
255        return Err(invalid());
256    }
257    let mut schema = schema;
258    let mut node = registry.graph.nodes.get(schema).ok_or_else(invalid)?;
259    for _ in 0..MAX_DEPTH {
260        if node["kind"] != "ZodLazy" {
261            break;
262        }
263        schema = child(node, "target")?;
264        node = registry.graph.nodes.get(schema).ok_or_else(invalid)?;
265    }
266    if node["kind"] != "ZodObject" {
267        return Err(invalid());
268    }
269    let properties = node
270        .get("properties")
271        .and_then(Value::as_object)
272        .ok_or_else(invalid)?;
273    let values = metadata.as_object().ok_or_else(invalid)?;
274    let mut total = 0usize;
275    for (index, (name, file)) in binaries.iter().enumerate() {
276        if values.contains_key(*name) || binaries[..index].iter().any(|(old, _)| old == name) {
277            return Err(invalid());
278        }
279        let property = properties.get(*name).ok_or_else(invalid)?;
280        boundary(child(property, "schema")?, "native_binary")?;
281        file.validate(max_file_bytes)?;
282        total = total.checked_add(file.bytes().len()).ok_or_else(limit)?;
283        if total > max_file_bytes {
284            return Err(limit());
285        }
286    }
287    let mut context = Context {
288        registry,
289        limits,
290        mode: Mode::Request,
291        work: 0,
292        copied: 0,
293    };
294    context.tick(0)?;
295    let mut result = Map::new();
296    for (key, property) in properties {
297        let child_schema = child(property, "schema")?;
298        if binaries.iter().any(|(name, _)| *name == key) {
299            context.tick(1)?;
300            continue;
301        }
302        if let Some(value) = context.walk(child_schema, values.get(key), 1)? {
303            result.insert(context.copy_text(key)?, value);
304        }
305    }
306    let catchall = child(node, "catchall")?;
307    let catchall_never = registry
308        .graph
309        .nodes
310        .get(catchall)
311        .is_some_and(|node| node["kind"] == "ZodNever");
312    for (key, value) in values {
313        if properties.contains_key(key) {
314            continue;
315        }
316        let value = if !catchall_never {
317            context
318                .walk(catchall, Some(value), 1)?
319                .ok_or_else(invalid)?
320        } else if node["unknownFields"] == "passthrough" {
321            context.clone_value(value, 1)?
322        } else if node["unknownFields"] == "strict" {
323            return Err(invalid());
324        } else {
325            continue;
326        };
327        if key != "__proto__" {
328            result.insert(context.copy_text(key)?, value);
329        }
330    }
331    Ok(Value::Object(result))
332}
333/// Validate an anonymous array contract with one shared resource budget.
334pub fn parse_array(schema: &str, input: Value, mode: Mode) -> Result<Value> {
335    parse_array_with_limits(schema, input, mode, &ValidationLimits::default())
336}
337/// Whole-array preflight and normalization; per-row budgets are never reset.
338pub fn parse_array_with_limits(
339    schema: &str,
340    input: Value,
341    mode: Mode,
342    limits: &ValidationLimits,
343) -> Result<Value> {
344    if let Err(error) = limits.validate() {
345        discard(input);
346        return Err(error);
347    }
348    if let Err(error) = preflight(&input, limits) {
349        discard(input);
350        return Err(error);
351    }
352    let registry = registry()?;
353    let schema = sid(registry, schema)?;
354    let values = input.as_array().ok_or_else(invalid)?;
355    let mut context = Context {
356        registry,
357        mode,
358        work: 0,
359        copied: 0,
360        limits,
361    };
362    context.tick(0)?;
363    let mut result = Vec::with_capacity(values.len());
364    for value in values {
365        result.push(context.walk(schema, Some(value), 1)?.ok_or_else(invalid)?);
366    }
367    Ok(Value::Array(result))
368}
369/// Missing-aware normalization with explicit resource limits.
370pub fn parse_optional_with_limits(
371    schema: &str,
372    input: Option<Value>,
373    mode: Mode,
374    limits: &ValidationLimits,
375) -> Result<Option<Value>> {
376    if let Err(error) = limits.validate() {
377        if let Some(input) = input {
378            discard(input);
379        }
380        return Err(error);
381    }
382    if let Some(value) = &input
383        && let Err(error) = preflight(value, limits)
384    {
385        if let Some(value) = input {
386            discard(value);
387        }
388        return Err(error);
389    }
390    let registry = registry()?;
391    let schema = sid(registry, schema)?;
392    let mut context = Context {
393        registry,
394        mode,
395        work: 0,
396        copied: 0,
397        limits,
398    };
399    let output = context.walk(schema, input.as_ref(), 0)?;
400    Ok(if input.is_none() && !mode.inserts_defaults() {
401        None
402    } else {
403        output
404    })
405}
406
407fn preflight(value: &Value, limits: &ValidationLimits) -> Result<()> {
408    let mut stack = vec![(value, 0usize)];
409    let mut nodes = 0usize;
410    let mut bytes = 0usize;
411    while let Some((value, depth)) = stack.pop() {
412        nodes = nodes.checked_add(1).ok_or_else(limit)?;
413        if nodes > limits.max_nodes || depth > limits.max_depth {
414            return Err(limit());
415        }
416        match value {
417            Value::String(text) => bytes = bytes.checked_add(text.len()).ok_or_else(limit)?,
418            Value::Array(values) => {
419                if values.len() > limits.max_nodes.saturating_sub(nodes) {
420                    return Err(limit());
421                }
422                stack.extend(values.iter().map(|value| (value, depth + 1)));
423            }
424            Value::Object(values) => {
425                if values.len() > limits.max_nodes.saturating_sub(nodes) {
426                    return Err(limit());
427                }
428                for (key, value) in values {
429                    bytes = bytes.checked_add(key.len()).ok_or_else(limit)?;
430                    stack.push((value, depth + 1));
431                }
432            }
433            _ => {}
434        }
435        if bytes > limits.max_input_bytes {
436            return Err(limit());
437        }
438    }
439    Ok(())
440}
441// An overdeep caller-created Value must not overflow the stack while being dropped.
442pub(crate) fn discard(value: Value) {
443    let mut values = vec![value];
444    while let Some(value) = values.pop() {
445        match value {
446            Value::Array(children) => values.extend(children),
447            Value::Object(children) => values.extend(children.into_values()),
448            _ => {}
449        }
450    }
451}
452
453struct Context<'a> {
454    registry: &'static Registry,
455    limits: &'a ValidationLimits,
456    mode: Mode,
457    work: usize,
458    copied: usize,
459}
460impl Context<'_> {
461    fn tick(&mut self, depth: usize) -> Result<()> {
462        self.work = self.work.checked_add(1).ok_or_else(limit)?;
463        if depth > self.limits.max_depth || self.work > self.limits.max_work {
464            Err(limit())
465        } else {
466            Ok(())
467        }
468    }
469    fn copy_text(&mut self, text: &str) -> Result<String> {
470        self.copied = self.copied.checked_add(text.len()).ok_or_else(limit)?;
471        if self.copied > self.limits.max_copied_bytes {
472            return Err(limit());
473        }
474        Ok(text.to_owned())
475    }
476    fn clone_value(&mut self, value: &Value, depth: usize) -> Result<Value> {
477        self.tick(depth)?;
478        Ok(match value {
479            Value::String(text) => Value::String(self.copy_text(text)?),
480            Value::Array(values) => Value::Array(
481                values
482                    .iter()
483                    .map(|value| self.clone_value(value, depth + 1))
484                    .collect::<Result<_>>()?,
485            ),
486            Value::Object(values) => {
487                let mut result = Map::new();
488                for (key, value) in values {
489                    result.insert(self.copy_text(key)?, self.clone_value(value, depth + 1)?);
490                }
491                Value::Object(result)
492            }
493            value => value.clone(),
494        })
495    }
496    fn check_finite_json(&mut self, value: &Value, depth: usize) -> Result<()> {
497        self.tick(depth)?;
498        match value {
499            Value::Object(values) => {
500                for (key, value) in values {
501                    if matches!(key.as_str(), "__proto__" | "constructor" | "prototype") {
502                        return Err(invalid());
503                    }
504                    self.check_finite_json(value, depth + 1)?;
505                }
506            }
507            Value::Array(values) => {
508                for value in values {
509                    self.check_finite_json(value, depth + 1)?;
510                }
511            }
512            Value::Number(value) if !value.as_f64().is_some_and(f64::is_finite) => {
513                return Err(invalid());
514            }
515            _ => {}
516        }
517        Ok(())
518    }
519    fn walk(&mut self, schema: &str, input: Option<&Value>, depth: usize) -> Result<Option<Value>> {
520        let registry = self.registry;
521        let mut schema = schema;
522        let mut input = input;
523        let mut effects = Vec::new();
524        let mut terminal = None;
525        let mut reached_core = false;
526        // Source wrappers are traversed iteratively. They do not increase JSON
527        // depth or grow the native call stack for optional/nullable/lazy chains.
528        for _ in 0..64 {
529            // A missing property has no JSON child. If a default supplies one,
530            // the next wrapper/core step checks its actual output depth.
531            self.tick(if input.is_none() {
532                depth.saturating_sub(1)
533            } else {
534                depth
535            })?;
536            let node = registry.graph.nodes.get(schema).ok_or_else(invalid)?;
537            match node
538                .get("kind")
539                .and_then(Value::as_str)
540                .ok_or_else(invalid)?
541            {
542                "ZodResponseCompatibility" => {
543                    // Generated by tools/red_team_dataplane.py.
544                    // Select the operation's frozen TypeScript floor exclusively
545                    // for responses; requests retain the strict vendor contract.
546                    // The shared tolerant policy applies after this selection.
547                    schema = child(
548                        node,
549                        if self.mode.is_response() {
550                            "response"
551                        } else {
552                            "request"
553                        },
554                    )?;
555                }
556                "ZodOptional" if input.is_none() => {
557                    if !self.mode.is_response()
558                        && node.get("responseOnly") == Some(&Value::Bool(true))
559                    {
560                        return Err(invalid());
561                    }
562                    terminal = Some(None);
563                    reached_core = true;
564                    break;
565                }
566                "ZodNullable" if input.is_some_and(Value::is_null) => {
567                    terminal = Some(Some(Value::Null));
568                    reached_core = true;
569                    break;
570                }
571                "ZodOptional" | "ZodNullable" => schema = child(node, "inner")?,
572                "ZodDefault" => {
573                    input = input.or(Some(
574                        &registry.semantics.get(schema).ok_or_else(invalid)?.default,
575                    ));
576                    schema = child(node, "inner")?;
577                }
578                "ZodLazy" => schema = child(node, "target")?,
579                "ZodEffects" => {
580                    effects.push(registry.semantics.get(schema).ok_or_else(invalid)?);
581                    schema = child(node, "inner")?;
582                }
583                _ => {
584                    reached_core = true;
585                    break;
586                }
587            }
588        }
589        if !reached_core {
590            return Err(limit());
591        }
592        let node = registry.graph.nodes.get(schema).ok_or_else(invalid)?;
593        let kind = node
594            .get("kind")
595            .and_then(Value::as_str)
596            .ok_or_else(invalid)?;
597        let mut result = if let Some(value) = terminal {
598            value
599        } else {
600            (|| -> Result<Option<Value>> {
601                match kind {
602                    "ZodPipeline" => {
603                        let first = self.walk(child(node, "input")?, input, depth)?;
604                        self.walk(child(node, "output")?, first.as_ref(), depth)
605                    }
606                    "ZodAny" | "ZodUnknown" => input
607                        .map(|value| self.clone_value(value, depth))
608                        .transpose(),
609                    "ZodNever" | "ZodDate" => Err(invalid()),
610                    "ZodObject" => self.object(node, input, depth),
611                    "ZodArray" => {
612                        let values = input.and_then(Value::as_array).ok_or_else(invalid)?;
613                        for (key, exact) in [
614                            ("minLength", false),
615                            ("maxLength", false),
616                            ("exactLength", true),
617                        ] {
618                            if let Some(length) = node
619                                .get(key)
620                                .and_then(|value| value.get("value"))
621                                .and_then(Value::as_u64)
622                            {
623                                let length = usize::try_from(length).map_err(|_| invalid())?;
624                                if (exact && values.len() != length)
625                                    || (key == "minLength" && values.len() < length)
626                                    || (key == "maxLength" && values.len() > length)
627                                {
628                                    return Err(invalid());
629                                }
630                            }
631                        }
632                        let mut result = Vec::with_capacity(values.len());
633                        for value in values {
634                            result.push(
635                                self.walk(child(node, "element")?, Some(value), depth + 1)?
636                                    .ok_or_else(invalid)?,
637                            );
638                        }
639                        Ok(Some(Value::Array(result)))
640                    }
641                    "ZodRecord" => {
642                        let values = input.and_then(Value::as_object).ok_or_else(invalid)?;
643                        let mut result = Map::new();
644                        for (key, value) in values {
645                            let key_value = Value::String(self.copy_text(key)?);
646                            self.walk(child(node, "key")?, Some(&key_value), depth + 1)?;
647                            let normalized = self
648                                .walk(child(node, "value")?, Some(value), depth + 1)?
649                                .ok_or_else(invalid)?;
650                            if self.mode.is_response()
651                                || node.get("preserveReservedKeys") == Some(&Value::Bool(true))
652                                || key != "__proto__"
653                            {
654                                result.insert(self.copy_text(key)?, normalized);
655                            }
656                        }
657                        Ok(Some(Value::Object(result)))
658                    }
659                    "ZodUnion" => {
660                        let variants = node
661                            .get("variants")
662                            .and_then(Value::as_array)
663                            .ok_or_else(invalid)?;
664                        if self.mode.is_response()
665                            && input.is_some_and(Value::is_string)
666                            && variants.iter().all(|variant| {
667                                variant
668                                    .get("$ref")
669                                    .and_then(Value::as_str)
670                                    .and_then(|sid| registry.graph.nodes.get(sid))
671                                    .is_some_and(|node| {
672                                        node["kind"] == "ZodLiteral" && node["value"].is_string()
673                                    })
674                            })
675                        {
676                            return input
677                                .map(|value| self.clone_value(value, depth))
678                                .transpose();
679                        }
680                        for variant in variants {
681                            let variant = variant
682                                .get("$ref")
683                                .and_then(Value::as_str)
684                                .ok_or_else(invalid)?;
685                            match self.walk(variant, input, depth) {
686                                Ok(value) => return Ok(value),
687                                Err(error) if error.kind() == ErrorKind::Limit => {
688                                    return Err(error);
689                                }
690                                Err(_) => {}
691                            }
692                        }
693                        Err(invalid())
694                    }
695                    "ZodDiscriminatedUnion" => {
696                        let values = input.and_then(Value::as_object).ok_or_else(invalid)?;
697                        let discriminator = node
698                            .get("discriminator")
699                            .and_then(Value::as_str)
700                            .ok_or_else(invalid)?;
701                        let value = match values.get(discriminator) {
702                            Some(value) => value,
703                            None if node.get("preserveUnknownResponse")
704                                == Some(&Value::Bool(true)) =>
705                            {
706                                // OpenAPI discriminators are hints; optional tags
707                                // still obey oneOf's exactly-one-success rule.
708                                let variants = node
709                                    .get("variants")
710                                    .and_then(Value::as_array)
711                                    .ok_or_else(invalid)?;
712                                let mut selected = None;
713                                for variant in variants {
714                                    let schema = variant
715                                        .get("$ref")
716                                        .and_then(Value::as_str)
717                                        .ok_or_else(invalid)?;
718                                    match self.walk(schema, input, depth) {
719                                        Ok(value) if selected.is_none() => selected = Some(value),
720                                        Ok(_) => return Err(invalid()),
721                                        Err(error) if error.kind() == ErrorKind::Limit => {
722                                            return Err(error);
723                                        }
724                                        Err(_) => {}
725                                    }
726                                }
727                                return selected.ok_or_else(invalid);
728                            }
729                            None => return Err(invalid()),
730                        };
731                        let mapping = node
732                            .get("discriminatorMapping")
733                            .and_then(Value::as_array)
734                            .ok_or_else(invalid)?;
735                        let selected = mapping
736                            .iter()
737                            .find(|mapping| mapping.get("value") == Some(value));
738                        if selected.is_none()
739                            && self.mode.is_response()
740                            && value.is_string()
741                            && node.get("preserveUnknownResponse") == Some(&Value::Bool(true))
742                        {
743                            return input
744                                .map(|value| self.clone_value(value, depth))
745                                .transpose();
746                        }
747                        let selected = selected.ok_or_else(invalid)?;
748                        self.walk(child(selected, "schema")?, input, depth)
749                    }
750                    "ZodIntersection" => {
751                        let left = self.walk(child(node, "left")?, input, depth)?;
752                        let right = self.walk(child(node, "right")?, input, depth)?;
753                        match (left, right) {
754                            (None, None) => Ok(None),
755                            (Some(left), Some(right)) => merge(left, right).map(Some),
756                            _ => Err(invalid()),
757                        }
758                    }
759                    "ZodEnum" | "ZodNativeEnum" => {
760                        let value = input.ok_or_else(invalid)?;
761                        let values = node
762                            .get("values")
763                            .and_then(Value::as_array)
764                            .ok_or_else(invalid)?;
765                        if values.contains(value)
766                            || (self.mode.is_response()
767                                && ((value.is_string() && values.iter().any(Value::is_string))
768                                    || (value.is_number() && values.iter().any(Value::is_number))))
769                        {
770                            Ok(Some(self.clone_value(value, depth)?))
771                        } else {
772                            Err(invalid())
773                        }
774                    }
775                    "ZodLiteral" => {
776                        let value = input.ok_or_else(invalid)?;
777                        if equal(value, node.get("value").ok_or_else(invalid)?) {
778                            Ok(Some(self.clone_value(value, depth)?))
779                        } else {
780                            Err(invalid())
781                        }
782                    }
783                    "ZodString" => {
784                        let value = input.and_then(Value::as_str).ok_or_else(invalid)?;
785                        check_string(
786                            value,
787                            node.get("checks")
788                                .and_then(Value::as_array)
789                                .ok_or_else(invalid)?,
790                            self.mode,
791                        )?;
792                        Ok(Some(self.clone_value(input.ok_or_else(invalid)?, depth)?))
793                    }
794                    "ZodNumber" => {
795                        let value = input.and_then(Value::as_f64).ok_or_else(invalid)?;
796                        check_number(
797                            value,
798                            node.get("checks")
799                                .and_then(Value::as_array)
800                                .ok_or_else(invalid)?,
801                        )?;
802                        Ok(Some(self.clone_value(input.ok_or_else(invalid)?, depth)?))
803                    }
804                    "ZodBoolean" => {
805                        if input.is_some_and(Value::is_boolean) {
806                            Ok(input.cloned())
807                        } else {
808                            Err(invalid())
809                        }
810                    }
811                    "ZodNull" => {
812                        if input.is_some_and(Value::is_null) {
813                            Ok(Some(Value::Null))
814                        } else {
815                            Err(invalid())
816                        }
817                    }
818                    _ => Err(invalid()),
819                }
820            })()?
821        };
822        for effect in effects.into_iter().rev() {
823            if matches!(
824                effect.binding.as_str(),
825                "finite_json" | "finite_json_object"
826            ) {
827                let value = result.as_ref().ok_or_else(invalid)?;
828                if effect.binding == "finite_json_object" && !value.is_object() {
829                    return Err(invalid());
830                }
831                self.check_finite_json(value, depth)?;
832            } else {
833                result = Some(apply(&effect.binding, result)?);
834            }
835        }
836        Ok(result)
837    }
838    fn object(
839        &mut self,
840        node: &Value,
841        input: Option<&Value>,
842        depth: usize,
843    ) -> Result<Option<Value>> {
844        let values = input.and_then(Value::as_object).ok_or_else(invalid)?;
845        let properties = node
846            .get("properties")
847            .and_then(Value::as_object)
848            .ok_or_else(invalid)?;
849        let mut result = Map::new();
850        for (key, property) in properties {
851            let schema = child(property, "schema")?;
852            let present = values.get(key);
853            // An absent property is still validated (including any default), so
854            // acceptance never depends on whether defaults are materialized.
855            if let Some(value) = self.walk(schema, present, depth + 1)?
856                && (self.mode.is_response()
857                    || node.get("preserveReservedKeys") == Some(&Value::Bool(true))
858                    || key != "__proto__")
859            {
860                // Charge the same key-copy budget even when an omitted default
861                // will not appear in the output. The response policies must
862                // agree on acceptance at resource boundaries too.
863                let key = self.copy_text(key)?;
864                if present.is_some() || self.mode.inserts_defaults() {
865                    result.insert(key, value);
866                }
867            }
868        }
869        let catchall = child(node, "catchall")?;
870        let catchall_never = self
871            .registry
872            .graph
873            .nodes
874            .get(catchall)
875            .is_some_and(|node| node["kind"] == "ZodNever");
876        for (key, value) in values {
877            if properties.contains_key(key) {
878                continue;
879            }
880            let value = if !catchall_never {
881                self.walk(catchall, Some(value), depth + 1)?
882                    .ok_or_else(invalid)?
883            } else if self.mode.is_response() || node["unknownFields"] == "passthrough" {
884                self.clone_value(value, depth + 1)?
885            } else if node["unknownFields"] == "strict" {
886                return Err(invalid());
887            } else {
888                continue;
889            };
890            if self.mode.is_response()
891                || node.get("preserveReservedKeys") == Some(&Value::Bool(true))
892                || key != "__proto__"
893            {
894                result.insert(self.copy_text(key)?, value);
895            }
896        }
897        Ok(Some(Value::Object(result)))
898    }
899}
900
901fn equal(left: &Value, right: &Value) -> bool {
902    match (left.as_f64(), right.as_f64()) {
903        (Some(left), Some(right)) => left == right,
904        _ => left == right,
905    }
906}
907fn merge(left: Value, right: Value) -> Result<Value> {
908    match (left, right) {
909        (Value::Object(mut left), Value::Object(right)) => {
910            for (key, right) in right {
911                if let Some(old) = left.remove(&key) {
912                    left.insert(key, merge(old, right)?);
913                } else {
914                    left.insert(key, right);
915                }
916            }
917            Ok(Value::Object(left))
918        }
919        (Value::Array(left), Value::Array(right)) if left.len() == right.len() => Ok(Value::Array(
920            left.into_iter()
921                .zip(right)
922                .map(|(left, right)| merge(left, right))
923                .collect::<Result<_>>()?,
924        )),
925        (left, right) if equal(&left, &right) => Ok(left),
926        _ => Err(invalid()),
927    }
928}
929
930fn js_space(value: char) -> bool {
931    matches!(value,
932        '\u{0009}'..='\u{000d}' | '\u{0020}' | '\u{00a0}' | '\u{1680}'
933        | '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}'
934        | '\u{205f}' | '\u{3000}' | '\u{feff}'
935    )
936}
937fn object(value: &Value) -> Result<&Map<String, Value>> {
938    value.as_object().ok_or_else(invalid)
939}
940fn not_null(map: &Map<String, Value>, key: &str) -> bool {
941    map.get(key).is_some_and(|value| !value.is_null())
942}
943fn apply(binding: &str, value: Option<Value>) -> Result<Value> {
944    let value = value.ok_or_else(invalid)?;
945    let valid = match binding {
946        "native_transport" | "native_cancellation" | "native_binary" => false,
947        "nonblank" => value
948            .as_str()
949            .is_some_and(|value| !value.trim_matches(js_space).is_empty()),
950        "chart_bounds" => {
951            let value = object(&value)?;
952            [("totalUnitsMin", "totalUnitsMax"), ("costMin", "costMax")]
953                .into_iter()
954                .all(|(lower, upper)| {
955                    match (
956                        value.get(lower).and_then(Value::as_f64),
957                        value.get(upper).and_then(Value::as_f64),
958                    ) {
959                        (Some(lower), Some(upper)) => lower <= upper,
960                        _ => true,
961                    }
962                })
963        }
964        "time_order" => {
965            let value = object(&value)?;
966            match (
967                value.get("start_time").and_then(Value::as_str),
968                value.get("end_time").and_then(Value::as_str),
969            ) {
970                (Some(start), Some(end)) => match (timestamp(start), timestamp(end)) {
971                    (Some(start), Some(end)) => start <= end,
972                    _ => false,
973                },
974                _ => true,
975            }
976        }
977        "nonempty_object" => value.as_object().is_some_and(|value| !value.is_empty()),
978        "secret_create" => {
979            let value = object(&value)?;
980            let allowed = !(value.get("allow_all_workspaces") == Some(&Value::Bool(true))
981                && value.contains_key("allowed_workspaces"));
982            let key = match value.get("manager_type").and_then(Value::as_str) {
983                Some("aws_sm") => "aws_auth_type",
984                Some("azure_kv") => "azure_auth_mode",
985                _ => "vault_auth_type",
986            };
987            allowed
988                && value
989                    .get("auth_config")
990                    .and_then(Value::as_object)
991                    .is_some_and(|config| config.contains_key(key))
992        }
993        "secret_update" => {
994            let value = object(&value)?;
995            !(value.is_empty()
996                || value.get("allow_all_workspaces") == Some(&Value::Bool(true))
997                    && value.contains_key("allowed_workspaces"))
998        }
999        "realtime_type" => value
1000            .get("type")
1001            .and_then(Value::as_str)
1002            .is_some_and(|value| !value.is_empty()),
1003        "usage_reset_exclusive" => {
1004            let value = object(&value)?;
1005            !not_null(value, "periodic_reset") || !not_null(value, "periodic_reset_days")
1006        }
1007        "rotation_exclusive" => {
1008            let value = object(&value)?;
1009            !not_null(value, "rotation_period") || !not_null(value, "next_rotation_at")
1010        }
1011        "guardrail_checks" => {
1012            let value = object(&value)?;
1013            value.get("target").and_then(Value::as_str) == Some("mcp_tools")
1014                || (value.contains_key("checks") && value.contains_key("actions"))
1015        }
1016        "profile_selector" => {
1017            let value = object(&value)?;
1018            ["profile_id", "profile_name"].into_iter().any(|key| {
1019                value
1020                    .get(key)
1021                    .and_then(Value::as_str)
1022                    .is_some_and(|value| !value.is_empty())
1023            })
1024        }
1025        "tool_event" => {
1026            let value = object(&value)?;
1027            value.contains_key("input") || value.contains_key("output")
1028        }
1029        "identity" => true,
1030        "unwrap_data" => {
1031            return match value {
1032                Value::Object(mut values) => values.remove("data").ok_or_else(invalid),
1033                _ => Err(invalid()),
1034            };
1035        }
1036        "message_object" => {
1037            return match value {
1038                Value::String(message) => {
1039                    let mut values = Map::new();
1040                    values.insert("message".to_owned(), Value::String(message));
1041                    Ok(Value::Object(values))
1042                }
1043                _ => Err(invalid()),
1044            };
1045        }
1046        _ => false,
1047    };
1048    if valid { Ok(value) } else { Err(invalid()) }
1049}
1050
1051fn patterns() -> &'static BTreeMap<String, regex::Regex> {
1052    static PATTERNS: OnceLock<BTreeMap<String, regex::Regex>> = OnceLock::new();
1053    PATTERNS.get_or_init(|| {
1054        let whitespace = concat!(
1055            r"[\x{0009}-\x{000d}\x{0020}\x{00a0}\x{1680}\x{2000}-\x{200a}",
1056            r"\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]",
1057        );
1058        [
1059            r"^[A-Za-z0-9][A-Za-z0-9_-]*$",
1060            r"^[^,\s]+__[^,\s]+$",
1061            r"^[a-zA-Z0-9_-]+$",
1062            r"^\d+$",
1063        ]
1064        .into_iter()
1065        .filter_map(|source| {
1066            let native = source.replace(r"\d", "[0-9]").replace(r"\s", whitespace);
1067            regex::RegexBuilder::new(&native)
1068                .size_limit(64 * 1024)
1069                .build()
1070                .ok()
1071                .map(|regex| (source.to_owned(), regex))
1072        })
1073        .collect()
1074    })
1075}
1076fn email(value: &str) -> bool {
1077    let Some((local, domain)) = value.split_once('@') else {
1078        return false;
1079    };
1080    let local_character = |byte: u8| byte.is_ascii_alphanumeric() || b"_'+-.".contains(&byte);
1081    if local.is_empty()
1082        || local.starts_with('.')
1083        || local.contains("..")
1084        || !local.bytes().all(local_character)
1085        || !local
1086            .as_bytes()
1087            .last()
1088            .is_some_and(|byte| byte.is_ascii_alphanumeric() || b"_+-".contains(byte))
1089    {
1090        return false;
1091    }
1092    let Some((labels, top_level)) = domain.rsplit_once('.') else {
1093        return false;
1094    };
1095    top_level.len() >= 2
1096        && top_level.bytes().all(|byte| byte.is_ascii_alphabetic())
1097        && labels.split('.').all(|part| {
1098            !part.is_empty()
1099                && part.as_bytes()[0].is_ascii_alphanumeric()
1100                && part
1101                    .bytes()
1102                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1103        })
1104}
1105fn datetime_pattern() -> Option<&'static regex::Regex> {
1106    static REGEX: OnceLock<Option<regex::Regex>> = OnceLock::new();
1107    REGEX
1108        .get_or_init(|| {
1109            regex::Regex::new(concat!(
1110                r"^([0-9]{4})-([0-9]{2})-([0-9]{2})T([01][0-9]|2[0-3]):([0-5][0-9])",
1111                r"(?::([0-5][0-9])(?:\.([0-9]+))?)?(Z|[+-][0-9]{2}:?[0-9]{2})$",
1112            ))
1113            .ok()
1114        })
1115        .as_ref()
1116}
1117fn datetime(value: &str) -> bool {
1118    let Some(captures) = datetime_pattern().and_then(|pattern| pattern.captures(value)) else {
1119        return false;
1120    };
1121    let year = captures[1].parse::<i32>().ok();
1122    let month = captures[2]
1123        .parse::<u8>()
1124        .ok()
1125        .and_then(|value| time::Month::try_from(value).ok());
1126    let day = captures[3].parse::<u8>().ok();
1127    match (year, month, day) {
1128        (Some(year), Some(month), Some(day)) => {
1129            time::Date::from_calendar_date(year, month, day).is_ok()
1130        }
1131        _ => false,
1132    }
1133}
1134fn timestamp(value: &str) -> Option<i128> {
1135    let captures = datetime_pattern()?.captures(value)?;
1136    let date = time::Date::from_calendar_date(
1137        captures[1].parse().ok()?,
1138        time::Month::try_from(captures[2].parse::<u8>().ok()?).ok()?,
1139        captures[3].parse().ok()?,
1140    )
1141    .ok()?;
1142    let seconds = captures
1143        .get(6)
1144        .map(|value| value.as_str().parse::<u8>())
1145        .transpose()
1146        .ok()?
1147        .unwrap_or(0);
1148    let millis = captures
1149        .get(7)
1150        .map(|value| {
1151            let mut text = value.as_str().chars().take(3).collect::<String>();
1152            while text.len() < 3 {
1153                text.push('0');
1154            }
1155            text.parse::<u16>()
1156        })
1157        .transpose()
1158        .ok()?
1159        .unwrap_or(0);
1160    let zone = &captures[8];
1161    let offset = if zone == "Z" {
1162        time::UtcOffset::UTC
1163    } else {
1164        let sign = if zone.starts_with('-') { -1 } else { 1 };
1165        let digits = zone[1..].replace(':', "");
1166        time::UtcOffset::from_hms(
1167            digits[..2].parse::<i8>().ok()? * sign,
1168            digits[2..].parse::<i8>().ok()? * sign,
1169            0,
1170        )
1171        .ok()?
1172    };
1173    Some(
1174        date.with_hms_milli(
1175            captures[4].parse().ok()?,
1176            captures[5].parse().ok()?,
1177            seconds,
1178            millis,
1179        )
1180        .ok()?
1181        .assume_offset(offset)
1182        .unix_timestamp_nanos()
1183            / 1_000_000,
1184    )
1185}
1186fn check_string(value: &str, checks: &[Value], mode: Mode) -> Result<()> {
1187    let length = value.encode_utf16().count() as u64;
1188    for check in checks {
1189        let kind = check
1190            .get("kind")
1191            .and_then(Value::as_str)
1192            .ok_or_else(invalid)?;
1193        let length = match check.get("unit") {
1194            None => length,
1195            Some(Value::String(unit)) if unit == "code_points" => value.chars().count() as u64,
1196            Some(_) => return Err(invalid()),
1197        };
1198        // Validate schema metadata even when response policy skips the constraint.
1199        let bound = if matches!(kind, "min" | "max") {
1200            Some(
1201                check
1202                    .get("value")
1203                    .and_then(Value::as_u64)
1204                    .ok_or_else(invalid)?,
1205            )
1206        } else {
1207            None
1208        };
1209        let regex = if kind == "regex" {
1210            let source = check
1211                .get("regex")
1212                .and_then(|value| value.get("$regexp"))
1213                .and_then(Value::as_str)
1214                .ok_or_else(invalid)?;
1215            Some(patterns().get(source).ok_or_else(invalid)?)
1216        } else {
1217            None
1218        };
1219        // Service strings are data, not caller input. Keep their exact wire value.
1220        // Unknown check kinds still fail closed; only these reviewed assertions widen.
1221        if mode.is_response()
1222            && (matches!(
1223                kind,
1224                "uuid" | "rfc3339" | "datetime" | "date" | "duration" | "email" | "url" | "regex"
1225            ) || (matches!(kind, "min" | "max")
1226                && check.get("requestOnly") == Some(&Value::Bool(true))))
1227        {
1228            continue;
1229        }
1230        let valid = match kind {
1231            "min" => length >= bound.ok_or_else(invalid)?,
1232            "max" => length <= bound.ok_or_else(invalid)?,
1233            "uuid" => crate::data::is_uuid(value),
1234            "email" => email(value),
1235            "url" => url::Url::parse(value).is_ok(),
1236            "datetime" => datetime(value),
1237            "duration" => duration(value),
1238            "date" => date(value),
1239            "rfc3339" => rfc3339(value),
1240            "regex" => regex.ok_or_else(invalid)?.is_match(value),
1241            _ => false,
1242        };
1243        if !valid {
1244            return Err(invalid());
1245        }
1246    }
1247    Ok(())
1248}
1249// RFC 3339 Appendix A duration grammar. Keep this separate from the frozen
1250// TypeScript regexp registry and reject empty P/T forms without lookarounds.
1251fn duration(value: &str) -> bool {
1252    static PATTERN: OnceLock<regex::Regex> = OnceLock::new();
1253    let pattern = PATTERN.get_or_init(|| {
1254        regex::Regex::new(r"^P(?:(?:[0-9]+Y)?(?:[0-9]+M)?(?:[0-9]+D)?(?:T(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+S)?)?|[0-9]+W)$")
1255            .expect("constant duration grammar")
1256    });
1257    value != "P" && !value.ends_with('T') && pattern.is_match(value)
1258}
1259
1260fn date(value: &str) -> bool {
1261    if value.len() != 10
1262        || !value.bytes().enumerate().all(|(i, byte)| {
1263            if i == 4 || i == 7 {
1264                byte == b'-'
1265            } else {
1266                byte.is_ascii_digit()
1267            }
1268        })
1269    {
1270        return false;
1271    }
1272    let Ok(year) = value[..4].parse::<i32>() else {
1273        return false;
1274    };
1275    let Ok(month) = value[5..7].parse::<u8>() else {
1276        return false;
1277    };
1278    let Ok(day) = value[8..].parse::<u8>() else {
1279        return false;
1280    };
1281    let Ok(month) = time::Month::try_from(month) else {
1282        return false;
1283    };
1284    time::Date::from_calendar_date(year, month, day).is_ok()
1285}
1286
1287fn rfc3339(value: &str) -> bool {
1288    static PATTERN: OnceLock<regex::Regex> = OnceLock::new();
1289    let pattern = PATTERN.get_or_init(|| {
1290        regex::Regex::new(r"^([0-9]{4}-[0-9]{2}-[0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?([Zz]|([+-])([0-9]{2}):([0-9]{2}))$")
1291            .expect("constant RFC 3339 grammar")
1292    });
1293    let Some(captures) = pattern.captures(value) else {
1294        return false;
1295    };
1296    if !date(&captures[1]) {
1297        return false;
1298    }
1299    let number = |index| {
1300        captures
1301            .get(index)
1302            .and_then(|v| v.as_str().parse::<i16>().ok())
1303    };
1304    let (Some(hour), Some(minute), Some(second)) = (number(2), number(3), number(4)) else {
1305        return false;
1306    };
1307    if hour > 23 || minute > 59 || second > 60 {
1308        return false;
1309    }
1310    let offset = if captures.get(6).is_some() {
1311        let (Some(hours), Some(minutes)) = (number(7), number(8)) else {
1312            return false;
1313        };
1314        if hours > 23 || minutes > 59 {
1315            return false;
1316        }
1317        (hours * 60 + minutes) * if &captures[6] == "-" { -1 } else { 1 }
1318    } else {
1319        0
1320    };
1321    second < 60 || (hour * 60 + minute - offset).rem_euclid(1440) == 1439
1322}
1323
1324fn check_number(value: f64, checks: &[Value]) -> Result<()> {
1325    if !value.is_finite() {
1326        return Err(invalid());
1327    }
1328    for check in checks {
1329        let valid = match check
1330            .get("kind")
1331            .and_then(Value::as_str)
1332            .ok_or_else(invalid)?
1333        {
1334            "int" => value.fract() == 0.0,
1335            "finite" => value.is_finite(),
1336            "min" => {
1337                let boundary = check["value"].as_f64().ok_or_else(invalid)?;
1338                if check["inclusive"] == true {
1339                    value >= boundary
1340                } else {
1341                    value > boundary
1342                }
1343            }
1344            "max" => {
1345                let boundary = check["value"].as_f64().ok_or_else(invalid)?;
1346                if check["inclusive"] == true {
1347                    value <= boundary
1348                } else {
1349                    value < boundary
1350                }
1351            }
1352            "multipleOf" => multiple_of(value, check["value"].as_f64().ok_or_else(invalid)?),
1353            _ => false,
1354        };
1355        if !valid {
1356            return Err(invalid());
1357        }
1358    }
1359    Ok(())
1360}
1361fn multiple_of(value: f64, step: f64) -> bool {
1362    fn decimal_count(value: f64) -> usize {
1363        ryu_js::Buffer::new()
1364            .format_finite(value)
1365            .split_once('.')
1366            .map(|(_, fraction)| fraction.len())
1367            .unwrap_or(0)
1368    }
1369    let decimals = decimal_count(value).max(decimal_count(step));
1370    if decimals > 100 {
1371        return false;
1372    }
1373    let integer = |value: f64| {
1374        let text = ryu_js::Buffer::new()
1375            .format_to_fixed(value, decimals as u8)
1376            .replace('.', "");
1377        let prefix = text
1378            .chars()
1379            .enumerate()
1380            .take_while(|(index, value)| {
1381                value.is_ascii_digit() || (*index == 0 && matches!(value, '-' | '+'))
1382            })
1383            .map(|(_, value)| value)
1384            .collect::<String>();
1385        prefix.parse::<f64>().ok()
1386    };
1387    match (integer(value), integer(step)) {
1388        (Some(value), Some(step)) => step != 0.0 && value % step == 0.0,
1389        _ => false,
1390    }
1391}
1392
1393/// A native JavaScript-Date equivalent represented as epoch milliseconds.
1394#[derive(Clone, Copy, PartialEq, Eq)]
1395pub struct NativeDate {
1396    epoch_millis: i64,
1397}
1398impl NativeDate {
1399    pub fn from_epoch_millis(value: i64) -> Result<Self> {
1400        if value.unsigned_abs() > 8_640_000_000_000_000 {
1401            Err(invalid())
1402        } else {
1403            Ok(Self {
1404                epoch_millis: value,
1405            })
1406        }
1407    }
1408    pub fn epoch_millis(&self) -> i64 {
1409        self.epoch_millis
1410    }
1411}
1412impl std::fmt::Debug for NativeDate {
1413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1414        f.write_str("NativeDate([REDACTED])")
1415    }
1416}
1417impl serde::Serialize for NativeDate {
1418    fn serialize<S: serde::Serializer>(&self, _: S) -> std::result::Result<S::Ok, S::Error> {
1419        Err(serde::ser::Error::custom(
1420            "native date requires a typed adapter",
1421        ))
1422    }
1423}
1424impl<'de> Deserialize<'de> for NativeDate {
1425    fn deserialize<D: serde::Deserializer<'de>>(_: D) -> std::result::Result<Self, D::Error> {
1426        Err(serde::de::Error::custom(
1427            "native date cannot be decoded from JSON",
1428        ))
1429    }
1430}
1431
1432/// The canonical native file/Blob input; JSON encoding is rejected.
1433#[cfg(feature = "models")]
1434pub use crate::models::BinaryFile as BinaryPart;
1435
1436fn boundary(schema: &str, expected: &str) -> Result<()> {
1437    let registry = registry()?;
1438    let mut schema = sid(registry, schema)?;
1439    for _ in 0..MAX_DEPTH {
1440        let node = registry.graph.nodes.get(schema).ok_or_else(invalid)?;
1441        if expected == "date" && node["kind"] == "ZodDate" {
1442            return Ok(());
1443        }
1444        if let Some(semantic) = registry.semantics.get(schema) {
1445            return if semantic.binding == expected {
1446                Ok(())
1447            } else {
1448                Err(invalid())
1449            };
1450        }
1451        schema = child(node, "inner")?;
1452    }
1453    Err(limit())
1454}
1455#[cfg(feature = "models")]
1456pub fn validate_binary(schema: &str, value: &BinaryPart) -> Result<()> {
1457    boundary(schema, "native_binary")?;
1458    value.validate(512 * 1024 * 1024)
1459}
1460pub fn validate_transport(schema: &str, _value: &dyn Transport) -> Result<()> {
1461    boundary(schema, "native_transport")
1462}
1463pub fn validate_cancellation(schema: &str, _value: &RequestOptions) -> Result<()> {
1464    boundary(schema, "native_cancellation")
1465}
1466pub fn validate_date(schema: &str, _value: &NativeDate) -> Result<()> {
1467    boundary(schema, "date")
1468}
1469
1470#[cfg(test)]
1471mod tests;