1use std::collections::HashMap;
2use std::io::{self, Write};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7
8use crate::tool_contract::ToolContract;
9
10#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
11pub struct LashSchema {
12 pub schema: Value,
13}
14
15impl LashSchema {
16 pub fn new(schema: Value) -> Self {
17 Self { schema }
18 }
19
20 pub fn any() -> Self {
21 Self::new(serde_json::json!({}))
22 }
23
24 pub fn object(properties: serde_json::Map<String, Value>, required: Vec<String>) -> Self {
25 let mut schema = serde_json::Map::new();
26 schema.insert("type".to_string(), Value::String("object".to_string()));
27 schema.insert("properties".to_string(), Value::Object(properties));
28 if !required.is_empty() {
29 schema.insert(
30 "required".to_string(),
31 Value::Array(required.into_iter().map(Value::String).collect()),
32 );
33 }
34 schema.insert("additionalProperties".to_string(), Value::Bool(true));
35 Self::new(Value::Object(schema))
36 }
37
38 pub fn validate(&self, value: &Value) -> Result<(), String> {
39 validate_schema(&self.schema, value)
40 }
41}
42
43const COMPILED_SCHEMA_CACHE_CAPACITY: usize = 1_024;
44const COMPILED_SCHEMA_CACHE_SCHEMA_BYTES: usize = 16 * 1024 * 1024;
45
46struct CachedSchema {
47 schema: Value,
48 compiled: Result<Arc<jsonschema::JSONSchema>, String>,
49}
50
51#[derive(Default)]
52struct CompiledSchemaCache {
53 entries: HashMap<[u8; 32], Vec<CachedSchema>>,
54 entry_count: usize,
55 schema_bytes: usize,
56}
57
58impl CompiledSchemaCache {
59 fn find_compiled(
60 &self,
61 hash: &[u8; 32],
62 schema: &Value,
63 ) -> Option<Result<Arc<jsonschema::JSONSchema>, String>> {
64 self.entries
65 .get(hash)
66 .and_then(|entries| entries.iter().find(|entry| entry.schema == *schema))
67 .map(|entry| entry.compiled.clone())
68 }
69
70 fn insert(
71 &mut self,
72 hash: [u8; 32],
73 schema: &Value,
74 serialized_bytes: usize,
75 compiled: Result<Arc<jsonschema::JSONSchema>, String>,
76 ) {
77 if self.entry_count >= COMPILED_SCHEMA_CACHE_CAPACITY
80 || serialized_bytes
81 > COMPILED_SCHEMA_CACHE_SCHEMA_BYTES.saturating_sub(self.schema_bytes)
82 {
83 return;
84 }
85 self.entries.entry(hash).or_default().push(CachedSchema {
86 schema: schema.clone(),
87 compiled,
88 });
89 self.entry_count += 1;
90 self.schema_bytes += serialized_bytes;
91 }
92}
93
94fn compiled_schema_cache() -> &'static Mutex<CompiledSchemaCache> {
95 static CACHE: OnceLock<Mutex<CompiledSchemaCache>> = OnceLock::new();
96 CACHE.get_or_init(|| Mutex::new(CompiledSchemaCache::default()))
97}
98
99fn schema_content_fingerprint(schema: &Value) -> Result<([u8; 32], usize), String> {
100 struct DigestWriter<'a> {
101 digest: &'a mut Sha256,
102 bytes_written: usize,
103 }
104
105 impl Write for DigestWriter<'_> {
106 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
107 self.digest.update(bytes);
108 self.bytes_written = self.bytes_written.saturating_add(bytes.len());
109 Ok(bytes.len())
110 }
111
112 fn flush(&mut self) -> io::Result<()> {
113 Ok(())
114 }
115 }
116
117 let mut digest = Sha256::new();
118 let serialized_bytes = {
119 let mut writer = DigestWriter {
120 digest: &mut digest,
121 bytes_written: 0,
122 };
123 serde_json::to_writer(&mut writer, schema).map_err(|error| error.to_string())?;
124 writer.bytes_written
125 };
126 Ok((digest.finalize().into(), serialized_bytes))
127}
128
129fn compiled_schema(schema: &Value) -> Result<Arc<jsonschema::JSONSchema>, String> {
130 let (hash, serialized_bytes) = schema_content_fingerprint(schema)?;
131 if let Some(cached) = compiled_schema_cache()
132 .lock()
133 .expect("compiled schema cache lock")
134 .find_compiled(&hash, schema)
135 {
136 return cached;
137 }
138
139 let compiled = reject_non_local_references(schema).and_then(|()| {
140 jsonschema::JSONSchema::compile(schema)
141 .map(Arc::new)
142 .map_err(|error| error.to_string())
143 });
144
145 let mut cache = compiled_schema_cache()
146 .lock()
147 .expect("compiled schema cache lock");
148 if let Some(existing) = cache.find_compiled(&hash, schema) {
149 return existing;
150 }
151 cache.insert(hash, schema, serialized_bytes, compiled.clone());
152 compiled
153}
154
155fn validate_schema(schema: &Value, value: &Value) -> Result<(), String> {
156 let compiled = compiled_schema(schema)?;
157 if compiled.is_valid(value) {
158 return Ok(());
159 }
160 compiled.validate(value).map_err(|mut errors| {
161 format_validation_error(errors.next().expect("validation failure contains an error"))
162 })
163}
164
165pub fn validate_tool_input(contract: &ToolContract, args: &Value) -> Result<(), String> {
166 validate_schema(contract.input_schema.canonical(), args)
167}
168
169fn reject_non_local_references(schema: &Value) -> Result<(), String> {
170 match schema {
171 Value::Array(values) => {
172 for value in values {
173 reject_non_local_references(value)?;
174 }
175 }
176 Value::Object(object) => {
177 for (keyword, value) in object {
178 if matches!(keyword.as_str(), "$ref" | "$dynamicRef")
179 && !value
180 .as_str()
181 .is_some_and(|reference| reference.starts_with('#'))
182 {
183 return Err(format!(
184 "non-local schema reference rejected: `{keyword}` must start with `#`, got {value}"
185 ));
186 }
187 reject_non_local_references(value)?;
188 }
189 }
190 _ => {}
191 }
192
193 Ok(())
194}
195
196fn format_validation_error(error: jsonschema::ValidationError<'_>) -> String {
197 let instance_path = error.instance_path.to_string();
198 if instance_path.is_empty() {
199 error.to_string()
200 } else {
201 format!("{instance_path}: {error}")
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::ToolDefinition;
209 use std::time::{Duration, Instant};
210
211 #[test]
212 fn repeated_schema_compilation_reuses_the_cached_validator() {
213 let schema = serde_json::json!({
214 "type": "object",
215 "properties": { "cache_probe_20260723": { "type": "string" } }
216 });
217
218 let first = compiled_schema(&schema).expect("compile schema once");
219 let second = compiled_schema(&schema).expect("reuse compiled schema");
220
221 assert!(Arc::ptr_eq(&first, &second));
222 }
223
224 #[test]
225 fn cache_hits_require_structural_equality_after_hash_match() {
226 let hash = [7; 32];
227 let first_schema = serde_json::json!({ "type": "string" });
228 let second_schema = serde_json::json!({ "type": "integer" });
229 let first = Arc::new(
230 jsonschema::JSONSchema::compile(&first_schema).expect("compile first validator"),
231 );
232 let second = Arc::new(
233 jsonschema::JSONSchema::compile(&second_schema).expect("compile second validator"),
234 );
235 let mut cache = CompiledSchemaCache::default();
236 cache.insert(hash, &first_schema, 17, Ok(first.clone()));
237 cache.insert(hash, &second_schema, 18, Ok(second.clone()));
238
239 let first_hit = cache
240 .find_compiled(&hash, &first_schema)
241 .expect("find first colliding schema")
242 .expect("first validator");
243 let second_hit = cache
244 .find_compiled(&hash, &second_schema)
245 .expect("find second colliding schema")
246 .expect("second validator");
247
248 assert!(Arc::ptr_eq(&first, &first_hit));
249 assert!(Arc::ptr_eq(&second, &second_hit));
250 assert!(!Arc::ptr_eq(&first_hit, &second_hit));
251 }
252
253 #[test]
254 fn validation_rejects_values_that_violate_local_refs() {
255 for definitions_key in ["$defs", "definitions"] {
256 let schema = serde_json::json!({
257 "type": "object",
258 "properties": {
259 "item": { "$ref": format!("#/{definitions_key}/Item") }
260 },
261 "required": ["item"],
262 "additionalProperties": false,
263 (definitions_key): {
264 "Item": {
265 "type": "object",
266 "properties": {
267 "name": { "type": "string" }
268 },
269 "required": ["name"],
270 "additionalProperties": false
271 }
272 }
273 });
274
275 let error = LashSchema::new(schema)
276 .validate(&serde_json::json!({ "item": { "name": 42 } }))
277 .unwrap_err();
278
279 assert_eq!(error, "/item/name: 42 is not of type \"string\"");
280 }
281 }
282
283 #[test]
284 fn validation_rejects_bad_value_through_all_of_wrapped_ref() {
285 let schema = LashSchema::new(serde_json::json!({
286 "definitions": {
287 "Inner": { "type": "string" }
288 },
289 "allOf": [
290 { "$ref": "#/definitions/Inner" }
291 ],
292 "description": "A documented field"
293 }));
294
295 let error = schema.validate(&serde_json::json!(42)).unwrap_err();
296
297 assert_eq!(error, "42 is not of type \"string\"");
298 }
299
300 #[test]
301 fn validation_of_chained_doubling_refs_is_bounded() {
302 const DEPTH: usize = 16;
305 const TIME_LIMIT: Duration = Duration::from_secs(1);
306
307 let mut definitions = serde_json::Map::new();
308 for depth in 0..DEPTH {
309 definitions.insert(
310 format!("D{depth}"),
311 serde_json::json!({
312 "type": "object",
313 "properties": {
314 "left": { "$ref": format!("#/definitions/D{}", depth + 1) },
315 "right": { "$ref": format!("#/definitions/D{}", depth + 1) }
316 }
317 }),
318 );
319 }
320 definitions.insert(format!("D{DEPTH}"), serde_json::json!({ "type": "string" }));
321 let schema = LashSchema::new(serde_json::json!({
322 "$ref": "#/definitions/D0",
323 "definitions": definitions
324 }));
325 let mut value = serde_json::json!(42);
326 for _ in 0..DEPTH {
327 value = serde_json::json!({ "left": value });
328 }
329
330 let started = Instant::now();
331 let error = schema.validate(&value).unwrap_err();
332 let elapsed = started.elapsed();
333
334 assert!(error.ends_with(": 42 is not of type \"string\""), "{error}");
335 assert!(error.contains("/left"), "{error}");
336 assert!(
337 elapsed < TIME_LIMIT,
338 "chained reference validation took {elapsed:?}, limit is {TIME_LIMIT:?}"
339 );
340 }
341
342 #[test]
343 fn validation_rejects_unresolvable_local_ref() {
344 let schema = LashSchema::new(serde_json::json!({
345 "$ref": "#/definitions/Missing"
346 }));
347
348 let error = schema.validate(&serde_json::json!(42)).unwrap_err();
349
350 assert!(error.starts_with("Invalid reference:"), "{error}");
351 }
352
353 #[test]
354 fn validation_rejects_external_ref_before_compilation() {
355 let schema = LashSchema::new(serde_json::json!({
356 "$ref": "https://example.com/schema.json"
357 }));
358
359 let error = schema.validate(&serde_json::json!(42)).unwrap_err();
360
361 assert_eq!(
362 error,
363 "non-local schema reference rejected: `$ref` must start with `#`, got \"https://example.com/schema.json\""
364 );
365 }
366
367 #[test]
368 fn validation_rejects_external_dynamic_ref_before_compilation() {
369 let schema = LashSchema::new(serde_json::json!({
370 "$dynamicRef": "https://example.com/schema.json#node"
371 }));
372
373 let error = schema.validate(&serde_json::json!(42)).unwrap_err();
374
375 assert!(
376 error.contains("`$dynamicRef` must start with `#`"),
377 "{error}"
378 );
379 }
380
381 #[test]
382 fn jsonschema_dependency_cannot_resolve_http_references() {
383 let schema = serde_json::json!({
384 "$ref": "https://example.com/schema.json"
385 });
386
387 let compiled = jsonschema::JSONSchema::compile(&schema).unwrap();
388 let error = compiled
389 .validate(&serde_json::json!(42))
390 .unwrap_err()
391 .next()
392 .expect("external reference resolution produces an error")
393 .to_string();
394
395 assert!(
396 error.contains("`resolve-http` feature or a custom resolver is required"),
397 "{error}"
398 );
399 }
400
401 #[test]
402 fn validation_rejects_deep_recursive_violation() {
403 let schema = LashSchema::new(serde_json::json!({
404 "$ref": "#/definitions/Node",
405 "definitions": {
406 "Node": {
407 "type": "object",
408 "properties": {
409 "name": { "type": "string" },
410 "child": { "$ref": "#/definitions/Node" }
411 },
412 "required": ["name"]
413 }
414 }
415 }));
416
417 let error = schema
418 .validate(&serde_json::json!({
419 "name": "root",
420 "child": {
421 "name": "level 1",
422 "child": {
423 "name": "level 2",
424 "child": { "name": 123 }
425 }
426 }
427 }))
428 .unwrap_err();
429
430 assert_eq!(
431 error,
432 "/child/child/child/name: 123 is not of type \"string\""
433 );
434 }
435
436 #[test]
437 fn validation_combines_ref_target_and_sibling_constraints() {
438 let schema = LashSchema::new(serde_json::json!({
439 "$ref": "#/$defs/AtLeastFive",
440 "minimum": 1,
441 "$defs": {
442 "AtLeastFive": {
443 "type": "number",
444 "minimum": 5
445 }
446 }
447 }));
448
449 let error = schema.validate(&serde_json::json!(2)).unwrap_err();
450
451 assert_eq!(error, "2 is less than the minimum of 5");
452 }
453
454 #[test]
455 fn validation_reports_missing_required_property_by_path() {
456 let tool = ToolDefinition::raw(
457 "tool:spotify",
458 "spotify",
459 "",
460 serde_json::json!({
461 "type": "object",
462 "properties": {
463 "access_token": { "type": "string" }
464 },
465 "required": ["access_token"],
466 "additionalProperties": false
467 }),
468 serde_json::json!({}),
469 );
470
471 let error = validate_tool_input(&tool.contract(), &serde_json::json!({})).unwrap_err();
472 assert_eq!(error, "\"access_token\" is a required property");
473 }
474
475 #[test]
476 fn validation_reports_numeric_limits_by_path() {
477 let tool = ToolDefinition::raw(
478 "tool:spotify",
479 "spotify",
480 "",
481 serde_json::json!({
482 "type": "object",
483 "properties": {
484 "page_limit": { "type": "integer", "maximum": 20 }
485 },
486 "required": ["page_limit"],
487 "additionalProperties": false
488 }),
489 serde_json::json!({}),
490 );
491
492 let error =
493 validate_tool_input(&tool.contract(), &serde_json::json!({ "page_limit": 100 }))
494 .unwrap_err();
495 assert_eq!(error, "/page_limit: 100 is greater than the maximum of 20");
496 }
497
498 #[test]
499 fn validation_allows_unknown_property_when_additional_properties_is_omitted() {
500 let tool = ToolDefinition::raw(
501 "tool:mcp__appworld__venmo_show_transactions",
502 "mcp__appworld__venmo_show_transactions",
503 "",
504 serde_json::json!({
505 "type": "object",
506 "properties": {
507 "min_created_at": { "type": "string" },
508 "max_created_at": { "type": "string" },
509 "limit": { "type": "integer", "maximum": 100 }
510 },
511 "required": ["limit"]
512 }),
513 serde_json::json!({}),
514 );
515
516 validate_tool_input(
517 &tool.contract(),
518 &serde_json::json!({
519 "min_datetime": "2024-01-01T00:00:00Z",
520 "limit": 20
521 }),
522 )
523 .unwrap();
524 }
525
526 #[test]
527 fn validation_allows_unknown_property_when_additional_properties_is_true() {
528 let tool = ToolDefinition::raw(
529 "tool:open",
530 "open",
531 "",
532 serde_json::json!({
533 "type": "object",
534 "properties": {
535 "path": { "type": "string" }
536 },
537 "additionalProperties": true
538 }),
539 serde_json::json!({}),
540 );
541
542 validate_tool_input(
543 &tool.contract(),
544 &serde_json::json!({
545 "path": "README.md",
546 "unknown": "preserved"
547 }),
548 )
549 .unwrap();
550 }
551}