Skip to main content

tea_tools/
schema.rs

1use std::fmt;
2use std::sync::Arc;
3
4use jsonschema::Validator;
5use serde_json::Value;
6use thiserror::Error;
7
8/// Maximum encoded JSON bytes in one tool schema or validated value.
9pub const MAX_TOOL_VALUE_BYTES: usize = 256 * 1024;
10/// Maximum JSON nesting depth in one tool schema or validated value.
11pub const MAX_TOOL_VALUE_DEPTH: usize = 32;
12/// Maximum normalized validation errors returned for one value.
13pub const MAX_SCHEMA_ERRORS: usize = 16;
14const MAX_SCHEMA_ERROR_MESSAGE_BYTES: usize = 4096;
15
16/// Offline compiled Draft 2020-12 tool schema.
17#[derive(Clone)]
18pub struct CompiledToolSchema {
19    source: Value,
20    validator: Arc<Validator>,
21}
22
23impl CompiledToolSchema {
24    /// Compiles a bounded, self-contained Draft 2020-12 schema.
25    ///
26    /// # Errors
27    ///
28    /// Rejects oversized/deep schemas, external references, and invalid schema
29    /// syntax. HTTP and file retrieval are not enabled.
30    pub fn compile(source: Value) -> Result<Self, SchemaCompilationError> {
31        validate_json_bounds(&source).map_err(|()| SchemaCompilationError::SchemaOutOfBounds)?;
32        if contains_external_reference(&source) {
33            return Err(SchemaCompilationError::ExternalReference);
34        }
35        let validator = jsonschema::draft202012::options()
36            .build(&source)
37            .map_err(|_| SchemaCompilationError::InvalidSchema)?;
38        Ok(Self {
39            source,
40            validator: Arc::new(validator),
41        })
42    }
43
44    /// Validates a bounded JSON value.
45    ///
46    /// # Errors
47    ///
48    /// Returns bounded deterministic diagnostics or a value-bounds failure.
49    pub fn validate(&self, value: &Value) -> Result<(), SchemaValidationFailure> {
50        validate_json_bounds(value).map_err(|()| SchemaValidationFailure::ValueOutOfBounds)?;
51        let mut errors = self
52            .validator
53            .iter_errors(value)
54            .map(|error| {
55                let instance_path = error.instance_path.to_string();
56                let schema_path = error.schema_path.to_string();
57                let code = schema_keyword(&schema_path);
58                let message = truncate_utf8(&error.to_string(), MAX_SCHEMA_ERROR_MESSAGE_BYTES);
59                SchemaValidationError {
60                    code,
61                    instance_path,
62                    schema_path,
63                    message,
64                }
65            })
66            .collect::<Vec<_>>();
67        errors.sort_by(|left, right| {
68            (
69                left.instance_path.as_str(),
70                left.schema_path.as_str(),
71                left.message.as_str(),
72            )
73                .cmp(&(
74                    right.instance_path.as_str(),
75                    right.schema_path.as_str(),
76                    right.message.as_str(),
77                ))
78        });
79        errors.truncate(MAX_SCHEMA_ERRORS);
80        if errors.is_empty() {
81            Ok(())
82        } else {
83            Err(SchemaValidationFailure::Invalid { errors })
84        }
85    }
86
87    /// Returns the immutable source schema.
88    #[must_use]
89    pub const fn source(&self) -> &Value {
90        &self.source
91    }
92}
93
94impl fmt::Debug for CompiledToolSchema {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter
97            .debug_struct("CompiledToolSchema")
98            .field("source", &self.source)
99            .finish_non_exhaustive()
100    }
101}
102
103/// Error compiling a tool schema.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
105pub enum SchemaCompilationError {
106    /// Schema exceeds encoded byte or nesting limits.
107    #[error("tool schema exceeds supported bounds")]
108    SchemaOutOfBounds,
109    /// Schema contains a non-local reference while retrieval is disabled.
110    #[error("tool schema contains an external reference")]
111    ExternalReference,
112    /// Schema is not valid Draft 2020-12 syntax.
113    #[error("tool schema is invalid")]
114    InvalidSchema,
115}
116
117/// Normalized bounded schema validation error.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct SchemaValidationError {
120    code: String,
121    instance_path: String,
122    schema_path: String,
123    message: String,
124}
125
126impl SchemaValidationError {
127    /// Returns the schema keyword or stable validation code.
128    #[must_use]
129    pub fn code(&self) -> &str {
130        &self.code
131    }
132
133    /// Returns the JSON Pointer into the rejected instance.
134    #[must_use]
135    pub fn instance_path(&self) -> &str {
136        &self.instance_path
137    }
138
139    /// Returns the JSON Pointer into the schema.
140    #[must_use]
141    pub fn schema_path(&self) -> &str {
142        &self.schema_path
143    }
144
145    /// Returns the bounded English technical message.
146    #[must_use]
147    pub fn message(&self) -> &str {
148        &self.message
149    }
150}
151
152/// Failure validating one tool argument or output value.
153#[derive(Debug, Clone, PartialEq, Eq, Error)]
154pub enum SchemaValidationFailure {
155    /// Value exceeds encoded byte or nesting limits.
156    #[error("tool value exceeds supported bounds")]
157    ValueOutOfBounds,
158    /// Value violates one or more schema constraints.
159    #[error("tool value violates its JSON Schema")]
160    Invalid {
161        /// Sorted bounded validation diagnostics.
162        errors: Vec<SchemaValidationError>,
163    },
164}
165
166impl SchemaValidationFailure {
167    /// Returns normalized diagnostics; bounds failures have no per-path errors.
168    #[must_use]
169    pub fn errors(&self) -> &[SchemaValidationError] {
170        match self {
171            Self::ValueOutOfBounds => &[],
172            Self::Invalid { errors } => errors,
173        }
174    }
175}
176
177fn validate_json_bounds(value: &Value) -> Result<(), ()> {
178    if serde_json::to_vec(value).map_err(|_| ())?.len() > MAX_TOOL_VALUE_BYTES
179        || json_depth(value) > MAX_TOOL_VALUE_DEPTH
180    {
181        Err(())
182    } else {
183        Ok(())
184    }
185}
186
187fn contains_external_reference(value: &Value) -> bool {
188    match value {
189        Value::Array(values) => values.iter().any(contains_external_reference),
190        Value::Object(values) => values.iter().any(|(key, value)| {
191            (key == "$ref"
192                && value
193                    .as_str()
194                    .is_some_and(|reference| !reference.starts_with('#')))
195                || contains_external_reference(value)
196        }),
197        _ => false,
198    }
199}
200
201fn json_depth(value: &Value) -> usize {
202    match value {
203        Value::Array(values) => 1 + values.iter().map(json_depth).max().unwrap_or(0),
204        Value::Object(values) => 1 + values.values().map(json_depth).max().unwrap_or(0),
205        _ => 1,
206    }
207}
208
209fn schema_keyword(schema_path: &str) -> String {
210    schema_path
211        .rsplit('/')
212        .find(|segment| !segment.is_empty() && !segment.bytes().all(|byte| byte.is_ascii_digit()))
213        .unwrap_or("schema_validation")
214        .replace('~', "_")
215}
216
217fn truncate_utf8(value: &str, max_bytes: usize) -> String {
218    if value.len() <= max_bytes {
219        return value.to_owned();
220    }
221    let mut end = max_bytes;
222    while !value.is_char_boundary(end) {
223        end -= 1;
224    }
225    value[..end].to_owned()
226}