1use std::collections::HashMap;
7
8use cel::Program;
9
10use crate::validation::values::SchemaFormat;
11
12#[derive(Clone, Debug, serde::Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct Rule {
16 pub rule: String,
18 #[serde(default)]
20 pub message: Option<String>,
21 #[serde(default)]
23 pub message_expression: Option<String>,
24 #[serde(default)]
26 pub reason: Option<String>,
27 #[serde(default)]
29 pub field_path: Option<String>,
30 #[serde(default)]
33 pub optional_old_self: Option<bool>,
34}
35
36#[derive(Debug)]
41#[non_exhaustive]
42pub struct CompilationResult {
43 pub program: Program,
45 pub rule: Rule,
47 pub is_transition_rule: bool,
49 pub message_program: Option<Program>,
53}
54
55#[derive(Debug)]
57#[non_exhaustive]
58pub enum CompilationError {
59 Parse {
61 rule: String,
63 source: Box<dyn std::error::Error + Send + Sync>,
68 },
69 MessageExpressionParse {
74 rule: String,
76 message_expression: String,
78 source: Box<dyn std::error::Error + Send + Sync>,
83 },
84 InvalidRule(serde_json::Error),
86 SchemaTooDeep {
90 depth: usize,
92 },
93}
94
95impl std::fmt::Display for CompilationError {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 CompilationError::Parse { rule, source } => {
99 write!(f, "failed to compile CEL rule \"{rule}\": {source}")
100 }
101 CompilationError::MessageExpressionParse {
102 rule,
103 message_expression,
104 source,
105 } => {
106 write!(
107 f,
108 "failed to compile messageExpression \"{message_expression}\" for rule \"{rule}\": {source}"
109 )
110 }
111 CompilationError::InvalidRule(err) => {
112 write!(f, "invalid rule definition: {err}")
113 }
114 CompilationError::SchemaTooDeep { depth } => {
115 write!(
116 f,
117 "schema nesting depth {depth} exceeds the maximum of {MAX_SCHEMA_DEPTH}"
118 )
119 }
120 }
121 }
122}
123
124impl std::error::Error for CompilationError {
125 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
126 match self {
127 CompilationError::Parse { source, .. } => Some(source.as_ref()),
128 CompilationError::MessageExpressionParse { source, .. } => Some(source.as_ref()),
129 CompilationError::InvalidRule(err) => Some(err),
130 CompilationError::SchemaTooDeep { .. } => None,
131 }
132 }
133}
134
135pub(crate) fn compile_rule(rule: &Rule) -> Result<CompilationResult, CompilationError> {
140 let program = Program::compile(&rule.rule).map_err(|e| CompilationError::Parse {
141 rule: rule.rule.clone(),
142 source: Box::new(e),
143 })?;
144 let is_transition_rule = program.references().has_variable("oldSelf");
145
146 let message_program = match rule.message_expression.as_deref() {
150 Some(expr) => Some(
151 Program::compile(expr).map_err(|e| CompilationError::MessageExpressionParse {
152 rule: rule.rule.clone(),
153 message_expression: expr.to_string(),
154 source: Box::new(e),
155 })?,
156 ),
157 None => None,
158 };
159
160 Ok(CompilationResult {
161 program,
162 rule: rule.clone(),
163 is_transition_rule,
164 message_program,
165 })
166}
167
168pub(crate) fn compile_schema_validations(
174 schema: &serde_json::Value,
175) -> Vec<Result<CompilationResult, CompilationError>> {
176 let rules = match schema.get("x-kubernetes-validations") {
177 Some(serde_json::Value::Array(arr)) => arr,
178 _ => return Vec::new(),
179 };
180
181 rules
182 .iter()
183 .map(|raw| {
184 let rule: Rule = serde_json::from_value(raw.clone()).map_err(CompilationError::InvalidRule)?;
185 compile_rule(&rule)
186 })
187 .collect()
188}
189
190#[derive(Debug)]
202#[non_exhaustive]
203pub struct CompiledSchema {
204 pub validations: Vec<Result<CompilationResult, CompilationError>>,
206 pub properties: HashMap<String, CompiledSchema>,
208 pub items: Option<Box<CompiledSchema>>,
210 pub additional_properties: Option<Box<CompiledSchema>>,
212 pub is_map: bool,
217 pub format: SchemaFormat,
219 pub all_of: Vec<CompiledSchema>,
221 pub one_of: Vec<CompiledSchema>,
223 pub any_of: Vec<CompiledSchema>,
225 pub max_length: Option<u64>,
227 pub max_items: Option<u64>,
229 pub max_properties: Option<u64>,
231 pub preserve_unknown_fields: bool,
234 pub embedded_resource: bool,
238 pub default: Option<serde_json::Value>,
242}
243
244impl CompiledSchema {
245 #[must_use]
247 pub fn compilation_errors(&self) -> Vec<&CompilationError> {
248 self.validations.iter().filter_map(|r| r.as_ref().err()).collect()
249 }
250
251 #[must_use]
253 pub fn has_errors(&self) -> bool {
254 self.validations.iter().any(|r| r.is_err())
255 }
256}
257
258pub(crate) const MAX_SCHEMA_DEPTH: usize = 64;
263
264fn compile_schema_array(schema: &serde_json::Value, key: &str, depth: usize) -> Vec<CompiledSchema> {
265 schema
266 .get(key)
267 .and_then(|v| v.as_array())
268 .map(|arr| arr.iter().map(|s| compile_schema_inner(s, depth)).collect())
269 .unwrap_or_default()
270}
271
272#[must_use]
277pub fn compile_schema(schema: &serde_json::Value) -> CompiledSchema {
278 compile_schema_inner(schema, 0)
279}
280
281fn compile_schema_inner(schema: &serde_json::Value, depth: usize) -> CompiledSchema {
282 if depth > MAX_SCHEMA_DEPTH {
283 return CompiledSchema {
287 validations: vec![Err(CompilationError::SchemaTooDeep { depth })],
288 properties: HashMap::new(),
289 items: None,
290 additional_properties: None,
291 is_map: false,
292 format: SchemaFormat::None,
293 all_of: Vec::new(),
294 one_of: Vec::new(),
295 any_of: Vec::new(),
296 max_length: None,
297 max_items: None,
298 max_properties: None,
299 preserve_unknown_fields: false,
300 embedded_resource: false,
301 default: None,
302 };
303 }
304
305 let validations = compile_schema_validations(schema);
306
307 let mut properties = HashMap::new();
308 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
309 for (name, prop_schema) in props {
310 properties.insert(name.clone(), compile_schema_inner(prop_schema, depth + 1));
311 }
312 }
313
314 let items = schema
315 .get("items")
316 .map(|s| Box::new(compile_schema_inner(s, depth + 1)));
317
318 let additional = schema.get("additionalProperties");
319 let additional_properties = additional
320 .filter(|a| a.is_object())
321 .map(|s| Box::new(compile_schema_inner(s, depth + 1)));
322 let is_map = !matches!(additional, None | Some(serde_json::Value::Bool(false)));
325
326 let format = SchemaFormat::from_schema(schema);
327
328 let all_of = compile_schema_array(schema, "allOf", depth + 1);
329 let one_of = compile_schema_array(schema, "oneOf", depth + 1);
330 let any_of = compile_schema_array(schema, "anyOf", depth + 1);
331
332 let max_length = schema.get("maxLength").and_then(|v| v.as_u64());
333 let max_items = schema.get("maxItems").and_then(|v| v.as_u64());
334 let max_properties = schema.get("maxProperties").and_then(|v| v.as_u64());
335
336 let preserve_unknown_fields = schema
337 .get("x-kubernetes-preserve-unknown-fields")
338 .and_then(|v| v.as_bool())
339 == Some(true);
340
341 let embedded_resource = schema
342 .get("x-kubernetes-embedded-resource")
343 .and_then(|v| v.as_bool())
344 == Some(true);
345
346 let default = schema.get("default").cloned();
347
348 CompiledSchema {
349 validations,
350 properties,
351 items,
352 additional_properties,
353 is_map,
354 format,
355 all_of,
356 one_of,
357 any_of,
358 max_length,
359 max_items,
360 max_properties,
361 preserve_unknown_fields,
362 embedded_resource,
363 default,
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use serde_json::json;
371
372 #[test]
373 fn compile_simple_rule() {
374 let rule = Rule {
375 rule: "self.replicas >= 0".into(),
376 message: None,
377 message_expression: None,
378 reason: None,
379 field_path: None,
380 optional_old_self: None,
381 };
382 let result = compile_rule(&rule).unwrap();
383 assert!(!result.is_transition_rule);
384 }
385
386 #[test]
387 fn detect_transition_rule() {
388 let rule = Rule {
389 rule: "self.replicas >= oldSelf.replicas".into(),
390 message: None,
391 message_expression: None,
392 reason: None,
393 field_path: None,
394 optional_old_self: None,
395 };
396 let result = compile_rule(&rule).unwrap();
397 assert!(result.is_transition_rule);
398 }
399
400 #[test]
401 fn detect_non_transition_rule() {
402 let rule = Rule {
403 rule: "self.replicas > 0".into(),
404 message: None,
405 message_expression: None,
406 reason: None,
407 field_path: None,
408 optional_old_self: None,
409 };
410 let result = compile_rule(&rule).unwrap();
411 assert!(!result.is_transition_rule);
412 }
413
414 #[test]
415 fn parse_error_on_invalid_cel() {
416 let rule = Rule {
417 rule: "self.replicas >=".into(),
418 message: None,
419 message_expression: None,
420 reason: None,
421 field_path: None,
422 optional_old_self: None,
423 };
424 let err = compile_rule(&rule).unwrap_err();
425 assert!(matches!(err, CompilationError::Parse { .. }));
426 let msg = err.to_string();
428 assert!(msg.contains("self.replicas >="));
429 }
430
431 #[test]
432 fn deserialize_rule_all_fields() {
433 let raw = json!({
434 "rule": "self.x > 0",
435 "message": "x must be positive",
436 "messageExpression": "\"x is \" + string(self.x)",
437 "reason": "FieldValueInvalid",
438 "fieldPath": ".spec.x",
439 "optionalOldSelf": true
440 });
441 let rule: Rule = serde_json::from_value(raw).unwrap();
442 assert_eq!(rule.rule, "self.x > 0");
443 assert_eq!(rule.message.as_deref(), Some("x must be positive"));
444 assert_eq!(
445 rule.message_expression.as_deref(),
446 Some("\"x is \" + string(self.x)")
447 );
448 assert_eq!(rule.reason.as_deref(), Some("FieldValueInvalid"));
449 assert_eq!(rule.field_path.as_deref(), Some(".spec.x"));
450 assert_eq!(rule.optional_old_self, Some(true));
451 }
452
453 #[test]
454 fn deserialize_rule_minimal() {
455 let raw = json!({"rule": "self.x > 0"});
456 let rule: Rule = serde_json::from_value(raw).unwrap();
457 assert_eq!(rule.rule, "self.x > 0");
458 assert!(rule.message.is_none());
459 assert!(rule.message_expression.is_none());
460 assert!(rule.reason.is_none());
461 assert!(rule.field_path.is_none());
462 assert!(rule.optional_old_self.is_none());
463 }
464
465 #[test]
466 fn schema_validations_extracts_and_compiles() {
467 let schema = json!({
468 "type": "object",
469 "x-kubernetes-validations": [
470 {"rule": "self.replicas >= 0", "message": "must be non-negative"},
471 {"rule": "self.name.size() > 0"}
472 ]
473 });
474 let results = compile_schema_validations(&schema);
475 assert_eq!(results.len(), 2);
476 assert!(results[0].is_ok());
477 assert!(results[1].is_ok());
478 }
479
480 #[test]
481 fn schema_validations_no_key() {
482 let schema = json!({"type": "object"});
483 let results = compile_schema_validations(&schema);
484 assert!(results.is_empty());
485 }
486
487 #[test]
488 fn schema_validations_empty_array() {
489 let schema = json!({
490 "x-kubernetes-validations": []
491 });
492 let results = compile_schema_validations(&schema);
493 assert!(results.is_empty());
494 }
495
496 #[test]
497 fn message_expression_compiled() {
498 let rule = Rule {
499 rule: "self.x > 0".into(),
500 message: Some("x must be positive".into()),
501 message_expression: Some("'x is ' + string(self.x)".into()),
502 reason: None,
503 field_path: None,
504 optional_old_self: None,
505 };
506 let result = compile_rule(&rule).unwrap();
507 assert!(result.message_program.is_some());
508 }
509
510 #[test]
511 fn message_expression_invalid_rejected() {
512 let rule = Rule {
513 rule: "self.x > 0".into(),
514 message: Some("fallback".into()),
515 message_expression: Some("invalid >=".into()),
516 reason: None,
517 field_path: None,
518 optional_old_self: None,
519 };
520 let err = compile_rule(&rule).unwrap_err();
525 assert!(
526 matches!(err, CompilationError::MessageExpressionParse { .. }),
527 "expected MessageExpressionParse, got {err:?}"
528 );
529 }
530
531 #[test]
532 fn message_expression_none() {
533 let rule = Rule {
534 rule: "self.x > 0".into(),
535 message: None,
536 message_expression: None,
537 reason: None,
538 field_path: None,
539 optional_old_self: None,
540 };
541 let result = compile_rule(&rule).unwrap();
542 assert!(result.message_program.is_none());
543 }
544
545 #[test]
546 fn compile_schema_tree() {
547 let schema = json!({
548 "type": "object",
549 "x-kubernetes-validations": [{"rule": "has(self.spec)"}],
550 "properties": {
551 "spec": {
552 "type": "object",
553 "x-kubernetes-validations": [{"rule": "self.replicas >= 0"}],
554 "properties": {
555 "replicas": {"type": "integer"}
556 }
557 }
558 }
559 });
560 let compiled = compile_schema(&schema);
561 assert_eq!(compiled.validations.len(), 1);
562 assert!(compiled.properties.contains_key("spec"));
563 let spec = &compiled.properties["spec"];
564 assert_eq!(spec.validations.len(), 1);
565 assert!(spec.properties.contains_key("replicas"));
566 }
567
568 #[test]
569 fn compile_schema_with_items() {
570 let schema = json!({
571 "type": "array",
572 "items": {
573 "type": "object",
574 "x-kubernetes-validations": [{"rule": "self.name.size() > 0"}]
575 }
576 });
577 let compiled = compile_schema(&schema);
578 assert!(compiled.items.is_some());
579 assert_eq!(compiled.items.as_ref().unwrap().validations.len(), 1);
580 }
581
582 #[test]
583 fn compile_schema_empty() {
584 let schema = json!({"type": "object"});
585 let compiled = compile_schema(&schema);
586 assert!(compiled.validations.is_empty());
587 assert!(compiled.properties.is_empty());
588 assert!(compiled.items.is_none());
589 assert!(compiled.additional_properties.is_none());
590 }
591
592 #[test]
593 fn schema_validations_partial_errors() {
594 let schema = json!({
595 "x-kubernetes-validations": [
596 {"rule": "self.x > 0"},
597 {"rule": "self.y >="},
598 {"rule": "self.z == true"}
599 ]
600 });
601 let results = compile_schema_validations(&schema);
602 assert_eq!(results.len(), 3);
603 assert!(results[0].is_ok());
604 assert!(results[1].is_err());
605 assert!(results[2].is_ok());
606 }
607
608 #[test]
609 fn compilation_errors_method() {
610 let schema = json!({
611 "x-kubernetes-validations": [
612 {"rule": "self.x > 0"},
613 {"rule": "self.y >="},
614 {"rule": "self.z == true"}
615 ]
616 });
617 let compiled = compile_schema(&schema);
618 let errors = compiled.compilation_errors();
619 assert_eq!(errors.len(), 1);
620 assert!(matches!(errors[0], CompilationError::Parse { .. }));
621 assert!(compiled.has_errors());
622 }
623
624 #[test]
625 fn compilation_errors_empty_when_all_valid() {
626 let schema = json!({
627 "x-kubernetes-validations": [
628 {"rule": "self.x > 0"},
629 {"rule": "self.z == true"}
630 ]
631 });
632 let compiled = compile_schema(&schema);
633 assert!(compiled.compilation_errors().is_empty());
634 assert!(!compiled.has_errors());
635 }
636}
637
638#[cfg(test)]
642mod end_to_end_tests {
643 use cel::{Context, Value};
644 use serde_json::json;
645
646 use super::{CompilationError, compile_schema};
647 use crate::{register_all, validation::values::json_to_cel};
648
649 fn compile_and_eval_first(schema: serde_json::Value, self_val: serde_json::Value) -> Value {
651 let compiled = compile_schema(&schema);
652 let cr = compiled.validations.into_iter().next().unwrap().unwrap();
653
654 let mut ctx = Context::default();
655 register_all(&mut ctx);
656 ctx.add_variable_from_value("self", json_to_cel(&self_val));
657 cr.program.execute(&ctx).unwrap()
658 }
659
660 #[test]
661 fn crd_schema_end_to_end() {
662 let schema = json!({
663 "type": "object",
664 "properties": {
665 "spec": {
666 "type": "object",
667 "properties": {
668 "replicas": {"type": "integer"},
669 "minReplicas": {"type": "integer"}
670 },
671 "x-kubernetes-validations": [
672 {"rule": "self.replicas >= self.minReplicas", "message": "replicas must be >= minReplicas"}
673 ]
674 }
675 }
676 });
677
678 let spec_schema = &schema["properties"]["spec"];
679 let self_val = json!({"replicas": 5, "minReplicas": 2});
680
681 let spec_compiled = compile_schema(spec_schema);
682 assert_eq!(spec_compiled.validations.len(), 1);
683 let compiled = spec_compiled.validations.into_iter().next().unwrap().unwrap();
684
685 assert!(!compiled.is_transition_rule);
686 assert_eq!(
687 compiled.rule.message.as_deref(),
688 Some("replicas must be >= minReplicas")
689 );
690
691 let mut ctx = Context::default();
692 register_all(&mut ctx);
693 ctx.add_variable_from_value("self", json_to_cel(&self_val));
694 assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
695 }
696
697 #[test]
698 fn compile_and_eval_with_json_to_cel() {
699 let schema = json!({
700 "x-kubernetes-validations": [{"rule": "self.name.size() > 0", "message": "name required"}]
701 });
702 let result = compile_and_eval_first(schema, json!({"name": "my-app"}));
703 assert_eq!(result, Value::Bool(true));
704 }
705
706 #[test]
707 fn transition_rule_compile_and_eval() {
708 let schema = json!({
709 "x-kubernetes-validations": [
710 {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down", "reason": "FieldValueForbidden"}
711 ]
712 });
713
714 let compiled_schema = compile_schema(&schema);
715 let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
716
717 assert!(compiled.is_transition_rule);
718 assert_eq!(compiled.rule.message.as_deref(), Some("cannot scale down"));
719 assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueForbidden"));
720
721 let mut ctx = Context::default();
722 register_all(&mut ctx);
723 ctx.add_variable_from_value("self", json_to_cel(&json!({"replicas": 5})));
724 ctx.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
725 assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
726
727 let mut ctx2 = Context::default();
728 register_all(&mut ctx2);
729 ctx2.add_variable_from_value("self", json_to_cel(&json!({"replicas": 1})));
730 ctx2.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
731 assert_eq!(compiled.program.execute(&ctx2).unwrap(), Value::Bool(false));
732 }
733
734 #[test]
735 fn message_and_reason_preserved() {
736 let schema = json!({
737 "x-kubernetes-validations": [{
738 "rule": "self.x > 0",
739 "message": "x must be positive",
740 "messageExpression": "\"x is \" + string(self.x)",
741 "reason": "FieldValueInvalid",
742 "fieldPath": ".spec.x"
743 }]
744 });
745
746 let compiled_schema = compile_schema(&schema);
747 let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
748
749 assert_eq!(compiled.rule.message.as_deref(), Some("x must be positive"));
750 assert_eq!(
751 compiled.rule.message_expression.as_deref(),
752 Some("\"x is \" + string(self.x)")
753 );
754 assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueInvalid"));
755 assert_eq!(compiled.rule.field_path.as_deref(), Some(".spec.x"));
756 }
757
758 #[test]
759 fn multiple_rules_mixed_results() {
760 let schema = json!({
761 "x-kubernetes-validations": [
762 {"rule": "self.a > 0"},
763 {"rule": "invalid >="},
764 {"rule": "self.b == true"}
765 ]
766 });
767
768 let compiled = compile_schema(&schema);
769 assert_eq!(compiled.validations.len(), 3);
770
771 let cr = compiled.validations[0].as_ref().unwrap();
772 let mut ctx = Context::default();
773 register_all(&mut ctx);
774 ctx.add_variable_from_value("self", json_to_cel(&json!({"a": 5})));
775 assert_eq!(cr.program.execute(&ctx).unwrap(), Value::Bool(true));
776
777 assert!(matches!(
778 compiled.validations[1].as_ref().unwrap_err(),
779 CompilationError::Parse { .. }
780 ));
781
782 assert!(compiled.validations[2].is_ok());
783 }
784
785 #[test]
786 fn realistic_crd_with_multiple_validation_levels() {
787 let crd_schema = json!({
788 "type": "object",
789 "properties": {
790 "spec": {
791 "type": "object",
792 "properties": {
793 "replicas": {"type": "integer"},
794 "template": {
795 "type": "object",
796 "properties": {"name": {"type": "string"}},
797 "x-kubernetes-validations": [
798 {"rule": "self.name.size() > 0", "message": "template name required"}
799 ]
800 }
801 },
802 "x-kubernetes-validations": [
803 {"rule": "self.replicas >= 1", "message": "at least one replica"}
804 ]
805 }
806 }
807 });
808
809 let spec_compiled = compile_schema(&crd_schema["properties"]["spec"]);
810 assert_eq!(spec_compiled.validations.len(), 1);
811 let spec_cr = spec_compiled.validations.into_iter().next().unwrap().unwrap();
812
813 let mut ctx = Context::default();
814 register_all(&mut ctx);
815 ctx.add_variable_from_value(
816 "self",
817 json_to_cel(&json!({"replicas": 3, "template": {"name": "web"}})),
818 );
819 assert_eq!(spec_cr.program.execute(&ctx).unwrap(), Value::Bool(true));
820
821 let tmpl_compiled = compile_schema(&crd_schema["properties"]["spec"]["properties"]["template"]);
822 assert_eq!(tmpl_compiled.validations.len(), 1);
823 let tmpl_cr = tmpl_compiled.validations.into_iter().next().unwrap().unwrap();
824
825 let mut ctx2 = Context::default();
826 register_all(&mut ctx2);
827 ctx2.add_variable_from_value("self", json_to_cel(&json!({"name": "web"})));
828 assert_eq!(tmpl_cr.program.execute(&ctx2).unwrap(), Value::Bool(true));
829
830 let mut ctx3 = Context::default();
831 register_all(&mut ctx3);
832 ctx3.add_variable_from_value("self", json_to_cel(&json!({"name": ""})));
833 assert_eq!(tmpl_cr.program.execute(&ctx3).unwrap(), Value::Bool(false));
834 }
835
836 #[test]
837 #[cfg(feature = "strings")]
838 fn compiled_rule_with_extension_functions() {
839 let schema = json!({
840 "x-kubernetes-validations": [{"rule": "self.name.trim().lowerAscii().size() > 0"}]
841 });
842 let result = compile_and_eval_first(schema, json!({"name": " Hello "}));
843 assert_eq!(result, Value::Bool(true));
844 }
845}