Skip to main content

kube_cel/validation/
compilation.rs

1//! Compilation of Kubernetes CRD `x-kubernetes-validations` rules into CEL programs.
2//!
3//! This module parses validation rules from CRD schemas and compiles them into
4//! [`cel::Program`] instances that can be evaluated against resource data.
5
6use std::collections::HashMap;
7
8use cel::Program;
9
10use crate::validation::values::SchemaFormat;
11
12/// A single CRD `x-kubernetes-validations` rule.
13#[derive(Clone, Debug, serde::Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct Rule {
16    /// The CEL expression to evaluate.
17    pub rule: String,
18    /// Static error message returned when validation fails.
19    #[serde(default)]
20    pub message: Option<String>,
21    /// CEL expression that produces a dynamic error message.
22    #[serde(default)]
23    pub message_expression: Option<String>,
24    /// Machine-readable reason for the validation failure (e.g. "FieldValueForbidden").
25    #[serde(default)]
26    pub reason: Option<String>,
27    /// JSONPath to the field that caused the failure.
28    #[serde(default)]
29    pub field_path: Option<String>,
30    /// Whether `oldSelf` is optional. When `true`, transition rules are
31    /// evaluated even on create (with `oldSelf` bound to null).
32    #[serde(default)]
33    pub optional_old_self: Option<bool>,
34}
35
36/// The result of successfully compiling a [`Rule`].
37///
38/// `#[non_exhaustive]`: an output type the crate constructs; new fields may be
39/// added without a breaking change.
40#[derive(Debug)]
41#[non_exhaustive]
42pub struct CompilationResult {
43    /// The compiled CEL program.
44    pub program: Program,
45    /// The original rule that was compiled.
46    pub rule: Rule,
47    /// Whether the rule references `oldSelf` (transition rule).
48    pub is_transition_rule: bool,
49    /// Pre-compiled `messageExpression` program, or `None` if the rule had no
50    /// `messageExpression`. A `messageExpression` that fails to compile yields a
51    /// [`CompilationError::MessageExpressionParse`] instead of a `None` here.
52    pub message_program: Option<Program>,
53}
54
55/// Errors that can occur during rule compilation.
56#[derive(Debug)]
57#[non_exhaustive]
58pub enum CompilationError {
59    /// CEL expression failed to parse.
60    Parse {
61        /// The original CEL expression that failed to compile.
62        rule: String,
63        /// The boxed parse error reported by the CEL compiler. Boxed (rather
64        /// than carrying the concrete `cel::ParseErrors`) so the pre-1.0 `cel`
65        /// type is not frozen into this public enum variant; reach it via
66        /// [`Error::source`](std::error::Error::source).
67        source: Box<dyn std::error::Error + Send + Sync>,
68    },
69    /// A rule's `messageExpression` failed to parse. The `rule` itself is
70    /// well-formed, but its dynamic error message cannot be compiled. Surfaced as
71    /// a compilation error (rather than silently dropped) so the rule fails
72    /// closed, mirroring the apiserver, which rejects such a CRD at registration.
73    MessageExpressionParse {
74        /// The rule whose `messageExpression` failed to compile.
75        rule: String,
76        /// The `messageExpression` that failed to compile.
77        message_expression: String,
78        /// The boxed parse error reported by the CEL compiler. Boxed (rather
79        /// than carrying the concrete `cel::ParseErrors`) so the pre-1.0 `cel`
80        /// type is not frozen into this public enum variant; reach it via
81        /// [`Error::source`](std::error::Error::source).
82        source: Box<dyn std::error::Error + Send + Sync>,
83    },
84    /// JSON value could not be deserialized into a [`Rule`].
85    InvalidRule(serde_json::Error),
86    /// Schema nesting exceeded the maximum depth. The over-deep subtree was
87    /// refused rather than silently truncated, so a too-deep schema cannot
88    /// quietly drop the validation rules nested beneath the cap (fail-closed).
89    SchemaTooDeep {
90        /// The nesting depth at which the limit was exceeded.
91        depth: usize,
92    },
93}
94
95impl std::fmt::Display for CompilationError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            CompilationError::Parse { rule, source } => {
99                write!(f, "failed to compile CEL rule \"{rule}\": {source}")
100            }
101            CompilationError::MessageExpressionParse {
102                rule,
103                message_expression,
104                source,
105            } => {
106                write!(
107                    f,
108                    "failed to compile messageExpression \"{message_expression}\" for rule \"{rule}\": {source}"
109                )
110            }
111            CompilationError::InvalidRule(err) => {
112                write!(f, "invalid rule definition: {err}")
113            }
114            CompilationError::SchemaTooDeep { depth } => {
115                write!(
116                    f,
117                    "schema nesting depth {depth} exceeds the maximum of {MAX_SCHEMA_DEPTH}"
118                )
119            }
120        }
121    }
122}
123
124impl std::error::Error for CompilationError {
125    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
126        match self {
127            CompilationError::Parse { source, .. } => Some(source.as_ref()),
128            CompilationError::MessageExpressionParse { source, .. } => Some(source.as_ref()),
129            CompilationError::InvalidRule(err) => Some(err),
130            CompilationError::SchemaTooDeep { .. } => None,
131        }
132    }
133}
134
135/// Compile a single [`Rule`] into a [`CompilationResult`].
136///
137/// Returns [`CompilationError::Parse`] if the rule expression is invalid, or
138/// [`CompilationError::MessageExpressionParse`] if its `messageExpression` is.
139pub(crate) fn compile_rule(rule: &Rule) -> Result<CompilationResult, CompilationError> {
140    let program = Program::compile(&rule.rule).map_err(|e| CompilationError::Parse {
141        rule: rule.rule.clone(),
142        source: Box::new(e),
143    })?;
144    let is_transition_rule = program.references().has_variable("oldSelf");
145
146    // Compile messageExpression if present. A failure fails closed (the apiserver
147    // rejects such a CRD at registration), mirroring the `rule` path above rather
148    // than silently dropping the dynamic message.
149    let message_program = match rule.message_expression.as_deref() {
150        Some(expr) => Some(
151            Program::compile(expr).map_err(|e| CompilationError::MessageExpressionParse {
152                rule: rule.rule.clone(),
153                message_expression: expr.to_string(),
154                source: Box::new(e),
155            })?,
156        ),
157        None => None,
158    };
159
160    Ok(CompilationResult {
161        program,
162        rule: rule.clone(),
163        is_transition_rule,
164        message_program,
165    })
166}
167
168/// Extract `x-kubernetes-validations` rules from a schema node and compile them.
169///
170/// If the schema has no `x-kubernetes-validations` key or it is not an array,
171/// returns an empty `Vec`. Each rule is compiled independently — failures in one
172/// rule do not prevent others from compiling.
173pub(crate) fn compile_schema_validations(
174    schema: &serde_json::Value,
175) -> Vec<Result<CompilationResult, CompilationError>> {
176    let rules = match schema.get("x-kubernetes-validations") {
177        Some(serde_json::Value::Array(arr)) => arr,
178        _ => return Vec::new(),
179    };
180
181    rules
182        .iter()
183        .map(|raw| {
184            let rule: Rule = serde_json::from_value(raw.clone()).map_err(CompilationError::InvalidRule)?;
185            compile_rule(&rule)
186        })
187        .collect()
188}
189
190/// A pre-compiled schema tree. Compile once with [`compile_schema`], then
191/// validate many objects via [`Validator::validate_compiled`](crate::Validator::validate_compiled).
192///
193/// # Note
194///
195/// `CompiledSchema` is not `Clone` because [`cel::Program`] is `!Clone`.
196/// Wrap in [`Arc`](std::sync::Arc) for shared ownership across threads.
197///
198/// `#[non_exhaustive]`: an output type the crate constructs; new fields may be
199/// added without a breaking change. Read its fields directly or via the
200/// accessor methods below.
201#[derive(Debug)]
202#[non_exhaustive]
203pub struct CompiledSchema {
204    /// Compiled validation rules at this schema node.
205    pub validations: Vec<Result<CompilationResult, CompilationError>>,
206    /// Compiled child property schemas.
207    pub properties: HashMap<String, CompiledSchema>,
208    /// Compiled array items schema.
209    pub items: Option<Box<CompiledSchema>>,
210    /// Compiled additionalProperties schema.
211    pub additional_properties: Option<Box<CompiledSchema>>,
212    /// The `format` hint from the schema (e.g., `date-time`, `duration`).
213    pub format: SchemaFormat,
214    /// Compiled `allOf` branch schemas.
215    pub all_of: Vec<CompiledSchema>,
216    /// Compiled `oneOf` branch schemas.
217    pub one_of: Vec<CompiledSchema>,
218    /// Compiled `anyOf` branch schemas.
219    pub any_of: Vec<CompiledSchema>,
220    /// `maxLength` from the schema (for cost estimation).
221    pub max_length: Option<u64>,
222    /// `maxItems` from the schema (for cost estimation).
223    pub max_items: Option<u64>,
224    /// `maxProperties` from the schema (for cost estimation).
225    pub max_properties: Option<u64>,
226    /// Whether `x-kubernetes-preserve-unknown-fields: true` is set on this node.
227    /// When true, `additionalProperties` walking is skipped.
228    pub preserve_unknown_fields: bool,
229    /// Whether `x-kubernetes-embedded-resource: true` is set on this node.
230    /// When true, `apiVersion`, `kind`, and `metadata` keys are injected with
231    /// defaults if absent during value conversion.
232    pub embedded_resource: bool,
233}
234
235impl CompiledSchema {
236    /// Returns references to all compilation errors in this node's validations.
237    #[must_use]
238    pub fn compilation_errors(&self) -> Vec<&CompilationError> {
239        self.validations.iter().filter_map(|r| r.as_ref().err()).collect()
240    }
241
242    /// Returns `true` if any validation rule at this node failed to compile.
243    #[must_use]
244    pub fn has_errors(&self) -> bool {
245        self.validations.iter().any(|r| r.is_err())
246    }
247}
248
249/// Maximum schema nesting depth to prevent unbounded recursion.
250///
251/// Shared across compile (`compilation`), validate (`validation`), and default
252/// injection (`defaults`) so the three depth guards stay in lockstep.
253pub(crate) const MAX_SCHEMA_DEPTH: usize = 64;
254
255fn compile_schema_array(schema: &serde_json::Value, key: &str, depth: usize) -> Vec<CompiledSchema> {
256    schema
257        .get(key)
258        .and_then(|v| v.as_array())
259        .map(|arr| arr.iter().map(|s| compile_schema_inner(s, depth)).collect())
260        .unwrap_or_default()
261}
262
263/// Recursively compile all `x-kubernetes-validations` rules in a schema tree.
264///
265/// Returns a [`CompiledSchema`] that can be reused across multiple validation
266/// calls, avoiding repeated compilation.
267#[must_use]
268pub fn compile_schema(schema: &serde_json::Value) -> CompiledSchema {
269    compile_schema_inner(schema, 0)
270}
271
272fn compile_schema_inner(schema: &serde_json::Value, depth: usize) -> CompiledSchema {
273    if depth > MAX_SCHEMA_DEPTH {
274        // Fail-closed: carry a SchemaTooDeep marker rather than returning a
275        // silently-empty node, so `compilation_errors()` and the validators
276        // surface the truncation instead of dropping the deep rules.
277        return CompiledSchema {
278            validations: vec![Err(CompilationError::SchemaTooDeep { depth })],
279            properties: HashMap::new(),
280            items: None,
281            additional_properties: None,
282            format: SchemaFormat::None,
283            all_of: Vec::new(),
284            one_of: Vec::new(),
285            any_of: Vec::new(),
286            max_length: None,
287            max_items: None,
288            max_properties: None,
289            preserve_unknown_fields: false,
290            embedded_resource: false,
291        };
292    }
293
294    let validations = compile_schema_validations(schema);
295
296    let mut properties = HashMap::new();
297    if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
298        for (name, prop_schema) in props {
299            properties.insert(name.clone(), compile_schema_inner(prop_schema, depth + 1));
300        }
301    }
302
303    let items = schema
304        .get("items")
305        .map(|s| Box::new(compile_schema_inner(s, depth + 1)));
306
307    let additional_properties = schema
308        .get("additionalProperties")
309        .filter(|a| a.is_object())
310        .map(|s| Box::new(compile_schema_inner(s, depth + 1)));
311
312    let format = SchemaFormat::from_schema(schema);
313
314    let all_of = compile_schema_array(schema, "allOf", depth + 1);
315    let one_of = compile_schema_array(schema, "oneOf", depth + 1);
316    let any_of = compile_schema_array(schema, "anyOf", depth + 1);
317
318    let max_length = schema.get("maxLength").and_then(|v| v.as_u64());
319    let max_items = schema.get("maxItems").and_then(|v| v.as_u64());
320    let max_properties = schema.get("maxProperties").and_then(|v| v.as_u64());
321
322    let preserve_unknown_fields = schema
323        .get("x-kubernetes-preserve-unknown-fields")
324        .and_then(|v| v.as_bool())
325        == Some(true);
326
327    let embedded_resource = schema
328        .get("x-kubernetes-embedded-resource")
329        .and_then(|v| v.as_bool())
330        == Some(true);
331
332    CompiledSchema {
333        validations,
334        properties,
335        items,
336        additional_properties,
337        format,
338        all_of,
339        one_of,
340        any_of,
341        max_length,
342        max_items,
343        max_properties,
344        preserve_unknown_fields,
345        embedded_resource,
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use serde_json::json;
353
354    #[test]
355    fn compile_simple_rule() {
356        let rule = Rule {
357            rule: "self.replicas >= 0".into(),
358            message: None,
359            message_expression: None,
360            reason: None,
361            field_path: None,
362            optional_old_self: None,
363        };
364        let result = compile_rule(&rule).unwrap();
365        assert!(!result.is_transition_rule);
366    }
367
368    #[test]
369    fn detect_transition_rule() {
370        let rule = Rule {
371            rule: "self.replicas >= oldSelf.replicas".into(),
372            message: None,
373            message_expression: None,
374            reason: None,
375            field_path: None,
376            optional_old_self: None,
377        };
378        let result = compile_rule(&rule).unwrap();
379        assert!(result.is_transition_rule);
380    }
381
382    #[test]
383    fn detect_non_transition_rule() {
384        let rule = Rule {
385            rule: "self.replicas > 0".into(),
386            message: None,
387            message_expression: None,
388            reason: None,
389            field_path: None,
390            optional_old_self: None,
391        };
392        let result = compile_rule(&rule).unwrap();
393        assert!(!result.is_transition_rule);
394    }
395
396    #[test]
397    fn parse_error_on_invalid_cel() {
398        let rule = Rule {
399            rule: "self.replicas >=".into(),
400            message: None,
401            message_expression: None,
402            reason: None,
403            field_path: None,
404            optional_old_self: None,
405        };
406        let err = compile_rule(&rule).unwrap_err();
407        assert!(matches!(err, CompilationError::Parse { .. }));
408        // Display should contain the rule text
409        let msg = err.to_string();
410        assert!(msg.contains("self.replicas >="));
411    }
412
413    #[test]
414    fn deserialize_rule_all_fields() {
415        let raw = json!({
416            "rule": "self.x > 0",
417            "message": "x must be positive",
418            "messageExpression": "\"x is \" + string(self.x)",
419            "reason": "FieldValueInvalid",
420            "fieldPath": ".spec.x",
421            "optionalOldSelf": true
422        });
423        let rule: Rule = serde_json::from_value(raw).unwrap();
424        assert_eq!(rule.rule, "self.x > 0");
425        assert_eq!(rule.message.as_deref(), Some("x must be positive"));
426        assert_eq!(
427            rule.message_expression.as_deref(),
428            Some("\"x is \" + string(self.x)")
429        );
430        assert_eq!(rule.reason.as_deref(), Some("FieldValueInvalid"));
431        assert_eq!(rule.field_path.as_deref(), Some(".spec.x"));
432        assert_eq!(rule.optional_old_self, Some(true));
433    }
434
435    #[test]
436    fn deserialize_rule_minimal() {
437        let raw = json!({"rule": "self.x > 0"});
438        let rule: Rule = serde_json::from_value(raw).unwrap();
439        assert_eq!(rule.rule, "self.x > 0");
440        assert!(rule.message.is_none());
441        assert!(rule.message_expression.is_none());
442        assert!(rule.reason.is_none());
443        assert!(rule.field_path.is_none());
444        assert!(rule.optional_old_self.is_none());
445    }
446
447    #[test]
448    fn schema_validations_extracts_and_compiles() {
449        let schema = json!({
450            "type": "object",
451            "x-kubernetes-validations": [
452                {"rule": "self.replicas >= 0", "message": "must be non-negative"},
453                {"rule": "self.name.size() > 0"}
454            ]
455        });
456        let results = compile_schema_validations(&schema);
457        assert_eq!(results.len(), 2);
458        assert!(results[0].is_ok());
459        assert!(results[1].is_ok());
460    }
461
462    #[test]
463    fn schema_validations_no_key() {
464        let schema = json!({"type": "object"});
465        let results = compile_schema_validations(&schema);
466        assert!(results.is_empty());
467    }
468
469    #[test]
470    fn schema_validations_empty_array() {
471        let schema = json!({
472            "x-kubernetes-validations": []
473        });
474        let results = compile_schema_validations(&schema);
475        assert!(results.is_empty());
476    }
477
478    #[test]
479    fn message_expression_compiled() {
480        let rule = Rule {
481            rule: "self.x > 0".into(),
482            message: Some("x must be positive".into()),
483            message_expression: Some("'x is ' + string(self.x)".into()),
484            reason: None,
485            field_path: None,
486            optional_old_self: None,
487        };
488        let result = compile_rule(&rule).unwrap();
489        assert!(result.message_program.is_some());
490    }
491
492    #[test]
493    fn message_expression_invalid_rejected() {
494        let rule = Rule {
495            rule: "self.x > 0".into(),
496            message: Some("fallback".into()),
497            message_expression: Some("invalid >=".into()),
498            reason: None,
499            field_path: None,
500            optional_old_self: None,
501        };
502        // A messageExpression that fails to compile must fail closed (mirroring
503        // the rule path + the apiserver, which rejects such a CRD at
504        // registration), not be silently dropped with a fall-back to the static
505        // message.
506        let err = compile_rule(&rule).unwrap_err();
507        assert!(
508            matches!(err, CompilationError::MessageExpressionParse { .. }),
509            "expected MessageExpressionParse, got {err:?}"
510        );
511    }
512
513    #[test]
514    fn message_expression_none() {
515        let rule = Rule {
516            rule: "self.x > 0".into(),
517            message: None,
518            message_expression: None,
519            reason: None,
520            field_path: None,
521            optional_old_self: None,
522        };
523        let result = compile_rule(&rule).unwrap();
524        assert!(result.message_program.is_none());
525    }
526
527    #[test]
528    fn compile_schema_tree() {
529        let schema = json!({
530            "type": "object",
531            "x-kubernetes-validations": [{"rule": "has(self.spec)"}],
532            "properties": {
533                "spec": {
534                    "type": "object",
535                    "x-kubernetes-validations": [{"rule": "self.replicas >= 0"}],
536                    "properties": {
537                        "replicas": {"type": "integer"}
538                    }
539                }
540            }
541        });
542        let compiled = compile_schema(&schema);
543        assert_eq!(compiled.validations.len(), 1);
544        assert!(compiled.properties.contains_key("spec"));
545        let spec = &compiled.properties["spec"];
546        assert_eq!(spec.validations.len(), 1);
547        assert!(spec.properties.contains_key("replicas"));
548    }
549
550    #[test]
551    fn compile_schema_with_items() {
552        let schema = json!({
553            "type": "array",
554            "items": {
555                "type": "object",
556                "x-kubernetes-validations": [{"rule": "self.name.size() > 0"}]
557            }
558        });
559        let compiled = compile_schema(&schema);
560        assert!(compiled.items.is_some());
561        assert_eq!(compiled.items.as_ref().unwrap().validations.len(), 1);
562    }
563
564    #[test]
565    fn compile_schema_empty() {
566        let schema = json!({"type": "object"});
567        let compiled = compile_schema(&schema);
568        assert!(compiled.validations.is_empty());
569        assert!(compiled.properties.is_empty());
570        assert!(compiled.items.is_none());
571        assert!(compiled.additional_properties.is_none());
572    }
573
574    #[test]
575    fn schema_validations_partial_errors() {
576        let schema = json!({
577            "x-kubernetes-validations": [
578                {"rule": "self.x > 0"},
579                {"rule": "self.y >="},
580                {"rule": "self.z == true"}
581            ]
582        });
583        let results = compile_schema_validations(&schema);
584        assert_eq!(results.len(), 3);
585        assert!(results[0].is_ok());
586        assert!(results[1].is_err());
587        assert!(results[2].is_ok());
588    }
589
590    #[test]
591    fn compilation_errors_method() {
592        let schema = json!({
593            "x-kubernetes-validations": [
594                {"rule": "self.x > 0"},
595                {"rule": "self.y >="},
596                {"rule": "self.z == true"}
597            ]
598        });
599        let compiled = compile_schema(&schema);
600        let errors = compiled.compilation_errors();
601        assert_eq!(errors.len(), 1);
602        assert!(matches!(errors[0], CompilationError::Parse { .. }));
603        assert!(compiled.has_errors());
604    }
605
606    #[test]
607    fn compilation_errors_empty_when_all_valid() {
608        let schema = json!({
609            "x-kubernetes-validations": [
610                {"rule": "self.x > 0"},
611                {"rule": "self.z == true"}
612            ]
613        });
614        let compiled = compile_schema(&schema);
615        assert!(compiled.compilation_errors().is_empty());
616        assert!(!compiled.has_errors());
617    }
618}
619
620/// White-box end-to-end tests of `compile_schema` + `CompilationResult` that
621/// bind `self` via the now-internal `json_to_cel`. Relocated from
622/// `tests/compilation_integration.rs` when `json_to_cel` became `pub(crate)`.
623#[cfg(test)]
624mod end_to_end_tests {
625    use cel::{Context, Value};
626    use serde_json::json;
627
628    use super::{CompilationError, compile_schema};
629    use crate::{register_all, validation::values::json_to_cel};
630
631    /// Compile rules from a schema, bind `self`, evaluate the first program.
632    fn compile_and_eval_first(schema: serde_json::Value, self_val: serde_json::Value) -> Value {
633        let compiled = compile_schema(&schema);
634        let cr = compiled.validations.into_iter().next().unwrap().unwrap();
635
636        let mut ctx = Context::default();
637        register_all(&mut ctx);
638        ctx.add_variable_from_value("self", json_to_cel(&self_val));
639        cr.program.execute(&ctx).unwrap()
640    }
641
642    #[test]
643    fn crd_schema_end_to_end() {
644        let schema = json!({
645            "type": "object",
646            "properties": {
647                "spec": {
648                    "type": "object",
649                    "properties": {
650                        "replicas": {"type": "integer"},
651                        "minReplicas": {"type": "integer"}
652                    },
653                    "x-kubernetes-validations": [
654                        {"rule": "self.replicas >= self.minReplicas", "message": "replicas must be >= minReplicas"}
655                    ]
656                }
657            }
658        });
659
660        let spec_schema = &schema["properties"]["spec"];
661        let self_val = json!({"replicas": 5, "minReplicas": 2});
662
663        let spec_compiled = compile_schema(spec_schema);
664        assert_eq!(spec_compiled.validations.len(), 1);
665        let compiled = spec_compiled.validations.into_iter().next().unwrap().unwrap();
666
667        assert!(!compiled.is_transition_rule);
668        assert_eq!(
669            compiled.rule.message.as_deref(),
670            Some("replicas must be >= minReplicas")
671        );
672
673        let mut ctx = Context::default();
674        register_all(&mut ctx);
675        ctx.add_variable_from_value("self", json_to_cel(&self_val));
676        assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
677    }
678
679    #[test]
680    fn compile_and_eval_with_json_to_cel() {
681        let schema = json!({
682            "x-kubernetes-validations": [{"rule": "self.name.size() > 0", "message": "name required"}]
683        });
684        let result = compile_and_eval_first(schema, json!({"name": "my-app"}));
685        assert_eq!(result, Value::Bool(true));
686    }
687
688    #[test]
689    fn transition_rule_compile_and_eval() {
690        let schema = json!({
691            "x-kubernetes-validations": [
692                {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down", "reason": "FieldValueForbidden"}
693            ]
694        });
695
696        let compiled_schema = compile_schema(&schema);
697        let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
698
699        assert!(compiled.is_transition_rule);
700        assert_eq!(compiled.rule.message.as_deref(), Some("cannot scale down"));
701        assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueForbidden"));
702
703        let mut ctx = Context::default();
704        register_all(&mut ctx);
705        ctx.add_variable_from_value("self", json_to_cel(&json!({"replicas": 5})));
706        ctx.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
707        assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
708
709        let mut ctx2 = Context::default();
710        register_all(&mut ctx2);
711        ctx2.add_variable_from_value("self", json_to_cel(&json!({"replicas": 1})));
712        ctx2.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
713        assert_eq!(compiled.program.execute(&ctx2).unwrap(), Value::Bool(false));
714    }
715
716    #[test]
717    fn message_and_reason_preserved() {
718        let schema = json!({
719            "x-kubernetes-validations": [{
720                "rule": "self.x > 0",
721                "message": "x must be positive",
722                "messageExpression": "\"x is \" + string(self.x)",
723                "reason": "FieldValueInvalid",
724                "fieldPath": ".spec.x"
725            }]
726        });
727
728        let compiled_schema = compile_schema(&schema);
729        let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
730
731        assert_eq!(compiled.rule.message.as_deref(), Some("x must be positive"));
732        assert_eq!(
733            compiled.rule.message_expression.as_deref(),
734            Some("\"x is \" + string(self.x)")
735        );
736        assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueInvalid"));
737        assert_eq!(compiled.rule.field_path.as_deref(), Some(".spec.x"));
738    }
739
740    #[test]
741    fn multiple_rules_mixed_results() {
742        let schema = json!({
743            "x-kubernetes-validations": [
744                {"rule": "self.a > 0"},
745                {"rule": "invalid >="},
746                {"rule": "self.b == true"}
747            ]
748        });
749
750        let compiled = compile_schema(&schema);
751        assert_eq!(compiled.validations.len(), 3);
752
753        let cr = compiled.validations[0].as_ref().unwrap();
754        let mut ctx = Context::default();
755        register_all(&mut ctx);
756        ctx.add_variable_from_value("self", json_to_cel(&json!({"a": 5})));
757        assert_eq!(cr.program.execute(&ctx).unwrap(), Value::Bool(true));
758
759        assert!(matches!(
760            compiled.validations[1].as_ref().unwrap_err(),
761            CompilationError::Parse { .. }
762        ));
763
764        assert!(compiled.validations[2].is_ok());
765    }
766
767    #[test]
768    fn realistic_crd_with_multiple_validation_levels() {
769        let crd_schema = json!({
770            "type": "object",
771            "properties": {
772                "spec": {
773                    "type": "object",
774                    "properties": {
775                        "replicas": {"type": "integer"},
776                        "template": {
777                            "type": "object",
778                            "properties": {"name": {"type": "string"}},
779                            "x-kubernetes-validations": [
780                                {"rule": "self.name.size() > 0", "message": "template name required"}
781                            ]
782                        }
783                    },
784                    "x-kubernetes-validations": [
785                        {"rule": "self.replicas >= 1", "message": "at least one replica"}
786                    ]
787                }
788            }
789        });
790
791        let spec_compiled = compile_schema(&crd_schema["properties"]["spec"]);
792        assert_eq!(spec_compiled.validations.len(), 1);
793        let spec_cr = spec_compiled.validations.into_iter().next().unwrap().unwrap();
794
795        let mut ctx = Context::default();
796        register_all(&mut ctx);
797        ctx.add_variable_from_value(
798            "self",
799            json_to_cel(&json!({"replicas": 3, "template": {"name": "web"}})),
800        );
801        assert_eq!(spec_cr.program.execute(&ctx).unwrap(), Value::Bool(true));
802
803        let tmpl_compiled = compile_schema(&crd_schema["properties"]["spec"]["properties"]["template"]);
804        assert_eq!(tmpl_compiled.validations.len(), 1);
805        let tmpl_cr = tmpl_compiled.validations.into_iter().next().unwrap().unwrap();
806
807        let mut ctx2 = Context::default();
808        register_all(&mut ctx2);
809        ctx2.add_variable_from_value("self", json_to_cel(&json!({"name": "web"})));
810        assert_eq!(tmpl_cr.program.execute(&ctx2).unwrap(), Value::Bool(true));
811
812        let mut ctx3 = Context::default();
813        register_all(&mut ctx3);
814        ctx3.add_variable_from_value("self", json_to_cel(&json!({"name": ""})));
815        assert_eq!(tmpl_cr.program.execute(&ctx3).unwrap(), Value::Bool(false));
816    }
817
818    #[test]
819    #[cfg(feature = "strings")]
820    fn compiled_rule_with_extension_functions() {
821        let schema = json!({
822            "x-kubernetes-validations": [{"rule": "self.name.trim().lowerAscii().size() > 0"}]
823        });
824        let result = compile_and_eval_first(schema, json!({"name": "  Hello  "}));
825        assert_eq!(result, Value::Bool(true));
826    }
827}