Skip to main content

logicaffeine_compile/semantics/
acceptance.rs

1//! C2 Layer C — the receiver's typed, bounded **acceptance contract** for shipped
2//! computation.
3//!
4//! `Send computed f` (Layer B) lets a peer ship a *pure function* — its `GenExpr` body,
5//! not its data — which the receiver evaluates in a bounded sandbox. That is safe in that
6//! the sandbox can only do total integer arithmetic over one argument; it is NOT safe in
7//! that the receiver would run *whatever shape* arrived on *whatever argument*.
8//!
9//! An [`AcceptanceContract`] closes that gap the way a web form's validator does: the
10//! receiver writes down **exactly** the interface it will run — a single integer argument
11//! within a declared inclusive range — and every invocation is validated against it
12//! *before* evaluation. A function of the wrong shape is refused at the signature check; an
13//! argument outside the range is **refused, never silently clamped**. The attack surface is
14//! precisely what the receiver wrote down, and nothing more.
15//!
16//! The check is O(1) — two integer comparisons over a value the sandbox already bounds — so
17//! the safety costs essentially nothing on the hot path.
18
19use crate::concurrency::marshal::gen_eval;
20use crate::interpreter::RuntimeValue;
21
22/// A receiver-declared acceptance contract: a single integer parameter accepted only within
23/// `[lo, hi]` (inclusive), returning an integer. The bound is what the receiver promises to
24/// honor; anything outside is refused at the seam.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub struct AcceptanceContract {
27    pub lo: i64,
28    pub hi: i64,
29}
30
31impl AcceptanceContract {
32    pub fn new(lo: i64, hi: i64) -> Self {
33        // A reversed range accepts nothing — normalize so the bound reads as the user wrote
34        // it but still rejects every argument (lo > hi ⇒ no `arg` satisfies lo ≤ arg ≤ hi).
35        AcceptanceContract { lo, hi }
36    }
37
38    /// Validate `function` and `arg` against this contract, then evaluate. The two failure
39    /// modes are distinct and both surface as `Err`:
40    ///
41    /// * **signature** — `function` must be a *shipped* pure computation (`generated`) of
42    ///   exactly one argument. An ordinary closure (arena body) or a wrong arity is refused;
43    ///   the contract only ever runs the bounded sandbox, never interpreter-resident code.
44    /// * **range** — `arg` must satisfy `lo ≤ arg ≤ hi`. Out-of-range is refused, NOT
45    ///   clamped: the receiver asked for a bounded domain, so an out-of-domain input is an
46    ///   error at the edge, not a quietly-different computation.
47    pub fn apply(&self, function: &RuntimeValue, arg: i64) -> Result<i64, String> {
48        let closure = match function {
49            RuntimeValue::Function(c) => c,
50            other => {
51                return Err(format!(
52                    "acceptance contract: expected a shipped computation, got {}",
53                    other.type_name()
54                ))
55            }
56        };
57        let gen = closure.generated.as_ref().ok_or_else(|| {
58            "acceptance contract: an ordinary closure is refused — only a `Send computed` \
59             shipped pure computation may be run under a contract"
60                .to_string()
61        })?;
62        if closure.param_names.len() != 1 {
63            return Err(format!(
64                "acceptance contract: a shipped computation must take exactly one argument, \
65                 this one takes {}",
66                closure.param_names.len()
67            ));
68        }
69        if arg < self.lo || arg > self.hi {
70            return Err(format!(
71                "acceptance contract: argument {arg} is outside the accepted range \
72                 {}..={} — refused (the contract is not satisfied; the value is not clamped)",
73                self.lo, self.hi
74            ));
75        }
76        Ok(gen_eval(gen, arg))
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::concurrency::marshal::GenExpr;
84    use crate::interpreter::ClosureValue;
85    use std::collections::HashMap;
86    use std::rc::Rc;
87
88    /// A shipped pure computation `3·x + 1` over one argument — exactly the shape a peer
89    /// ships via `Send computed`.
90    fn shipped_3x_plus_1() -> RuntimeValue {
91        let gen = GenExpr::Add(
92            Box::new(GenExpr::Mul(Box::new(GenExpr::Index), Box::new(GenExpr::Const(3)))),
93            Box::new(GenExpr::Const(1)),
94        );
95        RuntimeValue::Function(Box::new(ClosureValue {
96            body_index: usize::MAX,
97            captured_env: HashMap::default(),
98            param_names: vec![logicaffeine_base::Symbol::from_index(0)],
99            generated: Some(Rc::new(gen)),
100        }))
101    }
102
103    #[test]
104    fn in_range_argument_runs_in_the_sandbox() {
105        let contract = AcceptanceContract::new(0, 1000);
106        // 3·5 + 1 = 16, and 5 ∈ [0, 1000].
107        assert_eq!(contract.apply(&shipped_3x_plus_1(), 5).unwrap(), 16);
108        // Boundaries are inclusive.
109        assert_eq!(contract.apply(&shipped_3x_plus_1(), 0).unwrap(), 1);
110        assert_eq!(contract.apply(&shipped_3x_plus_1(), 1000).unwrap(), 3001);
111    }
112
113    #[test]
114    fn out_of_range_argument_is_refused_not_clamped() {
115        let contract = AcceptanceContract::new(0, 1000);
116        let above = contract.apply(&shipped_3x_plus_1(), 1001);
117        let below = contract.apply(&shipped_3x_plus_1(), -1);
118        assert!(above.is_err(), "an argument above the range must be refused");
119        assert!(below.is_err(), "an argument below the range must be refused");
120        // Refused, not clamped: the error names the offending value, and NO result is produced.
121        assert!(above.unwrap_err().contains("1001"));
122        assert!(below.unwrap_err().contains("refused"));
123    }
124
125    #[test]
126    fn an_ordinary_closure_is_refused_at_the_signature_check() {
127        // A closure with NO `generated` body (an arena-resident interpreter closure) must
128        // never run under a contract — the sandbox is the only thing a contract evaluates.
129        let ordinary = RuntimeValue::Function(Box::new(ClosureValue {
130            body_index: 7,
131            captured_env: HashMap::default(),
132            param_names: vec![logicaffeine_base::Symbol::from_index(0)],
133            generated: None,
134        }));
135        let contract = AcceptanceContract::new(0, 1000);
136        assert!(contract.apply(&ordinary, 5).is_err(), "an ordinary closure must be refused");
137    }
138
139    #[test]
140    fn a_wrong_arity_computation_is_refused() {
141        let gen = GenExpr::Index;
142        let two_arg = RuntimeValue::Function(Box::new(ClosureValue {
143            body_index: usize::MAX,
144            captured_env: HashMap::default(),
145            param_names: vec![
146                logicaffeine_base::Symbol::from_index(0),
147                logicaffeine_base::Symbol::from_index(1),
148            ],
149            generated: Some(Rc::new(gen)),
150        }));
151        let contract = AcceptanceContract::new(0, 1000);
152        assert!(contract.apply(&two_arg, 5).is_err(), "a 2-argument computation must be refused");
153    }
154
155    #[test]
156    fn a_non_function_value_is_refused() {
157        let contract = AcceptanceContract::new(0, 1000);
158        assert!(contract.apply(&RuntimeValue::Int(5), 5).is_err(), "a non-function must be refused");
159    }
160
161    #[test]
162    fn a_reversed_range_accepts_nothing() {
163        // lo > hi is a vacuous contract — every argument is out of range.
164        let contract = AcceptanceContract::new(1000, 0);
165        assert!(contract.apply(&shipped_3x_plus_1(), 5).is_err());
166        assert!(contract.apply(&shipped_3x_plus_1(), 500).is_err());
167    }
168}