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 format: SchemaFormat,
214 pub all_of: Vec<CompiledSchema>,
216 pub one_of: Vec<CompiledSchema>,
218 pub any_of: Vec<CompiledSchema>,
220 pub max_length: Option<u64>,
222 pub max_items: Option<u64>,
224 pub max_properties: Option<u64>,
226 pub preserve_unknown_fields: bool,
229 pub embedded_resource: bool,
233}
234
235impl CompiledSchema {
236 #[must_use]
238 pub fn compilation_errors(&self) -> Vec<&CompilationError> {
239 self.validations.iter().filter_map(|r| r.as_ref().err()).collect()
240 }
241
242 #[must_use]
244 pub fn has_errors(&self) -> bool {
245 self.validations.iter().any(|r| r.is_err())
246 }
247}
248
249pub(crate) const MAX_SCHEMA_DEPTH: usize = 64;
254
255fn compile_schema_array(schema: &serde_json::Value, key: &str, depth: usize) -> Vec<CompiledSchema> {
256 schema
257 .get(key)
258 .and_then(|v| v.as_array())
259 .map(|arr| arr.iter().map(|s| compile_schema_inner(s, depth)).collect())
260 .unwrap_or_default()
261}
262
263#[must_use]
268pub fn compile_schema(schema: &serde_json::Value) -> CompiledSchema {
269 compile_schema_inner(schema, 0)
270}
271
272fn compile_schema_inner(schema: &serde_json::Value, depth: usize) -> CompiledSchema {
273 if depth > MAX_SCHEMA_DEPTH {
274 return CompiledSchema {
278 validations: vec![Err(CompilationError::SchemaTooDeep { depth })],
279 properties: HashMap::new(),
280 items: None,
281 additional_properties: None,
282 format: SchemaFormat::None,
283 all_of: Vec::new(),
284 one_of: Vec::new(),
285 any_of: Vec::new(),
286 max_length: None,
287 max_items: None,
288 max_properties: None,
289 preserve_unknown_fields: false,
290 embedded_resource: false,
291 };
292 }
293
294 let validations = compile_schema_validations(schema);
295
296 let mut properties = HashMap::new();
297 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
298 for (name, prop_schema) in props {
299 properties.insert(name.clone(), compile_schema_inner(prop_schema, depth + 1));
300 }
301 }
302
303 let items = schema
304 .get("items")
305 .map(|s| Box::new(compile_schema_inner(s, depth + 1)));
306
307 let additional_properties = schema
308 .get("additionalProperties")
309 .filter(|a| a.is_object())
310 .map(|s| Box::new(compile_schema_inner(s, depth + 1)));
311
312 let format = SchemaFormat::from_schema(schema);
313
314 let all_of = compile_schema_array(schema, "allOf", depth + 1);
315 let one_of = compile_schema_array(schema, "oneOf", depth + 1);
316 let any_of = compile_schema_array(schema, "anyOf", depth + 1);
317
318 let max_length = schema.get("maxLength").and_then(|v| v.as_u64());
319 let max_items = schema.get("maxItems").and_then(|v| v.as_u64());
320 let max_properties = schema.get("maxProperties").and_then(|v| v.as_u64());
321
322 let preserve_unknown_fields = schema
323 .get("x-kubernetes-preserve-unknown-fields")
324 .and_then(|v| v.as_bool())
325 == Some(true);
326
327 let embedded_resource = schema
328 .get("x-kubernetes-embedded-resource")
329 .and_then(|v| v.as_bool())
330 == Some(true);
331
332 CompiledSchema {
333 validations,
334 properties,
335 items,
336 additional_properties,
337 format,
338 all_of,
339 one_of,
340 any_of,
341 max_length,
342 max_items,
343 max_properties,
344 preserve_unknown_fields,
345 embedded_resource,
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use serde_json::json;
353
354 #[test]
355 fn compile_simple_rule() {
356 let rule = Rule {
357 rule: "self.replicas >= 0".into(),
358 message: None,
359 message_expression: None,
360 reason: None,
361 field_path: None,
362 optional_old_self: None,
363 };
364 let result = compile_rule(&rule).unwrap();
365 assert!(!result.is_transition_rule);
366 }
367
368 #[test]
369 fn detect_transition_rule() {
370 let rule = Rule {
371 rule: "self.replicas >= oldSelf.replicas".into(),
372 message: None,
373 message_expression: None,
374 reason: None,
375 field_path: None,
376 optional_old_self: None,
377 };
378 let result = compile_rule(&rule).unwrap();
379 assert!(result.is_transition_rule);
380 }
381
382 #[test]
383 fn detect_non_transition_rule() {
384 let rule = Rule {
385 rule: "self.replicas > 0".into(),
386 message: None,
387 message_expression: None,
388 reason: None,
389 field_path: None,
390 optional_old_self: None,
391 };
392 let result = compile_rule(&rule).unwrap();
393 assert!(!result.is_transition_rule);
394 }
395
396 #[test]
397 fn parse_error_on_invalid_cel() {
398 let rule = Rule {
399 rule: "self.replicas >=".into(),
400 message: None,
401 message_expression: None,
402 reason: None,
403 field_path: None,
404 optional_old_self: None,
405 };
406 let err = compile_rule(&rule).unwrap_err();
407 assert!(matches!(err, CompilationError::Parse { .. }));
408 let msg = err.to_string();
410 assert!(msg.contains("self.replicas >="));
411 }
412
413 #[test]
414 fn deserialize_rule_all_fields() {
415 let raw = json!({
416 "rule": "self.x > 0",
417 "message": "x must be positive",
418 "messageExpression": "\"x is \" + string(self.x)",
419 "reason": "FieldValueInvalid",
420 "fieldPath": ".spec.x",
421 "optionalOldSelf": true
422 });
423 let rule: Rule = serde_json::from_value(raw).unwrap();
424 assert_eq!(rule.rule, "self.x > 0");
425 assert_eq!(rule.message.as_deref(), Some("x must be positive"));
426 assert_eq!(
427 rule.message_expression.as_deref(),
428 Some("\"x is \" + string(self.x)")
429 );
430 assert_eq!(rule.reason.as_deref(), Some("FieldValueInvalid"));
431 assert_eq!(rule.field_path.as_deref(), Some(".spec.x"));
432 assert_eq!(rule.optional_old_self, Some(true));
433 }
434
435 #[test]
436 fn deserialize_rule_minimal() {
437 let raw = json!({"rule": "self.x > 0"});
438 let rule: Rule = serde_json::from_value(raw).unwrap();
439 assert_eq!(rule.rule, "self.x > 0");
440 assert!(rule.message.is_none());
441 assert!(rule.message_expression.is_none());
442 assert!(rule.reason.is_none());
443 assert!(rule.field_path.is_none());
444 assert!(rule.optional_old_self.is_none());
445 }
446
447 #[test]
448 fn schema_validations_extracts_and_compiles() {
449 let schema = json!({
450 "type": "object",
451 "x-kubernetes-validations": [
452 {"rule": "self.replicas >= 0", "message": "must be non-negative"},
453 {"rule": "self.name.size() > 0"}
454 ]
455 });
456 let results = compile_schema_validations(&schema);
457 assert_eq!(results.len(), 2);
458 assert!(results[0].is_ok());
459 assert!(results[1].is_ok());
460 }
461
462 #[test]
463 fn schema_validations_no_key() {
464 let schema = json!({"type": "object"});
465 let results = compile_schema_validations(&schema);
466 assert!(results.is_empty());
467 }
468
469 #[test]
470 fn schema_validations_empty_array() {
471 let schema = json!({
472 "x-kubernetes-validations": []
473 });
474 let results = compile_schema_validations(&schema);
475 assert!(results.is_empty());
476 }
477
478 #[test]
479 fn message_expression_compiled() {
480 let rule = Rule {
481 rule: "self.x > 0".into(),
482 message: Some("x must be positive".into()),
483 message_expression: Some("'x is ' + string(self.x)".into()),
484 reason: None,
485 field_path: None,
486 optional_old_self: None,
487 };
488 let result = compile_rule(&rule).unwrap();
489 assert!(result.message_program.is_some());
490 }
491
492 #[test]
493 fn message_expression_invalid_rejected() {
494 let rule = Rule {
495 rule: "self.x > 0".into(),
496 message: Some("fallback".into()),
497 message_expression: Some("invalid >=".into()),
498 reason: None,
499 field_path: None,
500 optional_old_self: None,
501 };
502 let err = compile_rule(&rule).unwrap_err();
507 assert!(
508 matches!(err, CompilationError::MessageExpressionParse { .. }),
509 "expected MessageExpressionParse, got {err:?}"
510 );
511 }
512
513 #[test]
514 fn message_expression_none() {
515 let rule = Rule {
516 rule: "self.x > 0".into(),
517 message: None,
518 message_expression: None,
519 reason: None,
520 field_path: None,
521 optional_old_self: None,
522 };
523 let result = compile_rule(&rule).unwrap();
524 assert!(result.message_program.is_none());
525 }
526
527 #[test]
528 fn compile_schema_tree() {
529 let schema = json!({
530 "type": "object",
531 "x-kubernetes-validations": [{"rule": "has(self.spec)"}],
532 "properties": {
533 "spec": {
534 "type": "object",
535 "x-kubernetes-validations": [{"rule": "self.replicas >= 0"}],
536 "properties": {
537 "replicas": {"type": "integer"}
538 }
539 }
540 }
541 });
542 let compiled = compile_schema(&schema);
543 assert_eq!(compiled.validations.len(), 1);
544 assert!(compiled.properties.contains_key("spec"));
545 let spec = &compiled.properties["spec"];
546 assert_eq!(spec.validations.len(), 1);
547 assert!(spec.properties.contains_key("replicas"));
548 }
549
550 #[test]
551 fn compile_schema_with_items() {
552 let schema = json!({
553 "type": "array",
554 "items": {
555 "type": "object",
556 "x-kubernetes-validations": [{"rule": "self.name.size() > 0"}]
557 }
558 });
559 let compiled = compile_schema(&schema);
560 assert!(compiled.items.is_some());
561 assert_eq!(compiled.items.as_ref().unwrap().validations.len(), 1);
562 }
563
564 #[test]
565 fn compile_schema_empty() {
566 let schema = json!({"type": "object"});
567 let compiled = compile_schema(&schema);
568 assert!(compiled.validations.is_empty());
569 assert!(compiled.properties.is_empty());
570 assert!(compiled.items.is_none());
571 assert!(compiled.additional_properties.is_none());
572 }
573
574 #[test]
575 fn schema_validations_partial_errors() {
576 let schema = json!({
577 "x-kubernetes-validations": [
578 {"rule": "self.x > 0"},
579 {"rule": "self.y >="},
580 {"rule": "self.z == true"}
581 ]
582 });
583 let results = compile_schema_validations(&schema);
584 assert_eq!(results.len(), 3);
585 assert!(results[0].is_ok());
586 assert!(results[1].is_err());
587 assert!(results[2].is_ok());
588 }
589
590 #[test]
591 fn compilation_errors_method() {
592 let schema = json!({
593 "x-kubernetes-validations": [
594 {"rule": "self.x > 0"},
595 {"rule": "self.y >="},
596 {"rule": "self.z == true"}
597 ]
598 });
599 let compiled = compile_schema(&schema);
600 let errors = compiled.compilation_errors();
601 assert_eq!(errors.len(), 1);
602 assert!(matches!(errors[0], CompilationError::Parse { .. }));
603 assert!(compiled.has_errors());
604 }
605
606 #[test]
607 fn compilation_errors_empty_when_all_valid() {
608 let schema = json!({
609 "x-kubernetes-validations": [
610 {"rule": "self.x > 0"},
611 {"rule": "self.z == true"}
612 ]
613 });
614 let compiled = compile_schema(&schema);
615 assert!(compiled.compilation_errors().is_empty());
616 assert!(!compiled.has_errors());
617 }
618}
619
620#[cfg(test)]
624mod end_to_end_tests {
625 use cel::{Context, Value};
626 use serde_json::json;
627
628 use super::{CompilationError, compile_schema};
629 use crate::{register_all, validation::values::json_to_cel};
630
631 fn compile_and_eval_first(schema: serde_json::Value, self_val: serde_json::Value) -> Value {
633 let compiled = compile_schema(&schema);
634 let cr = compiled.validations.into_iter().next().unwrap().unwrap();
635
636 let mut ctx = Context::default();
637 register_all(&mut ctx);
638 ctx.add_variable_from_value("self", json_to_cel(&self_val));
639 cr.program.execute(&ctx).unwrap()
640 }
641
642 #[test]
643 fn crd_schema_end_to_end() {
644 let schema = json!({
645 "type": "object",
646 "properties": {
647 "spec": {
648 "type": "object",
649 "properties": {
650 "replicas": {"type": "integer"},
651 "minReplicas": {"type": "integer"}
652 },
653 "x-kubernetes-validations": [
654 {"rule": "self.replicas >= self.minReplicas", "message": "replicas must be >= minReplicas"}
655 ]
656 }
657 }
658 });
659
660 let spec_schema = &schema["properties"]["spec"];
661 let self_val = json!({"replicas": 5, "minReplicas": 2});
662
663 let spec_compiled = compile_schema(spec_schema);
664 assert_eq!(spec_compiled.validations.len(), 1);
665 let compiled = spec_compiled.validations.into_iter().next().unwrap().unwrap();
666
667 assert!(!compiled.is_transition_rule);
668 assert_eq!(
669 compiled.rule.message.as_deref(),
670 Some("replicas must be >= minReplicas")
671 );
672
673 let mut ctx = Context::default();
674 register_all(&mut ctx);
675 ctx.add_variable_from_value("self", json_to_cel(&self_val));
676 assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
677 }
678
679 #[test]
680 fn compile_and_eval_with_json_to_cel() {
681 let schema = json!({
682 "x-kubernetes-validations": [{"rule": "self.name.size() > 0", "message": "name required"}]
683 });
684 let result = compile_and_eval_first(schema, json!({"name": "my-app"}));
685 assert_eq!(result, Value::Bool(true));
686 }
687
688 #[test]
689 fn transition_rule_compile_and_eval() {
690 let schema = json!({
691 "x-kubernetes-validations": [
692 {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down", "reason": "FieldValueForbidden"}
693 ]
694 });
695
696 let compiled_schema = compile_schema(&schema);
697 let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
698
699 assert!(compiled.is_transition_rule);
700 assert_eq!(compiled.rule.message.as_deref(), Some("cannot scale down"));
701 assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueForbidden"));
702
703 let mut ctx = Context::default();
704 register_all(&mut ctx);
705 ctx.add_variable_from_value("self", json_to_cel(&json!({"replicas": 5})));
706 ctx.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
707 assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
708
709 let mut ctx2 = Context::default();
710 register_all(&mut ctx2);
711 ctx2.add_variable_from_value("self", json_to_cel(&json!({"replicas": 1})));
712 ctx2.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
713 assert_eq!(compiled.program.execute(&ctx2).unwrap(), Value::Bool(false));
714 }
715
716 #[test]
717 fn message_and_reason_preserved() {
718 let schema = json!({
719 "x-kubernetes-validations": [{
720 "rule": "self.x > 0",
721 "message": "x must be positive",
722 "messageExpression": "\"x is \" + string(self.x)",
723 "reason": "FieldValueInvalid",
724 "fieldPath": ".spec.x"
725 }]
726 });
727
728 let compiled_schema = compile_schema(&schema);
729 let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
730
731 assert_eq!(compiled.rule.message.as_deref(), Some("x must be positive"));
732 assert_eq!(
733 compiled.rule.message_expression.as_deref(),
734 Some("\"x is \" + string(self.x)")
735 );
736 assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueInvalid"));
737 assert_eq!(compiled.rule.field_path.as_deref(), Some(".spec.x"));
738 }
739
740 #[test]
741 fn multiple_rules_mixed_results() {
742 let schema = json!({
743 "x-kubernetes-validations": [
744 {"rule": "self.a > 0"},
745 {"rule": "invalid >="},
746 {"rule": "self.b == true"}
747 ]
748 });
749
750 let compiled = compile_schema(&schema);
751 assert_eq!(compiled.validations.len(), 3);
752
753 let cr = compiled.validations[0].as_ref().unwrap();
754 let mut ctx = Context::default();
755 register_all(&mut ctx);
756 ctx.add_variable_from_value("self", json_to_cel(&json!({"a": 5})));
757 assert_eq!(cr.program.execute(&ctx).unwrap(), Value::Bool(true));
758
759 assert!(matches!(
760 compiled.validations[1].as_ref().unwrap_err(),
761 CompilationError::Parse { .. }
762 ));
763
764 assert!(compiled.validations[2].is_ok());
765 }
766
767 #[test]
768 fn realistic_crd_with_multiple_validation_levels() {
769 let crd_schema = json!({
770 "type": "object",
771 "properties": {
772 "spec": {
773 "type": "object",
774 "properties": {
775 "replicas": {"type": "integer"},
776 "template": {
777 "type": "object",
778 "properties": {"name": {"type": "string"}},
779 "x-kubernetes-validations": [
780 {"rule": "self.name.size() > 0", "message": "template name required"}
781 ]
782 }
783 },
784 "x-kubernetes-validations": [
785 {"rule": "self.replicas >= 1", "message": "at least one replica"}
786 ]
787 }
788 }
789 });
790
791 let spec_compiled = compile_schema(&crd_schema["properties"]["spec"]);
792 assert_eq!(spec_compiled.validations.len(), 1);
793 let spec_cr = spec_compiled.validations.into_iter().next().unwrap().unwrap();
794
795 let mut ctx = Context::default();
796 register_all(&mut ctx);
797 ctx.add_variable_from_value(
798 "self",
799 json_to_cel(&json!({"replicas": 3, "template": {"name": "web"}})),
800 );
801 assert_eq!(spec_cr.program.execute(&ctx).unwrap(), Value::Bool(true));
802
803 let tmpl_compiled = compile_schema(&crd_schema["properties"]["spec"]["properties"]["template"]);
804 assert_eq!(tmpl_compiled.validations.len(), 1);
805 let tmpl_cr = tmpl_compiled.validations.into_iter().next().unwrap().unwrap();
806
807 let mut ctx2 = Context::default();
808 register_all(&mut ctx2);
809 ctx2.add_variable_from_value("self", json_to_cel(&json!({"name": "web"})));
810 assert_eq!(tmpl_cr.program.execute(&ctx2).unwrap(), Value::Bool(true));
811
812 let mut ctx3 = Context::default();
813 register_all(&mut ctx3);
814 ctx3.add_variable_from_value("self", json_to_cel(&json!({"name": ""})));
815 assert_eq!(tmpl_cr.program.execute(&ctx3).unwrap(), Value::Bool(false));
816 }
817
818 #[test]
819 #[cfg(feature = "strings")]
820 fn compiled_rule_with_extension_functions() {
821 let schema = json!({
822 "x-kubernetes-validations": [{"rule": "self.name.trim().lowerAscii().size() > 0"}]
823 });
824 let result = compile_and_eval_first(schema, json!({"name": " Hello "}));
825 assert_eq!(result, Value::Bool(true));
826 }
827}