Skip to main content

kopiur_api/
success_expr.rs

1//! `successExpr`: a sandboxed CEL pass/fail predicate over a verification result
2//! (ADR-0005 §4/§15).
3//!
4//! A verification (ADR-0005 §4) can run blob-level `kopia snapshot verify` or a
5//! deep scratch-restore. `successExpr` lets a user assert the result is *good* —
6//! killing the silent "0 files" success (a verify that technically succeeds but
7//! restored nothing). Example: `"stats.files > 0 && stats.errors == 0"`.
8//!
9//! This reuses the `cel`/`*Expr` foundation from [`crate::identity`] (ADR-0004 §5):
10//! compile → execute → require a typed result, length-capped as the cost-budget
11//! surrogate, out-of-scope variables rejected at admission. The difference is the
12//! environment and the required result type: `successExpr` returns a **bool** over a
13//! verify-result environment, where identity expressions return a **string** over an
14//! identity environment.
15//!
16//! ## CEL environment
17//!
18//! - `stats` — a map `{files, bytes, errors}` (integers). Always present.
19//! - `snapshot` — a map of snapshot metadata (the snapshot id under `id`). Always
20//!   present (possibly empty).
21//! - `restored` — a map `{files, checksumMatches}` for the *deep* (scratch-restore)
22//!   tier. Present (possibly empty) so an expression that only references it under a
23//!   guard validates; a quick verify leaves it empty.
24//! - `tier` — the string `"quick"` or `"deep"`, so a single predicate can branch on
25//!   the tier (e.g. `tier == 'deep' ? restored.checksumMatches : stats.files > 0`).
26//!
27//! Each value is supplied by the mover from the real verify result and evaluated
28//! there; admission validation ([`validate_success_expr`]) trial-evaluates against a
29//! representative environment so a typo / out-of-scope variable / non-bool result is
30//! rejected on `kubectl apply` rather than at first verify run.
31
32use std::collections::BTreeMap;
33
34use cel::{Context, Program, Value};
35
36use crate::error::{ValidationError, ValidationResult};
37use crate::identity::MAX_EXPR_LEN;
38
39/// The integer stats a verification reports, exposed to `successExpr` as `stats`.
40/// Used both by the mover (filled from the real kopia result) and by
41/// [`validate_success_expr`] (a representative trial value).
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub struct VerifyStats {
44    /// Number of files verified/restored.
45    pub files: i64,
46    /// Number of bytes verified/restored.
47    pub bytes: i64,
48    /// Number of errors encountered (0 = clean).
49    pub errors: i64,
50}
51
52/// The optional deep-restore stats, exposed as `restored`. `None` for the quick
53/// (blob-level) tier; the environment then exposes an empty `restored` map.
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
55pub struct RestoredStats {
56    /// Number of files written by the scratch-restore.
57    pub files: i64,
58    /// Whether the restored content's checksums matched the snapshot.
59    pub checksum_matches: bool,
60}
61
62/// The full environment a `successExpr` evaluates against.
63#[derive(Debug, Clone, Default)]
64pub struct SuccessExprInputs<'a> {
65    /// `stats.{files,bytes,errors}`.
66    pub stats: VerifyStats,
67    /// `snapshot.*` metadata (e.g. `snapshot.id`). Empty when unknown.
68    pub snapshot: BTreeMap<String, String>,
69    /// `restored.{files,checksumMatches}` for the deep tier; `None` for quick.
70    pub restored: Option<RestoredStats>,
71    /// `tier`: `"quick"` or `"deep"`, so a predicate can branch on the verify tier.
72    pub tier: String,
73    /// Lifetime tie so the struct can borrow string slices in future without churn.
74    pub _marker: std::marker::PhantomData<&'a ()>,
75}
76
77/// Compile a `successExpr`, enforcing the [`MAX_EXPR_LEN`] budget first (shared with
78/// identity expressions). Maps a parse failure to
79/// [`ValidationError::SuccessExprCompile`].
80fn compile(expr: &str) -> ValidationResult<Program> {
81    if expr.len() > MAX_EXPR_LEN {
82        return Err(ValidationError::SuccessExprCompile {
83            expr: expr.to_string(),
84            reason: format!(
85                "expression is {} bytes; the maximum is {MAX_EXPR_LEN}",
86                expr.len()
87            ),
88        });
89    }
90    Program::compile(expr).map_err(|e| ValidationError::SuccessExprCompile {
91        expr: expr.to_string(),
92        reason: e.to_string(),
93    })
94}
95
96/// Build the CEL context for a `successExpr`: `stats`, `snapshot`, `restored`.
97fn context<'a>(inputs: &SuccessExprInputs<'_>) -> Context<'a> {
98    let mut ctx = Context::default();
99    // Maps with string keys; the values that may be ints are added as a serde map.
100    let stats: BTreeMap<String, i64> = BTreeMap::from([
101        ("files".to_string(), inputs.stats.files),
102        ("bytes".to_string(), inputs.stats.bytes),
103        ("errors".to_string(), inputs.stats.errors),
104    ]);
105    let _ = ctx.add_variable("stats", &stats);
106    let _ = ctx.add_variable("snapshot", &inputs.snapshot);
107    let _ = ctx.add_variable("tier", &inputs.tier);
108    // `restored` is always present (possibly empty) so a guarded reference validates;
109    // a quick verify leaves it empty rather than absent.
110    match inputs.restored {
111        Some(r) => {
112            // files as int; checksumMatches as bool — two separate typed maps would
113            // not share a value type, so serialize a JSON object instead.
114            let restored = serde_json::json!({
115                "files": r.files,
116                "checksumMatches": r.checksum_matches,
117            });
118            let _ = ctx.add_variable("restored", &restored);
119        }
120        None => {
121            let empty: BTreeMap<String, i64> = BTreeMap::new();
122            let _ = ctx.add_variable("restored", &empty);
123        }
124    }
125    ctx
126}
127
128/// Evaluate a compiled `successExpr` [`Program`] against `inputs`, requiring a bool
129/// result. Maps an evaluation failure to [`ValidationError::SuccessExprEval`] and a
130/// non-bool result to [`ValidationError::SuccessExprType`].
131pub fn eval_success_expr(expr: &str, inputs: &SuccessExprInputs<'_>) -> ValidationResult<bool> {
132    let program = compile(expr)?;
133    let ctx = context(inputs);
134    match program.execute(&ctx) {
135        Ok(Value::Bool(b)) => Ok(b),
136        Ok(other) => Err(ValidationError::SuccessExprType {
137            expr: expr.to_string(),
138            got: other.type_of().to_string(),
139        }),
140        Err(e) => Err(ValidationError::SuccessExprEval {
141            expr: expr.to_string(),
142            reason: e.to_string(),
143        }),
144    }
145}
146
147/// Validate a `successExpr` at admission (ADR-0005 §4/§15): it must compile, and —
148/// because CEL reports an out-of-scope variable only at *evaluation* time — it must
149/// trial-evaluate against a representative environment without referencing an
150/// undeclared variable, returning a **bool**. Missing *map keys* (e.g.
151/// `snapshot.tags['x']` when the trial data lacks `x`) are tolerated as
152/// data-dependent, mirroring [`crate::identity::validate_identity_expr`].
153pub fn validate_success_expr(expr: &str) -> ValidationResult {
154    let program = compile(expr)?;
155    // A representative non-empty environment so guards behave: include the deep
156    // `restored` map so a deep-only expression validates, and a snapshot id.
157    let inputs = SuccessExprInputs {
158        stats: VerifyStats {
159            files: 1,
160            bytes: 1,
161            errors: 0,
162        },
163        snapshot: BTreeMap::from([("id".to_string(), "trial".to_string())]),
164        restored: Some(RestoredStats {
165            files: 1,
166            checksum_matches: true,
167        }),
168        tier: "deep".to_string(),
169        _marker: std::marker::PhantomData,
170    };
171    let ctx = context(&inputs);
172    match program.execute(&ctx) {
173        Ok(Value::Bool(_)) => Ok(()),
174        Ok(other) => Err(ValidationError::SuccessExprType {
175            expr: expr.to_string(),
176            got: other.type_of().to_string(),
177        }),
178        // An undeclared-variable reference (typo / out-of-scope) is a hard reject.
179        // Other runtime errors (NoSuchKey on a data-dependent map index) are tolerated.
180        Err(cel::ExecutionError::UndeclaredReference(name)) => {
181            Err(ValidationError::SuccessExprEval {
182                expr: expr.to_string(),
183                reason: format!("undeclared reference to '{name}'"),
184            })
185        }
186        Err(_) => Ok(()),
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn inputs(files: i64, errors: i64) -> SuccessExprInputs<'static> {
195        SuccessExprInputs {
196            stats: VerifyStats {
197                files,
198                bytes: files * 100,
199                errors,
200            },
201            snapshot: BTreeMap::from([("id".to_string(), "k1".to_string())]),
202            restored: None,
203            tier: "quick".to_string(),
204            _marker: std::marker::PhantomData,
205        }
206    }
207
208    #[test]
209    fn evaluates_true_and_false() {
210        let expr = "stats.files > 0 && stats.errors == 0";
211        assert!(eval_success_expr(expr, &inputs(5, 0)).unwrap());
212        // Zero files → the silent-empty-success guard fails.
213        assert!(!eval_success_expr(expr, &inputs(0, 0)).unwrap());
214        // Errors present → fails.
215        assert!(!eval_success_expr(expr, &inputs(5, 2)).unwrap());
216    }
217
218    #[test]
219    fn deep_restored_environment_is_available() {
220        let mut i = inputs(3, 0);
221        i.restored = Some(RestoredStats {
222            files: 3,
223            checksum_matches: true,
224        });
225        assert!(eval_success_expr("restored.files > 0 && restored.checksumMatches", &i).unwrap());
226        i.restored = Some(RestoredStats {
227            files: 3,
228            checksum_matches: false,
229        });
230        assert!(!eval_success_expr("restored.checksumMatches", &i).unwrap());
231    }
232
233    #[test]
234    fn snapshot_metadata_is_available() {
235        let i = inputs(1, 0);
236        assert!(eval_success_expr("snapshot.id == 'k1'", &i).unwrap());
237    }
238
239    #[test]
240    fn tier_is_available_for_branching() {
241        // `inputs` is the quick tier; a tier-aware predicate must see it.
242        let i = inputs(5, 0);
243        assert_eq!(i.tier, "quick");
244        let expr = "tier == 'deep' ? restored.checksumMatches : stats.files > 0";
245        assert!(eval_success_expr(expr, &i).unwrap());
246        // The deep branch reads `restored` (empty for quick) only when tier=='deep'.
247        let mut d = inputs(5, 0);
248        d.tier = "deep".to_string();
249        d.restored = Some(RestoredStats {
250            files: 5,
251            checksum_matches: true,
252        });
253        assert!(eval_success_expr(expr, &d).unwrap());
254        d.restored = Some(RestoredStats {
255            files: 5,
256            checksum_matches: false,
257        });
258        assert!(!eval_success_expr(expr, &d).unwrap());
259    }
260
261    #[test]
262    fn non_bool_result_is_an_error() {
263        let err = eval_success_expr("stats.files", &inputs(1, 0)).unwrap_err();
264        assert!(matches!(err, ValidationError::SuccessExprType { .. }));
265    }
266
267    #[test]
268    fn typo_is_an_error() {
269        let err = eval_success_expr("statss.files > 0", &inputs(1, 0)).unwrap_err();
270        assert!(matches!(err, ValidationError::SuccessExprEval { .. }));
271    }
272
273    // --- validate_success_expr (admission) ---
274
275    #[test]
276    fn validate_accepts_valid_bool_exprs() {
277        assert!(validate_success_expr("stats.files > 0 && stats.errors == 0").is_ok());
278        assert!(validate_success_expr("restored.checksumMatches").is_ok());
279        assert!(validate_success_expr("snapshot.id != ''").is_ok());
280        // A tier-aware predicate validates against the trial environment.
281        assert!(
282            validate_success_expr("tier == 'deep' ? restored.checksumMatches : stats.files > 0")
283                .is_ok()
284        );
285        // Data-dependent map index on snapshot is tolerated.
286        assert!(validate_success_expr("snapshot.id != '' && stats.errors == 0").is_ok());
287    }
288
289    #[test]
290    fn validate_rejects_syntax_error() {
291        let err = validate_success_expr("stats.files >").unwrap_err();
292        assert!(matches!(err, ValidationError::SuccessExprCompile { .. }));
293    }
294
295    #[test]
296    fn validate_rejects_out_of_scope_variable() {
297        let err = validate_success_expr("bogus > 0").unwrap_err();
298        assert!(matches!(err, ValidationError::SuccessExprEval { .. }));
299    }
300
301    #[test]
302    fn validate_rejects_non_bool_result() {
303        let err = validate_success_expr("stats.files + 1").unwrap_err();
304        assert!(matches!(err, ValidationError::SuccessExprType { .. }));
305    }
306
307    #[test]
308    fn validate_rejects_over_length_expr() {
309        let long = format!("stats.files == {} ", "1".repeat(MAX_EXPR_LEN));
310        let err = validate_success_expr(&long).unwrap_err();
311        assert!(matches!(err, ValidationError::SuccessExprCompile { .. }));
312    }
313}