1use serde_json::Value;
2
3pub 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 if bytes[i] == b'+' || bytes[i] == b'-' {
21 i += 1;
22 if i >= bytes.len() {
23 return false;
24 }
25 }
26
27 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 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 if !has_integer_part && !has_fractional_part {
47 return false;
48 }
49
50 if i >= bytes.len() {
52 return true;
53 }
54
55 let rest = &s[i..];
56
57 if is_suffix(rest) {
59 return true;
60 }
61
62 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 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#[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
120pub(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 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 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 }
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 #[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 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")); assert!(!is_valid_quantity("1mm")); }
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 #[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}