1pub(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#[derive(Clone, Debug, Default)]
27pub struct RootContext {
28 pub api_version: String,
30 pub api_group: String,
32 pub kind: String,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38#[non_exhaustive]
39pub enum ErrorKind {
40 CompilationFailure,
42 InvalidRule,
44 ValidationFailure,
46 InvalidResult,
48 EvaluationError,
50 UnsupportedReference,
58 SchemaTooDeep,
62}
63
64#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
70#[non_exhaustive]
71pub struct ValidationError {
72 pub rule: String,
74 pub message: String,
76 pub field_path: String,
78 pub reason: Option<String>,
80 pub kind: ErrorKind,
82 #[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
108impl 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
130fn 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
148pub 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 pub fn new() -> Self {
172 let mut ctx = Context::default();
173 crate::register_all(&mut ctx);
174 Self { base_ctx: ctx }
175 }
176
177 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 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 if cr.is_transition_rule && cel_old.is_none() && cr.rule.optional_old_self != Some(true) {
636 return; }
638
639 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 }
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 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 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#[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#[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#[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 #[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 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 let obj = json!({"replicas": 1, "name": "app"});
1090 let errors = validate(&schema, &obj, None);
1091 assert!(errors.is_empty()); }
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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 let errors = validate(&schema, &json!({}), None);
1546 assert!(errors.is_empty());
1547
1548 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 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}