Skip to main content

behavior_contracts/
guard.rs

1//! guard — Portability Guard (`assert_portable`).
2//!
3//! DSL-agnostic IR portability invariant: a portable IR must not contain
4//! non-serializable values. In Rust the runtime works over `serde_json::Value`
5//! (already JSON-serializable) plus the native [`crate::value::Value`], so the
6//! only structurally non-portable case is a non-finite float (NaN/±Inf) which
7//! cannot be represented in JSON. `assert_portable` walks a value and rejects it.
8
9use crate::expr::FORBIDDEN_OBJECT_KEY;
10use crate::value::Value;
11use serde_json::Value as J;
12
13#[derive(Debug, Clone)]
14pub struct PortabilityError {
15    pub path: String,
16    pub message: String,
17}
18
19impl std::fmt::Display for PortabilityError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        write!(f, "Portability Guard at {}: {}", self.path, self.message)
22    }
23}
24impl std::error::Error for PortabilityError {}
25
26/// Assert that a runtime [`Value`] is portable (JSON-serializable). The only
27/// non-portable scalar in the Rust value model is a non-finite float.
28pub fn assert_portable(value: &Value) -> Result<(), PortabilityError> {
29    assert_portable_at(value, "$")
30}
31
32fn assert_portable_at(value: &Value, path: &str) -> Result<(), PortabilityError> {
33    match value {
34        Value::Float(f) if !f.is_finite() => Err(PortabilityError {
35            path: path.to_string(),
36            message: format!("non-finite float {f} cannot be placed in portable IR"),
37        }),
38        Value::Arr(a) => {
39            for (i, v) in a.iter().enumerate() {
40                assert_portable_at(v, &format!("{path}[{i}]"))?;
41            }
42            Ok(())
43        }
44        Value::Obj(o) => {
45            for (k, v) in o {
46                assert_portable_at(v, &format!("{path}.{k}"))?;
47            }
48            Ok(())
49        }
50        _ => Ok(()),
51    }
52}
53
54/// The closed set of Expression-IR operators allowed in a portable IR (expression-ir.md §3).
55pub const PORTABLE_EXPR_OPERATORS: &[&str] = &[
56    "int", "float", "ref", "refOpt", "obj", "arr", "add", "sub", "mul", "neg", "div", "mod",
57    "concat", "eq", "ne", "lt", "le", "gt", "ge", "and", "or", "not", "coalesce", "cond", "len",
58];
59
60fn perr<T>(path: &str, message: impl Into<String>) -> Result<T, PortabilityError> {
61    Err(PortabilityError {
62        path: path.to_string(),
63        message: message.into(),
64    })
65}
66
67/// The closed set of scalar types in the portable type notation
68/// (scp-ir-architecture.md §5.2, bc#44 B0). Compound types are the single-key
69/// objects `{opt|arr|obj: ...}`.
70pub const PORTABLE_SCALAR_TYPES: &[&str] = &["string", "int", "float", "bool", "null"];
71
72/// Validate a portable type-notation node (§5.2) with the same fail-closed
73/// strength as the Expression IR guard. Closed set: a scalar-type string
74/// ([`PORTABLE_SCALAR_TYPES`], no `"any"` escape hatch); `{opt: T}` / `{arr: T}`
75/// (single-key object, value a type `T`); `{obj: {field: T, ...}}` (single-key
76/// object, value a field->type map with the `__proto__` own key forbidden).
77fn assert_portable_type_notation(node: &J, path: &str) -> Result<(), PortabilityError> {
78    match node {
79        J::String(s) => {
80            if !PORTABLE_SCALAR_TYPES.contains(&s.as_str()) {
81                return perr(path, format!("unknown scalar type '{s}' (fail-closed)"));
82            }
83            Ok(())
84        }
85        J::Object(o) => {
86            if o.len() != 1 {
87                return perr(
88                    path,
89                    format!(
90                        "compound type must be a single-key object (opt|arr|obj), got {} keys (fail-closed)",
91                        o.len()
92                    ),
93                );
94            }
95            let (kind, arg) = o.iter().next().unwrap();
96            match kind.as_str() {
97                "opt" | "arr" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
98                "obj" => {
99                    let Some(fields) = arg.as_object() else {
100                        return perr(
101                            &format!("{path}.obj"),
102                            "{obj: ...} type expects a field->type object (fail-closed)".to_string(),
103                        );
104                    };
105                    for (k, v) in fields {
106                        if k == FORBIDDEN_OBJECT_KEY {
107                            return perr(
108                                &format!("{path}.obj"),
109                                format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
110                            );
111                        }
112                        assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
113                    }
114                    Ok(())
115                }
116                other => perr(
117                    path,
118                    format!("unknown compound type kind '{other}' (opt|arr|obj) (fail-closed)"),
119                ),
120            }
121        }
122        _ => perr(
123            path,
124            "type notation must be a scalar-type string or a single-key {opt|arr|obj} object (fail-closed)".to_string(),
125        ),
126    }
127}
128
129/// Portability Guard for a component-graph IR (scp-ir-architecture.md §5), operating
130/// on the raw `serde_json::Value` IR (P0-2). Enforces, fail-closed:
131///   1. `component` (componentRef / map) is a string catalog reference only.
132///   2. every Expression-IR node in ports / output / map.over / cond uses only the
133///      known operator closed set ([`PORTABLE_EXPR_OPERATORS`]).
134///   3. any type annotation (`outType` / `outputType`, bc#44 B0) matches the
135///      portable type-notation closed set (§5.2). Annotations are additive: an IR
136///      without them validates identically to before B0.
137pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
138    assert_portable_cg_at(ir, "$")
139}
140
141fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
142    let components = ir
143        .get("components")
144        .and_then(|c| c.as_array())
145        .ok_or_else(|| PortabilityError {
146            path: format!("{path}.components"),
147            message: "IR.components must be an array".into(),
148        })?;
149    for (ci, c) in components.iter().enumerate() {
150        assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
151    }
152    Ok(())
153}
154
155fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
156    if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
157        return perr(&format!("{path}.name"), "component.name must be a string");
158    }
159    let body = c
160        .get("body")
161        .and_then(|b| b.as_array())
162        .ok_or_else(|| PortabilityError {
163            path: format!("{path}.body"),
164            message: "component.body must be an array".into(),
165        })?;
166    for (ni, n) in body.iter().enumerate() {
167        assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
168    }
169    assert_portable_expr(
170        c.get("output").unwrap_or(&J::Null),
171        &format!("{path}.output"),
172    )?;
173    // Portable type notation (bc#44 B0): `outputType` is additive/optional.
174    if let Some(ot) = c.get("outputType") {
175        assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
176    }
177    Ok(())
178}
179
180fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
181    assert_portable_body_node_kind(n, path)?;
182    // Portable type notation (bc#44 B0): a body node's `outType` is additive/optional
183    // (validated for every node kind).
184    if let Some(ot) = n.get("outType") {
185        assert_portable_type_notation(ot, &format!("{path}.outType"))?;
186    }
187    Ok(())
188}
189
190fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
191    if let Some(m) = n.get("map") {
192        require_string_component(m.get("component"), &format!("{path}.map.component"))?;
193        assert_portable_expr(
194            m.get("over").unwrap_or(&J::Null),
195            &format!("{path}.map.over"),
196        )?;
197        assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
198        // v2 vocabulary: `when` is an Expression IR node (unknown operators are
199        // rejected fail-closed); `into` must be a string key; `batched` a bool.
200        if let Some(w) = m.get("when") {
201            assert_portable_expr(w, &format!("{path}.map.when"))?;
202        }
203        if let Some(into) = m.get("into") {
204            if !into.is_string() {
205                return perr(&format!("{path}.map.into"), "'into' must be a string key");
206            }
207        }
208        if let Some(b) = m.get("batched") {
209            if !b.is_boolean() {
210                return perr(
211                    &format!("{path}.map.batched"),
212                    "'batched' must be a boolean",
213                );
214            }
215        }
216        Ok(())
217    } else if let Some(co) = n.get("cond") {
218        assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
219        assert_portable_expr(
220            co.get("then").unwrap_or(&J::Null),
221            &format!("{path}.cond.then"),
222        )?;
223        assert_portable_expr(
224            co.get("else").unwrap_or(&J::Null),
225            &format!("{path}.cond.else"),
226        )
227    } else if n.get("component").is_some() {
228        require_string_component(n.get("component"), &format!("{path}.component"))?;
229        assert_portable_ports(n.get("ports"), &format!("{path}.ports"))
230    } else {
231        perr(path, "unknown body node kind (not componentRef/map/cond)")
232    }
233}
234
235fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
236    match v {
237        Some(J::String(_)) => Ok(()),
238        _ => perr(path, "'component' must be a string catalog reference"),
239    }
240}
241
242fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
243    let obj = ports
244        .and_then(|p| p.as_object())
245        .ok_or_else(|| PortabilityError {
246            path: path.to_string(),
247            message: "ports must be an object".into(),
248        })?;
249    for (k, v) in obj {
250        assert_portable_expr(v, &format!("{path}.{k}"))?;
251    }
252    Ok(())
253}
254
255fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
256    match node {
257        J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
258        J::Array(a) => {
259            for (i, e) in a.iter().enumerate() {
260                assert_portable_expr(e, &format!("{path}[{i}]"))?;
261            }
262            Ok(())
263        }
264        J::Object(o) => {
265            if o.len() == 1 {
266                let (op, arg) = o.iter().next().unwrap();
267                if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
268                    return perr(path, format!("unknown operator '{op}' (fail-closed)"));
269                }
270                // obj は「任意の data key → 子式」の構築ノード(expression-ir.md §3)。arg の
271                // key は operator ではなく data であり、値だけを式として再帰する(単一 data key
272                // の obj が未知 operator と誤判定されないように — evaluate の意味論と一致)。
273                if op == "obj" {
274                    let Some(fields) = arg.as_object() else {
275                        return perr(
276                            &format!("{path}.obj"),
277                            "{obj: ...} expects an object".to_string(),
278                        );
279                    };
280                    for (k, v) in fields {
281                        // 静的 fail-closed(defense-in-depth): own key "__proto__" は evaluator
282                        // が実行時に FORBIDDEN_KEY で拒否する(expression-ir.md §2.3/§8)。
283                        // guard でも静的に拒否する(言語間発散 / prototype pollution 対策)。
284                        if k == FORBIDDEN_OBJECT_KEY {
285                            return perr(
286                                &format!("{path}.obj"),
287                                format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
288                            );
289                        }
290                        assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
291                    }
292                    return Ok(());
293                }
294                assert_portable_expr(arg, &format!("{path}.{op}"))
295            } else {
296                for (k, v) in o {
297                    assert_portable_expr(v, &format!("{path}.{k}"))?;
298                }
299                Ok(())
300            }
301        }
302    }
303}