1use std::fmt;
2use std::sync::Arc;
3
4use jsonschema::Validator;
5use serde_json::Value;
6use thiserror::Error;
7
8pub const MAX_TOOL_VALUE_BYTES: usize = 256 * 1024;
10pub const MAX_TOOL_VALUE_DEPTH: usize = 32;
12pub const MAX_SCHEMA_ERRORS: usize = 16;
14const MAX_SCHEMA_ERROR_MESSAGE_BYTES: usize = 4096;
15
16#[derive(Clone)]
18pub struct CompiledToolSchema {
19 source: Value,
20 validator: Arc<Validator>,
21}
22
23impl CompiledToolSchema {
24 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 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
105pub enum SchemaCompilationError {
106 #[error("tool schema exceeds supported bounds")]
108 SchemaOutOfBounds,
109 #[error("tool schema contains an external reference")]
111 ExternalReference,
112 #[error("tool schema is invalid")]
114 InvalidSchema,
115}
116
117#[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 #[must_use]
129 pub fn code(&self) -> &str {
130 &self.code
131 }
132
133 #[must_use]
135 pub fn instance_path(&self) -> &str {
136 &self.instance_path
137 }
138
139 #[must_use]
141 pub fn schema_path(&self) -> &str {
142 &self.schema_path
143 }
144
145 #[must_use]
147 pub fn message(&self) -> &str {
148 &self.message
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Error)]
154pub enum SchemaValidationFailure {
155 #[error("tool value exceeds supported bounds")]
157 ValueOutOfBounds,
158 #[error("tool value violates its JSON Schema")]
160 Invalid {
161 errors: Vec<SchemaValidationError>,
163 },
164}
165
166impl SchemaValidationFailure {
167 #[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}