Skip to main content

salvor_runtime/
validate.rs

1//! Minimal structural JSON Schema validation for resume inputs.
2//!
3//! When a run parks on a tool suspension, the `Suspended` event records the
4//! JSON Schema the resume input must satisfy. `Runtime::resume` checks the
5//! supplied input against that recorded schema *before* recording a
6//! `Resumed` event, so a wrong-shaped approval never becomes history.
7//!
8//! # What "validates" means in v0.1
9//!
10//! This is a structural subset of JSON Schema, not a full validator. The
11//! keywords honored are:
12//!
13//! - `type` (a string or an array of strings, with the standard seven type
14//!   names; `integer` accepts only integral JSON numbers)
15//! - `required` (each named property must be present on an object)
16//! - `properties` (present properties are validated recursively)
17//! - `items` (array elements are validated recursively against a single
18//!   schema object)
19//! - `enum` (the value must equal one of the listed values)
20//!
21//! Everything else (`format`, `pattern`, numeric ranges, `oneOf`, `$ref`,
22//! and so on) is ignored: an input is never *rejected* because of a keyword
23//! this subset does not implement. That bias is deliberate for v0.1. The
24//! recorded schema is written by tool authors for humans filling approval
25//! forms; the failure mode that matters is an obviously wrong shape (a
26//! string where an object was asked for, a missing required field), and
27//! that is exactly what this subset rejects. A full validator is a
28//! dependency decision deferred until the approval-inbox work (v0.3) makes
29//! the richer keywords real.
30//!
31//! A schema that is not a JSON object (for example `true`) accepts
32//! everything, matching JSON Schema's own boolean-schema semantics.
33
34use serde_json::Value;
35
36/// Validates `input` against the structural subset of `schema` documented
37/// at module level.
38///
39/// # Errors
40///
41/// Returns a human-readable description of the first violation, naming the
42/// JSON path where it occurred.
43pub fn validate_against_schema(input: &Value, schema: &Value) -> Result<(), String> {
44    validate_at(input, schema, "$")
45}
46
47/// The recursive worker behind [`validate_against_schema`]; `path` names
48/// the location being validated, for error messages.
49fn validate_at(input: &Value, schema: &Value, path: &str) -> Result<(), String> {
50    let Some(schema) = schema.as_object() else {
51        return Ok(());
52    };
53
54    if let Some(expected) = schema.get("type") {
55        let names: Vec<&str> = match expected {
56            Value::String(name) => vec![name.as_str()],
57            Value::Array(names) => names.iter().filter_map(Value::as_str).collect(),
58            _ => Vec::new(),
59        };
60        if !names.is_empty() && !names.iter().any(|name| matches_type(input, name)) {
61            return Err(format!(
62                "{path}: expected type {}, got {}",
63                names.join(" or "),
64                type_name(input)
65            ));
66        }
67    }
68
69    if let Some(allowed) = schema.get("enum").and_then(Value::as_array)
70        && !allowed.iter().any(|candidate| candidate == input)
71    {
72        return Err(format!("{path}: value is not one of the allowed values"));
73    }
74
75    if let Some(object) = input.as_object() {
76        if let Some(required) = schema.get("required").and_then(Value::as_array) {
77            for name in required.iter().filter_map(Value::as_str) {
78                if !object.contains_key(name) {
79                    return Err(format!("{path}: missing required property `{name}`"));
80                }
81            }
82        }
83        if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
84            for (name, property_schema) in properties {
85                if let Some(value) = object.get(name) {
86                    validate_at(value, property_schema, &format!("{path}.{name}"))?;
87                }
88            }
89        }
90    }
91
92    if let (Some(items), Some(item_schema)) = (input.as_array(), schema.get("items")) {
93        for (index, item) in items.iter().enumerate() {
94            validate_at(item, item_schema, &format!("{path}[{index}]"))?;
95        }
96    }
97
98    Ok(())
99}
100
101/// Whether `value` is of the named JSON Schema type.
102fn matches_type(value: &Value, name: &str) -> bool {
103    match name {
104        "object" => value.is_object(),
105        "array" => value.is_array(),
106        "string" => value.is_string(),
107        "number" => value.is_number(),
108        "integer" => value.is_i64() || value.is_u64(),
109        "boolean" => value.is_boolean(),
110        "null" => value.is_null(),
111        _ => true,
112    }
113}
114
115/// The JSON type name of a value, for error messages.
116fn type_name(value: &Value) -> &'static str {
117    match value {
118        Value::Null => "null",
119        Value::Bool(_) => "boolean",
120        Value::Number(_) => "number",
121        Value::String(_) => "string",
122        Value::Array(_) => "array",
123        Value::Object(_) => "object",
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use serde_json::json;
131
132    /// The shapes the subset must reject: wrong type, missing required
133    /// property, wrong nested property type, value outside an enum.
134    #[test]
135    fn obviously_wrong_shapes_are_rejected() {
136        let schema = json!({
137            "type": "object",
138            "required": ["approved"],
139            "properties": {
140                "approved": {"type": "boolean"},
141                "note": {"type": "string"}
142            }
143        });
144        assert!(validate_against_schema(&json!({"approved": true}), &schema).is_ok());
145        assert!(
146            validate_against_schema(&json!({"approved": false, "note": "no"}), &schema).is_ok()
147        );
148
149        assert!(validate_against_schema(&json!("yes"), &schema).is_err());
150        assert!(validate_against_schema(&json!({}), &schema).is_err());
151        assert!(validate_against_schema(&json!({"approved": "yes"}), &schema).is_err());
152
153        let choice = json!({"enum": ["approve", "reject"]});
154        assert!(validate_against_schema(&json!("approve"), &choice).is_ok());
155        assert!(validate_against_schema(&json!("maybe"), &choice).is_err());
156    }
157
158    /// Arrays validate their items; type unions accept any listed type.
159    #[test]
160    fn arrays_and_type_unions_validate() {
161        let schema = json!({"type": "array", "items": {"type": "integer"}});
162        assert!(validate_against_schema(&json!([1, 2, 3]), &schema).is_ok());
163        assert!(validate_against_schema(&json!([1, "two"]), &schema).is_err());
164
165        let union = json!({"type": ["string", "null"]});
166        assert!(validate_against_schema(&json!("text"), &union).is_ok());
167        assert!(validate_against_schema(&json!(null), &union).is_ok());
168        assert!(validate_against_schema(&json!(3), &union).is_err());
169    }
170
171    /// Keywords outside the subset never cause rejection, and non-object
172    /// schemas accept everything.
173    #[test]
174    fn unimplemented_keywords_do_not_reject() {
175        let schema = json!({"type": "string", "pattern": "^[0-9]+$", "minLength": 99});
176        assert!(validate_against_schema(&json!("not digits"), &schema).is_ok());
177        assert!(validate_against_schema(&json!({"anything": 1}), &json!(true)).is_ok());
178    }
179}