Skip to main content

kube_cel/validation/
values.rs

1//! Conversion from `serde_json::Value` to `cel::Value`.
2//!
3//! This module provides [`json_to_cel`] which recursively converts a JSON value
4//! into the CEL value representation used by the `cel` crate. The converted
5//! values can then be bound as variables (e.g. `self`, `oldSelf`) in a CEL
6//! evaluation context.
7//!
8//! For schema-aware conversion that respects `format: "date-time"` and
9//! `format: "duration"`, use [`json_to_cel_with_schema`] or
10//! [`json_to_cel_with_compiled`].
11
12use std::{collections::HashMap, sync::Arc};
13
14use base64::Engine;
15use cel::{
16    Value,
17    objects::{Key, Map},
18};
19
20use crate::validation::{compilation::CompiledSchema, escaping::escape_field_name};
21
22/// The `format` hint from an OpenAPI schema property.
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum SchemaFormat {
26    /// `format: "date-time"` — strings should be parsed as CEL `Timestamp`.
27    DateTime,
28    /// `format: "duration"` — strings should be parsed as CEL `Duration`.
29    Duration,
30    /// `format: "byte"` — base64 strings bind as CEL `bytes`. The apiserver
31    /// decodes the value, so `size()`/indexing operate on the decoded bytes;
32    /// keeping it a string would diverge (e.g. `size()` returns the encoded
33    /// length).
34    Bytes,
35    /// `x-kubernetes-int-or-string: true` — field can be int or string.
36    /// Primarily a marker to prevent format: "date-time" etc from being interpreted.
37    IntOrString,
38    /// No recognized format or not a string type.
39    #[default]
40    None,
41}
42
43impl SchemaFormat {
44    /// Extract a `SchemaFormat` from a raw JSON schema node.
45    pub(crate) fn from_schema(schema: &serde_json::Value) -> Self {
46        if schema.get("x-kubernetes-int-or-string").and_then(|v| v.as_bool()) == Some(true) {
47            return SchemaFormat::IntOrString;
48        }
49        match schema.get("format").and_then(|f| f.as_str()) {
50            Some("date-time") => SchemaFormat::DateTime,
51            Some("duration") => SchemaFormat::Duration,
52            Some("byte") => SchemaFormat::Bytes,
53            _ => SchemaFormat::None,
54        }
55    }
56}
57
58/// Convert a [`serde_json::Value`] into a [`cel::Value`].
59///
60/// Object keys are escaped via [`escape_field_name`]
61/// to handle CEL reserved words and special characters (`.`, `-`, `/`, `_`).
62///
63/// # Number conversion priority
64///
65/// JSON numbers are converted using the following priority:
66/// 1. `i64` — if the number fits in a signed 64-bit integer
67/// 2. `u64` — if the number fits in an unsigned 64-bit integer (but not `i64`)
68/// 3. `f64` — for all other numeric values (floating-point)
69#[must_use]
70pub(crate) fn json_to_cel(value: &serde_json::Value) -> Value {
71    match value {
72        serde_json::Value::Null => Value::Null,
73        serde_json::Value::Bool(b) => Value::Bool(*b),
74        serde_json::Value::Number(n) => convert_number(n),
75        serde_json::Value::String(s) => Value::String(Arc::new(s.clone())),
76        serde_json::Value::Array(arr) => {
77            let items: Vec<Value> = arr.iter().map(json_to_cel).collect();
78            Value::List(Arc::new(items))
79        }
80        serde_json::Value::Object(obj) => {
81            let mut map = HashMap::with_capacity(obj.len());
82            for (k, v) in obj {
83                map.insert(Key::String(Arc::new(escape_field_name(k))), json_to_cel(v));
84            }
85            Value::Map(Map { map: Arc::new(map) })
86        }
87    }
88}
89
90#[inline]
91fn convert_number(n: &serde_json::Number) -> Value {
92    if let Some(i) = n.as_i64() {
93        Value::Int(i)
94    } else if let Some(u) = n.as_u64() {
95        Value::UInt(u)
96    } else {
97        Value::Float(n.as_f64().unwrap_or(f64::NAN))
98    }
99}
100
101/// Convert a JSON value to a CEL value, using the raw JSON schema to recognize
102/// `format: "date-time"` and `format: "duration"` string fields.
103///
104/// This recursively walks both the value and the schema in parallel. For string
105/// values whose schema specifies a recognized format, the string is parsed into
106/// the corresponding CEL type (`Timestamp` or `Duration`). On parse failure,
107/// the value falls back to `Value::String`.
108#[must_use]
109pub(crate) fn json_to_cel_with_schema(value: &serde_json::Value, schema: &serde_json::Value) -> Value {
110    let format = SchemaFormat::from_schema(schema);
111    match value {
112        serde_json::Value::Null => Value::Null,
113        serde_json::Value::Bool(b) => Value::Bool(*b),
114        serde_json::Value::Number(n) => convert_number(n),
115        serde_json::Value::String(s) => convert_string_with_format(s, &format),
116        serde_json::Value::Array(arr) => {
117            let items_schema = schema.get("items");
118            let items: Vec<Value> = arr
119                .iter()
120                .map(|item| match items_schema {
121                    Some(s) => json_to_cel_with_schema(item, s),
122                    None => json_to_cel(item),
123                })
124                .collect();
125            Value::List(Arc::new(items))
126        }
127        serde_json::Value::Object(obj) => {
128            let props = schema.get("properties").and_then(|p| p.as_object());
129            let additional = schema.get("additionalProperties");
130            let additional_schema = additional.filter(|a| a.is_object());
131            // A node whose `additionalProperties` is a schema or `true` (i.e. not
132            // absent/`false`) is a map: its keys are user-supplied data, not
133            // declared field names, so they are NOT escaped — matching the
134            // apiserver's `MapValue` behavior (kube-rs/kube-cel#8).
135            let is_map = !matches!(additional, None | Some(serde_json::Value::Bool(false)));
136
137            let mut map = HashMap::with_capacity(obj.len());
138            for (k, v) in obj {
139                let child_val = if let Some(prop_schema) = props.and_then(|p| p.get(k)) {
140                    json_to_cel_with_schema(v, prop_schema)
141                } else if let Some(additional_schema) = additional_schema {
142                    json_to_cel_with_schema(v, additional_schema)
143                } else {
144                    json_to_cel(v)
145                };
146                let key = if is_map { k.clone() } else { escape_field_name(k) };
147                map.insert(Key::String(Arc::new(key)), child_val);
148            }
149
150            let is_embedded = schema
151                .get("x-kubernetes-embedded-resource")
152                .and_then(|v| v.as_bool())
153                == Some(true);
154            if is_embedded {
155                map.entry(Key::String(Arc::new("apiVersion".into())))
156                    .or_insert_with(|| Value::String(Arc::new(String::new())));
157                map.entry(Key::String(Arc::new("kind".into())))
158                    .or_insert_with(|| Value::String(Arc::new(String::new())));
159                map.entry(Key::String(Arc::new("metadata".into())))
160                    .or_insert_with(|| {
161                        Value::Map(Map {
162                            map: Arc::new(HashMap::new()),
163                        })
164                    });
165            }
166
167            Value::Map(Map { map: Arc::new(map) })
168        }
169    }
170}
171
172/// Convert a JSON value to a CEL value using a pre-compiled [`CompiledSchema`].
173///
174/// Behaves like [`json_to_cel_with_schema`] but uses the format metadata stored
175/// in the compiled schema tree instead of parsing the raw JSON schema.
176#[must_use]
177pub(crate) fn json_to_cel_with_compiled(value: &serde_json::Value, compiled: &CompiledSchema) -> Value {
178    match value {
179        serde_json::Value::Null => Value::Null,
180        serde_json::Value::Bool(b) => Value::Bool(*b),
181        serde_json::Value::Number(n) => convert_number(n),
182        serde_json::Value::String(s) => convert_string_with_format(s, &compiled.format),
183        serde_json::Value::Array(arr) => {
184            let items: Vec<Value> = arr
185                .iter()
186                .map(|item| match &compiled.items {
187                    Some(items_compiled) => json_to_cel_with_compiled(item, items_compiled),
188                    None => json_to_cel(item),
189                })
190                .collect();
191            Value::List(Arc::new(items))
192        }
193        serde_json::Value::Object(obj) => {
194            let mut map = HashMap::with_capacity(obj.len());
195            for (k, v) in obj {
196                let child_val = if let Some(prop_compiled) = compiled.properties.get(k) {
197                    json_to_cel_with_compiled(v, prop_compiled)
198                } else if let Some(ref additional) = compiled.additional_properties {
199                    json_to_cel_with_compiled(v, additional)
200                } else {
201                    json_to_cel(v)
202                };
203                // Map keys (additionalProperties) are user data, not field names,
204                // so they are not escaped — matching the apiserver (#8).
205                let key = if compiled.is_map {
206                    k.clone()
207                } else {
208                    escape_field_name(k)
209                };
210                map.insert(Key::String(Arc::new(key)), child_val);
211            }
212
213            if compiled.embedded_resource {
214                map.entry(Key::String(Arc::new("apiVersion".into())))
215                    .or_insert_with(|| Value::String(Arc::new(String::new())));
216                map.entry(Key::String(Arc::new("kind".into())))
217                    .or_insert_with(|| Value::String(Arc::new(String::new())));
218                map.entry(Key::String(Arc::new("metadata".into())))
219                    .or_insert_with(|| {
220                        Value::Map(Map {
221                            map: Arc::new(HashMap::new()),
222                        })
223                    });
224            }
225
226            Value::Map(Map { map: Arc::new(map) })
227        }
228    }
229}
230
231/// Convert a string using the schema format hint.
232#[inline]
233fn convert_string_with_format(s: &str, format: &SchemaFormat) -> Value {
234    match format {
235        SchemaFormat::DateTime => {
236            if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
237                return Value::Timestamp(dt);
238            }
239            Value::String(Arc::new(s.to_string()))
240        }
241        SchemaFormat::Duration => {
242            if let Some(d) = parse_go_duration(s) {
243                return Value::Duration(d);
244            }
245            Value::String(Arc::new(s.to_string()))
246        }
247        SchemaFormat::Bytes => match base64::engine::general_purpose::STANDARD.decode(s) {
248            Ok(bytes) => Value::Bytes(Arc::new(bytes)),
249            // Structural validation would already have rejected malformed base64
250            // upstream; fall back to the raw string rather than failing here.
251            Err(_) => Value::String(Arc::new(s.to_string())),
252        },
253        SchemaFormat::IntOrString => Value::String(Arc::new(s.to_string())),
254        SchemaFormat::None => Value::String(Arc::new(s.to_string())),
255    }
256}
257
258/// Parse a Go-style duration string into a [`chrono::Duration`].
259///
260/// Supported units: `h` (hours), `m` (minutes), `s` (seconds), `ms` (milliseconds),
261/// `us` (microseconds), `ns` (nanoseconds). Multiple units can be combined
262/// (e.g., `"1h30m"`). An optional leading `-` makes the duration negative.
263/// The bare string `"0"` is treated as zero duration.
264///
265/// Returns `None` if the string cannot be parsed.
266pub(crate) fn parse_go_duration(input: &str) -> Option<chrono::Duration> {
267    let (input, negative) = if let Some(rest) = input.strip_prefix('-') {
268        (rest, true)
269    } else {
270        (input, false)
271    };
272
273    if input == "0" {
274        return Some(chrono::Duration::zero());
275    }
276
277    let mut remaining = input;
278    let mut total_nanos: i64 = 0;
279    let mut parsed_any = false;
280
281    while !remaining.is_empty() {
282        // Parse the numeric part (integer or float)
283        let num_end = remaining
284            .find(|c: char| !c.is_ascii_digit() && c != '.')
285            .unwrap_or(0);
286        if num_end == 0 {
287            return None; // no digits found
288        }
289        let num_str = &remaining[..num_end];
290        let num: f64 = num_str.parse().ok()?;
291        remaining = &remaining[num_end..];
292
293        // Parse the unit suffix
294        let (unit_nanos, unit_len) = if remaining.starts_with("ms") {
295            (1_000_000i64, 2)
296        } else if remaining.starts_with("us") {
297            (1_000i64, 2)
298        } else if remaining.starts_with("ns") {
299            (1i64, 2)
300        } else if remaining.starts_with('h') {
301            (3_600_000_000_000i64, 1)
302        } else if remaining.starts_with('m') {
303            (60_000_000_000i64, 1)
304        } else if remaining.starts_with('s') {
305            (1_000_000_000i64, 1)
306        } else {
307            return None; // unknown unit
308        };
309
310        remaining = &remaining[unit_len..];
311        total_nanos += (num * unit_nanos as f64).trunc() as i64;
312        parsed_any = true;
313    }
314
315    if !parsed_any {
316        return None;
317    }
318
319    if negative {
320        total_nanos = -total_nanos;
321    }
322    Some(chrono::Duration::nanoseconds(total_nanos))
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use serde_json::json;
329
330    #[test]
331    fn test_null() {
332        assert_eq!(json_to_cel(&json!(null)), Value::Null);
333    }
334
335    #[test]
336    fn test_bool() {
337        assert_eq!(json_to_cel(&json!(true)), Value::Bool(true));
338        assert_eq!(json_to_cel(&json!(false)), Value::Bool(false));
339    }
340
341    #[test]
342    fn test_i64() {
343        assert_eq!(json_to_cel(&json!(42)), Value::Int(42));
344        assert_eq!(json_to_cel(&json!(-1)), Value::Int(-1));
345        assert_eq!(json_to_cel(&json!(0)), Value::Int(0));
346    }
347
348    #[test]
349    fn test_u64_beyond_i64() {
350        let big: u64 = (i64::MAX as u64) + 1;
351        let v = json_to_cel(&serde_json::Value::Number(serde_json::Number::from(big)));
352        assert_eq!(v, Value::UInt(big));
353    }
354
355    #[test]
356    fn test_float() {
357        assert_eq!(json_to_cel(&json!(2.5)), Value::Float(2.5));
358        assert_eq!(json_to_cel(&json!(0.0)), Value::Float(0.0));
359    }
360
361    #[test]
362    fn test_string() {
363        assert_eq!(
364            json_to_cel(&json!("hello")),
365            Value::String(Arc::new("hello".into()))
366        );
367    }
368
369    #[test]
370    fn test_empty_string() {
371        assert_eq!(json_to_cel(&json!("")), Value::String(Arc::new(String::new())));
372    }
373
374    #[test]
375    fn test_array_mixed() {
376        let v = json_to_cel(&json!([1, "two", true, null]));
377        let expected = Value::List(Arc::new(vec![
378            Value::Int(1),
379            Value::String(Arc::new("two".into())),
380            Value::Bool(true),
381            Value::Null,
382        ]));
383        assert_eq!(v, expected);
384    }
385
386    #[test]
387    fn test_empty_array() {
388        assert_eq!(json_to_cel(&json!([])), Value::List(Arc::new(vec![])));
389    }
390
391    #[test]
392    fn test_object() {
393        let v = json_to_cel(&json!({"name": "test", "count": 5}));
394        if let Value::Map(map) = v {
395            assert_eq!(
396                map.map.get(&Key::String(Arc::new("name".into()))),
397                Some(&Value::String(Arc::new("test".into())))
398            );
399            assert_eq!(
400                map.map.get(&Key::String(Arc::new("count".into()))),
401                Some(&Value::Int(5))
402            );
403        } else {
404            panic!("expected Map");
405        }
406    }
407
408    #[test]
409    fn test_empty_object() {
410        let v = json_to_cel(&json!({}));
411        if let Value::Map(map) = v {
412            assert!(map.map.is_empty());
413        } else {
414            panic!("expected Map");
415        }
416    }
417
418    #[test]
419    fn test_nested_structure() {
420        let v = json_to_cel(&json!({
421            "spec": {
422                "replicas": 3,
423                "items": [1, 2, 3]
424            }
425        }));
426        if let Value::Map(outer) = v {
427            let spec = outer.map.get(&Key::String(Arc::new("spec".into()))).unwrap();
428            if let Value::Map(inner) = spec {
429                assert_eq!(
430                    inner.map.get(&Key::String(Arc::new("replicas".into()))),
431                    Some(&Value::Int(3))
432                );
433                assert_eq!(
434                    inner.map.get(&Key::String(Arc::new("items".into()))),
435                    Some(&Value::List(Arc::new(vec![
436                        Value::Int(1),
437                        Value::Int(2),
438                        Value::Int(3),
439                    ])))
440                );
441            } else {
442                panic!("expected inner Map");
443            }
444        } else {
445            panic!("expected outer Map");
446        }
447    }
448
449    #[test]
450    fn test_number_priority() {
451        // i64 range → Int
452        assert_eq!(json_to_cel(&json!(42)), Value::Int(42));
453        // u64 beyond i64 → UInt
454        let big: u64 = (i64::MAX as u64) + 1;
455        assert_eq!(
456            json_to_cel(&serde_json::Value::Number(serde_json::Number::from(big))),
457            Value::UInt(big)
458        );
459        // float → Float
460        assert_eq!(json_to_cel(&json!(1.5)), Value::Float(1.5));
461    }
462
463    // ── parse_go_duration tests ─────────────────────────────────────
464
465    #[test]
466    fn parse_duration_hours() {
467        assert_eq!(parse_go_duration("1h"), Some(chrono::Duration::hours(1)));
468    }
469
470    #[test]
471    fn parse_duration_minutes() {
472        assert_eq!(parse_go_duration("30m"), Some(chrono::Duration::minutes(30)));
473    }
474
475    #[test]
476    fn parse_duration_seconds() {
477        assert_eq!(parse_go_duration("45s"), Some(chrono::Duration::seconds(45)));
478    }
479
480    #[test]
481    fn parse_duration_milliseconds() {
482        assert_eq!(
483            parse_go_duration("500ms"),
484            Some(chrono::Duration::milliseconds(500))
485        );
486    }
487
488    #[test]
489    fn parse_duration_microseconds() {
490        assert_eq!(
491            parse_go_duration("100us"),
492            Some(chrono::Duration::microseconds(100))
493        );
494    }
495
496    #[test]
497    fn parse_duration_nanoseconds() {
498        assert_eq!(
499            parse_go_duration("999ns"),
500            Some(chrono::Duration::nanoseconds(999))
501        );
502    }
503
504    #[test]
505    fn parse_duration_compound() {
506        assert_eq!(
507            parse_go_duration("1h30m"),
508            Some(chrono::Duration::hours(1) + chrono::Duration::minutes(30))
509        );
510        assert_eq!(
511            parse_go_duration("1h30m10s"),
512            Some(chrono::Duration::hours(1) + chrono::Duration::minutes(30) + chrono::Duration::seconds(10))
513        );
514    }
515
516    #[test]
517    fn parse_duration_negative() {
518        assert_eq!(parse_go_duration("-1h"), Some(chrono::Duration::hours(-1)));
519        assert_eq!(parse_go_duration("-30s"), Some(chrono::Duration::seconds(-30)));
520    }
521
522    #[test]
523    fn parse_duration_zero() {
524        assert_eq!(parse_go_duration("0"), Some(chrono::Duration::zero()));
525    }
526
527    #[test]
528    fn parse_duration_invalid() {
529        assert_eq!(parse_go_duration(""), None);
530        assert_eq!(parse_go_duration("abc"), None);
531        assert_eq!(parse_go_duration("1x"), None);
532        assert_eq!(parse_go_duration("h"), None);
533    }
534
535    // ── Schema-aware conversion tests ───────────────────────────────
536
537    #[test]
538    fn timestamp_parsed_from_schema() {
539        let schema = json!({
540            "type": "string",
541            "format": "date-time"
542        });
543        let value = json!("2024-01-01T00:00:00Z");
544        let result = json_to_cel_with_schema(&value, &schema);
545        assert!(matches!(result, Value::Timestamp(_)));
546    }
547
548    #[test]
549    fn byte_format_decodes_to_bytes() {
550        let schema = json!({ "type": "string", "format": "byte" });
551        // "aGVsbG8=" decodes to "hello" (5 bytes); the apiserver binds it as
552        // CEL bytes, so size() must see the decoded length, not the encoded one.
553        let result = json_to_cel_with_schema(&json!("aGVsbG8="), &schema);
554        assert_eq!(result, Value::Bytes(Arc::new(b"hello".to_vec())));
555    }
556
557    #[test]
558    fn byte_format_invalid_falls_back_to_string() {
559        let schema = json!({ "type": "string", "format": "byte" });
560        let result = json_to_cel_with_schema(&json!("!!!not-base64!!!"), &schema);
561        assert_eq!(result, Value::String(Arc::new("!!!not-base64!!!".into())));
562    }
563
564    #[test]
565    fn timestamp_parse_failure_falls_back_to_string() {
566        let schema = json!({
567            "type": "string",
568            "format": "date-time"
569        });
570        let value = json!("not-a-date");
571        let result = json_to_cel_with_schema(&value, &schema);
572        assert_eq!(result, Value::String(Arc::new("not-a-date".into())));
573    }
574
575    #[test]
576    fn duration_parsed_from_schema() {
577        let schema = json!({
578            "type": "string",
579            "format": "duration"
580        });
581        let value = json!("1h30m");
582        let result = json_to_cel_with_schema(&value, &schema);
583        assert!(matches!(result, Value::Duration(_)));
584    }
585
586    #[test]
587    fn duration_parse_failure_falls_back_to_string() {
588        let schema = json!({
589            "type": "string",
590            "format": "duration"
591        });
592        let value = json!("not-a-duration");
593        let result = json_to_cel_with_schema(&value, &schema);
594        assert_eq!(result, Value::String(Arc::new("not-a-duration".into())));
595    }
596
597    #[test]
598    fn nested_object_properties_format() {
599        let schema = json!({
600            "type": "object",
601            "properties": {
602                "createdAt": {
603                    "type": "string",
604                    "format": "date-time"
605                },
606                "name": {
607                    "type": "string"
608                },
609                "timeout": {
610                    "type": "string",
611                    "format": "duration"
612                }
613            }
614        });
615        let value = json!({
616            "createdAt": "2024-06-15T10:30:00Z",
617            "name": "test",
618            "timeout": "30s"
619        });
620        let result = json_to_cel_with_schema(&value, &schema);
621        if let Value::Map(map) = result {
622            assert!(matches!(
623                map.map.get(&Key::String(Arc::new("createdAt".into()))),
624                Some(Value::Timestamp(_))
625            ));
626            assert!(matches!(
627                map.map.get(&Key::String(Arc::new("name".into()))),
628                Some(Value::String(_))
629            ));
630            assert!(matches!(
631                map.map.get(&Key::String(Arc::new("timeout".into()))),
632                Some(Value::Duration(_))
633            ));
634        } else {
635            panic!("expected Map");
636        }
637    }
638
639    #[test]
640    fn array_items_format() {
641        let schema = json!({
642            "type": "array",
643            "items": {
644                "type": "string",
645                "format": "date-time"
646            }
647        });
648        let value = json!(["2024-01-01T00:00:00Z", "2024-06-15T12:00:00+09:00"]);
649        let result = json_to_cel_with_schema(&value, &schema);
650        if let Value::List(items) = result {
651            assert!(matches!(items[0], Value::Timestamp(_)));
652            assert!(matches!(items[1], Value::Timestamp(_)));
653        } else {
654            panic!("expected List");
655        }
656    }
657
658    #[test]
659    fn no_format_leaves_string_unchanged() {
660        let schema = json!({
661            "type": "string"
662        });
663        let value = json!("2024-01-01T00:00:00Z");
664        let result = json_to_cel_with_schema(&value, &schema);
665        assert_eq!(result, Value::String(Arc::new("2024-01-01T00:00:00Z".into())));
666    }
667
668    #[test]
669    fn json_to_cel_unchanged_with_no_schema() {
670        // Original json_to_cel should still work as before
671        let value = json!("2024-01-01T00:00:00Z");
672        let result = json_to_cel(&value);
673        assert_eq!(result, Value::String(Arc::new("2024-01-01T00:00:00Z".into())));
674    }
675
676    #[test]
677    fn int_or_string_schema_format_detected() {
678        let schema = json!({"x-kubernetes-int-or-string": true});
679        assert_eq!(SchemaFormat::from_schema(&schema), SchemaFormat::IntOrString);
680    }
681
682    #[test]
683    fn int_or_string_int_value_preserved() {
684        let schema = json!({"x-kubernetes-int-or-string": true});
685        let result = json_to_cel_with_schema(&json!(8080), &schema);
686        assert_eq!(result, Value::Int(8080));
687    }
688
689    #[test]
690    fn int_or_string_string_value_preserved() {
691        let schema = json!({"x-kubernetes-int-or-string": true});
692        let result = json_to_cel_with_schema(&json!("http"), &schema);
693        assert_eq!(result, Value::String(Arc::new("http".into())));
694    }
695
696    #[test]
697    fn int_or_string_overrides_format() {
698        // Even with format: date-time, int-or-string takes precedence
699        let schema = json!({"x-kubernetes-int-or-string": true, "format": "date-time"});
700        assert_eq!(SchemaFormat::from_schema(&schema), SchemaFormat::IntOrString);
701    }
702
703    // ── additionalProperties map-key escaping (#8) ──────────────────
704    // The apiserver escapes only declared `properties` (struct field names);
705    // `additionalProperties` map keys are passed through literally. We must
706    // match that, or rules like `'a.b/c' in self.m` never fire client-side.
707
708    /// Look up a key in a converted object, asserting the result is a Map.
709    fn map_has_key(value: &Value, key: &str) -> bool {
710        match value {
711            Value::Map(m) => m.map.contains_key(&Key::String(Arc::new(key.into()))),
712            _ => panic!("expected Map"),
713        }
714    }
715
716    #[test]
717    fn additional_properties_object_keys_not_escaped() {
718        // map[string]string: additionalProperties is an object schema.
719        let schema = json!({
720            "type": "object",
721            "additionalProperties": {"type": "string"}
722        });
723        let value = json!({"rio.build/fetcher": "true"});
724        let result = json_to_cel_with_schema(&value, &schema);
725        // Literal key present (apiserver behavior); escaped key absent (the #8 bug).
726        assert!(map_has_key(&result, "rio.build/fetcher"));
727        assert!(!map_has_key(&result, "rio__dot__build__slash__fetcher"));
728    }
729
730    #[test]
731    fn additional_properties_true_keys_not_escaped() {
732        // Free-form map: additionalProperties is `true`.
733        let schema = json!({
734            "type": "object",
735            "additionalProperties": true
736        });
737        let value = json!({"a.b/c": "x"});
738        let result = json_to_cel_with_schema(&value, &schema);
739        assert!(map_has_key(&result, "a.b/c"));
740        assert!(!map_has_key(&result, "a__dot__b__slash__c"));
741    }
742
743    #[test]
744    fn declared_properties_still_escaped() {
745        // Regression guard: struct field names (declared properties) keep escaping.
746        // additionalProperties:false marks a closed struct, not a map.
747        let schema = json!({
748            "type": "object",
749            "additionalProperties": false,
750            "properties": {
751                "app.kubernetes.io/name": {"type": "string"}
752            }
753        });
754        let value = json!({"app.kubernetes.io/name": "web"});
755        let result = json_to_cel_with_schema(&value, &schema);
756        assert!(map_has_key(&result, "app__dot__kubernetes__dot__io__slash__name"));
757        assert!(!map_has_key(&result, "app.kubernetes.io/name"));
758    }
759
760    #[test]
761    fn nested_map_under_property_keys_not_escaped() {
762        // nodeSelector typed as map[string]string under a declared property.
763        let schema = json!({
764            "type": "object",
765            "properties": {
766                "nodeSelector": {
767                    "type": "object",
768                    "additionalProperties": {"type": "string"}
769                }
770            }
771        });
772        let value = json!({"nodeSelector": {"rio.build/fetcher": "true"}});
773        let result = json_to_cel_with_schema(&value, &schema);
774        let Value::Map(outer) = &result else {
775            panic!("expected Map")
776        };
777        let inner = outer
778            .map
779            .get(&Key::String(Arc::new("nodeSelector".into())))
780            .unwrap();
781        assert!(map_has_key(inner, "rio.build/fetcher"));
782        assert!(!map_has_key(inner, "rio__dot__build__slash__fetcher"));
783    }
784
785    #[test]
786    fn compiled_additional_properties_keys_not_escaped() {
787        // Same as the object case but through the pre-compiled schema path.
788        let schema = json!({
789            "type": "object",
790            "additionalProperties": {"type": "string"}
791        });
792        let compiled = crate::validation::compilation::compile_schema(&schema);
793        let value = json!({"rio.build/fetcher": "true"});
794        let result = json_to_cel_with_compiled(&value, &compiled);
795        assert!(map_has_key(&result, "rio.build/fetcher"));
796        assert!(!map_has_key(&result, "rio__dot__build__slash__fetcher"));
797    }
798
799    #[test]
800    fn compiled_additional_properties_true_keys_not_escaped() {
801        let schema = json!({
802            "type": "object",
803            "additionalProperties": true
804        });
805        let compiled = crate::validation::compilation::compile_schema(&schema);
806        let value = json!({"a.b/c": "x"});
807        let result = json_to_cel_with_compiled(&value, &compiled);
808        assert!(map_has_key(&result, "a.b/c"));
809        assert!(!map_has_key(&result, "a__dot__b__slash__c"));
810    }
811
812    #[test]
813    fn compiled_declared_properties_still_escaped() {
814        let schema = json!({
815            "type": "object",
816            "additionalProperties": false,
817            "properties": {
818                "app.kubernetes.io/name": {"type": "string"}
819            }
820        });
821        let compiled = crate::validation::compilation::compile_schema(&schema);
822        let value = json!({"app.kubernetes.io/name": "web"});
823        let result = json_to_cel_with_compiled(&value, &compiled);
824        assert!(map_has_key(&result, "app__dot__kubernetes__dot__io__slash__name"));
825        assert!(!map_has_key(&result, "app.kubernetes.io/name"));
826    }
827}
828
829/// End-to-end tests of the internal `json_to_cel` conversion through real CEL
830/// compilation + evaluation. Relocated from `tests/cel_evaluation.rs` when
831/// `json_to_cel` became `pub(crate)` — an integration test can no longer reach
832/// it, so its coverage lives here in-crate.
833#[cfg(test)]
834mod cel_evaluation_tests {
835    use std::sync::Arc;
836
837    use cel::{Context, Program, Value};
838    use serde_json::json;
839
840    use super::{json_to_cel, json_to_cel_with_compiled};
841    use crate::{register_all, validation::compilation::compile_schema};
842
843    /// Build a context with kube-cel functions, bind `self` from JSON, compile,
844    /// and return the evaluation result.
845    fn eval_self(json_val: serde_json::Value, expr: &str) -> Value {
846        let mut ctx = Context::default();
847        register_all(&mut ctx);
848        ctx.add_variable_from_value("self", json_to_cel(&json_val));
849        Program::compile(expr).unwrap().execute(&ctx).unwrap()
850    }
851
852    /// `'key' in self.<map>` must fire on the literal user-supplied key, not an
853    /// escaped form. Mirrors the kube-rs/kube-cel#8 repro end-to-end: without the
854    /// fix the rule passes for inputs the apiserver rejects (fail-open).
855    #[test]
856    fn in_operator_matches_unescaped_map_key() {
857        let schema = json!({
858            "type": "object",
859            "properties": {
860                "m": {"type": "object", "additionalProperties": {"type": "string"}}
861            }
862        });
863        let compiled = compile_schema(&schema);
864        let self_val = json!({"m": {"a.b/c": "x"}});
865
866        let mut ctx = Context::default();
867        register_all(&mut ctx);
868        ctx.add_variable_from_value("self", json_to_cel_with_compiled(&self_val, &compiled));
869
870        // Rule from the issue: must REJECT (false) when the forbidden key is present.
871        let prog = Program::compile("!('a.b/c' in self.m)").unwrap();
872        assert_eq!(prog.execute(&ctx).unwrap(), Value::Bool(false));
873    }
874
875    /// Same as `eval_self` but also binds `oldSelf`.
876    fn eval_transition(json_self: serde_json::Value, json_old: serde_json::Value, expr: &str) -> Value {
877        let mut ctx = Context::default();
878        register_all(&mut ctx);
879        ctx.add_variable_from_value("self", json_to_cel(&json_self));
880        ctx.add_variable_from_value("oldSelf", json_to_cel(&json_old));
881        Program::compile(expr).unwrap().execute(&ctx).unwrap()
882    }
883
884    #[test]
885    fn scalar_comparison() {
886        assert_eq!(eval_self(json!(10), "self >= 0"), Value::Bool(true));
887        assert_eq!(eval_self(json!(-1), "self >= 0"), Value::Bool(false));
888    }
889
890    #[test]
891    fn field_access_int() {
892        let obj = json!({"replicas": 3});
893        assert_eq!(eval_self(obj, "self.replicas"), Value::Int(3));
894    }
895
896    #[test]
897    fn field_access_string() {
898        let obj = json!({"name": "my-app"});
899        assert_eq!(
900            eval_self(obj, "self.name"),
901            Value::String(Arc::new("my-app".into()))
902        );
903    }
904
905    #[test]
906    fn nested_field_comparison() {
907        let obj = json!({"spec": {"replicas": 5, "minReplicas": 2}});
908        assert_eq!(
909            eval_self(obj, "self.spec.replicas >= self.spec.minReplicas"),
910            Value::Bool(true)
911        );
912    }
913
914    #[test]
915    fn transition_rule_oldself() {
916        let new = json!({"replicas": 5});
917        let old = json!({"replicas": 3});
918        assert_eq!(
919            eval_transition(new, old, "self.replicas >= oldSelf.replicas"),
920            Value::Bool(true)
921        );
922    }
923
924    #[test]
925    fn transition_rule_downscale_rejected() {
926        let new = json!({"replicas": 1});
927        let old = json!({"replicas": 3});
928        assert_eq!(
929            eval_transition(new, old, "self.replicas >= oldSelf.replicas"),
930            Value::Bool(false)
931        );
932    }
933
934    #[test]
935    fn detect_oldself_reference() {
936        let prog1 = Program::compile("self.replicas >= oldSelf.replicas").unwrap();
937        assert!(prog1.references().has_variable("oldSelf"));
938        assert!(prog1.references().has_variable("self"));
939
940        let prog2 = Program::compile("self.replicas >= 0").unwrap();
941        assert!(!prog2.references().has_variable("oldSelf"));
942        assert!(prog2.references().has_variable("self"));
943    }
944
945    #[test]
946    #[cfg(feature = "strings")]
947    fn extension_trim_lower_ascii() {
948        let obj = json!({"name": "  Hello World  "});
949        assert_eq!(
950            eval_self(obj, "self.name.trim().lowerAscii()"),
951            Value::String(Arc::new("hello world".into()))
952        );
953    }
954
955    #[test]
956    #[cfg(feature = "lists")]
957    fn extension_is_sorted() {
958        let obj = json!({"items": [1, 2, 3, 4]});
959        assert_eq!(eval_self(obj, "self.items.isSorted()"), Value::Bool(true));
960
961        let obj2 = json!({"items": [3, 1, 2]});
962        assert_eq!(eval_self(obj2, "self.items.isSorted()"), Value::Bool(false));
963    }
964
965    #[test]
966    fn array_indexing() {
967        let obj = json!({"containers": [{"name": "nginx"}, {"name": "sidecar"}]});
968        assert_eq!(
969            eval_self(obj, "self.containers[0].name"),
970            Value::String(Arc::new("nginx".into()))
971        );
972    }
973
974    #[test]
975    fn null_comparison() {
976        let obj = json!({"extra": null});
977        assert_eq!(eval_self(obj, "self.extra == null"), Value::Bool(true));
978    }
979
980    #[test]
981    fn non_null_comparison() {
982        let obj = json!({"extra": "present"});
983        assert_eq!(eval_self(obj, "self.extra == null"), Value::Bool(false));
984    }
985
986    #[test]
987    fn has_macro_present() {
988        let obj = json!({"name": "test"});
989        assert_eq!(eval_self(obj, "has(self.name)"), Value::Bool(true));
990    }
991
992    #[test]
993    fn has_macro_missing() {
994        let obj = json!({"name": "test"});
995        assert_eq!(eval_self(obj, "has(self.missing)"), Value::Bool(false));
996    }
997
998    #[test]
999    fn size_of_list() {
1000        let obj = json!({"items": [1, 2, 3]});
1001        assert_eq!(eval_self(obj, "size(self.items)"), Value::Int(3));
1002    }
1003
1004    #[test]
1005    fn size_of_string() {
1006        let obj = json!({"name": "hello"});
1007        assert_eq!(eval_self(obj, "size(self.name)"), Value::Int(5));
1008    }
1009}