Skip to main content

lattice_plugin_host/
boundary_config.rs

1//! TC.3 — the config schema / value boundary: arena on the wire, tree in the host.
2//!
3//! Design: `docs/dev/architecture/typed-configuration.md`.
4//!
5//! **WIT has no recursive types.** A schema and a value are both trees, and the
6//! obvious spelling — a variant whose arm holds another variant — does not
7//! parse: "type `config-schema` depends on itself". So both cross as an arena:
8//! a flat list of nodes plus the index of the root, children referenced by
9//! index. This module is where that arena becomes a tree and back.
10//!
11//! ## What the boundary has to defend against
12//!
13//! An arena is guest-controlled input in a shape that *invites* two failures no
14//! other boundary in this crate has:
15//!
16//! - **an index that points nowhere** — trivially rejected, and
17//! - **an index that points back up the tree**, i.e. a cycle. Following one
18//!   naively is unbounded recursion on the host's stack, from a value a plugin
19//!   chose. The tree-building walk therefore carries the set of nodes currently
20//!   on its own path and refuses a node it is already inside.
21//!
22//! Both are typed errors, never a trap and never a panic: a malformed
23//! declaration registers nothing and the plugin is told so, the same contract
24//! `register-option` has for a default that does not parse.
25
26use std::collections::BTreeMap;
27use std::collections::BTreeSet;
28
29use lattice_config::{ConfigSchema, ConfigValue, ScalarKind, SchemaField};
30
31use crate::config_host::bindings::lattice::plugin_host::config as wit;
32
33/// What went wrong turning an arena into a tree. Rendered into the `false` /
34/// warn paths the config seam already uses — a guest never traps over one.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ArenaError {
37    /// A child link points outside the node list.
38    OutOfRange { index: u32, len: usize },
39    /// A child link points at a node already on the path from the root. Left as
40    /// its own variant rather than folded into `OutOfRange` because the two say
41    /// different things about the guest that sent it: one is an off-by-one, the
42    /// other is a structure that cannot exist.
43    Cycle { index: u32 },
44}
45
46impl std::fmt::Display for ArenaError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            ArenaError::OutOfRange { index, len } => {
50                write!(f, "node index {index} is out of range (arena has {len})")
51            }
52            ArenaError::Cycle { index } => {
53                write!(f, "node index {index} is a cycle — a schema is a tree")
54            }
55        }
56    }
57}
58
59impl std::error::Error for ArenaError {}
60
61fn scalar_kind_from_wit(ty: wit::OptionType) -> ScalarKind {
62    match ty {
63        wit::OptionType::Boolean => ScalarKind::Bool,
64        wit::OptionType::Integer => ScalarKind::Int,
65        wit::OptionType::String => ScalarKind::Str,
66    }
67}
68
69fn scalar_kind_to_wit(kind: ScalarKind) -> wit::OptionType {
70    match kind {
71        ScalarKind::Bool => wit::OptionType::Boolean,
72        ScalarKind::Int => wit::OptionType::Integer,
73        ScalarKind::Str => wit::OptionType::String,
74    }
75}
76
77/// Resolve one index against `len`, or say why not.
78fn checked(index: u32, len: usize) -> Result<usize, ArenaError> {
79    let i = index as usize;
80    if i < len {
81        Ok(i)
82    } else {
83        Err(ArenaError::OutOfRange { index, len })
84    }
85}
86
87/// A guest's schema arena as a [`ConfigSchema`].
88pub fn schema_from_wit(arena: &wit::ConfigSchema) -> Result<ConfigSchema, ArenaError> {
89    let mut on_path = BTreeSet::new();
90    schema_node(arena, arena.root, &mut on_path)
91}
92
93fn schema_node(
94    arena: &wit::ConfigSchema,
95    index: u32,
96    on_path: &mut BTreeSet<u32>,
97) -> Result<ConfigSchema, ArenaError> {
98    let i = checked(index, arena.nodes.len())?;
99    // The cycle guard. `on_path` is the chain from the root to here, not every
100    // node seen — a schema may legitimately reuse a node from two places (two
101    // fields of the same shape), and forbidding that would reject a DAG, which
102    // is a perfectly good encoding of a tree.
103    if !on_path.insert(index) {
104        return Err(ArenaError::Cycle { index });
105    }
106    let out = match &arena.nodes[i] {
107        wit::SchemaNode::Scalar(ty) => ConfigSchema::Scalar(scalar_kind_from_wit(*ty)),
108        wit::SchemaNode::EnumOf(forms) => ConfigSchema::Enum(forms.clone()),
109        wit::SchemaNode::ListOf(child) => ConfigSchema::list(schema_node(arena, *child, on_path)?),
110        wit::SchemaNode::Record(fields) => {
111            let mut out = Vec::with_capacity(fields.len());
112            for f in fields {
113                out.push(SchemaField {
114                    name: f.name.clone(),
115                    schema: schema_node(arena, f.schema, on_path)?,
116                    required: f.required,
117                    doc: f.doc.clone(),
118                });
119            }
120            ConfigSchema::Record(out)
121        }
122    };
123    on_path.remove(&index);
124    Ok(out)
125}
126
127/// A guest's value arena as a [`ConfigValue`].
128pub fn value_from_wit(arena: &wit::ConfigValue) -> Result<ConfigValue, ArenaError> {
129    let mut on_path = BTreeSet::new();
130    value_node(arena, arena.root, &mut on_path)
131}
132
133fn value_node(
134    arena: &wit::ConfigValue,
135    index: u32,
136    on_path: &mut BTreeSet<u32>,
137) -> Result<ConfigValue, ArenaError> {
138    let i = checked(index, arena.nodes.len())?;
139    if !on_path.insert(index) {
140        return Err(ArenaError::Cycle { index });
141    }
142    let out = match &arena.nodes[i] {
143        wit::ValueNode::Bool(b) => ConfigValue::Bool(*b),
144        wit::ValueNode::Int(n) => ConfigValue::Int(*n),
145        wit::ValueNode::String(s) => ConfigValue::Str(s.clone()),
146        wit::ValueNode::List(children) => {
147            let mut out = Vec::with_capacity(children.len());
148            for c in children {
149                out.push(value_node(arena, *c, on_path)?);
150            }
151            ConfigValue::List(out)
152        }
153        wit::ValueNode::Record(fields) => {
154            let mut map = BTreeMap::new();
155            for (name, child) in fields {
156                map.insert(name.clone(), value_node(arena, *child, on_path)?);
157            }
158            ConfigValue::Record(map)
159        }
160    };
161    on_path.remove(&index);
162    Ok(out)
163}
164
165/// A [`ConfigValue`] as an arena for the guest.
166///
167/// Emits one node per tree node — no sharing, no dedup. A `get-option-value`
168/// answer is read once and dropped, so the only thing dedup would buy is a
169/// smaller copy, at the cost of a hash of every subtree on a path that is
170/// already cold.
171pub fn value_to_wit(value: &ConfigValue) -> wit::ConfigValue {
172    let mut nodes = Vec::new();
173    let root = push_value(&mut nodes, value);
174    wit::ConfigValue { nodes, root }
175}
176
177fn push_value(nodes: &mut Vec<wit::ValueNode>, value: &ConfigValue) -> u32 {
178    // Children are pushed BEFORE the parent's own node is reserved, so an index
179    // is never handed out for a slot that is still being built.
180    let node = match value {
181        ConfigValue::Bool(b) => wit::ValueNode::Bool(*b),
182        ConfigValue::Int(n) => wit::ValueNode::Int(*n),
183        ConfigValue::Str(s) => wit::ValueNode::String(s.clone()),
184        ConfigValue::List(items) => {
185            let children: Vec<u32> = items.iter().map(|v| push_value(nodes, v)).collect();
186            wit::ValueNode::List(children)
187        }
188        ConfigValue::Record(map) => {
189            let fields: Vec<(String, u32)> = map
190                .iter()
191                .map(|(k, v)| (k.clone(), push_value(nodes, v)))
192                .collect();
193            wit::ValueNode::Record(fields)
194        }
195    };
196    nodes.push(node);
197    (nodes.len() - 1) as u32
198}
199
200/// A [`ConfigSchema`] as an arena for the guest. The mirror of
201/// [`schema_from_wit`], for `:describe-option`-shaped reads from a plugin.
202pub fn schema_to_wit(schema: &ConfigSchema) -> wit::ConfigSchema {
203    let mut nodes = Vec::new();
204    let root = push_schema(&mut nodes, schema);
205    wit::ConfigSchema { nodes, root }
206}
207
208fn push_schema(nodes: &mut Vec<wit::SchemaNode>, schema: &ConfigSchema) -> u32 {
209    let node = match schema {
210        ConfigSchema::Scalar(k) => wit::SchemaNode::Scalar(scalar_kind_to_wit(*k)),
211        ConfigSchema::Enum(forms) => wit::SchemaNode::EnumOf(forms.clone()),
212        ConfigSchema::List(inner) => wit::SchemaNode::ListOf(push_schema(nodes, inner)),
213        ConfigSchema::Record(fields) => {
214            let out: Vec<wit::SchemaField> = fields
215                .iter()
216                .map(|f| wit::SchemaField {
217                    name: f.name.clone(),
218                    schema: push_schema(nodes, &f.schema),
219                    required: f.required,
220                    doc: f.doc.clone(),
221                })
222                .collect();
223            wit::SchemaNode::Record(out)
224        }
225    };
226    nodes.push(node);
227    (nodes.len() - 1) as u32
228}
229
230#[cfg(test)]
231mod tests {
232    #![allow(clippy::unwrap_used, clippy::panic)]
233    use super::*;
234
235    fn templates() -> ConfigSchema {
236        ConfigSchema::list(ConfigSchema::record([
237            SchemaField::new("key", ConfigSchema::string(), "the key to press"),
238            SchemaField::new(
239                "target",
240                ConfigSchema::record([SchemaField::new(
241                    "file",
242                    ConfigSchema::string(),
243                    "where it lands",
244                )]),
245                "where the capture goes",
246            ),
247            SchemaField::new("body", ConfigSchema::string(), "body").optional(),
248        ]))
249    }
250
251    #[test]
252    fn a_nested_schema_survives_the_arena_round_trip() {
253        // Two levels of record inside a list — the shape the whole design
254        // exists for, and the one a flattening is most likely to mangle.
255        let schema = templates();
256        let round = schema_from_wit(&schema_to_wit(&schema)).unwrap();
257        assert_eq!(round, schema);
258    }
259
260    #[test]
261    fn a_nested_value_survives_the_arena_round_trip() {
262        let value = ConfigValue::List(vec![ConfigValue::record([
263            ("key".to_string(), ConfigValue::Str("t".into())),
264            (
265                "target".to_string(),
266                ConfigValue::record([("file".to_string(), ConfigValue::Str("a.org".into()))]),
267            ),
268        ])]);
269        let round = value_from_wit(&value_to_wit(&value)).unwrap();
270        assert_eq!(round, value);
271    }
272
273    #[test]
274    fn every_scalar_kind_crosses_as_itself() {
275        // A kind that collapsed to a string on the way out would make
276        // `set-option-value` on an integer option fail for a value the guest
277        // built correctly — and the failure would read as the guest's fault.
278        for v in [
279            ConfigValue::Bool(true),
280            ConfigValue::Bool(false),
281            ConfigValue::Int(0),
282            ConfigValue::Int(-7),
283            ConfigValue::Int(i64::MAX),
284            ConfigValue::Str(String::new()),
285            ConfigValue::Str("x".into()),
286        ] {
287            assert_eq!(value_from_wit(&value_to_wit(&v)).unwrap(), v);
288        }
289        for s in [
290            ConfigSchema::bool(),
291            ConfigSchema::int(),
292            ConfigSchema::string(),
293            ConfigSchema::Enum(vec!["a".into(), "b".into()]),
294        ] {
295            assert_eq!(schema_from_wit(&schema_to_wit(&s)).unwrap(), s);
296        }
297    }
298
299    #[test]
300    fn an_out_of_range_index_is_refused_rather_than_indexed() {
301        let arena = wit::ConfigValue {
302            nodes: vec![wit::ValueNode::List(vec![9])],
303            root: 0,
304        };
305        assert_eq!(
306            value_from_wit(&arena),
307            Err(ArenaError::OutOfRange { index: 9, len: 1 })
308        );
309        // …including the root itself, which is the index nobody remembers to
310        // check because it does not arrive through a child link.
311        let empty = wit::ConfigValue {
312            nodes: vec![],
313            root: 0,
314        };
315        assert!(matches!(
316            value_from_wit(&empty),
317            Err(ArenaError::OutOfRange { .. })
318        ));
319    }
320
321    #[test]
322    fn a_cycle_is_refused_rather_than_followed() {
323        // The failure this boundary exists to prevent: a guest-chosen value
324        // that recurses forever on the HOST's stack. A test that only checked
325        // for an error would hang before it could fail, so the guard has to be
326        // in the walk itself rather than in a depth counter after the fact.
327        let arena = wit::ConfigValue {
328            nodes: vec![wit::ValueNode::List(vec![1]), wit::ValueNode::List(vec![0])],
329            root: 0,
330        };
331        assert_eq!(value_from_wit(&arena), Err(ArenaError::Cycle { index: 0 }));
332
333        let schema = wit::ConfigSchema {
334            nodes: vec![wit::SchemaNode::ListOf(0)],
335            root: 0,
336        };
337        assert_eq!(
338            schema_from_wit(&schema),
339            Err(ArenaError::Cycle { index: 0 })
340        );
341    }
342
343    #[test]
344    fn sharing_a_node_from_two_places_is_not_a_cycle() {
345        // The guard tracks the path from the root, not every node seen. A guest
346        // that emits one `string` node and points three fields at it has sent a
347        // DAG, which is a perfectly good encoding of a tree — rejecting it
348        // would punish exactly the encoding a careful generator produces.
349        let arena = wit::ConfigSchema {
350            nodes: vec![
351                wit::SchemaNode::Scalar(wit::OptionType::String),
352                wit::SchemaNode::Record(vec![
353                    wit::SchemaField {
354                        name: "a".into(),
355                        schema: 0,
356                        required: true,
357                        doc: String::new(),
358                    },
359                    wit::SchemaField {
360                        name: "b".into(),
361                        schema: 0,
362                        required: true,
363                        doc: String::new(),
364                    },
365                ]),
366            ],
367            root: 1,
368        };
369        let got = schema_from_wit(&arena).unwrap();
370        assert_eq!(
371            got,
372            ConfigSchema::record([
373                SchemaField::new("a", ConfigSchema::string(), ""),
374                SchemaField::new("b", ConfigSchema::string(), ""),
375            ])
376        );
377    }
378
379    #[test]
380    fn field_metadata_crosses_intact() {
381        // `required` and `doc` are what `:customize` renders and what makes an
382        // omitted field an error rather than a default. A round-trip that kept
383        // the shape and dropped these would look correct in every structural
384        // assertion above.
385        let schema = templates();
386        let round = schema_from_wit(&schema_to_wit(&schema)).unwrap();
387        let ConfigSchema::List(inner) = &round else {
388            panic!("expected a list");
389        };
390        let ConfigSchema::Record(fields) = inner.as_ref() else {
391            panic!("expected a record");
392        };
393        let body = fields.iter().find(|f| f.name == "body").unwrap();
394        assert!(!body.required, "the optional field stayed optional");
395        let key = fields.iter().find(|f| f.name == "key").unwrap();
396        assert!(key.required);
397        assert_eq!(key.doc, "the key to press", "per-field docs cross");
398    }
399}