Skip to main content

fresh_core/
plugin_schemas.rs

1//! Validation, default extraction, and merging for plugin-provided
2//! config schemas.
3//!
4//! Plugins register config fields at load time by calling one of the
5//! strongly-typed `editor.defineConfigBoolean / Integer / Number /
6//! String / Enum / StringArray(...)` methods from TypeScript. Each
7//! call sends an `AddPluginConfigField` command to the host. The host
8//! accumulates fields into a per-plugin JSON Schema fragment stored in
9//! `Editor::plugin_schemas`, pre-populates the declared default into
10//! `plugins.<name>.settings.<field>`, and the Settings UI uses the
11//! accumulated schema to render a per-plugin sub-category.
12//!
13//! Trust boundary: schemas reach the host already validated per-field
14//! on the JS-binding side (where errors are thrown back to plugin
15//! authors). This module re-validates defensively before merging into
16//! the runtime tree:
17//!
18//! 1. Top-level must be a JSON object with `"type": "object"`.
19//! 2. No `$ref`s allowed (cross-tree references would let a plugin
20//!    pull types out of the host schema namespace).
21//! 3. No `x-enum-from` extension (would let a plugin point at host
22//!    config paths like `/languages` — explicit design decision).
23
24use serde_json::{Map, Value};
25
26/// Validate a plugin-supplied JSON Schema. Returns `Ok(())` if safe to
27/// merge into the host's runtime schema tree; otherwise an error
28/// describing why.
29pub fn validate_plugin_schema(value: &Value) -> Result<(), String> {
30    let obj = value
31        .as_object()
32        .ok_or_else(|| "schema root must be an object".to_string())?;
33
34    match obj.get("type") {
35        Some(Value::String(s)) if s == "object" => {}
36        _ => return Err("schema root must have \"type\": \"object\"".to_string()),
37    }
38
39    check_no_forbidden_keys(value)?;
40
41    // Allow empty `properties` so a brand-new plugin that hasn't yet
42    // sent its first AddPluginConfigField doesn't fail validation.
43    let _ = obj
44        .get("properties")
45        .and_then(|p| p.as_object())
46        .ok_or_else(|| "schema must declare \"properties\"".to_string())?;
47    Ok(())
48}
49
50fn check_no_forbidden_keys(value: &Value) -> Result<(), String> {
51    match value {
52        Value::Object(m) => {
53            for (k, v) in m {
54                if k == "$ref" {
55                    return Err("plugin schemas may not use $ref".to_string());
56                }
57                if k == "x-enum-from" {
58                    return Err(
59                        "plugin schemas may not use x-enum-from (host coupling)".to_string()
60                    );
61                }
62                check_no_forbidden_keys(v)?;
63            }
64        }
65        Value::Array(a) => {
66            for v in a {
67                check_no_forbidden_keys(v)?;
68            }
69        }
70        _ => {}
71    }
72    Ok(())
73}
74
75/// Deep-merge `defaults` UNDER `target` — i.e. fill in keys that
76/// `target` does not already have. Used to seed
77/// `plugins.<name>.settings` from registered schema defaults without
78/// clobbering values the user has already saved.
79pub fn deep_merge_under(target: &mut Value, defaults: &Value) {
80    if target.is_null() {
81        *target = defaults.clone();
82        return;
83    }
84    let (Value::Object(t_map), Value::Object(d_map)) = (target, defaults) else {
85        return;
86    };
87    for (k, v) in d_map {
88        match t_map.get_mut(k) {
89            Some(existing) => deep_merge_under(existing, v),
90            None => {
91                t_map.insert(k.clone(), v.clone());
92            }
93        }
94    }
95}
96
97/// Extract default values from a schema recursively, walking
98/// `properties.<name>.default`. Returns an object with defaults filled
99/// in for every property that declares one.
100pub fn defaults_from_schema(schema: &Value) -> Value {
101    let mut out = Map::new();
102    if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
103        for (k, prop) in props {
104            if let Some(d) = prop.get("default") {
105                out.insert(k.clone(), d.clone());
106            } else if let Some(t) = prop.get("type").and_then(|t| t.as_str()) {
107                if t == "object" {
108                    let nested = defaults_from_schema(prop);
109                    if !nested.as_object().map(|o| o.is_empty()).unwrap_or(true) {
110                        out.insert(k.clone(), nested);
111                    }
112                }
113            }
114        }
115    }
116    Value::Object(out)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use serde_json::json;
123
124    #[test]
125    fn rejects_non_object_root() {
126        assert!(validate_plugin_schema(&json!("hello")).is_err());
127        assert!(validate_plugin_schema(&json!([])).is_err());
128    }
129
130    #[test]
131    fn rejects_missing_object_type() {
132        assert!(validate_plugin_schema(&json!({})).is_err());
133        assert!(validate_plugin_schema(&json!({"type": "string"})).is_err());
134    }
135
136    #[test]
137    fn rejects_refs() {
138        let schema = json!({
139            "type": "object",
140            "properties": {"foo": {"$ref": "#/$defs/Bar"}}
141        });
142        assert!(validate_plugin_schema(&schema).is_err());
143    }
144
145    #[test]
146    fn rejects_x_enum_from() {
147        let schema = json!({
148            "type": "object",
149            "properties": {"lang": {"type": "string", "x-enum-from": "/languages"}}
150        });
151        assert!(validate_plugin_schema(&schema).is_err());
152    }
153
154    #[test]
155    fn accepts_valid_schema() {
156        let schema = json!({
157            "type": "object",
158            "properties": {
159                "auto_enable": {"type": "boolean", "default": false},
160                "max_items": {"type": "integer", "minimum": 1, "default": 3}
161            }
162        });
163        assert!(validate_plugin_schema(&schema).is_ok());
164    }
165
166    #[test]
167    fn accepts_empty_properties() {
168        let schema = json!({"type": "object", "properties": {}});
169        assert!(validate_plugin_schema(&schema).is_ok());
170    }
171
172    #[test]
173    fn defaults_extraction() {
174        let schema = json!({
175            "type": "object",
176            "properties": {
177                "auto_enable": {"type": "boolean", "default": false},
178                "max_items": {"type": "integer", "minimum": 1, "default": 3},
179                "no_default": {"type": "string"}
180            }
181        });
182        let d = defaults_from_schema(&schema);
183        assert_eq!(d, json!({"auto_enable": false, "max_items": 3}));
184    }
185}