Skip to main content

husako_core/
quantity.rs

1use serde_json::Value;
2
3/// Validates a string against the Kubernetes quantity grammar.
4///
5/// Grammar: `sign? digits (. digits?)? (exponent | suffix)?`
6/// - DecimalSI suffixes: n, u, m, k, M, G, T, P, E
7/// - BinarySI suffixes: Ki, Mi, Gi, Ti, Pi, Ei
8/// - Exponent: e/E followed by optional sign and digits
9///
10/// Disambiguation: `"1E"` is suffix Exa, not an incomplete exponent.
11pub fn is_valid_quantity(s: &str) -> bool {
12    let bytes = s.as_bytes();
13    if bytes.is_empty() {
14        return false;
15    }
16
17    let mut i = 0;
18
19    // Optional sign
20    if bytes[i] == b'+' || bytes[i] == b'-' {
21        i += 1;
22        if i >= bytes.len() {
23            return false;
24        }
25    }
26
27    // Must have at least one digit or a leading dot followed by digit
28    let digits_start = i;
29    while i < bytes.len() && bytes[i].is_ascii_digit() {
30        i += 1;
31    }
32    let has_integer_part = i > digits_start;
33
34    // Optional decimal point
35    let mut has_fractional_part = false;
36    if i < bytes.len() && bytes[i] == b'.' {
37        i += 1;
38        let frac_start = i;
39        while i < bytes.len() && bytes[i].is_ascii_digit() {
40            i += 1;
41        }
42        has_fractional_part = i > frac_start;
43    }
44
45    // Must have at least some numeric content
46    if !has_integer_part && !has_fractional_part {
47        return false;
48    }
49
50    // Nothing left — bare number is valid
51    if i >= bytes.len() {
52        return true;
53    }
54
55    let rest = &s[i..];
56
57    // Try suffix match first (before exponent, to handle "1E" as Exa)
58    if is_suffix(rest) {
59        return true;
60    }
61
62    // Try exponent: e/E followed by optional sign and digits
63    if rest.starts_with('e') || rest.starts_with('E') {
64        let mut j = 1;
65        let rest_bytes = rest.as_bytes();
66        if j < rest_bytes.len() && (rest_bytes[j] == b'+' || rest_bytes[j] == b'-') {
67            j += 1;
68        }
69        let exp_digits_start = j;
70        while j < rest_bytes.len() && rest_bytes[j].is_ascii_digit() {
71            j += 1;
72        }
73        // Must have at least one digit after e/E (and optional sign)
74        if j > exp_digits_start && j == rest_bytes.len() {
75            return true;
76        }
77    }
78
79    false
80}
81
82fn is_suffix(s: &str) -> bool {
83    matches!(
84        s,
85        "n" | "u"
86            | "m"
87            | "k"
88            | "M"
89            | "G"
90            | "T"
91            | "P"
92            | "E"
93            | "Ki"
94            | "Mi"
95            | "Gi"
96            | "Ti"
97            | "Pi"
98            | "Ei"
99    )
100}
101
102/// A single quantity validation error (used by the fallback heuristic).
103#[derive(Debug)]
104pub struct QuantityError {
105    pub doc_index: usize,
106    pub path: String,
107    pub value: String,
108}
109
110impl std::fmt::Display for QuantityError {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        write!(
113            f,
114            "doc[{}] at {}: invalid quantity {:?}",
115            self.doc_index, self.path, self.value
116        )
117    }
118}
119
120// --- Fallback heuristic ---
121
122/// Recursively searches for `resources.requests.*` and `resources.limits.*`
123/// at any depth, validating leaf values as quantities.
124pub(crate) fn validate_doc_fallback(
125    doc: &Value,
126    doc_index: usize,
127    errors: &mut Vec<QuantityError>,
128) {
129    search_resources(doc, "$", doc_index, errors);
130}
131
132fn search_resources(
133    value: &Value,
134    current_path: &str,
135    doc_index: usize,
136    errors: &mut Vec<QuantityError>,
137) {
138    let obj = match value.as_object() {
139        Some(o) => o,
140        None => return,
141    };
142
143    // Check if this object has a "resources" key
144    if let Some(resources) = obj.get("resources")
145        && let Some(res_obj) = resources.as_object()
146    {
147        let res_path = format!("{current_path}.resources");
148        for target in &["requests", "limits"] {
149            if let Some(target_val) = res_obj.get(*target) {
150                let target_path = format!("{res_path}.{target}");
151                validate_quantity_map(target_val, &target_path, doc_index, errors);
152            }
153        }
154    }
155
156    // Recurse into all object/array children
157    for (key, child) in obj {
158        let child_path = format!("{current_path}.{key}");
159        match child {
160            Value::Object(_) => {
161                search_resources(child, &child_path, doc_index, errors);
162            }
163            Value::Array(arr) => {
164                for (i, item) in arr.iter().enumerate() {
165                    let item_path = format!("{child_path}[{i}]");
166                    search_resources(item, &item_path, doc_index, errors);
167                }
168            }
169            _ => {}
170        }
171    }
172}
173
174fn validate_quantity_map(
175    value: &Value,
176    path: &str,
177    doc_index: usize,
178    errors: &mut Vec<QuantityError>,
179) {
180    if let Some(obj) = value.as_object() {
181        for (key, val) in obj {
182            let leaf_path = format!("{path}.{key}");
183            validate_leaf(val, &leaf_path, doc_index, errors);
184        }
185    }
186}
187
188fn validate_leaf(value: &Value, path: &str, doc_index: usize, errors: &mut Vec<QuantityError>) {
189    match value {
190        Value::String(s) => {
191            if !is_valid_quantity(s) {
192                errors.push(QuantityError {
193                    doc_index,
194                    path: path.to_string(),
195                    value: s.clone(),
196                });
197            }
198        }
199        Value::Number(_) | Value::Null => {
200            // Valid: numbers are implicit quantities, null is optional
201        }
202        _ => {
203            errors.push(QuantityError {
204                doc_index,
205                path: path.to_string(),
206                value: format!("{value}"),
207            });
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use serde_json::json;
216
217    // --- is_valid_quantity ---
218
219    #[test]
220    fn valid_bare_number() {
221        assert!(is_valid_quantity("1"));
222        assert!(is_valid_quantity("0"));
223        assert!(is_valid_quantity("100"));
224    }
225
226    #[test]
227    fn valid_decimal() {
228        assert!(is_valid_quantity(".5"));
229        assert!(is_valid_quantity("2.5"));
230        assert!(is_valid_quantity("0.1"));
231    }
232
233    #[test]
234    fn valid_with_sign() {
235        assert!(is_valid_quantity("+1"));
236        assert!(is_valid_quantity("-1"));
237    }
238
239    #[test]
240    fn valid_millicores() {
241        assert!(is_valid_quantity("500m"));
242        assert!(is_valid_quantity("100m"));
243    }
244
245    #[test]
246    fn valid_binary_si() {
247        assert!(is_valid_quantity("1Gi"));
248        assert!(is_valid_quantity("100Mi"));
249        assert!(is_valid_quantity("2.5Gi"));
250        assert!(is_valid_quantity("1Ki"));
251    }
252
253    #[test]
254    fn valid_decimal_si() {
255        assert!(is_valid_quantity("1k"));
256        assert!(is_valid_quantity("1M"));
257        assert!(is_valid_quantity("1G"));
258        assert!(is_valid_quantity("1n"));
259        assert!(is_valid_quantity("1u"));
260    }
261
262    #[test]
263    fn valid_exa_suffix() {
264        // "1E" should be Exa suffix, not an incomplete exponent
265        assert!(is_valid_quantity("1E"));
266        assert!(is_valid_quantity("1Ei"));
267    }
268
269    #[test]
270    fn valid_exponent() {
271        assert!(is_valid_quantity("1e3"));
272        assert!(is_valid_quantity("1E3"));
273        assert!(is_valid_quantity("1e+3"));
274        assert!(is_valid_quantity("1e-3"));
275    }
276
277    #[test]
278    fn invalid_empty() {
279        assert!(!is_valid_quantity(""));
280    }
281
282    #[test]
283    fn invalid_no_digits() {
284        assert!(!is_valid_quantity("abc"));
285        assert!(!is_valid_quantity("Gi"));
286        assert!(!is_valid_quantity("e3"));
287    }
288
289    #[test]
290    fn invalid_wrong_suffix() {
291        assert!(!is_valid_quantity("2gb"));
292        assert!(!is_valid_quantity("1gi")); // lowercase gi is not valid
293        assert!(!is_valid_quantity("1mm")); // double m
294    }
295
296    #[test]
297    fn invalid_space() {
298        assert!(!is_valid_quantity("1 Gi"));
299    }
300
301    #[test]
302    fn invalid_multiple_dots() {
303        assert!(!is_valid_quantity("1.2.3"));
304    }
305
306    // --- Fallback heuristic ---
307
308    #[test]
309    fn fallback_validates_resources() {
310        let doc = json!({
311            "spec": {
312                "template": {
313                    "spec": {
314                        "containers": [{
315                            "resources": {
316                                "requests": {"cpu": "bad"},
317                                "limits": {"memory": "1Gi"}
318                            }
319                        }]
320                    }
321                }
322            }
323        });
324        let mut errors = Vec::new();
325        validate_doc_fallback(&doc, 0, &mut errors);
326        assert_eq!(errors.len(), 1);
327        assert!(errors[0].path.contains("requests.cpu"));
328        assert_eq!(errors[0].value, "bad");
329    }
330
331    #[test]
332    fn fallback_valid_resources() {
333        let doc = json!({
334            "spec": {
335                "template": {
336                    "spec": {
337                        "containers": [{
338                            "resources": {
339                                "requests": {"cpu": "500m", "memory": "1Gi"},
340                                "limits": {"cpu": "1", "memory": "2Gi"}
341                            }
342                        }]
343                    }
344                }
345            }
346        });
347        let mut errors = Vec::new();
348        validate_doc_fallback(&doc, 0, &mut errors);
349        assert!(errors.is_empty());
350    }
351
352    #[test]
353    fn fallback_no_resources_is_ok() {
354        let doc = json!({"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": "test"}});
355        let mut errors = Vec::new();
356        validate_doc_fallback(&doc, 0, &mut errors);
357        assert!(errors.is_empty());
358    }
359}