Skip to main content

kube_cel/validation/
mod.rs

1//! Client-side CRD validation engine (Tier 2 — the oracle).
2//!
3//! The crate root re-exports this module's public items flatly; the submodules
4//! here are internal. This file is the engine entry: [`Validator`] recursively
5//! walks an OpenAPI schema, compiles `x-kubernetes-validations` rules, evaluates
6//! them against object data, and collects [`ValidationError`]s. See the
7//! crate-level "Versioning and stability" docs (Tier 2 — evolving).
8
9pub(crate) mod analysis;
10pub(crate) mod compilation;
11pub(crate) mod defaults;
12pub(crate) mod escaping;
13pub(crate) mod values;
14pub(crate) mod vap;
15
16use crate::validation::{
17    compilation::{CompilationError, CompilationResult, CompiledSchema, compile_schema_validations},
18    values::{json_to_cel_with_compiled, json_to_cel_with_schema},
19};
20use cel::Context;
21
22/// CRD-level context variables available at the root schema node.
23///
24/// These are derived from the CRD definition, not from the object being validated.
25/// Available as root-level CEL variables: `apiVersion`, `apiGroup`, `kind`.
26#[derive(Clone, Debug, Default)]
27pub struct RootContext {
28    /// CRD API version (e.g., `"apps/v1"`).
29    pub api_version: String,
30    /// CRD API group (e.g., `"apps"`). Empty string for core resources.
31    pub api_group: String,
32    /// CRD kind (e.g., `"Deployment"`).
33    pub kind: String,
34}
35
36/// The kind of error that occurred during validation.
37#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38#[non_exhaustive]
39pub enum ErrorKind {
40    /// CEL expression syntax error.
41    CompilationFailure,
42    /// Malformed rule JSON.
43    InvalidRule,
44    /// Rule evaluated to `false`.
45    ValidationFailure,
46    /// Rule returned a non-bool value.
47    InvalidResult,
48    /// Runtime evaluation error.
49    EvaluationError,
50    /// The rule referenced a CEL function or identifier this build does not
51    /// provide — typically a CEL macro not yet supported by the `cel` crate
52    /// (`sortBy`, `cel.bind`, the two-argument comprehensions), or an
53    /// extension-function feature disabled at compile time. The rule is
54    /// well-formed but cannot be evaluated client-side, so the object is
55    /// rejected (fail-closed). Distinct from [`Self::EvaluationError`] so
56    /// callers can tell a coverage gap apart from a genuine runtime error.
57    UnsupportedReference,
58    /// Schema nesting exceeded the maximum supported depth.
59    /// The over-deep subtree was refused rather than skipped, so validation
60    /// fails closed instead of silently passing rules it never evaluated.
61    SchemaTooDeep,
62}
63
64/// An error produced when a CEL validation rule fails.
65///
66/// `#[non_exhaustive]`: this is an output type the crate constructs, never the
67/// caller. New fields may be added without a breaking change; downstream code
68/// reads the public fields and the cause chain via [`std::error::Error::source`].
69#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
70#[non_exhaustive]
71pub struct ValidationError {
72    /// The CEL expression that failed.
73    pub rule: String,
74    /// Human-readable error message.
75    pub message: String,
76    /// JSON path to the field (e.g., "spec.replicas").
77    pub field_path: String,
78    /// Machine-readable reason (e.g., "FieldValueInvalid").
79    pub reason: Option<String>,
80    /// Classification of the error.
81    pub kind: ErrorKind,
82    /// The underlying cause, exposed via [`std::error::Error::source`].
83    ///
84    /// `Arc` (not `Box`) so `ValidationError` stays `Clone`; skipped during
85    /// serialization because `dyn Error` is not `Serialize`. Carried only for
86    /// runtime evaluation failures ([`ErrorKind::EvaluationError`] /
87    /// [`ErrorKind::UnsupportedReference`]), where the owned `cel`
88    /// `ExecutionError` is the cause. Compile-time causes (parse/JSON errors)
89    /// are not chained here: `cel::ParseErrors` is `!Clone` and reached only
90    /// behind a shared borrow, so they cannot be moved into an owned
91    /// `ValidationError` — their detail is preserved in `message`, and the
92    /// typed cause remains reachable via
93    /// [`CompiledSchema::compilation_errors`](crate::CompiledSchema::compilation_errors).
94    #[serde(skip)]
95    source: Option<std::sync::Arc<dyn std::error::Error + Send + Sync>>,
96}
97
98impl std::fmt::Display for ValidationError {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        if self.field_path.is_empty() {
101            write!(f, "{}", self.message)
102        } else {
103            write!(f, "{}: {}", self.field_path, self.message)
104        }
105    }
106}
107
108// Manual `PartialEq`/`Eq`: the `source` cause is auxiliary metadata (and
109// `dyn Error` is not `PartialEq`), so equality is decided by the data fields.
110impl PartialEq for ValidationError {
111    fn eq(&self, other: &Self) -> bool {
112        self.rule == other.rule
113            && self.message == other.message
114            && self.field_path == other.field_path
115            && self.reason == other.reason
116            && self.kind == other.kind
117    }
118}
119
120impl Eq for ValidationError {}
121
122impl std::error::Error for ValidationError {
123    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
124        self.source
125            .as_ref()
126            .map(|s| &**s as &(dyn std::error::Error + 'static))
127    }
128}
129
130/// Aggregate of CEL validation failures.
131///
132/// The `Err` payload of the Result-returning validation entry points (e.g.
133/// [`Validator::validate`]), so callers can `?` instead of checking
134/// `Vec::is_empty`. Derefs to `[ValidationError]` and is iterable, so every
135/// individual failure stays inspectable; [`into_vec`](Self::into_vec) recovers
136/// ownership of the full `Vec`.
137///
138/// A `ValidationErrors` is never empty: the entry points return `Ok(())` when no
139/// rule fails and `Err(ValidationErrors)` only when at least one does.
140#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
141pub struct ValidationErrors(Vec<ValidationError>);
142
143impl ValidationErrors {
144    /// Consume the aggregate and return the underlying `Vec`.
145    #[must_use]
146    pub fn into_vec(self) -> Vec<ValidationError> {
147        self.0
148    }
149
150    /// The individual failures as a slice.
151    #[must_use]
152    pub fn as_slice(&self) -> &[ValidationError] {
153        &self.0
154    }
155
156    /// Whether the aggregate holds no failures. Always `false` for a value
157    /// produced by the validation entry points (which return `Ok` in that case),
158    /// but meaningful for aggregates built directly via [`From`].
159    #[must_use]
160    pub fn is_empty(&self) -> bool {
161        self.0.is_empty()
162    }
163
164    /// Number of individual failures.
165    #[must_use]
166    pub fn len(&self) -> usize {
167        self.0.len()
168    }
169}
170
171impl std::fmt::Display for ValidationErrors {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        writeln!(f, "{} validation error(s):", self.0.len())?;
174        for (i, err) in self.0.iter().enumerate() {
175            if i + 1 == self.0.len() {
176                write!(f, "  - {err}")?;
177            } else {
178                writeln!(f, "  - {err}")?;
179            }
180        }
181        Ok(())
182    }
183}
184
185// Aggregate error: the constituent `ValidationError`s are the payload (reachable
186// via `Deref`/`IntoIterator`), not a single underlying cause, so `source()` is
187// `None`. Mirrors `ValidationError`'s own `Error` impl style.
188impl std::error::Error for ValidationErrors {}
189
190impl std::ops::Deref for ValidationErrors {
191    type Target = [ValidationError];
192
193    fn deref(&self) -> &Self::Target {
194        &self.0
195    }
196}
197
198impl From<Vec<ValidationError>> for ValidationErrors {
199    fn from(errors: Vec<ValidationError>) -> Self {
200        Self(errors)
201    }
202}
203
204impl IntoIterator for ValidationErrors {
205    type IntoIter = std::vec::IntoIter<ValidationError>;
206    type Item = ValidationError;
207
208    fn into_iter(self) -> Self::IntoIter {
209        self.0.into_iter()
210    }
211}
212
213impl<'a> IntoIterator for &'a ValidationErrors {
214    type IntoIter = std::slice::Iter<'a, ValidationError>;
215    type Item = &'a ValidationError;
216
217    fn into_iter(self) -> Self::IntoIter {
218        self.0.iter()
219    }
220}
221
222/// Convert a collected error `Vec` into the Result shape the entry points return:
223/// `Ok(())` when empty, `Err(ValidationErrors)` otherwise.
224fn into_result(errors: Vec<ValidationError>) -> Result<(), ValidationErrors> {
225    if errors.is_empty() {
226        Ok(())
227    } else {
228        Err(ValidationErrors(errors))
229    }
230}
231
232/// Builds the fail-closed error emitted when schema nesting exceeds
233/// [`MAX_SCHEMA_DEPTH`](crate::validation::compilation::MAX_SCHEMA_DEPTH). Surfacing this —
234/// rather than silently skipping the over-deep subtree — is what keeps the
235/// validator fail-closed: a too-deep schema cannot quietly report success.
236fn schema_too_deep_error(path: &str) -> ValidationError {
237    ValidationError {
238        rule: String::new(),
239        message: format!(
240            "schema nesting exceeds the maximum depth of {}",
241            crate::validation::compilation::MAX_SCHEMA_DEPTH
242        ),
243        field_path: path.to_string(),
244        reason: None,
245        kind: ErrorKind::SchemaTooDeep,
246        source: None,
247    }
248}
249
250/// Validates Kubernetes objects against CRD schema CEL validation rules.
251///
252/// Walks the OpenAPI schema tree, compiles `x-kubernetes-validations` rules at
253/// each node, and evaluates them against the corresponding object values.
254///
255/// For repeated validation against the same schema, use [`compile_schema`](crate::compile_schema) +
256/// [`validate_compiled`](Validator::validate_compiled) to avoid re-compilation.
257///
258/// # Thread Safety
259///
260/// `Validator` is `Send` and can be moved across threads.
261pub struct Validator {
262    base_ctx: Context<'static>,
263}
264
265impl std::fmt::Debug for Validator {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        f.debug_struct("Validator").finish()
268    }
269}
270
271impl Validator {
272    /// Create a new `Validator` with all K8s CEL functions pre-registered.
273    pub fn new() -> Self {
274        let mut ctx = Context::default();
275        crate::register_all(&mut ctx);
276        Self { base_ctx: ctx }
277    }
278
279    /// Validate an object against a CRD schema's CEL validation rules.
280    ///
281    /// Schema `default`s are applied to the object before the rules run, matching
282    /// the apiserver, which defaults during admission before evaluating CEL. A CR
283    /// that omits a defaulted field is therefore validated as the apiserver sees
284    /// it (with the default in place), not as the raw object.
285    ///
286    /// Compiles rules on each call. For repeated validation against the same
287    /// schema, prefer [`compile_schema`](crate::compile_schema) + [`validate_compiled`](Self::validate_compiled).
288    pub fn validate(
289        &self,
290        schema: &serde_json::Value,
291        object: &serde_json::Value,
292        old_object: Option<&serde_json::Value>,
293    ) -> Result<(), ValidationErrors> {
294        self.validate_with_context(schema, object, old_object, None)
295    }
296
297    /// Validate an object against a CRD schema's CEL validation rules, with optional root context.
298    ///
299    /// Like [`validate`](Self::validate), but also binds `apiVersion`, `apiGroup`, and `kind`
300    /// as root-level CEL variables when a [`RootContext`] is provided.
301    ///
302    /// Schema `default`s are applied to the object before evaluation, matching the
303    /// apiserver's admission order (defaults run before CEL rules).
304    pub fn validate_with_context(
305        &self,
306        schema: &serde_json::Value,
307        object: &serde_json::Value,
308        old_object: Option<&serde_json::Value>,
309        root_ctx: Option<&RootContext>,
310    ) -> Result<(), ValidationErrors> {
311        let defaulted = crate::validation::defaults::apply_defaults(schema, object);
312        let defaulted_old = old_object.map(|o| crate::validation::defaults::apply_defaults(schema, o));
313        let mut errors = Vec::new();
314        self.walk_schema(
315            schema,
316            &defaulted,
317            defaulted_old.as_ref(),
318            String::new(),
319            &mut errors,
320            &self.base_ctx,
321            root_ctx,
322            0,
323        );
324        into_result(errors)
325    }
326
327    /// Validate an object using a pre-compiled schema tree.
328    ///
329    /// Use [`compile_schema`](crate::compile_schema) to build the [`CompiledSchema`], then call this
330    /// method for each object to validate — rules are compiled only once.
331    ///
332    /// Schema `default`s (retained on the compiled tree) are applied to the object
333    /// before evaluation, matching [`validate`](Self::validate) and the apiserver.
334    pub fn validate_compiled(
335        &self,
336        compiled: &CompiledSchema,
337        object: &serde_json::Value,
338        old_object: Option<&serde_json::Value>,
339    ) -> Result<(), ValidationErrors> {
340        self.validate_compiled_with_context(compiled, object, old_object, None)
341    }
342
343    /// Validate an object using a pre-compiled schema tree, with optional root context.
344    ///
345    /// Like [`validate_compiled`](Self::validate_compiled), but also binds `apiVersion`,
346    /// `apiGroup`, and `kind` as root-level CEL variables when a [`RootContext`] is provided.
347    pub fn validate_compiled_with_context(
348        &self,
349        compiled: &CompiledSchema,
350        object: &serde_json::Value,
351        old_object: Option<&serde_json::Value>,
352        root_ctx: Option<&RootContext>,
353    ) -> Result<(), ValidationErrors> {
354        let defaulted = crate::validation::defaults::apply_defaults_compiled(compiled, object);
355        let defaulted_old =
356            old_object.map(|o| crate::validation::defaults::apply_defaults_compiled(compiled, o));
357        let mut errors = Vec::new();
358        self.walk_compiled(
359            compiled,
360            &defaulted,
361            defaulted_old.as_ref(),
362            String::new(),
363            &mut errors,
364            &self.base_ctx,
365            root_ctx,
366            0,
367        );
368        into_result(errors)
369    }
370
371    /// Validate with schema defaults applied to the object first.
372    ///
373    /// Alias for [`validate`](Self::validate). As of 0.8, plain `validate` applies
374    /// schema defaults itself, so this method is equivalent and retained only for
375    /// backward compatibility.
376    pub fn validate_with_defaults(
377        &self,
378        schema: &serde_json::Value,
379        object: &serde_json::Value,
380        old_object: Option<&serde_json::Value>,
381    ) -> Result<(), ValidationErrors> {
382        self.validate(schema, object, old_object)
383    }
384
385    /// Validate with schema defaults applied and root context variables bound.
386    ///
387    /// Alias for [`validate_with_context`](Self::validate_with_context). As of 0.8,
388    /// that method applies schema defaults itself, so this is equivalent and
389    /// retained only for backward compatibility.
390    pub fn validate_with_defaults_and_context(
391        &self,
392        schema: &serde_json::Value,
393        object: &serde_json::Value,
394        old_object: Option<&serde_json::Value>,
395        root_ctx: Option<&RootContext>,
396    ) -> Result<(), ValidationErrors> {
397        self.validate_with_context(schema, object, old_object, root_ctx)
398    }
399
400    // ── Schema-based walking (compiles on each call) ────────────────
401
402    #[allow(clippy::too_many_arguments)]
403    fn walk_schema(
404        &self,
405        schema: &serde_json::Value,
406        value: &serde_json::Value,
407        old_value: Option<&serde_json::Value>,
408        path: String,
409        errors: &mut Vec<ValidationError>,
410        base_ctx: &Context<'_>,
411        root_ctx: Option<&RootContext>,
412        depth: usize,
413    ) {
414        if depth > crate::validation::compilation::MAX_SCHEMA_DEPTH {
415            errors.push(schema_too_deep_error(&path));
416            return;
417        }
418
419        let cel_value = json_to_cel_with_schema(value, schema);
420        let cel_old = old_value.map(|o| json_to_cel_with_schema(o, schema));
421        self.evaluate_validations(
422            schema,
423            &cel_value,
424            cel_old.as_ref(),
425            &path,
426            errors,
427            base_ctx,
428            root_ctx,
429        );
430
431        if let (Some(properties), Some(obj)) = (
432            schema.get("properties").and_then(|p| p.as_object()),
433            value.as_object(),
434        ) {
435            for (prop_name, prop_schema) in properties {
436                if let Some(child_value) = obj.get(prop_name) {
437                    let child_old = old_value.and_then(|o| o.get(prop_name));
438                    let child_path = join_path(&path, prop_name);
439                    self.walk_schema(
440                        prop_schema,
441                        child_value,
442                        child_old,
443                        child_path,
444                        errors,
445                        base_ctx,
446                        None,
447                        depth + 1,
448                    );
449                }
450            }
451        }
452
453        if let (Some(items_schema), Some(arr)) = (schema.get("items"), value.as_array()) {
454            for (i, item) in arr.iter().enumerate() {
455                let old_item = old_value.and_then(|o| o.as_array()).and_then(|a| a.get(i));
456                let item_path = join_path_index(&path, i);
457                self.walk_schema(
458                    items_schema,
459                    item,
460                    old_item,
461                    item_path,
462                    errors,
463                    base_ctx,
464                    None,
465                    depth + 1,
466                );
467            }
468        }
469
470        let preserve_unknown = schema
471            .get("x-kubernetes-preserve-unknown-fields")
472            .and_then(|v| v.as_bool())
473            == Some(true);
474
475        if !preserve_unknown
476            && let (Some(additional_schema), Some(obj)) = (
477                schema.get("additionalProperties").filter(|a| a.is_object()),
478                value.as_object(),
479            )
480        {
481            let known: std::collections::HashSet<&str> = schema
482                .get("properties")
483                .and_then(|p| p.as_object())
484                .map(|p| p.keys().map(|k| k.as_str()).collect())
485                .unwrap_or_default();
486
487            for (key, val) in obj {
488                if known.contains(key.as_str()) {
489                    continue;
490                }
491                let old_val = old_value.and_then(|o| o.get(key));
492                let child_path = join_path(&path, key);
493                self.walk_schema(
494                    additional_schema,
495                    val,
496                    old_val,
497                    child_path,
498                    errors,
499                    base_ctx,
500                    None,
501                    depth + 1,
502                );
503            }
504        }
505
506        // Walk allOf/oneOf/anyOf branches — all treated identically for CEL evaluation
507        for keyword in &["allOf", "oneOf", "anyOf"] {
508            if let Some(branches) = schema.get(keyword).and_then(|v| v.as_array()) {
509                for branch in branches {
510                    self.walk_schema(
511                        branch,
512                        value,
513                        old_value,
514                        path.clone(),
515                        errors,
516                        base_ctx,
517                        root_ctx,
518                        depth + 1,
519                    );
520                }
521            }
522        }
523    }
524
525    #[allow(clippy::too_many_arguments)]
526    fn evaluate_validations(
527        &self,
528        schema: &serde_json::Value,
529        cel_value: &cel::Value,
530        cel_old: Option<&cel::Value>,
531        path: &str,
532        errors: &mut Vec<ValidationError>,
533        base_ctx: &Context<'_>,
534        root_ctx: Option<&RootContext>,
535    ) {
536        let compiled = compile_schema_validations(schema);
537        self.evaluate_compiled_results(&compiled, cel_value, cel_old, path, errors, base_ctx, root_ctx);
538    }
539
540    // ── CompiledSchema-based walking ────────────────────────────────
541
542    #[allow(clippy::too_many_arguments)]
543    fn walk_compiled(
544        &self,
545        compiled: &CompiledSchema,
546        value: &serde_json::Value,
547        old_value: Option<&serde_json::Value>,
548        path: String,
549        errors: &mut Vec<ValidationError>,
550        base_ctx: &Context<'_>,
551        root_ctx: Option<&RootContext>,
552        depth: usize,
553    ) {
554        if depth > crate::validation::compilation::MAX_SCHEMA_DEPTH {
555            errors.push(schema_too_deep_error(&path));
556            return;
557        }
558
559        let cel_value = json_to_cel_with_compiled(value, compiled);
560        let cel_old = old_value.map(|o| json_to_cel_with_compiled(o, compiled));
561        self.evaluate_compiled_results(
562            &compiled.validations,
563            &cel_value,
564            cel_old.as_ref(),
565            &path,
566            errors,
567            base_ctx,
568            root_ctx,
569        );
570
571        if let Some(obj) = value.as_object() {
572            for (prop_name, child_compiled) in &compiled.properties {
573                if let Some(child_value) = obj.get(prop_name) {
574                    let child_old = old_value.and_then(|o| o.get(prop_name));
575                    let child_path = join_path(&path, prop_name);
576                    self.walk_compiled(
577                        child_compiled,
578                        child_value,
579                        child_old,
580                        child_path,
581                        errors,
582                        base_ctx,
583                        None,
584                        depth + 1,
585                    );
586                }
587            }
588        }
589
590        if let (Some(items_compiled), Some(arr)) = (&compiled.items, value.as_array()) {
591            for (i, item) in arr.iter().enumerate() {
592                let old_item = old_value.and_then(|o| o.as_array()).and_then(|a| a.get(i));
593                let item_path = join_path_index(&path, i);
594                self.walk_compiled(
595                    items_compiled,
596                    item,
597                    old_item,
598                    item_path,
599                    errors,
600                    base_ctx,
601                    None,
602                    depth + 1,
603                );
604            }
605        }
606
607        if !compiled.preserve_unknown_fields
608            && let (Some(additional_compiled), Some(obj)) =
609                (&compiled.additional_properties, value.as_object())
610        {
611            for (key, val) in obj {
612                if compiled.properties.contains_key(key) {
613                    continue;
614                }
615                let old_val = old_value.and_then(|o| o.get(key));
616                let child_path = join_path(&path, key);
617                self.walk_compiled(
618                    additional_compiled,
619                    val,
620                    old_val,
621                    child_path,
622                    errors,
623                    base_ctx,
624                    None,
625                    depth + 1,
626                );
627            }
628        }
629
630        for branch in compiled
631            .all_of
632            .iter()
633            .chain(compiled.one_of.iter())
634            .chain(compiled.any_of.iter())
635        {
636            self.walk_compiled(
637                branch,
638                value,
639                old_value,
640                path.clone(),
641                errors,
642                base_ctx,
643                root_ctx,
644                depth + 1,
645            );
646        }
647    }
648
649    // ── Shared evaluation logic ─────────────────────────────────────
650
651    #[allow(clippy::too_many_arguments)]
652    fn evaluate_compiled_results(
653        &self,
654        results: &[Result<CompilationResult, CompilationError>],
655        cel_value: &cel::Value,
656        cel_old: Option<&cel::Value>,
657        path: &str,
658        errors: &mut Vec<ValidationError>,
659        base_ctx: &Context<'_>,
660        root_ctx: Option<&RootContext>,
661    ) {
662        // Create a node-level scope once with self/oldSelf bound
663        let mut node_ctx = base_ctx.new_inner_scope();
664        node_ctx.add_variable_from_value("self", cel_value.clone());
665        if let Some(old) = cel_old {
666            node_ctx.add_variable_from_value("oldSelf", old.clone());
667        }
668
669        if path.is_empty()
670            && let Some(rc) = root_ctx
671        {
672            node_ctx.add_variable_from_value(
673                "apiVersion",
674                cel::Value::String(std::sync::Arc::new(rc.api_version.clone())),
675            );
676            node_ctx.add_variable_from_value(
677                "apiGroup",
678                cel::Value::String(std::sync::Arc::new(rc.api_group.clone())),
679            );
680            node_ctx
681                .add_variable_from_value("kind", cel::Value::String(std::sync::Arc::new(rc.kind.clone())));
682        }
683
684        for result in results {
685            match result {
686                Ok(cr) => {
687                    self.evaluate_rule(cr, &node_ctx, cel_old, path, errors);
688                }
689                Err(CompilationError::Parse { rule, source }) => {
690                    errors.push(ValidationError {
691                        rule: rule.clone(),
692                        message: format!("failed to compile rule \"{rule}\": {source}"),
693                        field_path: path.to_string(),
694                        reason: None,
695                        kind: ErrorKind::CompilationFailure,
696                        // `cel::ParseErrors` is `!Clone` and only borrowed here,
697                        // so the typed cause cannot be owned; detail is in `message`.
698                        source: None,
699                    });
700                }
701                Err(CompilationError::MessageExpressionParse {
702                    rule,
703                    message_expression,
704                    source,
705                }) => {
706                    errors.push(ValidationError {
707                        rule: rule.clone(),
708                        message: format!(
709                            "failed to compile messageExpression \"{message_expression}\" for rule \"{rule}\": {source}"
710                        ),
711                        field_path: path.to_string(),
712                        reason: None,
713                        kind: ErrorKind::CompilationFailure,
714                        // `cel::ParseErrors` is `!Clone` and only borrowed here,
715                        // so the typed cause cannot be owned; detail is in `message`.
716                        source: None,
717                    });
718                }
719                Err(CompilationError::InvalidRule(e)) => {
720                    errors.push(ValidationError {
721                        rule: String::new(),
722                        message: format!("invalid rule definition: {e}"),
723                        field_path: path.to_string(),
724                        reason: None,
725                        kind: ErrorKind::InvalidRule,
726                        // `serde_json::Error` is borrowed from the shared
727                        // results slice and `!Clone`; detail is in `message`.
728                        source: None,
729                    });
730                }
731                Err(CompilationError::SchemaTooDeep { .. }) => {
732                    errors.push(schema_too_deep_error(path));
733                }
734            }
735        }
736    }
737
738    fn evaluate_rule(
739        &self,
740        cr: &CompilationResult,
741        node_ctx: &Context<'_>,
742        cel_old: Option<&cel::Value>,
743        path: &str,
744        errors: &mut Vec<ValidationError>,
745    ) {
746        // Handle transition rules
747        if cr.is_transition_rule && cel_old.is_none() && cr.rule.optional_old_self != Some(true) {
748            return; // skip transition rule without old value
749        }
750
751        // optionalOldSelf: true + no old object → child scope with oldSelf = null
752        let use_null_old_self = cel_old.is_none() && cr.rule.optional_old_self == Some(true);
753        let null_scope;
754        let effective_ctx: &Context<'_> = if use_null_old_self {
755            null_scope = {
756                let mut s = node_ctx.new_inner_scope();
757                s.add_variable_from_value("oldSelf", cel::Value::Null);
758                s
759            };
760            &null_scope
761        } else {
762            node_ctx
763        };
764
765        let result = cr.program.execute(effective_ctx);
766        let error_path = effective_path(path, cr.rule.field_path.as_deref());
767
768        match result {
769            Ok(cel::Value::Bool(true)) => {
770                // Validation passed
771            }
772            Ok(cel::Value::Bool(false)) => {
773                let message = self.resolve_message(cr, effective_ctx);
774                errors.push(ValidationError {
775                    rule: cr.rule.rule.clone(),
776                    message,
777                    field_path: error_path,
778                    reason: cr.rule.reason.clone(),
779                    kind: ErrorKind::ValidationFailure,
780                    source: None,
781                });
782            }
783            Ok(_) => {
784                errors.push(ValidationError {
785                    rule: cr.rule.rule.clone(),
786                    message: format!("rule \"{}\" did not evaluate to bool", cr.rule.rule),
787                    field_path: error_path,
788                    reason: None,
789                    kind: ErrorKind::InvalidResult,
790                    source: None,
791                });
792            }
793            Err(e) => {
794                // The rule compiled but evaluation failed. An
795                // `UndeclaredReference` means the rule references something this
796                // build does not provide — a cel-gated macro (sortBy/cel.bind/
797                // two-arg comprehensions) or a disabled feature — classified
798                // distinctly so callers can tell a coverage gap from a real
799                // runtime error. Either way the owned `ExecutionError` is the
800                // cause and is preserved via `source()`.
801                let (kind, message) = match &e {
802                    cel::ExecutionError::UndeclaredReference(name) => (
803                        ErrorKind::UnsupportedReference,
804                        format!(
805                            "rule references '{name}', which this kube-cel build does not support \
806                             (an unsupported CEL macro, or a feature disabled at compile time); \
807                             it cannot be evaluated client-side"
808                        ),
809                    ),
810                    _ => (ErrorKind::EvaluationError, format!("rule evaluation error: {e}")),
811                };
812                errors.push(ValidationError {
813                    rule: cr.rule.rule.clone(),
814                    message,
815                    field_path: error_path,
816                    reason: None,
817                    kind,
818                    source: Some(std::sync::Arc::new(e)),
819                });
820            }
821        }
822    }
823
824    /// Resolve the error message: try messageExpression first, fall back to
825    /// static message, then default.
826    fn resolve_message(&self, cr: &CompilationResult, ctx: &Context<'_>) -> String {
827        if let Some(ref msg_prog) = cr.message_program
828            && let Ok(cel::Value::String(s)) = msg_prog.execute(ctx)
829        {
830            return (*s).clone();
831        }
832        cr.rule
833            .message
834            .clone()
835            .unwrap_or_else(|| format!("failed rule: {}", cr.rule.rule))
836    }
837}
838
839impl Default for Validator {
840    fn default() -> Self {
841        Self::new()
842    }
843}
844
845thread_local! {
846    static THREAD_VALIDATOR: Validator = Validator::new();
847}
848
849/// Convenience function to validate without creating a [`Validator`] instance.
850///
851/// Uses a thread-local [`Validator`] to avoid re-registering CEL functions on each call.
852///
853/// See [`Validator::validate`] for details.
854pub fn validate(
855    schema: &serde_json::Value,
856    object: &serde_json::Value,
857    old_object: Option<&serde_json::Value>,
858) -> Result<(), ValidationErrors> {
859    THREAD_VALIDATOR.with(|v| v.validate(schema, object, old_object))
860}
861
862/// Convenience function to validate using a pre-compiled schema.
863///
864/// Uses a thread-local [`Validator`] to avoid re-registering CEL functions on each call.
865///
866/// See [`Validator::validate_compiled`] for details.
867pub fn validate_compiled(
868    compiled: &CompiledSchema,
869    object: &serde_json::Value,
870    old_object: Option<&serde_json::Value>,
871) -> Result<(), ValidationErrors> {
872    THREAD_VALIDATOR.with(|v| v.validate_compiled(compiled, object, old_object))
873}
874
875// ── Path helpers ────────────────────────────────────────────────────
876
877#[inline]
878fn effective_path(base_path: &str, rule_field_path: Option<&str>) -> String {
879    match rule_field_path {
880        Some(fp) if fp.starts_with('.') => format!("{base_path}{fp}"),
881        Some(fp) if !base_path.is_empty() => format!("{base_path}.{fp}"),
882        Some(fp) => fp.to_string(),
883        None => base_path.to_string(),
884    }
885}
886
887#[inline]
888fn join_path(base: &str, segment: &str) -> String {
889    if base.is_empty() {
890        segment.to_string()
891    } else {
892        format!("{base}.{segment}")
893    }
894}
895
896#[inline]
897fn join_path_index(base: &str, index: usize) -> String {
898    if base.is_empty() {
899        format!("[{index}]")
900    } else {
901        format!("{base}[{index}]")
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908    use crate::validation::compilation::compile_schema;
909    use serde_json::json;
910
911    fn make_schema(validations: serde_json::Value) -> serde_json::Value {
912        json!({
913            "type": "object",
914            "properties": {
915                "replicas": {"type": "integer"},
916                "name": {"type": "string"}
917            },
918            "x-kubernetes-validations": validations
919        })
920    }
921
922    #[test]
923    fn validation_passes() {
924        let schema = make_schema(json!([
925            {"rule": "self.replicas >= 0", "message": "must be non-negative"}
926        ]));
927        let obj = json!({"replicas": 3, "name": "app"});
928        assert!(validate(&schema, &obj, None).is_ok());
929    }
930
931    #[test]
932    fn validation_fails() {
933        let schema = make_schema(json!([
934            {"rule": "self.replicas >= 0", "message": "must be non-negative"}
935        ]));
936        let obj = json!({"replicas": -1, "name": "app"});
937        let errors = validate(&schema, &obj, None).unwrap_err();
938        assert_eq!(errors.len(), 1);
939        assert_eq!(errors[0].message, "must be non-negative");
940        assert_eq!(errors[0].rule, "self.replicas >= 0");
941    }
942
943    #[test]
944    fn default_message_when_none() {
945        let schema = make_schema(json!([
946            {"rule": "self.replicas >= 0"}
947        ]));
948        let obj = json!({"replicas": -1, "name": "app"});
949        let errors = validate(&schema, &obj, None).unwrap_err();
950        assert_eq!(errors.len(), 1);
951        assert!(errors[0].message.contains("self.replicas >= 0"));
952    }
953
954    #[test]
955    fn reason_preserved() {
956        let schema = make_schema(json!([
957            {"rule": "self.replicas >= 0", "message": "bad", "reason": "FieldValueInvalid"}
958        ]));
959        let obj = json!({"replicas": -1, "name": "app"});
960        let errors = validate(&schema, &obj, None).unwrap_err();
961        assert_eq!(errors[0].reason.as_deref(), Some("FieldValueInvalid"));
962    }
963
964    #[test]
965    fn transition_rule_skipped_without_old_object() {
966        let schema = make_schema(json!([
967            {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down"}
968        ]));
969        let obj = json!({"replicas": 1, "name": "app"});
970        assert!(validate(&schema, &obj, None).is_ok());
971    }
972
973    #[test]
974    fn transition_rule_evaluated_with_old_object() {
975        let schema = make_schema(json!([
976            {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down"}
977        ]));
978        let obj = json!({"replicas": 1, "name": "app"});
979        let old = json!({"replicas": 3, "name": "app"});
980        let errors = validate(&schema, &obj, Some(&old)).unwrap_err();
981        assert_eq!(errors.len(), 1);
982        assert_eq!(errors[0].message, "cannot scale down");
983    }
984
985    #[test]
986    fn transition_rule_passes() {
987        let schema = make_schema(json!([
988            {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down"}
989        ]));
990        let obj = json!({"replicas": 5, "name": "app"});
991        let old = json!({"replicas": 3, "name": "app"});
992        assert!(validate(&schema, &obj, Some(&old)).is_ok());
993    }
994
995    #[test]
996    fn nested_property_field_path() {
997        let schema = json!({
998            "type": "object",
999            "properties": {
1000                "spec": {
1001                    "type": "object",
1002                    "properties": {
1003                        "replicas": {
1004                            "type": "integer",
1005                            "x-kubernetes-validations": [
1006                                {"rule": "self >= 0", "message": "must be non-negative"}
1007                            ]
1008                        }
1009                    }
1010                }
1011            }
1012        });
1013        let obj = json!({"spec": {"replicas": -1}});
1014        let errors = validate(&schema, &obj, None).unwrap_err();
1015        assert_eq!(errors.len(), 1);
1016        assert_eq!(errors[0].field_path, "spec.replicas");
1017        assert_eq!(errors[0].message, "must be non-negative");
1018    }
1019
1020    #[test]
1021    fn array_items_validation() {
1022        let schema = json!({
1023            "type": "object",
1024            "properties": {
1025                "items": {
1026                    "type": "array",
1027                    "items": {
1028                        "type": "object",
1029                        "properties": {
1030                            "name": {"type": "string"}
1031                        },
1032                        "x-kubernetes-validations": [
1033                            {"rule": "self.name.size() > 0", "message": "name required"}
1034                        ]
1035                    }
1036                }
1037            }
1038        });
1039        let obj = json!({
1040            "items": [
1041                {"name": "good"},
1042                {"name": ""},
1043                {"name": "also-good"}
1044            ]
1045        });
1046        let errors = validate(&schema, &obj, None).unwrap_err();
1047        assert_eq!(errors.len(), 1);
1048        assert_eq!(errors[0].field_path, "items[1]");
1049        assert_eq!(errors[0].message, "name required");
1050    }
1051
1052    #[test]
1053    fn missing_field_not_validated() {
1054        let schema = json!({
1055            "type": "object",
1056            "properties": {
1057                "optional_field": {
1058                    "type": "integer",
1059                    "x-kubernetes-validations": [
1060                        {"rule": "self >= 0", "message": "must be non-negative"}
1061                    ]
1062                }
1063            }
1064        });
1065        let obj = json!({});
1066        assert!(validate(&schema, &obj, None).is_ok());
1067    }
1068
1069    #[test]
1070    fn multiple_rules_partial_failure() {
1071        let schema = make_schema(json!([
1072            {"rule": "self.replicas >= 0", "message": "non-negative"},
1073            {"rule": "self.name.size() > 0", "message": "name required"}
1074        ]));
1075        let obj = json!({"replicas": -1, "name": ""});
1076        let errors = validate(&schema, &obj, None).unwrap_err();
1077        assert_eq!(errors.len(), 2);
1078    }
1079
1080    #[test]
1081    fn compilation_error_reported() {
1082        let schema = make_schema(json!([
1083            {"rule": "self.replicas >="}
1084        ]));
1085        let obj = json!({"replicas": 1, "name": "app"});
1086        let errors = validate(&schema, &obj, None).unwrap_err();
1087        assert_eq!(errors.len(), 1);
1088        assert!(errors[0].message.contains("failed to compile"));
1089    }
1090
1091    #[test]
1092    fn no_validations_no_errors() {
1093        let schema = json!({
1094            "type": "object",
1095            "properties": {
1096                "replicas": {"type": "integer"}
1097            }
1098        });
1099        let obj = json!({"replicas": -1});
1100        assert!(validate(&schema, &obj, None).is_ok());
1101    }
1102
1103    #[test]
1104    fn display_with_field_path() {
1105        let err = ValidationError {
1106            rule: "self >= 0".into(),
1107            message: "must be non-negative".into(),
1108            field_path: "spec.replicas".into(),
1109            reason: None,
1110            kind: ErrorKind::ValidationFailure,
1111            source: None,
1112        };
1113        assert_eq!(err.to_string(), "spec.replicas: must be non-negative");
1114    }
1115
1116    #[test]
1117    fn display_without_field_path() {
1118        let err = ValidationError {
1119            rule: "self >= 0".into(),
1120            message: "must be non-negative".into(),
1121            field_path: String::new(),
1122            reason: None,
1123            kind: ErrorKind::ValidationFailure,
1124            source: None,
1125        };
1126        assert_eq!(err.to_string(), "must be non-negative");
1127    }
1128
1129    #[test]
1130    fn validator_default() {
1131        let v = Validator::default();
1132        let schema = make_schema(json!([{"rule": "self.replicas >= 0"}]));
1133        let obj = json!({"replicas": 1, "name": "app"});
1134        assert!(v.validate(&schema, &obj, None).is_ok());
1135    }
1136
1137    #[test]
1138    fn additional_properties_walking() {
1139        let schema = json!({
1140            "type": "object",
1141            "additionalProperties": {
1142                "type": "integer",
1143                "x-kubernetes-validations": [
1144                    {"rule": "self >= 0", "message": "must be non-negative"}
1145                ]
1146            }
1147        });
1148        let obj = json!({"a": 1, "b": -1, "c": 5});
1149        let errors = validate(&schema, &obj, None).unwrap_err();
1150        assert_eq!(errors.len(), 1);
1151        assert_eq!(errors[0].field_path, "b");
1152    }
1153
1154    // ── Phase 5 tests ───────────────────────────────────────────────
1155
1156    #[test]
1157    fn message_expression_produces_dynamic_message() {
1158        let schema = make_schema(json!([{
1159            "rule": "self.replicas >= 0",
1160            "message": "static fallback",
1161            "messageExpression": "'replicas is ' + string(self.replicas) + ', must be >= 0'"
1162        }]));
1163        let obj = json!({"replicas": -5, "name": "app"});
1164        let errors = validate(&schema, &obj, None).unwrap_err();
1165        assert_eq!(errors.len(), 1);
1166        assert_eq!(errors[0].message, "replicas is -5, must be >= 0");
1167    }
1168
1169    #[test]
1170    fn invalid_message_expression_fails_closed() {
1171        let schema = make_schema(json!([{
1172            "rule": "self.replicas >= 0",
1173            "message": "static message",
1174            "messageExpression": "invalid >="
1175        }]));
1176        // The object *satisfies* the rule, yet a messageExpression that fails to
1177        // compile fails closed (CompilationFailure) — mirroring the apiserver,
1178        // which rejects such a CRD at registration — rather than being silently
1179        // dropped and the rule evaluated with the static message.
1180        let obj = json!({"replicas": 5, "name": "app"});
1181        let errors = validate(&schema, &obj, None).unwrap_err();
1182        assert_eq!(errors.len(), 1);
1183        assert_eq!(errors[0].kind, ErrorKind::CompilationFailure);
1184    }
1185
1186    #[test]
1187    fn optional_old_self_evaluated_on_create() {
1188        let schema = make_schema(json!([{
1189            "rule": "oldSelf == null || self.replicas >= oldSelf.replicas",
1190            "message": "cannot scale down",
1191            "optionalOldSelf": true
1192        }]));
1193        // Create (no old object): rule is evaluated with oldSelf = null
1194        let obj = json!({"replicas": 1, "name": "app"});
1195        assert!(validate(&schema, &obj, None).is_ok()); // oldSelf == null → true
1196    }
1197
1198    #[test]
1199    fn optional_old_self_with_old_object() {
1200        let schema = make_schema(json!([{
1201            "rule": "oldSelf == null || self.replicas >= oldSelf.replicas",
1202            "message": "cannot scale down",
1203            "optionalOldSelf": true
1204        }]));
1205        let obj = json!({"replicas": 1, "name": "app"});
1206        let old = json!({"replicas": 3, "name": "app"});
1207        let errors = validate(&schema, &obj, Some(&old)).unwrap_err();
1208        assert_eq!(errors.len(), 1);
1209        assert_eq!(errors[0].message, "cannot scale down");
1210    }
1211
1212    #[test]
1213    fn optional_old_self_false_still_skips() {
1214        let schema = make_schema(json!([{
1215            "rule": "self.replicas >= oldSelf.replicas",
1216            "message": "cannot scale down",
1217            "optionalOldSelf": false
1218        }]));
1219        let obj = json!({"replicas": 1, "name": "app"});
1220        // optionalOldSelf: false → transition rule skipped on create
1221        assert!(validate(&schema, &obj, None).is_ok());
1222    }
1223
1224    #[test]
1225    fn validate_compiled_matches_validate() {
1226        let schema = json!({
1227            "type": "object",
1228            "properties": {
1229                "spec": {
1230                    "type": "object",
1231                    "x-kubernetes-validations": [
1232                        {"rule": "self.replicas >= 0", "message": "non-negative"}
1233                    ],
1234                    "properties": {
1235                        "replicas": {"type": "integer"}
1236                    }
1237                }
1238            }
1239        });
1240        let obj = json!({"spec": {"replicas": -1}});
1241
1242        let errors_schema = validate(&schema, &obj, None).unwrap_err();
1243        let compiled = compile_schema(&schema);
1244        let errors_compiled = validate_compiled(&compiled, &obj, None).unwrap_err();
1245
1246        assert_eq!(errors_schema.len(), errors_compiled.len());
1247        assert_eq!(errors_schema[0].message, errors_compiled[0].message);
1248        assert_eq!(errors_schema[0].field_path, errors_compiled[0].field_path);
1249    }
1250
1251    #[test]
1252    fn validate_compiled_reuse() {
1253        let schema = json!({
1254            "type": "object",
1255            "x-kubernetes-validations": [
1256                {"rule": "self.x > 0", "message": "x must be positive"}
1257            ],
1258            "properties": {"x": {"type": "integer"}}
1259        });
1260        let compiled = compile_schema(&schema);
1261
1262        // Validate multiple objects with the same compiled schema
1263        assert!(validate_compiled(&compiled, &json!({"x": 1}), None).is_ok());
1264        assert_eq!(
1265            validate_compiled(&compiled, &json!({"x": -1}), None)
1266                .unwrap_err()
1267                .len(),
1268            1
1269        );
1270        assert!(validate_compiled(&compiled, &json!({"x": 5}), None).is_ok());
1271        assert_eq!(
1272            validate_compiled(&compiled, &json!({"x": 0}), None)
1273                .unwrap_err()
1274                .len(),
1275            1
1276        );
1277    }
1278
1279    // ── fieldPath override tests ────────────────────────────────────
1280
1281    #[test]
1282    fn fieldpath_overrides_auto_path() {
1283        let schema = json!({
1284            "type": "object",
1285            "properties": {
1286                "spec": {
1287                    "type": "object",
1288                    "properties": {
1289                        "x": {"type": "integer"}
1290                    },
1291                    "x-kubernetes-validations": [
1292                        {"rule": "self.x >= 0", "message": "bad", "fieldPath": ".spec.x"}
1293                    ]
1294                }
1295            }
1296        });
1297        let obj = json!({"spec": {"x": -1}});
1298        let errors = validate(&schema, &obj, None).unwrap_err();
1299        assert_eq!(errors.len(), 1);
1300        assert_eq!(errors[0].field_path, "spec.spec.x");
1301    }
1302
1303    #[test]
1304    fn fieldpath_without_dot() {
1305        let schema = json!({
1306            "type": "object",
1307            "properties": {
1308                "spec": {
1309                    "type": "object",
1310                    "properties": {
1311                        "name": {"type": "string"}
1312                    },
1313                    "x-kubernetes-validations": [
1314                        {"rule": "self.name.size() > 0", "message": "bad", "fieldPath": "name"}
1315                    ]
1316                }
1317            }
1318        });
1319        let obj = json!({"spec": {"name": ""}});
1320        let errors = validate(&schema, &obj, None).unwrap_err();
1321        assert_eq!(errors.len(), 1);
1322        assert_eq!(errors[0].field_path, "spec.name");
1323    }
1324
1325    #[test]
1326    fn fieldpath_at_root() {
1327        let schema = json!({
1328            "type": "object",
1329            "properties": {
1330                "x": {"type": "integer"}
1331            },
1332            "x-kubernetes-validations": [
1333                {"rule": "self.x >= 0", "message": "bad", "fieldPath": ".spec.x"}
1334            ]
1335        });
1336        let obj = json!({"x": -1});
1337        let errors = validate(&schema, &obj, None).unwrap_err();
1338        assert_eq!(errors.len(), 1);
1339        assert_eq!(errors[0].field_path, ".spec.x");
1340    }
1341
1342    #[test]
1343    fn fieldpath_none_uses_auto() {
1344        let schema = json!({
1345            "type": "object",
1346            "properties": {
1347                "spec": {
1348                    "type": "object",
1349                    "properties": {
1350                        "x": {"type": "integer"}
1351                    },
1352                    "x-kubernetes-validations": [
1353                        {"rule": "self.x >= 0", "message": "bad"}
1354                    ]
1355                }
1356            }
1357        });
1358        let obj = json!({"spec": {"x": -1}});
1359        let errors = validate(&schema, &obj, None).unwrap_err();
1360        assert_eq!(errors.len(), 1);
1361        assert_eq!(errors[0].field_path, "spec");
1362    }
1363
1364    // ── ErrorKind tests ─────────────────────────────────────────────
1365
1366    #[test]
1367    fn error_kind_compilation_failure() {
1368        let schema = make_schema(json!([
1369            {"rule": "self.replicas >="}
1370        ]));
1371        let obj = json!({"replicas": 1, "name": "app"});
1372        let errors = validate(&schema, &obj, None).unwrap_err();
1373        assert_eq!(errors.len(), 1);
1374        assert_eq!(errors[0].kind, ErrorKind::CompilationFailure);
1375    }
1376
1377    #[test]
1378    fn error_kind_validation_failure() {
1379        let schema = make_schema(json!([
1380            {"rule": "self.replicas >= 0", "message": "must be non-negative"}
1381        ]));
1382        let obj = json!({"replicas": -1, "name": "app"});
1383        let errors = validate(&schema, &obj, None).unwrap_err();
1384        assert_eq!(errors.len(), 1);
1385        assert_eq!(errors[0].kind, ErrorKind::ValidationFailure);
1386    }
1387
1388    #[test]
1389    fn error_kind_evaluation_error() {
1390        let schema = make_schema(json!([
1391            {"rule": "self.missing_field > 0"}
1392        ]));
1393        let obj = json!({"replicas": 1, "name": "app"});
1394        let errors = validate(&schema, &obj, None).unwrap_err();
1395        assert_eq!(errors.len(), 1);
1396        assert_eq!(errors[0].kind, ErrorKind::EvaluationError);
1397    }
1398
1399    // ── allOf/oneOf/anyOf tests ──────────────────────────────────────
1400
1401    #[test]
1402    fn all_of_validations_evaluated() {
1403        let schema = json!({
1404            "type": "object",
1405            "properties": {
1406                "x": {"type": "integer"},
1407                "y": {"type": "integer"}
1408            },
1409            "allOf": [
1410                {
1411                    "x-kubernetes-validations": [
1412                        {"rule": "self.x >= 0", "message": "x must be non-negative"}
1413                    ]
1414                },
1415                {
1416                    "x-kubernetes-validations": [
1417                        {"rule": "self.y >= 0", "message": "y must be non-negative"}
1418                    ]
1419                }
1420            ]
1421        });
1422        let obj = json!({"x": -1, "y": -1});
1423        let errors = validate(&schema, &obj, None).unwrap_err();
1424        assert_eq!(errors.len(), 2);
1425    }
1426
1427    #[test]
1428    fn one_of_validations_evaluated() {
1429        let schema = json!({
1430            "type": "object",
1431            "properties": {"x": {"type": "integer"}},
1432            "oneOf": [{
1433                "x-kubernetes-validations": [
1434                    {"rule": "self.x != 0", "message": "x must not be zero"}
1435                ]
1436            }]
1437        });
1438        let obj = json!({"x": 0});
1439        let errors = validate(&schema, &obj, None).unwrap_err();
1440        assert_eq!(errors.len(), 1);
1441    }
1442
1443    #[test]
1444    fn nested_all_of_properties_walked() {
1445        let schema = json!({
1446            "type": "object",
1447            "allOf": [{
1448                "properties": {
1449                    "name": {
1450                        "type": "string",
1451                        "x-kubernetes-validations": [
1452                            {"rule": "self.size() > 0", "message": "name required"}
1453                        ]
1454                    }
1455                }
1456            }]
1457        });
1458        let obj = json!({"name": ""});
1459        let errors = validate(&schema, &obj, None).unwrap_err();
1460        assert_eq!(errors.len(), 1);
1461    }
1462
1463    #[test]
1464    fn all_of_compiled_matches_schema() {
1465        let schema = json!({
1466            "type": "object",
1467            "properties": {"x": {"type": "integer"}},
1468            "allOf": [{
1469                "x-kubernetes-validations": [
1470                    {"rule": "self.x >= 0", "message": "x must be non-negative"}
1471                ]
1472            }]
1473        });
1474        let obj = json!({"x": -1});
1475        let errors_schema = validate(&schema, &obj, None).unwrap_err();
1476        let compiled = compile_schema(&schema);
1477        let errors_compiled = validate_compiled(&compiled, &obj, None).unwrap_err();
1478        assert_eq!(errors_schema.len(), errors_compiled.len());
1479        assert_eq!(errors_schema[0].message, errors_compiled[0].message);
1480    }
1481
1482    // ── x-kubernetes-preserve-unknown-fields tests ──────────────────
1483
1484    #[test]
1485    fn preserve_unknown_fields_skips_additional_properties_walk() {
1486        let schema = json!({
1487            "type": "object",
1488            "x-kubernetes-preserve-unknown-fields": true,
1489            "additionalProperties": {
1490                "type": "integer",
1491                "x-kubernetes-validations": [
1492                    {"rule": "self >= 0", "message": "must be non-negative"}
1493                ]
1494            }
1495        });
1496        let obj = json!({"unknown_field": -1});
1497        assert!(validate(&schema, &obj, None).is_ok());
1498    }
1499
1500    #[test]
1501    fn without_preserve_unknown_fields_additional_properties_still_walked() {
1502        let schema = json!({
1503            "type": "object",
1504            "additionalProperties": {
1505                "type": "integer",
1506                "x-kubernetes-validations": [
1507                    {"rule": "self >= 0", "message": "must be non-negative"}
1508                ]
1509            }
1510        });
1511        let obj = json!({"unknown_field": -1});
1512        let errors = validate(&schema, &obj, None).unwrap_err();
1513        assert_eq!(errors.len(), 1);
1514    }
1515
1516    // ── x-kubernetes-embedded-resource tests ────────────────────────
1517
1518    #[test]
1519    fn embedded_resource_fields_accessible() {
1520        let schema = json!({
1521            "type": "object",
1522            "x-kubernetes-embedded-resource": true,
1523            "properties": {
1524                "spec": {"type": "object"}
1525            },
1526            "x-kubernetes-validations": [{
1527                "rule": "self.apiVersion.size() >= 0",
1528                "message": "apiVersion must exist"
1529            }]
1530        });
1531        let obj = json!({"spec": {}});
1532        assert!(validate(&schema, &obj, None).is_ok());
1533    }
1534
1535    #[test]
1536    fn embedded_resource_preserves_existing_fields() {
1537        let schema = json!({
1538            "type": "object",
1539            "x-kubernetes-embedded-resource": true,
1540            "x-kubernetes-validations": [{
1541                "rule": "self.apiVersion == 'v1'",
1542                "message": "wrong version"
1543            }]
1544        });
1545        let obj = json!({"apiVersion": "v1", "kind": "Pod", "metadata": {"name": "test"}});
1546        assert!(validate(&schema, &obj, None).is_ok());
1547    }
1548
1549    #[test]
1550    fn embedded_resource_compiled_path() {
1551        let schema = json!({
1552            "type": "object",
1553            "x-kubernetes-embedded-resource": true,
1554            "x-kubernetes-validations": [{
1555                "rule": "self.kind.size() >= 0",
1556                "message": "kind must exist"
1557            }]
1558        });
1559        let obj = json!({"spec": {}});
1560        let compiled = compile_schema(&schema);
1561        assert!(validate_compiled(&compiled, &obj, None).is_ok());
1562    }
1563
1564    // ── RootContext tests ────────────────────────────────────────────
1565
1566    #[test]
1567    fn root_context_variables_bound() {
1568        let schema = json!({
1569            "type": "object",
1570            "properties": {"name": {"type": "string"}},
1571            "x-kubernetes-validations": [{
1572                "rule": "apiVersion == 'apps/v1'",
1573                "message": "wrong api version"
1574            }]
1575        });
1576        let obj = json!({"name": "test"});
1577        let root_ctx = RootContext {
1578            api_version: "apps/v1".into(),
1579            api_group: "apps".into(),
1580            kind: "Deployment".into(),
1581        };
1582        assert!(
1583            Validator::new()
1584                .validate_with_context(&schema, &obj, None, Some(&root_ctx))
1585                .is_ok()
1586        );
1587    }
1588
1589    #[test]
1590    fn root_context_empty_api_group_for_core() {
1591        let schema = json!({
1592            "type": "object",
1593            "properties": {"name": {"type": "string"}},
1594            "x-kubernetes-validations": [{
1595                "rule": "apiGroup == ''",
1596                "message": "not core"
1597            }]
1598        });
1599        let obj = json!({"name": "test"});
1600        let root_ctx = RootContext {
1601            api_version: "v1".into(),
1602            api_group: "".into(),
1603            kind: "Pod".into(),
1604        };
1605        assert!(
1606            Validator::new()
1607                .validate_with_context(&schema, &obj, None, Some(&root_ctx))
1608                .is_ok()
1609        );
1610    }
1611
1612    #[test]
1613    fn validate_without_root_context_still_works() {
1614        let schema = json!({
1615            "type": "object",
1616            "properties": {"x": {"type": "integer"}},
1617            "x-kubernetes-validations": [{"rule": "self.x >= 0", "message": "bad"}]
1618        });
1619        let obj = json!({"x": -1});
1620        let errors = validate(&schema, &obj, None).unwrap_err();
1621        assert_eq!(errors.len(), 1);
1622    }
1623
1624    #[test]
1625    fn root_context_compiled_path() {
1626        let schema = json!({
1627            "type": "object",
1628            "properties": {"x": {"type": "integer"}},
1629            "x-kubernetes-validations": [{
1630                "rule": "kind == 'MyResource'",
1631                "message": "wrong kind"
1632            }]
1633        });
1634        let obj = json!({"x": 1});
1635        let root_ctx = RootContext {
1636            api_version: "v1".into(),
1637            api_group: "example.com".into(),
1638            kind: "MyResource".into(),
1639        };
1640        let compiled = crate::validation::compilation::compile_schema(&schema);
1641        assert!(
1642            Validator::new()
1643                .validate_compiled_with_context(&compiled, &obj, None, Some(&root_ctx))
1644                .is_ok()
1645        );
1646    }
1647
1648    #[test]
1649    fn validate_with_defaults_fills_missing_then_validates() {
1650        let schema = json!({
1651            "type": "object",
1652            "properties": {
1653                "replicas": {
1654                    "type": "integer",
1655                    "default": 1,
1656                    "x-kubernetes-validations": [
1657                        {"rule": "self >= 0", "message": "must be non-negative"}
1658                    ]
1659                }
1660            }
1661        });
1662        // As of 0.8, plain validate injects replicas=1 and the rule passes.
1663        assert!(validate(&schema, &json!({}), None).is_ok());
1664
1665        // validate_with_defaults is now an alias and behaves identically.
1666        assert!(
1667            Validator::new()
1668                .validate_with_defaults(&schema, &json!({}), None)
1669                .is_ok()
1670        );
1671    }
1672
1673    #[test]
1674    fn plain_validate_applies_defaults_closing_fail_open() {
1675        // kube-rs/kube-cel#9: a defaulted field omitted by the object. The
1676        // apiserver defaults it in, so `!has(self.x)` is FALSE → REJECT. Plain
1677        // validate must now match (pre-0.8 it accepted, a fail-open divergence).
1678        let schema = json!({
1679            "type": "object",
1680            "properties": { "x": { "type": "string", "default": "d" } },
1681            "x-kubernetes-validations": [{ "rule": "!has(self.x)", "message": "x must be absent" }]
1682        });
1683        assert!(
1684            validate(&schema, &json!({}), None).is_err(),
1685            "default makes x present → !has(self.x) false → REJECT"
1686        );
1687
1688        // Same closure through the compiled path and through nested map values.
1689        let compiled = crate::validation::compilation::compile_schema(&schema);
1690        assert!(
1691            Validator::new()
1692                .validate_compiled(&compiled, &json!({}), None)
1693                .is_err()
1694        );
1695
1696        let nested = json!({
1697            "type": "object",
1698            "properties": {
1699                "m": {
1700                    "type": "object",
1701                    "additionalProperties": {
1702                        "type": "object",
1703                        "properties": { "x": { "type": "string", "default": "d" } },
1704                        "x-kubernetes-validations": [{ "rule": "!has(self.x)", "message": "x must be absent" }]
1705                    }
1706                }
1707            }
1708        });
1709        assert!(
1710            validate(&nested, &json!({ "m": { "a": {} } }), None).is_err(),
1711            "nested map value defaulted → REJECT (was fail-open even via validate_with_defaults)"
1712        );
1713    }
1714
1715    #[test]
1716    fn validate_with_defaults_and_context_combined() {
1717        let schema = json!({
1718            "type": "object",
1719            "properties": {
1720                "replicas": {
1721                    "type": "integer",
1722                    "default": 1,
1723                    "x-kubernetes-validations": [
1724                        {"rule": "self >= 0", "message": "must be non-negative"}
1725                    ]
1726                }
1727            },
1728            "x-kubernetes-validations": [
1729                {"rule": "kind == 'Deployment'", "message": "wrong kind"}
1730            ]
1731        });
1732        let root_ctx = RootContext {
1733            api_version: "apps/v1".into(),
1734            api_group: "apps".into(),
1735            kind: "Deployment".into(),
1736        };
1737        // Empty object: defaults fill replicas=1, root context provides kind
1738        assert!(
1739            Validator::new()
1740                .validate_with_defaults_and_context(&schema, &json!({}), None, Some(&root_ctx))
1741                .is_ok()
1742        );
1743    }
1744
1745    #[test]
1746    fn validation_error_serializable() {
1747        let err = ValidationError {
1748            rule: "self.x >= 0".into(),
1749            message: "must be non-negative".into(),
1750            field_path: "spec.x".into(),
1751            reason: Some("FieldValueInvalid".into()),
1752            kind: ErrorKind::ValidationFailure,
1753            source: None,
1754        };
1755        let json = serde_json::to_value(&err).unwrap();
1756        assert_eq!(json["rule"], "self.x >= 0");
1757        assert_eq!(json["field_path"], "spec.x");
1758        assert_eq!(json["kind"], "ValidationFailure");
1759    }
1760}