Skip to main content

greentic_setup/
schema_validation.rs

1//! Minimal JSON Schema validation for provider setup contracts.
2//!
3//! This intentionally supports only the subset used by this crate's setup
4//! schemas. It avoids adding a heavy dependency while still making doctor use
5//! the published schema files as the first validation layer.
6
7use std::collections::BTreeSet;
8
9use serde_json::Value;
10
11pub fn validate(value: &Value, schema: &Value) -> Vec<String> {
12    let mut errors = Vec::new();
13    validate_at(value, schema, schema, "$", &mut errors);
14    errors
15}
16
17fn validate_at(value: &Value, schema: &Value, root: &Value, path: &str, errors: &mut Vec<String>) {
18    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
19        match resolve_ref(root, reference) {
20            Some(resolved) => validate_at(value, resolved, root, path, errors),
21            None => errors.push(format!("{path}: unresolved schema ref {reference}")),
22        }
23        return;
24    }
25
26    if let Some(one_of) = schema.get("oneOf").and_then(Value::as_array) {
27        let valid = one_of.iter().any(|candidate| {
28            let mut candidate_errors = Vec::new();
29            validate_at(value, candidate, root, path, &mut candidate_errors);
30            candidate_errors.is_empty()
31        });
32        if !valid {
33            errors.push(format!("{path}: value does not match any oneOf schema"));
34        }
35        return;
36    }
37
38    if let Some(expected) = schema.get("const")
39        && value != expected
40    {
41        errors.push(format!("{path}: expected const {expected}"));
42    }
43    if let Some(values) = schema.get("enum").and_then(Value::as_array)
44        && !values.iter().any(|candidate| candidate == value)
45    {
46        errors.push(format!("{path}: value is not in enum"));
47    }
48    if let Some(schema_type) = schema.get("type").and_then(Value::as_str)
49        && !type_matches(value, schema_type)
50    {
51        errors.push(format!("{path}: expected {schema_type}"));
52        return;
53    }
54
55    match value {
56        Value::Object(object) => {
57            if let Some(required) = schema.get("required").and_then(Value::as_array) {
58                for key in required.iter().filter_map(Value::as_str) {
59                    if !object.contains_key(key) {
60                        errors.push(format!("{path}: missing required property {key}"));
61                    }
62                }
63            }
64            if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
65                for (key, property_schema) in properties {
66                    if let Some(child) = object.get(key) {
67                        validate_at(
68                            child,
69                            property_schema,
70                            root,
71                            &format!("{path}.{key}"),
72                            errors,
73                        );
74                    }
75                }
76            }
77        }
78        Value::Array(items) => {
79            if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64)
80                && items.len() < min_items as usize
81            {
82                errors.push(format!("{path}: expected at least {min_items} items"));
83            }
84            if schema
85                .get("uniqueItems")
86                .and_then(Value::as_bool)
87                .unwrap_or(false)
88            {
89                let mut seen = BTreeSet::new();
90                for item in items {
91                    if !seen.insert(item.to_string()) {
92                        errors.push(format!("{path}: array items must be unique"));
93                        break;
94                    }
95                }
96            }
97            if let Some(item_schema) = schema.get("items") {
98                for (idx, item) in items.iter().enumerate() {
99                    validate_at(item, item_schema, root, &format!("{path}[{idx}]"), errors);
100                }
101            }
102        }
103        Value::String(text) => {
104            if let Some(min_length) = schema.get("minLength").and_then(Value::as_u64)
105                && text.len() < min_length as usize
106            {
107                errors.push(format!("{path}: expected string length >= {min_length}"));
108            }
109        }
110        Value::Number(number) => {
111            if let Some(minimum) = schema.get("minimum").and_then(Value::as_i64)
112                && number.as_i64().is_some_and(|actual| actual < minimum)
113            {
114                errors.push(format!("{path}: expected number >= {minimum}"));
115            }
116        }
117        _ => {}
118    }
119}
120
121fn resolve_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
122    let pointer = reference.strip_prefix('#')?;
123    root.pointer(pointer)
124}
125
126fn type_matches(value: &Value, schema_type: &str) -> bool {
127    match schema_type {
128        "object" => value.is_object(),
129        "array" => value.is_array(),
130        "string" => value.is_string(),
131        "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
132        "number" => value.is_number(),
133        "boolean" => value.is_boolean(),
134        "null" => value.is_null(),
135        _ => true,
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use serde_json::json;
143
144    #[test]
145    fn validates_required_types_enum_and_refs() {
146        let schema = json!({
147            "type": "object",
148            "required": ["items"],
149            "properties": {
150                "items": {
151                    "type": "array",
152                    "minItems": 1,
153                    "items": {"$ref": "#/$defs/item"}
154                }
155            },
156            "$defs": {
157                "item": {
158                    "type": "object",
159                    "required": ["kind"],
160                    "properties": {
161                        "kind": {"enum": ["a", "b"]}
162                    }
163                }
164            }
165        });
166
167        assert!(validate(&json!({"items": [{"kind": "a"}]}), &schema).is_empty());
168        let errors = validate(&json!({"items": [{"kind": "c"}]}), &schema);
169        assert_eq!(errors.len(), 1);
170        assert!(errors[0].contains("enum"));
171    }
172}