1use serde_json::Value;
5
6use crate::services::mcp::ConfigScope;
7use crate::utils::settings::permission_validation::validate_permission_rule;
8
9pub type FieldPath = String;
11
12#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
14pub struct ValidationError {
15 pub file: Option<String>,
16 pub path: FieldPath,
17 pub message: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub expected: Option<String>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub invalid_value: Option<serde_json::Value>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub suggestion: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub doc_link: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub mcp_error_metadata: Option<McpErrorMetadata>,
28}
29
30#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
32pub struct McpErrorMetadata {
33 pub scope: ConfigScope,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub server_name: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub severity: Option<McpErrorSeverity>,
38}
39
40#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum McpErrorSeverity {
43 Fatal,
44 Warning,
45}
46
47#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
49pub struct SettingsWithErrors {
50 pub settings: Value,
51 pub errors: Vec<ValidationError>,
52}
53
54fn received_type(v: &Value) -> &str {
56 match v {
57 Value::Null => "null",
58 Value::Bool(_) => "boolean",
59 Value::Number(_) => "number",
60 Value::String(_) => "string",
61 Value::Array(_) => "array",
62 Value::Object(_) => "object",
63 }
64}
65
66pub fn validate_settings_json(data: &Value) -> SettingsWithErrors {
69 let mut errors = Vec::new();
70
71 if !data.is_object() {
73 return SettingsWithErrors {
74 settings: Value::Object(serde_json::Map::new()),
75 errors: vec![ValidationError {
76 path: "".into(),
77 message: "Settings must be a JSON object".into(),
78 expected: Some("object".into()),
79 invalid_value: Some(data.clone()),
80 suggestion: None,
81 doc_link: None,
82 file: None,
83 mcp_error_metadata: None,
84 }],
85 };
86 }
87
88 if let Some(perms) = data.get("permissions") {
90 if !perms.is_object() {
91 errors.push(ValidationError {
92 path: "permissions".into(),
93 message: "Expected permissions to be an object".into(),
94 expected: Some("object".into()),
95 invalid_value: Some(received_type(perms).into()),
96 suggestion: None,
97 doc_link: None,
98 file: None,
99 mcp_error_metadata: None,
100 });
101 } else if let Some(perms_obj) = perms.as_object() {
102 if let Some(mode) = perms_obj.get("defaultMode") {
104 if let Some(mode_str) = mode.as_str() {
105 match mode_str {
106 "allow" | "deny" | "ask" => {} _ => {
108 errors.push(ValidationError {
109 path: "permissions.defaultMode".into(),
110 message: format!("Invalid permission mode: \"{}\"", mode_str),
111 expected: Some("\"allow\", \"deny\", or \"ask\"".into()),
112 invalid_value: Some(mode.clone()),
113 suggestion: Some("Use \"allow\", \"deny\", or \"ask\"".into()),
114 doc_link: None,
115 file: None,
116 mcp_error_metadata: None,
117 });
118 }
119 }
120 } else {
121 errors.push(ValidationError {
122 path: "permissions.defaultMode".into(),
123 message: "Expected defaultMode to be a string".into(),
124 expected: Some("string".into()),
125 invalid_value: Some(received_type(mode).into()),
126 suggestion: None,
127 doc_link: None,
128 file: None,
129 mcp_error_metadata: None,
130 });
131 }
132 }
133
134 for key in ["allow", "deny", "ask"] {
136 if let Some(rules) = perms_obj.get(key) {
137 if !rules.is_array() {
138 errors.push(ValidationError {
139 path: format!("permissions.{}", key),
140 message: format!("Expected permissions.{} to be an array", key),
141 expected: Some("array".into()),
142 invalid_value: Some(received_type(rules).into()),
143 suggestion: None,
144 doc_link: None,
145 file: None,
146 mcp_error_metadata: None,
147 });
148 } else if let Some(rules_arr) = rules.as_array() {
149 for (idx, rule) in rules_arr.iter().enumerate() {
150 if !rule.is_string() {
151 errors.push(ValidationError {
152 path: format!("permissions.{}.{}", key, idx),
153 message: format!(
154 "Non-string value in {} array was removed",
155 key
156 ),
157 expected: Some("string".into()),
158 invalid_value: Some(rule.clone()),
159 suggestion: None,
160 doc_link: None,
161 file: None,
162 mcp_error_metadata: None,
163 });
164 } else if let Some(rule_str) = rule.as_str() {
165 let result = validate_permission_rule(rule_str);
166 if !result.valid {
167 errors.push(ValidationError {
168 path: format!("permissions.{}.{}", key, idx),
169 message: format!(
170 "Invalid permission rule \"{}\": {}",
171 rule_str,
172 result.error.unwrap_or_else(|| "unknown error".into())
173 ),
174 expected: None,
175 suggestion: result.suggestion,
176 invalid_value: Some(rule.clone()),
177 doc_link: None,
178 file: None,
179 mcp_error_metadata: None,
180 });
181 }
182 }
183 }
184 }
185 }
186 }
187
188 if let Some(dirs) = perms_obj.get("additionalDirectories") {
190 if !dirs.is_array() {
191 errors.push(ValidationError {
192 path: "permissions.additionalDirectories".into(),
193 message: "Expected additionalDirectories to be an array".into(),
194 expected: Some("array".into()),
195 invalid_value: Some(received_type(dirs).into()),
196 suggestion: None,
197 doc_link: None,
198 file: None,
199 mcp_error_metadata: None,
200 });
201 } else if let Some(dirs_arr) = dirs.as_array() {
202 for (idx, dir) in dirs_arr.iter().enumerate() {
203 if !dir.is_string() {
204 errors.push(ValidationError {
205 path: format!("permissions.additionalDirectories.{}", idx),
206 message: "Non-string value in additionalDirectories".into(),
207 expected: Some("string".into()),
208 invalid_value: Some(dir.clone()),
209 suggestion: None,
210 doc_link: None,
211 file: None,
212 mcp_error_metadata: None,
213 });
214 }
215 }
216 }
217 }
218 }
219 }
220
221 if let Some(env) = data.get("env") {
223 if !env.is_object() {
224 errors.push(ValidationError {
225 path: "env".into(),
226 message: "Expected env to be an object".into(),
227 expected: Some("object".into()),
228 invalid_value: Some(received_type(env).into()),
229 suggestion: None,
230 doc_link: None,
231 file: None,
232 mcp_error_metadata: None,
233 });
234 } else if let Some(env_obj) = env.as_object() {
235 for (key, val) in env_obj {
236 if !val.is_string() && val.is_null() {
237 continue;
239 }
240 if !val.is_string() {
241 errors.push(ValidationError {
242 path: format!("env.{}", key),
243 message: format!("Expected env.{} to be a string or null", key),
244 expected: Some("string or null".into()),
245 invalid_value: Some(received_type(val).into()),
246 suggestion: None,
247 doc_link: None,
248 file: None,
249 mcp_error_metadata: None,
250 });
251 }
252 }
253 }
254 }
255
256 if let Some(model) = data.get("model") {
258 if let Some(model_obj) = model.as_object() {
259 if let Some(name) = model_obj.get("name") {
260 if !name.is_string() {
261 errors.push(ValidationError {
262 path: "model.name".into(),
263 message: "Expected model.name to be a string".into(),
264 expected: Some("string".into()),
265 invalid_value: Some(received_type(name).into()),
266 suggestion: None,
267 doc_link: None,
268 file: None,
269 mcp_error_metadata: None,
270 });
271 }
272 }
273 if let Some(max_tokens) = model_obj.get("maxTokens") {
274 if !max_tokens.is_number() {
275 errors.push(ValidationError {
276 path: "model.maxTokens".into(),
277 message: "Expected model.maxTokens to be a number".into(),
278 expected: Some("number".into()),
279 invalid_value: Some(received_type(max_tokens).into()),
280 suggestion: None,
281 doc_link: None,
282 file: None,
283 mcp_error_metadata: None,
284 });
285 }
286 }
287 } else if !model.is_string() {
288 errors.push(ValidationError {
289 path: "model".into(),
290 message: "Expected model to be a string or object".into(),
291 expected: Some("string or object".into()),
292 invalid_value: Some(received_type(model).into()),
293 suggestion: None,
294 doc_link: None,
295 file: None,
296 mcp_error_metadata: None,
297 });
298 }
299 }
300
301 if let Some(hooks) = data.get("hooks") {
303 if !hooks.is_object() {
304 errors.push(ValidationError {
305 path: "hooks".into(),
306 message: "Expected hooks to be an object".into(),
307 expected: Some("object".into()),
308 invalid_value: Some(received_type(hooks).into()),
309 suggestion: None,
310 doc_link: None,
311 file: None,
312 mcp_error_metadata: None,
313 });
314 }
315 }
316
317 SettingsWithErrors {
318 settings: data.clone(),
319 errors,
320 }
321}
322
323pub fn validate_settings_file_content(content: &str) -> SettingsWithErrors {
325 match serde_json::from_str(content) {
326 Ok(json) => validate_settings_json(&json),
327 Err(e) => SettingsWithErrors {
328 settings: Value::Object(serde_json::Map::new()),
329 errors: vec![ValidationError {
330 path: "".into(),
331 message: format!("Invalid JSON: {}", e),
332 expected: Some("valid JSON object".into()),
333 invalid_value: None,
334 suggestion: Some("Check for trailing commas, missing quotes, or mismatched braces"
335 .into()),
336 doc_link: None,
337 file: None,
338 mcp_error_metadata: None,
339 }],
340 },
341 }
342}
343
344pub fn filter_invalid_permission_rules(
347 data: &Value,
348 file_path: &str,
349) -> Vec<ValidationError> {
350 let mut warnings = Vec::new();
351
352 let Some(obj) = data.as_object() else {
353 return warnings;
354 };
355 let Some(perms) = obj.get("permissions") else {
356 return warnings;
357 };
358 let Some(perms_obj) = perms.as_object() else {
359 return warnings;
360 };
361
362 for key in ["allow", "deny", "ask"] {
363 let Some(rules) = perms_obj.get(key) else {
364 continue;
365 };
366 let Some(rules_arr) = rules.as_array() else {
367 continue;
368 };
369
370 let valid_rules: Vec<Value> = rules_arr
371 .iter()
372 .filter_map(|rule| {
373 if !rule.is_string() {
374 warnings.push(ValidationError {
375 file: Some(file_path.to_string()),
376 path: format!("permissions.{}", key),
377 message: format!("Non-string value in {} array was removed", key),
378 expected: Some("string".into()),
379 invalid_value: Some(rule.clone()),
380 suggestion: None,
381 doc_link: None,
382 mcp_error_metadata: None,
383 });
384 return None;
385 }
386 if let Some(rule_str) = rule.as_str() {
387 let result = validate_permission_rule(rule_str);
388 if !result.valid {
389 let mut msg = format!("Invalid permission rule \"{}\" was skipped", rule_str);
390 if let Some(ref err) = result.error {
391 msg += ": ";
392 msg += err;
393 }
394 warnings.push(ValidationError {
395 file: Some(file_path.to_string()),
396 path: format!("permissions.{}", key),
397 message: msg,
398 expected: None,
399 invalid_value: Some(rule.clone()),
400 suggestion: result.suggestion,
401 doc_link: None,
402 mcp_error_metadata: None,
403 });
404 return None;
405 }
406 }
407 Some(rule.clone())
408 })
409 .collect();
410
411 let _ = valid_rules;
413 }
414
415 warnings
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn test_validate_empty_object() {
424 let result = validate_settings_json(&Value::Object(serde_json::Map::new()));
425 assert!(result.errors.is_empty());
426 }
427
428 #[test]
429 fn test_validate_not_object() {
430 let result = validate_settings_json(&Value::String("not an object".into()));
431 assert!(!result.errors.is_empty());
432 }
433
434 #[test]
435 fn test_validate_invalid_mode() {
436 let json = serde_json::json!({
437 "permissions": {
438 "defaultMode": "invalid"
439 }
440 });
441 let result = validate_settings_json(&json);
442 assert!(!result.errors.is_empty());
443 assert_eq!(result.errors[0].path, "permissions.defaultMode");
444 }
445
446 #[test]
447 fn test_validate_valid_mode() {
448 for mode in &["allow", "deny", "ask"] {
449 let json = serde_json::json!({
450 "permissions": {
451 "defaultMode": mode
452 }
453 });
454 let result = validate_settings_json(&json);
455 assert!(
456 result.errors.is_empty(),
457 "mode '{}' should be valid",
458 mode
459 );
460 }
461 }
462
463 #[test]
464 fn test_validate_invalid_permission_rule() {
465 let json = serde_json::json!({
466 "permissions": {
467 "allow": ["read(*.ts)", "Read()"]
468 }
469 });
470 let result = validate_settings_json(&json);
471 assert!(!result.errors.is_empty());
473 }
474
475 #[test]
476 fn test_validate_valid_settings() {
477 let json = serde_json::json!({
478 "permissions": {
479 "defaultMode": "allow",
480 "allow": ["Read(*.ts)", "Bash"]
481 },
482 "env": {
483 "DEBUG": "true"
484 }
485 });
486 let result = validate_settings_json(&json);
487 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
488 }
489
490 #[test]
491 fn test_validate_invalid_json() {
492 let result = validate_settings_file_content("{ invalid json }");
493 assert!(!result.errors.is_empty());
494 }
495
496 #[test]
497 fn test_validate_valid_json_string() {
498 let result = validate_settings_file_content(r#"{"permissions": {"defaultMode": "allow"}}"#);
499 assert!(result.errors.is_empty());
500 }
501
502 #[test]
503 fn test_filter_invalid_permission_rules() {
504 let json = serde_json::json!({
505 "permissions": {
506 "allow": ["Read(*.ts)", "read()", 123]
507 }
508 });
509 let warnings = filter_invalid_permission_rules(&json, "test.json");
510 assert!(!warnings.is_empty());
512 }
513
514 #[test]
515 fn test_env_validation() {
516 let json = serde_json::json!({
517 "env": {
518 "VALID": "string",
519 "ALSO_VALID": null,
520 "INVALID": 123
521 }
522 });
523 let result = validate_settings_json(&json);
524 assert!(!result.errors.is_empty());
525 assert_eq!(result.errors[0].path, "env.INVALID");
526 }
527
528 #[test]
529 fn test_model_validation() {
530 let json = serde_json::json!({
531 "model": {
532 "name": 123
533 }
534 });
535 let result = validate_settings_json(&json);
536 assert!(!result.errors.is_empty());
537 }
538}