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/// Builds the fail-closed error emitted when schema nesting exceeds
131/// [`MAX_SCHEMA_DEPTH`](crate::validation::compilation::MAX_SCHEMA_DEPTH). Surfacing this —
132/// rather than silently skipping the over-deep subtree — is what keeps the
133/// validator fail-closed: a too-deep schema cannot quietly report success.
134fn schema_too_deep_error(path: &str) -> ValidationError {
135    ValidationError {
136        rule: String::new(),
137        message: format!(
138            "schema nesting exceeds the maximum depth of {}",
139            crate::validation::compilation::MAX_SCHEMA_DEPTH
140        ),
141        field_path: path.to_string(),
142        reason: None,
143        kind: ErrorKind::SchemaTooDeep,
144        source: None,
145    }
146}
147
148/// Validates Kubernetes objects against CRD schema CEL validation rules.
149///
150/// Walks the OpenAPI schema tree, compiles `x-kubernetes-validations` rules at
151/// each node, and evaluates them against the corresponding object values.
152///
153/// For repeated validation against the same schema, use [`compile_schema`](crate::compile_schema) +
154/// [`validate_compiled`](Validator::validate_compiled) to avoid re-compilation.
155///
156/// # Thread Safety
157///
158/// `Validator` is `Send` and can be moved across threads.
159pub struct Validator {
160    base_ctx: Context<'static>,
161}
162
163impl std::fmt::Debug for Validator {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.debug_struct("Validator").finish()
166    }
167}
168
169impl Validator {
170    /// Create a new `Validator` with all K8s CEL functions pre-registered.
171    pub fn new() -> Self {
172        let mut ctx = Context::default();
173        crate::register_all(&mut ctx);
174        Self { base_ctx: ctx }
175    }
176
177    /// Validate an object against a CRD schema's CEL validation rules.
178    ///
179    /// Compiles rules on each call. For repeated validation against the same
180    /// schema, prefer [`compile_schema`](crate::compile_schema) + [`validate_compiled`](Self::validate_compiled).
181    #[must_use]
182    pub fn validate(
183        &self,
184        schema: &serde_json::Value,
185        object: &serde_json::Value,
186        old_object: Option<&serde_json::Value>,
187    ) -> Vec<ValidationError> {
188        self.validate_with_context(schema, object, old_object, None)
189    }
190
191    /// Validate an object against a CRD schema's CEL validation rules, with optional root context.
192    ///
193    /// Like [`validate`](Self::validate), but also binds `apiVersion`, `apiGroup`, and `kind`
194    /// as root-level CEL variables when a [`RootContext`] is provided.
195    #[must_use]
196    pub fn validate_with_context(
197        &self,
198        schema: &serde_json::Value,
199        object: &serde_json::Value,
200        old_object: Option<&serde_json::Value>,
201        root_ctx: Option<&RootContext>,
202    ) -> Vec<ValidationError> {
203        let mut errors = Vec::new();
204        self.walk_schema(
205            schema,
206            object,
207            old_object,
208            String::new(),
209            &mut errors,
210            &self.base_ctx,
211            root_ctx,
212            0,
213        );
214        errors
215    }
216
217    /// Validate an object using a pre-compiled schema tree.
218    ///
219    /// Use [`compile_schema`](crate::compile_schema) to build the [`CompiledSchema`], then call this
220    /// method for each object to validate — rules are compiled only once.
221    #[must_use]
222    pub fn validate_compiled(
223        &self,
224        compiled: &CompiledSchema,
225        object: &serde_json::Value,
226        old_object: Option<&serde_json::Value>,
227    ) -> Vec<ValidationError> {
228        self.validate_compiled_with_context(compiled, object, old_object, None)
229    }
230
231    /// Validate an object using a pre-compiled schema tree, with optional root context.
232    ///
233    /// Like [`validate_compiled`](Self::validate_compiled), but also binds `apiVersion`,
234    /// `apiGroup`, and `kind` as root-level CEL variables when a [`RootContext`] is provided.
235    #[must_use]
236    pub fn validate_compiled_with_context(
237        &self,
238        compiled: &CompiledSchema,
239        object: &serde_json::Value,
240        old_object: Option<&serde_json::Value>,
241        root_ctx: Option<&RootContext>,
242    ) -> Vec<ValidationError> {
243        let mut errors = Vec::new();
244        self.walk_compiled(
245            compiled,
246            object,
247            old_object,
248            String::new(),
249            &mut errors,
250            &self.base_ctx,
251            root_ctx,
252            0,
253        );
254        errors
255    }
256
257    /// Validate with schema defaults applied to the object first.
258    ///
259    /// Equivalent to calling [`crate::validation::defaults::apply_defaults`] followed by [`validate`].
260    #[must_use]
261    pub fn validate_with_defaults(
262        &self,
263        schema: &serde_json::Value,
264        object: &serde_json::Value,
265        old_object: Option<&serde_json::Value>,
266    ) -> Vec<ValidationError> {
267        let defaulted = crate::validation::defaults::apply_defaults(schema, object);
268        let defaulted_old = old_object.map(|o| crate::validation::defaults::apply_defaults(schema, o));
269        self.validate(schema, &defaulted, defaulted_old.as_ref())
270    }
271
272    /// Validate with schema defaults applied and root context variables bound.
273    ///
274    /// Combines [`crate::validation::defaults::apply_defaults`] with [`Self::validate_with_context`].
275    #[must_use]
276    pub fn validate_with_defaults_and_context(
277        &self,
278        schema: &serde_json::Value,
279        object: &serde_json::Value,
280        old_object: Option<&serde_json::Value>,
281        root_ctx: Option<&RootContext>,
282    ) -> Vec<ValidationError> {
283        let defaulted = crate::validation::defaults::apply_defaults(schema, object);
284        let defaulted_old = old_object.map(|o| crate::validation::defaults::apply_defaults(schema, o));
285        self.validate_with_context(schema, &defaulted, defaulted_old.as_ref(), root_ctx)
286    }
287
288    // ── Schema-based walking (compiles on each call) ────────────────
289
290    #[allow(clippy::too_many_arguments)]
291    fn walk_schema(
292        &self,
293        schema: &serde_json::Value,
294        value: &serde_json::Value,
295        old_value: Option<&serde_json::Value>,
296        path: String,
297        errors: &mut Vec<ValidationError>,
298        base_ctx: &Context<'_>,
299        root_ctx: Option<&RootContext>,
300        depth: usize,
301    ) {
302        if depth > crate::validation::compilation::MAX_SCHEMA_DEPTH {
303            errors.push(schema_too_deep_error(&path));
304            return;
305        }
306
307        let cel_value = json_to_cel_with_schema(value, schema);
308        let cel_old = old_value.map(|o| json_to_cel_with_schema(o, schema));
309        self.evaluate_validations(
310            schema,
311            &cel_value,
312            cel_old.as_ref(),
313            &path,
314            errors,
315            base_ctx,
316            root_ctx,
317        );
318
319        if let (Some(properties), Some(obj)) = (
320            schema.get("properties").and_then(|p| p.as_object()),
321            value.as_object(),
322        ) {
323            for (prop_name, prop_schema) in properties {
324                if let Some(child_value) = obj.get(prop_name) {
325                    let child_old = old_value.and_then(|o| o.get(prop_name));
326                    let child_path = join_path(&path, prop_name);
327                    self.walk_schema(
328                        prop_schema,
329                        child_value,
330                        child_old,
331                        child_path,
332                        errors,
333                        base_ctx,
334                        None,
335                        depth + 1,
336                    );
337                }
338            }
339        }
340
341        if let (Some(items_schema), Some(arr)) = (schema.get("items"), value.as_array()) {
342            for (i, item) in arr.iter().enumerate() {
343                let old_item = old_value.and_then(|o| o.as_array()).and_then(|a| a.get(i));
344                let item_path = join_path_index(&path, i);
345                self.walk_schema(
346                    items_schema,
347                    item,
348                    old_item,
349                    item_path,
350                    errors,
351                    base_ctx,
352                    None,
353                    depth + 1,
354                );
355            }
356        }
357
358        let preserve_unknown = schema
359            .get("x-kubernetes-preserve-unknown-fields")
360            .and_then(|v| v.as_bool())
361            == Some(true);
362
363        if !preserve_unknown
364            && let (Some(additional_schema), Some(obj)) = (
365                schema.get("additionalProperties").filter(|a| a.is_object()),
366                value.as_object(),
367            )
368        {
369            let known: std::collections::HashSet<&str> = schema
370                .get("properties")
371                .and_then(|p| p.as_object())
372                .map(|p| p.keys().map(|k| k.as_str()).collect())
373                .unwrap_or_default();
374
375            for (key, val) in obj {
376                if known.contains(key.as_str()) {
377                    continue;
378                }
379                let old_val = old_value.and_then(|o| o.get(key));
380                let child_path = join_path(&path, key);
381                self.walk_schema(
382                    additional_schema,
383                    val,
384                    old_val,
385                    child_path,
386                    errors,
387                    base_ctx,
388                    None,
389                    depth + 1,
390                );
391            }
392        }
393
394        // Walk allOf/oneOf/anyOf branches — all treated identically for CEL evaluation
395        for keyword in &["allOf", "oneOf", "anyOf"] {
396            if let Some(branches) = schema.get(keyword).and_then(|v| v.as_array()) {
397                for branch in branches {
398                    self.walk_schema(
399                        branch,
400                        value,
401                        old_value,
402                        path.clone(),
403                        errors,
404                        base_ctx,
405                        root_ctx,
406                        depth + 1,
407                    );
408                }
409            }
410        }
411    }
412
413    #[allow(clippy::too_many_arguments)]
414    fn evaluate_validations(
415        &self,
416        schema: &serde_json::Value,
417        cel_value: &cel::Value,
418        cel_old: Option<&cel::Value>,
419        path: &str,
420        errors: &mut Vec<ValidationError>,
421        base_ctx: &Context<'_>,
422        root_ctx: Option<&RootContext>,
423    ) {
424        let compiled = compile_schema_validations(schema);
425        self.evaluate_compiled_results(&compiled, cel_value, cel_old, path, errors, base_ctx, root_ctx);
426    }
427
428    // ── CompiledSchema-based walking ────────────────────────────────
429
430    #[allow(clippy::too_many_arguments)]
431    fn walk_compiled(
432        &self,
433        compiled: &CompiledSchema,
434        value: &serde_json::Value,
435        old_value: Option<&serde_json::Value>,
436        path: String,
437        errors: &mut Vec<ValidationError>,
438        base_ctx: &Context<'_>,
439        root_ctx: Option<&RootContext>,
440        depth: usize,
441    ) {
442        if depth > crate::validation::compilation::MAX_SCHEMA_DEPTH {
443            errors.push(schema_too_deep_error(&path));
444            return;
445        }
446
447        let cel_value = json_to_cel_with_compiled(value, compiled);
448        let cel_old = old_value.map(|o| json_to_cel_with_compiled(o, compiled));
449        self.evaluate_compiled_results(
450            &compiled.validations,
451            &cel_value,
452            cel_old.as_ref(),
453            &path,
454            errors,
455            base_ctx,
456            root_ctx,
457        );
458
459        if let Some(obj) = value.as_object() {
460            for (prop_name, child_compiled) in &compiled.properties {
461                if let Some(child_value) = obj.get(prop_name) {
462                    let child_old = old_value.and_then(|o| o.get(prop_name));
463                    let child_path = join_path(&path, prop_name);
464                    self.walk_compiled(
465                        child_compiled,
466                        child_value,
467                        child_old,
468                        child_path,
469                        errors,
470                        base_ctx,
471                        None,
472                        depth + 1,
473                    );
474                }
475            }
476        }
477
478        if let (Some(items_compiled), Some(arr)) = (&compiled.items, value.as_array()) {
479            for (i, item) in arr.iter().enumerate() {
480                let old_item = old_value.and_then(|o| o.as_array()).and_then(|a| a.get(i));
481                let item_path = join_path_index(&path, i);
482                self.walk_compiled(
483                    items_compiled,
484                    item,
485                    old_item,
486                    item_path,
487                    errors,
488                    base_ctx,
489                    None,
490                    depth + 1,
491                );
492            }
493        }
494
495        if !compiled.preserve_unknown_fields
496            && let (Some(additional_compiled), Some(obj)) =
497                (&compiled.additional_properties, value.as_object())
498        {
499            for (key, val) in obj {
500                if compiled.properties.contains_key(key) {
501                    continue;
502                }
503                let old_val = old_value.and_then(|o| o.get(key));
504                let child_path = join_path(&path, key);
505                self.walk_compiled(
506                    additional_compiled,
507                    val,
508                    old_val,
509                    child_path,
510                    errors,
511                    base_ctx,
512                    None,
513                    depth + 1,
514                );
515            }
516        }
517
518        for branch in compiled
519            .all_of
520            .iter()
521            .chain(compiled.one_of.iter())
522            .chain(compiled.any_of.iter())
523        {
524            self.walk_compiled(
525                branch,
526                value,
527                old_value,
528                path.clone(),
529                errors,
530                base_ctx,
531                root_ctx,
532                depth + 1,
533            );
534        }
535    }
536
537    // ── Shared evaluation logic ─────────────────────────────────────
538
539    #[allow(clippy::too_many_arguments)]
540    fn evaluate_compiled_results(
541        &self,
542        results: &[Result<CompilationResult, CompilationError>],
543        cel_value: &cel::Value,
544        cel_old: Option<&cel::Value>,
545        path: &str,
546        errors: &mut Vec<ValidationError>,
547        base_ctx: &Context<'_>,
548        root_ctx: Option<&RootContext>,
549    ) {
550        // Create a node-level scope once with self/oldSelf bound
551        let mut node_ctx = base_ctx.new_inner_scope();
552        node_ctx.add_variable_from_value("self", cel_value.clone());
553        if let Some(old) = cel_old {
554            node_ctx.add_variable_from_value("oldSelf", old.clone());
555        }
556
557        if path.is_empty()
558            && let Some(rc) = root_ctx
559        {
560            node_ctx.add_variable_from_value(
561                "apiVersion",
562                cel::Value::String(std::sync::Arc::new(rc.api_version.clone())),
563            );
564            node_ctx.add_variable_from_value(
565                "apiGroup",
566                cel::Value::String(std::sync::Arc::new(rc.api_group.clone())),
567            );
568            node_ctx
569                .add_variable_from_value("kind", cel::Value::String(std::sync::Arc::new(rc.kind.clone())));
570        }
571
572        for result in results {
573            match result {
574                Ok(cr) => {
575                    self.evaluate_rule(cr, &node_ctx, cel_old, path, errors);
576                }
577                Err(CompilationError::Parse { rule, source }) => {
578                    errors.push(ValidationError {
579                        rule: rule.clone(),
580                        message: format!("failed to compile rule \"{rule}\": {source}"),
581                        field_path: path.to_string(),
582                        reason: None,
583                        kind: ErrorKind::CompilationFailure,
584                        // `cel::ParseErrors` is `!Clone` and only borrowed here,
585                        // so the typed cause cannot be owned; detail is in `message`.
586                        source: None,
587                    });
588                }
589                Err(CompilationError::MessageExpressionParse {
590                    rule,
591                    message_expression,
592                    source,
593                }) => {
594                    errors.push(ValidationError {
595                        rule: rule.clone(),
596                        message: format!(
597                            "failed to compile messageExpression \"{message_expression}\" for rule \"{rule}\": {source}"
598                        ),
599                        field_path: path.to_string(),
600                        reason: None,
601                        kind: ErrorKind::CompilationFailure,
602                        // `cel::ParseErrors` is `!Clone` and only borrowed here,
603                        // so the typed cause cannot be owned; detail is in `message`.
604                        source: None,
605                    });
606                }
607                Err(CompilationError::InvalidRule(e)) => {
608                    errors.push(ValidationError {
609                        rule: String::new(),
610                        message: format!("invalid rule definition: {e}"),
611                        field_path: path.to_string(),
612                        reason: None,
613                        kind: ErrorKind::InvalidRule,
614                        // `serde_json::Error` is borrowed from the shared
615                        // results slice and `!Clone`; detail is in `message`.
616                        source: None,
617                    });
618                }
619                Err(CompilationError::SchemaTooDeep { .. }) => {
620                    errors.push(schema_too_deep_error(path));
621                }
622            }
623        }
624    }
625
626    fn evaluate_rule(
627        &self,
628        cr: &CompilationResult,
629        node_ctx: &Context<'_>,
630        cel_old: Option<&cel::Value>,
631        path: &str,
632        errors: &mut Vec<ValidationError>,
633    ) {
634        // Handle transition rules
635        if cr.is_transition_rule && cel_old.is_none() && cr.rule.optional_old_self != Some(true) {
636            return; // skip transition rule without old value
637        }
638
639        // optionalOldSelf: true + no old object → child scope with oldSelf = null
640        let use_null_old_self = cel_old.is_none() && cr.rule.optional_old_self == Some(true);
641        let null_scope;
642        let effective_ctx: &Context<'_> = if use_null_old_self {
643            null_scope = {
644                let mut s = node_ctx.new_inner_scope();
645                s.add_variable_from_value("oldSelf", cel::Value::Null);
646                s
647            };
648            &null_scope
649        } else {
650            node_ctx
651        };
652
653        let result = cr.program.execute(effective_ctx);
654        let error_path = effective_path(path, cr.rule.field_path.as_deref());
655
656        match result {
657            Ok(cel::Value::Bool(true)) => {
658                // Validation passed
659            }
660            Ok(cel::Value::Bool(false)) => {
661                let message = self.resolve_message(cr, effective_ctx);
662                errors.push(ValidationError {
663                    rule: cr.rule.rule.clone(),
664                    message,
665                    field_path: error_path,
666                    reason: cr.rule.reason.clone(),
667                    kind: ErrorKind::ValidationFailure,
668                    source: None,
669                });
670            }
671            Ok(_) => {
672                errors.push(ValidationError {
673                    rule: cr.rule.rule.clone(),
674                    message: format!("rule \"{}\" did not evaluate to bool", cr.rule.rule),
675                    field_path: error_path,
676                    reason: None,
677                    kind: ErrorKind::InvalidResult,
678                    source: None,
679                });
680            }
681            Err(e) => {
682                // The rule compiled but evaluation failed. An
683                // `UndeclaredReference` means the rule references something this
684                // build does not provide — a cel-gated macro (sortBy/cel.bind/
685                // two-arg comprehensions) or a disabled feature — classified
686                // distinctly so callers can tell a coverage gap from a real
687                // runtime error. Either way the owned `ExecutionError` is the
688                // cause and is preserved via `source()`.
689                let (kind, message) = match &e {
690                    cel::ExecutionError::UndeclaredReference(name) => (
691                        ErrorKind::UnsupportedReference,
692                        format!(
693                            "rule references '{name}', which this kube-cel build does not support \
694                             (an unsupported CEL macro, or a feature disabled at compile time); \
695                             it cannot be evaluated client-side"
696                        ),
697                    ),
698                    _ => (ErrorKind::EvaluationError, format!("rule evaluation error: {e}")),
699                };
700                errors.push(ValidationError {
701                    rule: cr.rule.rule.clone(),
702                    message,
703                    field_path: error_path,
704                    reason: None,
705                    kind,
706                    source: Some(std::sync::Arc::new(e)),
707                });
708            }
709        }
710    }
711
712    /// Resolve the error message: try messageExpression first, fall back to
713    /// static message, then default.
714    fn resolve_message(&self, cr: &CompilationResult, ctx: &Context<'_>) -> String {
715        if let Some(ref msg_prog) = cr.message_program
716            && let Ok(cel::Value::String(s)) = msg_prog.execute(ctx)
717        {
718            return (*s).clone();
719        }
720        cr.rule
721            .message
722            .clone()
723            .unwrap_or_else(|| format!("failed rule: {}", cr.rule.rule))
724    }
725}
726
727impl Default for Validator {
728    fn default() -> Self {
729        Self::new()
730    }
731}
732
733thread_local! {
734    static THREAD_VALIDATOR: Validator = Validator::new();
735}
736
737/// Convenience function to validate without creating a [`Validator`] instance.
738///
739/// Uses a thread-local [`Validator`] to avoid re-registering CEL functions on each call.
740///
741/// See [`Validator::validate`] for details.
742#[must_use]
743pub fn validate(
744    schema: &serde_json::Value,
745    object: &serde_json::Value,
746    old_object: Option<&serde_json::Value>,
747) -> Vec<ValidationError> {
748    THREAD_VALIDATOR.with(|v| v.validate(schema, object, old_object))
749}
750
751/// Convenience function to validate using a pre-compiled schema.
752///
753/// Uses a thread-local [`Validator`] to avoid re-registering CEL functions on each call.
754///
755/// See [`Validator::validate_compiled`] for details.
756#[must_use]
757pub fn validate_compiled(
758    compiled: &CompiledSchema,
759    object: &serde_json::Value,
760    old_object: Option<&serde_json::Value>,
761) -> Vec<ValidationError> {
762    THREAD_VALIDATOR.with(|v| v.validate_compiled(compiled, object, old_object))
763}
764
765// ── Path helpers ────────────────────────────────────────────────────
766
767#[inline]
768fn effective_path(base_path: &str, rule_field_path: Option<&str>) -> String {
769    match rule_field_path {
770        Some(fp) if fp.starts_with('.') => format!("{base_path}{fp}"),
771        Some(fp) if !base_path.is_empty() => format!("{base_path}.{fp}"),
772        Some(fp) => fp.to_string(),
773        None => base_path.to_string(),
774    }
775}
776
777#[inline]
778fn join_path(base: &str, segment: &str) -> String {
779    if base.is_empty() {
780        segment.to_string()
781    } else {
782        format!("{base}.{segment}")
783    }
784}
785
786#[inline]
787fn join_path_index(base: &str, index: usize) -> String {
788    if base.is_empty() {
789        format!("[{index}]")
790    } else {
791        format!("{base}[{index}]")
792    }
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use crate::validation::compilation::compile_schema;
799    use serde_json::json;
800
801    fn make_schema(validations: serde_json::Value) -> serde_json::Value {
802        json!({
803            "type": "object",
804            "properties": {
805                "replicas": {"type": "integer"},
806                "name": {"type": "string"}
807            },
808            "x-kubernetes-validations": validations
809        })
810    }
811
812    #[test]
813    fn validation_passes() {
814        let schema = make_schema(json!([
815            {"rule": "self.replicas >= 0", "message": "must be non-negative"}
816        ]));
817        let obj = json!({"replicas": 3, "name": "app"});
818        let errors = validate(&schema, &obj, None);
819        assert!(errors.is_empty());
820    }
821
822    #[test]
823    fn validation_fails() {
824        let schema = make_schema(json!([
825            {"rule": "self.replicas >= 0", "message": "must be non-negative"}
826        ]));
827        let obj = json!({"replicas": -1, "name": "app"});
828        let errors = validate(&schema, &obj, None);
829        assert_eq!(errors.len(), 1);
830        assert_eq!(errors[0].message, "must be non-negative");
831        assert_eq!(errors[0].rule, "self.replicas >= 0");
832    }
833
834    #[test]
835    fn default_message_when_none() {
836        let schema = make_schema(json!([
837            {"rule": "self.replicas >= 0"}
838        ]));
839        let obj = json!({"replicas": -1, "name": "app"});
840        let errors = validate(&schema, &obj, None);
841        assert_eq!(errors.len(), 1);
842        assert!(errors[0].message.contains("self.replicas >= 0"));
843    }
844
845    #[test]
846    fn reason_preserved() {
847        let schema = make_schema(json!([
848            {"rule": "self.replicas >= 0", "message": "bad", "reason": "FieldValueInvalid"}
849        ]));
850        let obj = json!({"replicas": -1, "name": "app"});
851        let errors = validate(&schema, &obj, None);
852        assert_eq!(errors[0].reason.as_deref(), Some("FieldValueInvalid"));
853    }
854
855    #[test]
856    fn transition_rule_skipped_without_old_object() {
857        let schema = make_schema(json!([
858            {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down"}
859        ]));
860        let obj = json!({"replicas": 1, "name": "app"});
861        let errors = validate(&schema, &obj, None);
862        assert!(errors.is_empty());
863    }
864
865    #[test]
866    fn transition_rule_evaluated_with_old_object() {
867        let schema = make_schema(json!([
868            {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down"}
869        ]));
870        let obj = json!({"replicas": 1, "name": "app"});
871        let old = json!({"replicas": 3, "name": "app"});
872        let errors = validate(&schema, &obj, Some(&old));
873        assert_eq!(errors.len(), 1);
874        assert_eq!(errors[0].message, "cannot scale down");
875    }
876
877    #[test]
878    fn transition_rule_passes() {
879        let schema = make_schema(json!([
880            {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down"}
881        ]));
882        let obj = json!({"replicas": 5, "name": "app"});
883        let old = json!({"replicas": 3, "name": "app"});
884        let errors = validate(&schema, &obj, Some(&old));
885        assert!(errors.is_empty());
886    }
887
888    #[test]
889    fn nested_property_field_path() {
890        let schema = json!({
891            "type": "object",
892            "properties": {
893                "spec": {
894                    "type": "object",
895                    "properties": {
896                        "replicas": {
897                            "type": "integer",
898                            "x-kubernetes-validations": [
899                                {"rule": "self >= 0", "message": "must be non-negative"}
900                            ]
901                        }
902                    }
903                }
904            }
905        });
906        let obj = json!({"spec": {"replicas": -1}});
907        let errors = validate(&schema, &obj, None);
908        assert_eq!(errors.len(), 1);
909        assert_eq!(errors[0].field_path, "spec.replicas");
910        assert_eq!(errors[0].message, "must be non-negative");
911    }
912
913    #[test]
914    fn array_items_validation() {
915        let schema = json!({
916            "type": "object",
917            "properties": {
918                "items": {
919                    "type": "array",
920                    "items": {
921                        "type": "object",
922                        "properties": {
923                            "name": {"type": "string"}
924                        },
925                        "x-kubernetes-validations": [
926                            {"rule": "self.name.size() > 0", "message": "name required"}
927                        ]
928                    }
929                }
930            }
931        });
932        let obj = json!({
933            "items": [
934                {"name": "good"},
935                {"name": ""},
936                {"name": "also-good"}
937            ]
938        });
939        let errors = validate(&schema, &obj, None);
940        assert_eq!(errors.len(), 1);
941        assert_eq!(errors[0].field_path, "items[1]");
942        assert_eq!(errors[0].message, "name required");
943    }
944
945    #[test]
946    fn missing_field_not_validated() {
947        let schema = json!({
948            "type": "object",
949            "properties": {
950                "optional_field": {
951                    "type": "integer",
952                    "x-kubernetes-validations": [
953                        {"rule": "self >= 0", "message": "must be non-negative"}
954                    ]
955                }
956            }
957        });
958        let obj = json!({});
959        let errors = validate(&schema, &obj, None);
960        assert!(errors.is_empty());
961    }
962
963    #[test]
964    fn multiple_rules_partial_failure() {
965        let schema = make_schema(json!([
966            {"rule": "self.replicas >= 0", "message": "non-negative"},
967            {"rule": "self.name.size() > 0", "message": "name required"}
968        ]));
969        let obj = json!({"replicas": -1, "name": ""});
970        let errors = validate(&schema, &obj, None);
971        assert_eq!(errors.len(), 2);
972    }
973
974    #[test]
975    fn compilation_error_reported() {
976        let schema = make_schema(json!([
977            {"rule": "self.replicas >="}
978        ]));
979        let obj = json!({"replicas": 1, "name": "app"});
980        let errors = validate(&schema, &obj, None);
981        assert_eq!(errors.len(), 1);
982        assert!(errors[0].message.contains("failed to compile"));
983    }
984
985    #[test]
986    fn no_validations_no_errors() {
987        let schema = json!({
988            "type": "object",
989            "properties": {
990                "replicas": {"type": "integer"}
991            }
992        });
993        let obj = json!({"replicas": -1});
994        let errors = validate(&schema, &obj, None);
995        assert!(errors.is_empty());
996    }
997
998    #[test]
999    fn display_with_field_path() {
1000        let err = ValidationError {
1001            rule: "self >= 0".into(),
1002            message: "must be non-negative".into(),
1003            field_path: "spec.replicas".into(),
1004            reason: None,
1005            kind: ErrorKind::ValidationFailure,
1006            source: None,
1007        };
1008        assert_eq!(err.to_string(), "spec.replicas: must be non-negative");
1009    }
1010
1011    #[test]
1012    fn display_without_field_path() {
1013        let err = ValidationError {
1014            rule: "self >= 0".into(),
1015            message: "must be non-negative".into(),
1016            field_path: String::new(),
1017            reason: None,
1018            kind: ErrorKind::ValidationFailure,
1019            source: None,
1020        };
1021        assert_eq!(err.to_string(), "must be non-negative");
1022    }
1023
1024    #[test]
1025    fn validator_default() {
1026        let v = Validator::default();
1027        let schema = make_schema(json!([{"rule": "self.replicas >= 0"}]));
1028        let obj = json!({"replicas": 1, "name": "app"});
1029        assert!(v.validate(&schema, &obj, None).is_empty());
1030    }
1031
1032    #[test]
1033    fn additional_properties_walking() {
1034        let schema = json!({
1035            "type": "object",
1036            "additionalProperties": {
1037                "type": "integer",
1038                "x-kubernetes-validations": [
1039                    {"rule": "self >= 0", "message": "must be non-negative"}
1040                ]
1041            }
1042        });
1043        let obj = json!({"a": 1, "b": -1, "c": 5});
1044        let errors = validate(&schema, &obj, None);
1045        assert_eq!(errors.len(), 1);
1046        assert_eq!(errors[0].field_path, "b");
1047    }
1048
1049    // ── Phase 5 tests ───────────────────────────────────────────────
1050
1051    #[test]
1052    fn message_expression_produces_dynamic_message() {
1053        let schema = make_schema(json!([{
1054            "rule": "self.replicas >= 0",
1055            "message": "static fallback",
1056            "messageExpression": "'replicas is ' + string(self.replicas) + ', must be >= 0'"
1057        }]));
1058        let obj = json!({"replicas": -5, "name": "app"});
1059        let errors = validate(&schema, &obj, None);
1060        assert_eq!(errors.len(), 1);
1061        assert_eq!(errors[0].message, "replicas is -5, must be >= 0");
1062    }
1063
1064    #[test]
1065    fn invalid_message_expression_fails_closed() {
1066        let schema = make_schema(json!([{
1067            "rule": "self.replicas >= 0",
1068            "message": "static message",
1069            "messageExpression": "invalid >="
1070        }]));
1071        // The object *satisfies* the rule, yet a messageExpression that fails to
1072        // compile fails closed (CompilationFailure) — mirroring the apiserver,
1073        // which rejects such a CRD at registration — rather than being silently
1074        // dropped and the rule evaluated with the static message.
1075        let obj = json!({"replicas": 5, "name": "app"});
1076        let errors = validate(&schema, &obj, None);
1077        assert_eq!(errors.len(), 1);
1078        assert_eq!(errors[0].kind, ErrorKind::CompilationFailure);
1079    }
1080
1081    #[test]
1082    fn optional_old_self_evaluated_on_create() {
1083        let schema = make_schema(json!([{
1084            "rule": "oldSelf == null || self.replicas >= oldSelf.replicas",
1085            "message": "cannot scale down",
1086            "optionalOldSelf": true
1087        }]));
1088        // Create (no old object): rule is evaluated with oldSelf = null
1089        let obj = json!({"replicas": 1, "name": "app"});
1090        let errors = validate(&schema, &obj, None);
1091        assert!(errors.is_empty()); // oldSelf == null → true
1092    }
1093
1094    #[test]
1095    fn optional_old_self_with_old_object() {
1096        let schema = make_schema(json!([{
1097            "rule": "oldSelf == null || self.replicas >= oldSelf.replicas",
1098            "message": "cannot scale down",
1099            "optionalOldSelf": true
1100        }]));
1101        let obj = json!({"replicas": 1, "name": "app"});
1102        let old = json!({"replicas": 3, "name": "app"});
1103        let errors = validate(&schema, &obj, Some(&old));
1104        assert_eq!(errors.len(), 1);
1105        assert_eq!(errors[0].message, "cannot scale down");
1106    }
1107
1108    #[test]
1109    fn optional_old_self_false_still_skips() {
1110        let schema = make_schema(json!([{
1111            "rule": "self.replicas >= oldSelf.replicas",
1112            "message": "cannot scale down",
1113            "optionalOldSelf": false
1114        }]));
1115        let obj = json!({"replicas": 1, "name": "app"});
1116        // optionalOldSelf: false → transition rule skipped on create
1117        let errors = validate(&schema, &obj, None);
1118        assert!(errors.is_empty());
1119    }
1120
1121    #[test]
1122    fn validate_compiled_matches_validate() {
1123        let schema = json!({
1124            "type": "object",
1125            "properties": {
1126                "spec": {
1127                    "type": "object",
1128                    "x-kubernetes-validations": [
1129                        {"rule": "self.replicas >= 0", "message": "non-negative"}
1130                    ],
1131                    "properties": {
1132                        "replicas": {"type": "integer"}
1133                    }
1134                }
1135            }
1136        });
1137        let obj = json!({"spec": {"replicas": -1}});
1138
1139        let errors_schema = validate(&schema, &obj, None);
1140        let compiled = compile_schema(&schema);
1141        let errors_compiled = validate_compiled(&compiled, &obj, None);
1142
1143        assert_eq!(errors_schema.len(), errors_compiled.len());
1144        assert_eq!(errors_schema[0].message, errors_compiled[0].message);
1145        assert_eq!(errors_schema[0].field_path, errors_compiled[0].field_path);
1146    }
1147
1148    #[test]
1149    fn validate_compiled_reuse() {
1150        let schema = json!({
1151            "type": "object",
1152            "x-kubernetes-validations": [
1153                {"rule": "self.x > 0", "message": "x must be positive"}
1154            ],
1155            "properties": {"x": {"type": "integer"}}
1156        });
1157        let compiled = compile_schema(&schema);
1158
1159        // Validate multiple objects with the same compiled schema
1160        assert_eq!(validate_compiled(&compiled, &json!({"x": 1}), None).len(), 0);
1161        assert_eq!(validate_compiled(&compiled, &json!({"x": -1}), None).len(), 1);
1162        assert_eq!(validate_compiled(&compiled, &json!({"x": 5}), None).len(), 0);
1163        assert_eq!(validate_compiled(&compiled, &json!({"x": 0}), None).len(), 1);
1164    }
1165
1166    // ── fieldPath override tests ────────────────────────────────────
1167
1168    #[test]
1169    fn fieldpath_overrides_auto_path() {
1170        let schema = json!({
1171            "type": "object",
1172            "properties": {
1173                "spec": {
1174                    "type": "object",
1175                    "properties": {
1176                        "x": {"type": "integer"}
1177                    },
1178                    "x-kubernetes-validations": [
1179                        {"rule": "self.x >= 0", "message": "bad", "fieldPath": ".spec.x"}
1180                    ]
1181                }
1182            }
1183        });
1184        let obj = json!({"spec": {"x": -1}});
1185        let errors = validate(&schema, &obj, None);
1186        assert_eq!(errors.len(), 1);
1187        assert_eq!(errors[0].field_path, "spec.spec.x");
1188    }
1189
1190    #[test]
1191    fn fieldpath_without_dot() {
1192        let schema = json!({
1193            "type": "object",
1194            "properties": {
1195                "spec": {
1196                    "type": "object",
1197                    "properties": {
1198                        "name": {"type": "string"}
1199                    },
1200                    "x-kubernetes-validations": [
1201                        {"rule": "self.name.size() > 0", "message": "bad", "fieldPath": "name"}
1202                    ]
1203                }
1204            }
1205        });
1206        let obj = json!({"spec": {"name": ""}});
1207        let errors = validate(&schema, &obj, None);
1208        assert_eq!(errors.len(), 1);
1209        assert_eq!(errors[0].field_path, "spec.name");
1210    }
1211
1212    #[test]
1213    fn fieldpath_at_root() {
1214        let schema = json!({
1215            "type": "object",
1216            "properties": {
1217                "x": {"type": "integer"}
1218            },
1219            "x-kubernetes-validations": [
1220                {"rule": "self.x >= 0", "message": "bad", "fieldPath": ".spec.x"}
1221            ]
1222        });
1223        let obj = json!({"x": -1});
1224        let errors = validate(&schema, &obj, None);
1225        assert_eq!(errors.len(), 1);
1226        assert_eq!(errors[0].field_path, ".spec.x");
1227    }
1228
1229    #[test]
1230    fn fieldpath_none_uses_auto() {
1231        let schema = json!({
1232            "type": "object",
1233            "properties": {
1234                "spec": {
1235                    "type": "object",
1236                    "properties": {
1237                        "x": {"type": "integer"}
1238                    },
1239                    "x-kubernetes-validations": [
1240                        {"rule": "self.x >= 0", "message": "bad"}
1241                    ]
1242                }
1243            }
1244        });
1245        let obj = json!({"spec": {"x": -1}});
1246        let errors = validate(&schema, &obj, None);
1247        assert_eq!(errors.len(), 1);
1248        assert_eq!(errors[0].field_path, "spec");
1249    }
1250
1251    // ── ErrorKind tests ─────────────────────────────────────────────
1252
1253    #[test]
1254    fn error_kind_compilation_failure() {
1255        let schema = make_schema(json!([
1256            {"rule": "self.replicas >="}
1257        ]));
1258        let obj = json!({"replicas": 1, "name": "app"});
1259        let errors = validate(&schema, &obj, None);
1260        assert_eq!(errors.len(), 1);
1261        assert_eq!(errors[0].kind, ErrorKind::CompilationFailure);
1262    }
1263
1264    #[test]
1265    fn error_kind_validation_failure() {
1266        let schema = make_schema(json!([
1267            {"rule": "self.replicas >= 0", "message": "must be non-negative"}
1268        ]));
1269        let obj = json!({"replicas": -1, "name": "app"});
1270        let errors = validate(&schema, &obj, None);
1271        assert_eq!(errors.len(), 1);
1272        assert_eq!(errors[0].kind, ErrorKind::ValidationFailure);
1273    }
1274
1275    #[test]
1276    fn error_kind_evaluation_error() {
1277        let schema = make_schema(json!([
1278            {"rule": "self.missing_field > 0"}
1279        ]));
1280        let obj = json!({"replicas": 1, "name": "app"});
1281        let errors = validate(&schema, &obj, None);
1282        assert_eq!(errors.len(), 1);
1283        assert_eq!(errors[0].kind, ErrorKind::EvaluationError);
1284    }
1285
1286    // ── allOf/oneOf/anyOf tests ──────────────────────────────────────
1287
1288    #[test]
1289    fn all_of_validations_evaluated() {
1290        let schema = json!({
1291            "type": "object",
1292            "properties": {
1293                "x": {"type": "integer"},
1294                "y": {"type": "integer"}
1295            },
1296            "allOf": [
1297                {
1298                    "x-kubernetes-validations": [
1299                        {"rule": "self.x >= 0", "message": "x must be non-negative"}
1300                    ]
1301                },
1302                {
1303                    "x-kubernetes-validations": [
1304                        {"rule": "self.y >= 0", "message": "y must be non-negative"}
1305                    ]
1306                }
1307            ]
1308        });
1309        let obj = json!({"x": -1, "y": -1});
1310        let errors = validate(&schema, &obj, None);
1311        assert_eq!(errors.len(), 2);
1312    }
1313
1314    #[test]
1315    fn one_of_validations_evaluated() {
1316        let schema = json!({
1317            "type": "object",
1318            "properties": {"x": {"type": "integer"}},
1319            "oneOf": [{
1320                "x-kubernetes-validations": [
1321                    {"rule": "self.x != 0", "message": "x must not be zero"}
1322                ]
1323            }]
1324        });
1325        let obj = json!({"x": 0});
1326        let errors = validate(&schema, &obj, None);
1327        assert_eq!(errors.len(), 1);
1328    }
1329
1330    #[test]
1331    fn nested_all_of_properties_walked() {
1332        let schema = json!({
1333            "type": "object",
1334            "allOf": [{
1335                "properties": {
1336                    "name": {
1337                        "type": "string",
1338                        "x-kubernetes-validations": [
1339                            {"rule": "self.size() > 0", "message": "name required"}
1340                        ]
1341                    }
1342                }
1343            }]
1344        });
1345        let obj = json!({"name": ""});
1346        let errors = validate(&schema, &obj, None);
1347        assert_eq!(errors.len(), 1);
1348    }
1349
1350    #[test]
1351    fn all_of_compiled_matches_schema() {
1352        let schema = json!({
1353            "type": "object",
1354            "properties": {"x": {"type": "integer"}},
1355            "allOf": [{
1356                "x-kubernetes-validations": [
1357                    {"rule": "self.x >= 0", "message": "x must be non-negative"}
1358                ]
1359            }]
1360        });
1361        let obj = json!({"x": -1});
1362        let errors_schema = validate(&schema, &obj, None);
1363        let compiled = compile_schema(&schema);
1364        let errors_compiled = validate_compiled(&compiled, &obj, None);
1365        assert_eq!(errors_schema.len(), errors_compiled.len());
1366        assert_eq!(errors_schema[0].message, errors_compiled[0].message);
1367    }
1368
1369    // ── x-kubernetes-preserve-unknown-fields tests ──────────────────
1370
1371    #[test]
1372    fn preserve_unknown_fields_skips_additional_properties_walk() {
1373        let schema = json!({
1374            "type": "object",
1375            "x-kubernetes-preserve-unknown-fields": true,
1376            "additionalProperties": {
1377                "type": "integer",
1378                "x-kubernetes-validations": [
1379                    {"rule": "self >= 0", "message": "must be non-negative"}
1380                ]
1381            }
1382        });
1383        let obj = json!({"unknown_field": -1});
1384        let errors = validate(&schema, &obj, None);
1385        assert!(errors.is_empty());
1386    }
1387
1388    #[test]
1389    fn without_preserve_unknown_fields_additional_properties_still_walked() {
1390        let schema = json!({
1391            "type": "object",
1392            "additionalProperties": {
1393                "type": "integer",
1394                "x-kubernetes-validations": [
1395                    {"rule": "self >= 0", "message": "must be non-negative"}
1396                ]
1397            }
1398        });
1399        let obj = json!({"unknown_field": -1});
1400        let errors = validate(&schema, &obj, None);
1401        assert_eq!(errors.len(), 1);
1402    }
1403
1404    // ── x-kubernetes-embedded-resource tests ────────────────────────
1405
1406    #[test]
1407    fn embedded_resource_fields_accessible() {
1408        let schema = json!({
1409            "type": "object",
1410            "x-kubernetes-embedded-resource": true,
1411            "properties": {
1412                "spec": {"type": "object"}
1413            },
1414            "x-kubernetes-validations": [{
1415                "rule": "self.apiVersion.size() >= 0",
1416                "message": "apiVersion must exist"
1417            }]
1418        });
1419        let obj = json!({"spec": {}});
1420        let errors = validate(&schema, &obj, None);
1421        assert!(errors.is_empty());
1422    }
1423
1424    #[test]
1425    fn embedded_resource_preserves_existing_fields() {
1426        let schema = json!({
1427            "type": "object",
1428            "x-kubernetes-embedded-resource": true,
1429            "x-kubernetes-validations": [{
1430                "rule": "self.apiVersion == 'v1'",
1431                "message": "wrong version"
1432            }]
1433        });
1434        let obj = json!({"apiVersion": "v1", "kind": "Pod", "metadata": {"name": "test"}});
1435        let errors = validate(&schema, &obj, None);
1436        assert!(errors.is_empty());
1437    }
1438
1439    #[test]
1440    fn embedded_resource_compiled_path() {
1441        let schema = json!({
1442            "type": "object",
1443            "x-kubernetes-embedded-resource": true,
1444            "x-kubernetes-validations": [{
1445                "rule": "self.kind.size() >= 0",
1446                "message": "kind must exist"
1447            }]
1448        });
1449        let obj = json!({"spec": {}});
1450        let compiled = compile_schema(&schema);
1451        let errors = validate_compiled(&compiled, &obj, None);
1452        assert!(errors.is_empty());
1453    }
1454
1455    // ── RootContext tests ────────────────────────────────────────────
1456
1457    #[test]
1458    fn root_context_variables_bound() {
1459        let schema = json!({
1460            "type": "object",
1461            "properties": {"name": {"type": "string"}},
1462            "x-kubernetes-validations": [{
1463                "rule": "apiVersion == 'apps/v1'",
1464                "message": "wrong api version"
1465            }]
1466        });
1467        let obj = json!({"name": "test"});
1468        let root_ctx = RootContext {
1469            api_version: "apps/v1".into(),
1470            api_group: "apps".into(),
1471            kind: "Deployment".into(),
1472        };
1473        let errors = Validator::new().validate_with_context(&schema, &obj, None, Some(&root_ctx));
1474        assert!(errors.is_empty());
1475    }
1476
1477    #[test]
1478    fn root_context_empty_api_group_for_core() {
1479        let schema = json!({
1480            "type": "object",
1481            "properties": {"name": {"type": "string"}},
1482            "x-kubernetes-validations": [{
1483                "rule": "apiGroup == ''",
1484                "message": "not core"
1485            }]
1486        });
1487        let obj = json!({"name": "test"});
1488        let root_ctx = RootContext {
1489            api_version: "v1".into(),
1490            api_group: "".into(),
1491            kind: "Pod".into(),
1492        };
1493        let errors = Validator::new().validate_with_context(&schema, &obj, None, Some(&root_ctx));
1494        assert!(errors.is_empty());
1495    }
1496
1497    #[test]
1498    fn validate_without_root_context_still_works() {
1499        let schema = json!({
1500            "type": "object",
1501            "properties": {"x": {"type": "integer"}},
1502            "x-kubernetes-validations": [{"rule": "self.x >= 0", "message": "bad"}]
1503        });
1504        let obj = json!({"x": -1});
1505        let errors = validate(&schema, &obj, None);
1506        assert_eq!(errors.len(), 1);
1507    }
1508
1509    #[test]
1510    fn root_context_compiled_path() {
1511        let schema = json!({
1512            "type": "object",
1513            "properties": {"x": {"type": "integer"}},
1514            "x-kubernetes-validations": [{
1515                "rule": "kind == 'MyResource'",
1516                "message": "wrong kind"
1517            }]
1518        });
1519        let obj = json!({"x": 1});
1520        let root_ctx = RootContext {
1521            api_version: "v1".into(),
1522            api_group: "example.com".into(),
1523            kind: "MyResource".into(),
1524        };
1525        let compiled = crate::validation::compilation::compile_schema(&schema);
1526        let errors = Validator::new().validate_compiled_with_context(&compiled, &obj, None, Some(&root_ctx));
1527        assert!(errors.is_empty());
1528    }
1529
1530    #[test]
1531    fn validate_with_defaults_fills_missing_then_validates() {
1532        let schema = json!({
1533            "type": "object",
1534            "properties": {
1535                "replicas": {
1536                    "type": "integer",
1537                    "default": 1,
1538                    "x-kubernetes-validations": [
1539                        {"rule": "self >= 0", "message": "must be non-negative"}
1540                    ]
1541                }
1542            }
1543        });
1544        // Without defaults, replicas is missing -> no validation runs
1545        let errors = validate(&schema, &json!({}), None);
1546        assert!(errors.is_empty());
1547
1548        // With defaults, replicas=1 is injected -> validation runs and passes
1549        let errors = Validator::new().validate_with_defaults(&schema, &json!({}), None);
1550        assert!(errors.is_empty());
1551    }
1552
1553    #[test]
1554    fn validate_with_defaults_and_context_combined() {
1555        let schema = json!({
1556            "type": "object",
1557            "properties": {
1558                "replicas": {
1559                    "type": "integer",
1560                    "default": 1,
1561                    "x-kubernetes-validations": [
1562                        {"rule": "self >= 0", "message": "must be non-negative"}
1563                    ]
1564                }
1565            },
1566            "x-kubernetes-validations": [
1567                {"rule": "kind == 'Deployment'", "message": "wrong kind"}
1568            ]
1569        });
1570        let root_ctx = RootContext {
1571            api_version: "apps/v1".into(),
1572            api_group: "apps".into(),
1573            kind: "Deployment".into(),
1574        };
1575        // Empty object: defaults fill replicas=1, root context provides kind
1576        let errors =
1577            Validator::new().validate_with_defaults_and_context(&schema, &json!({}), None, Some(&root_ctx));
1578        assert!(errors.is_empty());
1579    }
1580
1581    #[test]
1582    fn validation_error_serializable() {
1583        let err = ValidationError {
1584            rule: "self.x >= 0".into(),
1585            message: "must be non-negative".into(),
1586            field_path: "spec.x".into(),
1587            reason: Some("FieldValueInvalid".into()),
1588            kind: ErrorKind::ValidationFailure,
1589            source: None,
1590        };
1591        let json = serde_json::to_value(&err).unwrap();
1592        assert_eq!(json["rule"], "self.x >= 0");
1593        assert_eq!(json["field_path"], "spec.x");
1594        assert_eq!(json["kind"], "ValidationFailure");
1595    }
1596}