use std::collections::HashMap;
use cel::Program;
use crate::validation::values::SchemaFormat;
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
pub rule: String,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub message_expression: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub field_path: Option<String>,
#[serde(default)]
pub optional_old_self: Option<bool>,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct CompilationResult {
pub program: Program,
pub rule: Rule,
pub is_transition_rule: bool,
pub message_program: Option<Program>,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum CompilationError {
Parse {
rule: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
MessageExpressionParse {
rule: String,
message_expression: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
InvalidRule(serde_json::Error),
SchemaTooDeep {
depth: usize,
},
}
impl std::fmt::Display for CompilationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompilationError::Parse { rule, source } => {
write!(f, "failed to compile CEL rule \"{rule}\": {source}")
}
CompilationError::MessageExpressionParse {
rule,
message_expression,
source,
} => {
write!(
f,
"failed to compile messageExpression \"{message_expression}\" for rule \"{rule}\": {source}"
)
}
CompilationError::InvalidRule(err) => {
write!(f, "invalid rule definition: {err}")
}
CompilationError::SchemaTooDeep { depth } => {
write!(
f,
"schema nesting depth {depth} exceeds the maximum of {MAX_SCHEMA_DEPTH}"
)
}
}
}
}
impl std::error::Error for CompilationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
CompilationError::Parse { source, .. } => Some(source.as_ref()),
CompilationError::MessageExpressionParse { source, .. } => Some(source.as_ref()),
CompilationError::InvalidRule(err) => Some(err),
CompilationError::SchemaTooDeep { .. } => None,
}
}
}
pub(crate) fn compile_rule(rule: &Rule) -> Result<CompilationResult, CompilationError> {
let program = Program::compile(&rule.rule).map_err(|e| CompilationError::Parse {
rule: rule.rule.clone(),
source: Box::new(e),
})?;
let is_transition_rule = program.references().has_variable("oldSelf");
let message_program = match rule.message_expression.as_deref() {
Some(expr) => Some(
Program::compile(expr).map_err(|e| CompilationError::MessageExpressionParse {
rule: rule.rule.clone(),
message_expression: expr.to_string(),
source: Box::new(e),
})?,
),
None => None,
};
Ok(CompilationResult {
program,
rule: rule.clone(),
is_transition_rule,
message_program,
})
}
pub(crate) fn compile_schema_validations(
schema: &serde_json::Value,
) -> Vec<Result<CompilationResult, CompilationError>> {
let rules = match schema.get("x-kubernetes-validations") {
Some(serde_json::Value::Array(arr)) => arr,
_ => return Vec::new(),
};
rules
.iter()
.map(|raw| {
let rule: Rule = serde_json::from_value(raw.clone()).map_err(CompilationError::InvalidRule)?;
compile_rule(&rule)
})
.collect()
}
#[derive(Debug)]
#[non_exhaustive]
pub struct CompiledSchema {
pub validations: Vec<Result<CompilationResult, CompilationError>>,
pub properties: HashMap<String, CompiledSchema>,
pub items: Option<Box<CompiledSchema>>,
pub additional_properties: Option<Box<CompiledSchema>>,
pub format: SchemaFormat,
pub all_of: Vec<CompiledSchema>,
pub one_of: Vec<CompiledSchema>,
pub any_of: Vec<CompiledSchema>,
pub max_length: Option<u64>,
pub max_items: Option<u64>,
pub max_properties: Option<u64>,
pub preserve_unknown_fields: bool,
pub embedded_resource: bool,
}
impl CompiledSchema {
#[must_use]
pub fn compilation_errors(&self) -> Vec<&CompilationError> {
self.validations.iter().filter_map(|r| r.as_ref().err()).collect()
}
#[must_use]
pub fn has_errors(&self) -> bool {
self.validations.iter().any(|r| r.is_err())
}
}
pub(crate) const MAX_SCHEMA_DEPTH: usize = 64;
fn compile_schema_array(schema: &serde_json::Value, key: &str, depth: usize) -> Vec<CompiledSchema> {
schema
.get(key)
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(|s| compile_schema_inner(s, depth)).collect())
.unwrap_or_default()
}
#[must_use]
pub fn compile_schema(schema: &serde_json::Value) -> CompiledSchema {
compile_schema_inner(schema, 0)
}
fn compile_schema_inner(schema: &serde_json::Value, depth: usize) -> CompiledSchema {
if depth > MAX_SCHEMA_DEPTH {
return CompiledSchema {
validations: vec![Err(CompilationError::SchemaTooDeep { depth })],
properties: HashMap::new(),
items: None,
additional_properties: None,
format: SchemaFormat::None,
all_of: Vec::new(),
one_of: Vec::new(),
any_of: Vec::new(),
max_length: None,
max_items: None,
max_properties: None,
preserve_unknown_fields: false,
embedded_resource: false,
};
}
let validations = compile_schema_validations(schema);
let mut properties = HashMap::new();
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
for (name, prop_schema) in props {
properties.insert(name.clone(), compile_schema_inner(prop_schema, depth + 1));
}
}
let items = schema
.get("items")
.map(|s| Box::new(compile_schema_inner(s, depth + 1)));
let additional_properties = schema
.get("additionalProperties")
.filter(|a| a.is_object())
.map(|s| Box::new(compile_schema_inner(s, depth + 1)));
let format = SchemaFormat::from_schema(schema);
let all_of = compile_schema_array(schema, "allOf", depth + 1);
let one_of = compile_schema_array(schema, "oneOf", depth + 1);
let any_of = compile_schema_array(schema, "anyOf", depth + 1);
let max_length = schema.get("maxLength").and_then(|v| v.as_u64());
let max_items = schema.get("maxItems").and_then(|v| v.as_u64());
let max_properties = schema.get("maxProperties").and_then(|v| v.as_u64());
let preserve_unknown_fields = schema
.get("x-kubernetes-preserve-unknown-fields")
.and_then(|v| v.as_bool())
== Some(true);
let embedded_resource = schema
.get("x-kubernetes-embedded-resource")
.and_then(|v| v.as_bool())
== Some(true);
CompiledSchema {
validations,
properties,
items,
additional_properties,
format,
all_of,
one_of,
any_of,
max_length,
max_items,
max_properties,
preserve_unknown_fields,
embedded_resource,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn compile_simple_rule() {
let rule = Rule {
rule: "self.replicas >= 0".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(!result.is_transition_rule);
}
#[test]
fn detect_transition_rule() {
let rule = Rule {
rule: "self.replicas >= oldSelf.replicas".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(result.is_transition_rule);
}
#[test]
fn detect_non_transition_rule() {
let rule = Rule {
rule: "self.replicas > 0".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(!result.is_transition_rule);
}
#[test]
fn parse_error_on_invalid_cel() {
let rule = Rule {
rule: "self.replicas >=".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let err = compile_rule(&rule).unwrap_err();
assert!(matches!(err, CompilationError::Parse { .. }));
let msg = err.to_string();
assert!(msg.contains("self.replicas >="));
}
#[test]
fn deserialize_rule_all_fields() {
let raw = json!({
"rule": "self.x > 0",
"message": "x must be positive",
"messageExpression": "\"x is \" + string(self.x)",
"reason": "FieldValueInvalid",
"fieldPath": ".spec.x",
"optionalOldSelf": true
});
let rule: Rule = serde_json::from_value(raw).unwrap();
assert_eq!(rule.rule, "self.x > 0");
assert_eq!(rule.message.as_deref(), Some("x must be positive"));
assert_eq!(
rule.message_expression.as_deref(),
Some("\"x is \" + string(self.x)")
);
assert_eq!(rule.reason.as_deref(), Some("FieldValueInvalid"));
assert_eq!(rule.field_path.as_deref(), Some(".spec.x"));
assert_eq!(rule.optional_old_self, Some(true));
}
#[test]
fn deserialize_rule_minimal() {
let raw = json!({"rule": "self.x > 0"});
let rule: Rule = serde_json::from_value(raw).unwrap();
assert_eq!(rule.rule, "self.x > 0");
assert!(rule.message.is_none());
assert!(rule.message_expression.is_none());
assert!(rule.reason.is_none());
assert!(rule.field_path.is_none());
assert!(rule.optional_old_self.is_none());
}
#[test]
fn schema_validations_extracts_and_compiles() {
let schema = json!({
"type": "object",
"x-kubernetes-validations": [
{"rule": "self.replicas >= 0", "message": "must be non-negative"},
{"rule": "self.name.size() > 0"}
]
});
let results = compile_schema_validations(&schema);
assert_eq!(results.len(), 2);
assert!(results[0].is_ok());
assert!(results[1].is_ok());
}
#[test]
fn schema_validations_no_key() {
let schema = json!({"type": "object"});
let results = compile_schema_validations(&schema);
assert!(results.is_empty());
}
#[test]
fn schema_validations_empty_array() {
let schema = json!({
"x-kubernetes-validations": []
});
let results = compile_schema_validations(&schema);
assert!(results.is_empty());
}
#[test]
fn message_expression_compiled() {
let rule = Rule {
rule: "self.x > 0".into(),
message: Some("x must be positive".into()),
message_expression: Some("'x is ' + string(self.x)".into()),
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(result.message_program.is_some());
}
#[test]
fn message_expression_invalid_rejected() {
let rule = Rule {
rule: "self.x > 0".into(),
message: Some("fallback".into()),
message_expression: Some("invalid >=".into()),
reason: None,
field_path: None,
optional_old_self: None,
};
let err = compile_rule(&rule).unwrap_err();
assert!(
matches!(err, CompilationError::MessageExpressionParse { .. }),
"expected MessageExpressionParse, got {err:?}"
);
}
#[test]
fn message_expression_none() {
let rule = Rule {
rule: "self.x > 0".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(result.message_program.is_none());
}
#[test]
fn compile_schema_tree() {
let schema = json!({
"type": "object",
"x-kubernetes-validations": [{"rule": "has(self.spec)"}],
"properties": {
"spec": {
"type": "object",
"x-kubernetes-validations": [{"rule": "self.replicas >= 0"}],
"properties": {
"replicas": {"type": "integer"}
}
}
}
});
let compiled = compile_schema(&schema);
assert_eq!(compiled.validations.len(), 1);
assert!(compiled.properties.contains_key("spec"));
let spec = &compiled.properties["spec"];
assert_eq!(spec.validations.len(), 1);
assert!(spec.properties.contains_key("replicas"));
}
#[test]
fn compile_schema_with_items() {
let schema = json!({
"type": "array",
"items": {
"type": "object",
"x-kubernetes-validations": [{"rule": "self.name.size() > 0"}]
}
});
let compiled = compile_schema(&schema);
assert!(compiled.items.is_some());
assert_eq!(compiled.items.as_ref().unwrap().validations.len(), 1);
}
#[test]
fn compile_schema_empty() {
let schema = json!({"type": "object"});
let compiled = compile_schema(&schema);
assert!(compiled.validations.is_empty());
assert!(compiled.properties.is_empty());
assert!(compiled.items.is_none());
assert!(compiled.additional_properties.is_none());
}
#[test]
fn schema_validations_partial_errors() {
let schema = json!({
"x-kubernetes-validations": [
{"rule": "self.x > 0"},
{"rule": "self.y >="},
{"rule": "self.z == true"}
]
});
let results = compile_schema_validations(&schema);
assert_eq!(results.len(), 3);
assert!(results[0].is_ok());
assert!(results[1].is_err());
assert!(results[2].is_ok());
}
#[test]
fn compilation_errors_method() {
let schema = json!({
"x-kubernetes-validations": [
{"rule": "self.x > 0"},
{"rule": "self.y >="},
{"rule": "self.z == true"}
]
});
let compiled = compile_schema(&schema);
let errors = compiled.compilation_errors();
assert_eq!(errors.len(), 1);
assert!(matches!(errors[0], CompilationError::Parse { .. }));
assert!(compiled.has_errors());
}
#[test]
fn compilation_errors_empty_when_all_valid() {
let schema = json!({
"x-kubernetes-validations": [
{"rule": "self.x > 0"},
{"rule": "self.z == true"}
]
});
let compiled = compile_schema(&schema);
assert!(compiled.compilation_errors().is_empty());
assert!(!compiled.has_errors());
}
}
#[cfg(test)]
mod end_to_end_tests {
use cel::{Context, Value};
use serde_json::json;
use super::{CompilationError, compile_schema};
use crate::{register_all, validation::values::json_to_cel};
fn compile_and_eval_first(schema: serde_json::Value, self_val: serde_json::Value) -> Value {
let compiled = compile_schema(&schema);
let cr = compiled.validations.into_iter().next().unwrap().unwrap();
let mut ctx = Context::default();
register_all(&mut ctx);
ctx.add_variable_from_value("self", json_to_cel(&self_val));
cr.program.execute(&ctx).unwrap()
}
#[test]
fn crd_schema_end_to_end() {
let schema = json!({
"type": "object",
"properties": {
"spec": {
"type": "object",
"properties": {
"replicas": {"type": "integer"},
"minReplicas": {"type": "integer"}
},
"x-kubernetes-validations": [
{"rule": "self.replicas >= self.minReplicas", "message": "replicas must be >= minReplicas"}
]
}
}
});
let spec_schema = &schema["properties"]["spec"];
let self_val = json!({"replicas": 5, "minReplicas": 2});
let spec_compiled = compile_schema(spec_schema);
assert_eq!(spec_compiled.validations.len(), 1);
let compiled = spec_compiled.validations.into_iter().next().unwrap().unwrap();
assert!(!compiled.is_transition_rule);
assert_eq!(
compiled.rule.message.as_deref(),
Some("replicas must be >= minReplicas")
);
let mut ctx = Context::default();
register_all(&mut ctx);
ctx.add_variable_from_value("self", json_to_cel(&self_val));
assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
}
#[test]
fn compile_and_eval_with_json_to_cel() {
let schema = json!({
"x-kubernetes-validations": [{"rule": "self.name.size() > 0", "message": "name required"}]
});
let result = compile_and_eval_first(schema, json!({"name": "my-app"}));
assert_eq!(result, Value::Bool(true));
}
#[test]
fn transition_rule_compile_and_eval() {
let schema = json!({
"x-kubernetes-validations": [
{"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down", "reason": "FieldValueForbidden"}
]
});
let compiled_schema = compile_schema(&schema);
let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
assert!(compiled.is_transition_rule);
assert_eq!(compiled.rule.message.as_deref(), Some("cannot scale down"));
assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueForbidden"));
let mut ctx = Context::default();
register_all(&mut ctx);
ctx.add_variable_from_value("self", json_to_cel(&json!({"replicas": 5})));
ctx.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
let mut ctx2 = Context::default();
register_all(&mut ctx2);
ctx2.add_variable_from_value("self", json_to_cel(&json!({"replicas": 1})));
ctx2.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
assert_eq!(compiled.program.execute(&ctx2).unwrap(), Value::Bool(false));
}
#[test]
fn message_and_reason_preserved() {
let schema = json!({
"x-kubernetes-validations": [{
"rule": "self.x > 0",
"message": "x must be positive",
"messageExpression": "\"x is \" + string(self.x)",
"reason": "FieldValueInvalid",
"fieldPath": ".spec.x"
}]
});
let compiled_schema = compile_schema(&schema);
let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
assert_eq!(compiled.rule.message.as_deref(), Some("x must be positive"));
assert_eq!(
compiled.rule.message_expression.as_deref(),
Some("\"x is \" + string(self.x)")
);
assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueInvalid"));
assert_eq!(compiled.rule.field_path.as_deref(), Some(".spec.x"));
}
#[test]
fn multiple_rules_mixed_results() {
let schema = json!({
"x-kubernetes-validations": [
{"rule": "self.a > 0"},
{"rule": "invalid >="},
{"rule": "self.b == true"}
]
});
let compiled = compile_schema(&schema);
assert_eq!(compiled.validations.len(), 3);
let cr = compiled.validations[0].as_ref().unwrap();
let mut ctx = Context::default();
register_all(&mut ctx);
ctx.add_variable_from_value("self", json_to_cel(&json!({"a": 5})));
assert_eq!(cr.program.execute(&ctx).unwrap(), Value::Bool(true));
assert!(matches!(
compiled.validations[1].as_ref().unwrap_err(),
CompilationError::Parse { .. }
));
assert!(compiled.validations[2].is_ok());
}
#[test]
fn realistic_crd_with_multiple_validation_levels() {
let crd_schema = json!({
"type": "object",
"properties": {
"spec": {
"type": "object",
"properties": {
"replicas": {"type": "integer"},
"template": {
"type": "object",
"properties": {"name": {"type": "string"}},
"x-kubernetes-validations": [
{"rule": "self.name.size() > 0", "message": "template name required"}
]
}
},
"x-kubernetes-validations": [
{"rule": "self.replicas >= 1", "message": "at least one replica"}
]
}
}
});
let spec_compiled = compile_schema(&crd_schema["properties"]["spec"]);
assert_eq!(spec_compiled.validations.len(), 1);
let spec_cr = spec_compiled.validations.into_iter().next().unwrap().unwrap();
let mut ctx = Context::default();
register_all(&mut ctx);
ctx.add_variable_from_value(
"self",
json_to_cel(&json!({"replicas": 3, "template": {"name": "web"}})),
);
assert_eq!(spec_cr.program.execute(&ctx).unwrap(), Value::Bool(true));
let tmpl_compiled = compile_schema(&crd_schema["properties"]["spec"]["properties"]["template"]);
assert_eq!(tmpl_compiled.validations.len(), 1);
let tmpl_cr = tmpl_compiled.validations.into_iter().next().unwrap().unwrap();
let mut ctx2 = Context::default();
register_all(&mut ctx2);
ctx2.add_variable_from_value("self", json_to_cel(&json!({"name": "web"})));
assert_eq!(tmpl_cr.program.execute(&ctx2).unwrap(), Value::Bool(true));
let mut ctx3 = Context::default();
register_all(&mut ctx3);
ctx3.add_variable_from_value("self", json_to_cel(&json!({"name": ""})));
assert_eq!(tmpl_cr.program.execute(&ctx3).unwrap(), Value::Bool(false));
}
#[test]
#[cfg(feature = "strings")]
fn compiled_rule_with_extension_functions() {
let schema = json!({
"x-kubernetes-validations": [{"rule": "self.name.trim().lowerAscii().size() > 0"}]
});
let result = compile_and_eval_first(schema, json!({"name": " Hello "}));
assert_eq!(result, Value::Bool(true));
}
}