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/// Portability Guard for a component-graph IR (scp-ir-architecture.md §5), operating
68/// on the raw `serde_json::Value` IR (P0-2). Enforces, fail-closed:
69///   1. `component` (componentRef / map) is a string catalog reference only.
70///   2. every Expression-IR node in ports / output / map.over / cond uses only the
71///      known operator closed set ([`PORTABLE_EXPR_OPERATORS`]).
72pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
73    assert_portable_cg_at(ir, "$")
74}
75
76fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
77    let components = ir
78        .get("components")
79        .and_then(|c| c.as_array())
80        .ok_or_else(|| PortabilityError {
81            path: format!("{path}.components"),
82            message: "IR.components must be an array".into(),
83        })?;
84    for (ci, c) in components.iter().enumerate() {
85        assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
86    }
87    Ok(())
88}
89
90fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
91    if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
92        return perr(&format!("{path}.name"), "component.name must be a string");
93    }
94    let body = c
95        .get("body")
96        .and_then(|b| b.as_array())
97        .ok_or_else(|| PortabilityError {
98            path: format!("{path}.body"),
99            message: "component.body must be an array".into(),
100        })?;
101    for (ni, n) in body.iter().enumerate() {
102        assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
103    }
104    assert_portable_expr(
105        c.get("output").unwrap_or(&J::Null),
106        &format!("{path}.output"),
107    )
108}
109
110fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
111    if let Some(m) = n.get("map") {
112        require_string_component(m.get("component"), &format!("{path}.map.component"))?;
113        assert_portable_expr(
114            m.get("over").unwrap_or(&J::Null),
115            &format!("{path}.map.over"),
116        )?;
117        assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
118        // v2 vocabulary: `when` is an Expression IR node (unknown operators are
119        // rejected fail-closed); `into` must be a string key; `batched` a bool.
120        if let Some(w) = m.get("when") {
121            assert_portable_expr(w, &format!("{path}.map.when"))?;
122        }
123        if let Some(into) = m.get("into") {
124            if !into.is_string() {
125                return perr(&format!("{path}.map.into"), "'into' must be a string key");
126            }
127        }
128        if let Some(b) = m.get("batched") {
129            if !b.is_boolean() {
130                return perr(
131                    &format!("{path}.map.batched"),
132                    "'batched' must be a boolean",
133                );
134            }
135        }
136        Ok(())
137    } else if let Some(co) = n.get("cond") {
138        assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
139        assert_portable_expr(
140            co.get("then").unwrap_or(&J::Null),
141            &format!("{path}.cond.then"),
142        )?;
143        assert_portable_expr(
144            co.get("else").unwrap_or(&J::Null),
145            &format!("{path}.cond.else"),
146        )
147    } else if n.get("component").is_some() {
148        require_string_component(n.get("component"), &format!("{path}.component"))?;
149        assert_portable_ports(n.get("ports"), &format!("{path}.ports"))
150    } else {
151        perr(path, "unknown body node kind (not componentRef/map/cond)")
152    }
153}
154
155fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
156    match v {
157        Some(J::String(_)) => Ok(()),
158        _ => perr(path, "'component' must be a string catalog reference"),
159    }
160}
161
162fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
163    let obj = ports
164        .and_then(|p| p.as_object())
165        .ok_or_else(|| PortabilityError {
166            path: path.to_string(),
167            message: "ports must be an object".into(),
168        })?;
169    for (k, v) in obj {
170        assert_portable_expr(v, &format!("{path}.{k}"))?;
171    }
172    Ok(())
173}
174
175fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
176    match node {
177        J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
178        J::Array(a) => {
179            for (i, e) in a.iter().enumerate() {
180                assert_portable_expr(e, &format!("{path}[{i}]"))?;
181            }
182            Ok(())
183        }
184        J::Object(o) => {
185            if o.len() == 1 {
186                let (op, arg) = o.iter().next().unwrap();
187                if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
188                    return perr(path, format!("unknown operator '{op}' (fail-closed)"));
189                }
190                // obj は「任意の data key → 子式」の構築ノード(expression-ir.md §3)。arg の
191                // key は operator ではなく data であり、値だけを式として再帰する(単一 data key
192                // の obj が未知 operator と誤判定されないように — evaluate の意味論と一致)。
193                if op == "obj" {
194                    let Some(fields) = arg.as_object() else {
195                        return perr(
196                            &format!("{path}.obj"),
197                            "{obj: ...} expects an object".to_string(),
198                        );
199                    };
200                    for (k, v) in fields {
201                        // 静的 fail-closed(defense-in-depth): own key "__proto__" は evaluator
202                        // が実行時に FORBIDDEN_KEY で拒否する(expression-ir.md §2.3/§8)。
203                        // guard でも静的に拒否する(言語間発散 / prototype pollution 対策)。
204                        if k == FORBIDDEN_OBJECT_KEY {
205                            return perr(
206                                &format!("{path}.obj"),
207                                format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
208                            );
209                        }
210                        assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
211                    }
212                    return Ok(());
213                }
214                assert_portable_expr(arg, &format!("{path}.{op}"))
215            } else {
216                for (k, v) in o {
217                    assert_portable_expr(v, &format!("{path}.{k}"))?;
218                }
219                Ok(())
220            }
221        }
222    }
223}