1use std::collections::HashMap;
2use std::fmt;
3use std::path::Path;
4
5use serde_json::Value;
6
7use crate::quantity;
8
9const MAX_DEPTH: usize = 64;
10
11#[derive(Debug, Clone)]
17pub struct SchemaStore {
18 gvk_index: HashMap<String, String>,
19 schemas: HashMap<String, Value>,
20}
21
22impl SchemaStore {
23 pub fn from_json(value: &Value) -> Option<Self> {
25 let obj = value.as_object()?;
26
27 let version = obj.get("version")?.as_u64()?;
28 if version != 2 {
29 return None;
30 }
31
32 let gvk_index: HashMap<String, String> = obj
33 .get("gvk_index")?
34 .as_object()?
35 .iter()
36 .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
37 .collect();
38
39 let schemas: HashMap<String, Value> = obj
40 .get("schemas")?
41 .as_object()?
42 .iter()
43 .map(|(k, v)| (k.clone(), v.clone()))
44 .collect();
45
46 Some(Self { gvk_index, schemas })
47 }
48
49 fn schema_for_gvk(&self, api_version: &str, kind: &str) -> Option<&Value> {
50 let key = format!("{api_version}:{kind}");
51 let schema_name = self.gvk_index.get(&key)?;
52 self.schemas.get(schema_name)
53 }
54
55 fn resolve_ref(&self, ref_name: &str) -> Option<&Value> {
56 self.schemas.get(ref_name)
57 }
58}
59
60pub fn load_schema_store(project_root: &Path) -> Option<SchemaStore> {
62 let path = project_root.join(".husako/types/k8s/_schema.json");
63 let content = std::fs::read_to_string(path).ok()?;
64 let value: Value = serde_json::from_str(&content).ok()?;
65 SchemaStore::from_json(&value)
66}
67
68#[derive(Debug)]
73pub struct ValidationError {
74 pub doc_index: usize,
75 pub path: String,
76 pub kind: ValidationErrorKind,
77}
78
79#[derive(Debug)]
80pub enum ValidationErrorKind {
81 TypeMismatch { expected: &'static str, got: String },
82 MissingRequired { field: String },
83 InvalidEnum { value: String, allowed: Vec<String> },
84 InvalidQuantity { value: String },
85 PatternMismatch { value: String, pattern: String },
86 BelowMinimum { value: f64, minimum: f64 },
87 AboveMaximum { value: f64, maximum: f64 },
88}
89
90impl fmt::Display for ValidationError {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 write!(f, "doc[{}] at {}: ", self.doc_index, self.path)?;
93 match &self.kind {
94 ValidationErrorKind::TypeMismatch { expected, got } => {
95 write!(f, "expected type {expected}, got {got}")
96 }
97 ValidationErrorKind::MissingRequired { field } => {
98 write!(f, "missing required field \"{field}\"")
99 }
100 ValidationErrorKind::InvalidEnum { value, allowed } => {
101 let opts = allowed.join(", ");
102 write!(f, "invalid value \"{value}\", expected one of: {opts}")
103 }
104 ValidationErrorKind::InvalidQuantity { value } => {
105 write!(f, "invalid quantity \"{value}\"")
106 }
107 ValidationErrorKind::PatternMismatch { value, pattern } => {
108 write!(f, "value \"{value}\" does not match pattern \"{pattern}\"")
109 }
110 ValidationErrorKind::BelowMinimum { value, minimum } => {
111 write!(f, "value {value} is below minimum {minimum}")
112 }
113 ValidationErrorKind::AboveMaximum { value, maximum } => {
114 write!(f, "value {value} is above maximum {maximum}")
115 }
116 }
117 }
118}
119
120pub fn validate(value: &Value, store: Option<&SchemaStore>) -> Result<(), Vec<ValidationError>> {
130 let docs = match value.as_array() {
131 Some(arr) => arr,
132 None => return Ok(()),
133 };
134
135 let mut errors = Vec::new();
136
137 for (idx, doc) in docs.iter().enumerate() {
138 if let Some(store) = store {
139 let api_version = doc.get("apiVersion").and_then(Value::as_str).unwrap_or("");
140 let kind = doc.get("kind").and_then(Value::as_str).unwrap_or("");
141
142 if let Some(schema) = store.schema_for_gvk(api_version, kind) {
143 validate_value(doc, schema, store, "$", idx, 0, &mut errors);
144 continue;
145 }
146 }
147 validate_doc_fallback(doc, idx, &mut errors);
149 }
150
151 if errors.is_empty() {
152 Ok(())
153 } else {
154 Err(errors)
155 }
156}
157
158fn validate_doc_fallback(doc: &Value, doc_index: usize, errors: &mut Vec<ValidationError>) {
159 let mut qty_errors = Vec::new();
160 quantity::validate_doc_fallback(doc, doc_index, &mut qty_errors);
161 for qe in qty_errors {
162 errors.push(ValidationError {
163 doc_index: qe.doc_index,
164 path: qe.path,
165 kind: ValidationErrorKind::InvalidQuantity { value: qe.value },
166 });
167 }
168}
169
170fn validate_value(
175 value: &Value,
176 schema: &Value,
177 store: &SchemaStore,
178 path: &str,
179 doc_index: usize,
180 depth: usize,
181 errors: &mut Vec<ValidationError>,
182) {
183 if depth > MAX_DEPTH {
184 return;
185 }
186
187 if value.is_null() {
189 return;
190 }
191
192 if let Some(ref_name) = schema.get("$ref").and_then(Value::as_str) {
194 if let Some(resolved) = store.resolve_ref(ref_name) {
195 validate_value(value, resolved, store, path, doc_index, depth + 1, errors);
196 }
197 return;
198 }
199
200 if let Some(all_of) = schema.get("allOf").and_then(Value::as_array) {
202 for sub in all_of {
203 validate_value(value, sub, store, path, doc_index, depth + 1, errors);
204 }
205 return;
206 }
207
208 if schema
210 .get("x-kubernetes-int-or-string")
211 .and_then(Value::as_bool)
212 == Some(true)
213 {
214 match value {
215 Value::Number(_) | Value::String(_) => {}
216 _ => {
217 errors.push(ValidationError {
218 doc_index,
219 path: path.to_string(),
220 kind: ValidationErrorKind::TypeMismatch {
221 expected: "integer or string",
222 got: json_type_name(value).to_string(),
223 },
224 });
225 }
226 }
227 return;
228 }
229
230 if let Some(format) = schema.get("format").and_then(Value::as_str)
232 && format == "quantity"
233 {
234 validate_quantity(value, path, doc_index, errors);
235 return;
236 }
237
238 if let Some(type_str) = schema.get("type").and_then(Value::as_str)
240 && !check_type(value, type_str)
241 {
242 errors.push(ValidationError {
243 doc_index,
244 path: path.to_string(),
245 kind: ValidationErrorKind::TypeMismatch {
246 expected: type_str_to_label(type_str),
247 got: json_type_name(value).to_string(),
248 },
249 });
250 return;
251 }
252
253 if let Some(enum_vals) = schema.get("enum").and_then(Value::as_array)
255 && let Value::String(s) = value
256 {
257 let allowed: Vec<String> = enum_vals
258 .iter()
259 .filter_map(Value::as_str)
260 .map(String::from)
261 .collect();
262 if !allowed.iter().any(|a| a == s) {
263 errors.push(ValidationError {
264 doc_index,
265 path: path.to_string(),
266 kind: ValidationErrorKind::InvalidEnum {
267 value: s.clone(),
268 allowed,
269 },
270 });
271 return;
272 }
273 }
274
275 if let Some(n) = value_as_f64(value) {
277 if let Some(min) = schema.get("minimum").and_then(value_as_f64_ref)
278 && n < min
279 {
280 errors.push(ValidationError {
281 doc_index,
282 path: path.to_string(),
283 kind: ValidationErrorKind::BelowMinimum {
284 value: n,
285 minimum: min,
286 },
287 });
288 }
289 if let Some(max) = schema.get("maximum").and_then(value_as_f64_ref)
290 && n > max
291 {
292 errors.push(ValidationError {
293 doc_index,
294 path: path.to_string(),
295 kind: ValidationErrorKind::AboveMaximum {
296 value: n,
297 maximum: max,
298 },
299 });
300 }
301 }
302
303 if let Some(pattern) = schema.get("pattern").and_then(Value::as_str)
305 && let Value::String(s) = value
306 && let Ok(re) = regex_lite::Regex::new(pattern)
307 && !re.is_match(s)
308 {
309 errors.push(ValidationError {
310 doc_index,
311 path: path.to_string(),
312 kind: ValidationErrorKind::PatternMismatch {
313 value: s.clone(),
314 pattern: pattern.to_string(),
315 },
316 });
317 }
318
319 if let Value::Object(obj) = value {
321 if let Some(required) = schema.get("required").and_then(Value::as_array) {
322 for req in required {
323 if let Some(field) = req.as_str()
324 && !obj.contains_key(field)
325 {
326 errors.push(ValidationError {
327 doc_index,
328 path: path.to_string(),
329 kind: ValidationErrorKind::MissingRequired {
330 field: field.to_string(),
331 },
332 });
333 }
334 }
335 }
336
337 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
338 for (prop_name, prop_schema) in properties {
339 if let Some(child) = obj.get(prop_name) {
340 let child_path = format!("{path}.{prop_name}");
341 validate_value(
342 child,
343 prop_schema,
344 store,
345 &child_path,
346 doc_index,
347 depth + 1,
348 errors,
349 );
350 }
351 }
352 }
353
354 if let Some(additional) = schema.get("additionalProperties") {
355 let known_props: std::collections::HashSet<&str> = schema
356 .get("properties")
357 .and_then(Value::as_object)
358 .map(|p| p.keys().map(String::as_str).collect())
359 .unwrap_or_default();
360
361 for (key, child) in obj {
362 if !known_props.contains(key.as_str()) {
363 let child_path = format!("{path}.{key}");
364 validate_value(
365 child,
366 additional,
367 store,
368 &child_path,
369 doc_index,
370 depth + 1,
371 errors,
372 );
373 }
374 }
375 }
376 }
377
378 if let Value::Array(arr) = value
380 && let Some(items) = schema.get("items")
381 {
382 for (i, item) in arr.iter().enumerate() {
383 let item_path = format!("{path}[{i}]");
384 validate_value(item, items, store, &item_path, doc_index, depth + 1, errors);
385 }
386 }
387}
388
389fn validate_quantity(
394 value: &Value,
395 path: &str,
396 doc_index: usize,
397 errors: &mut Vec<ValidationError>,
398) {
399 match value {
400 Value::String(s) => {
401 if !quantity::is_valid_quantity(s) {
402 errors.push(ValidationError {
403 doc_index,
404 path: path.to_string(),
405 kind: ValidationErrorKind::InvalidQuantity { value: s.clone() },
406 });
407 }
408 }
409 Value::Number(_) | Value::Null => {} _ => {
411 errors.push(ValidationError {
412 doc_index,
413 path: path.to_string(),
414 kind: ValidationErrorKind::TypeMismatch {
415 expected: "string or number (quantity)",
416 got: json_type_name(value).to_string(),
417 },
418 });
419 }
420 }
421}
422
423fn check_type(value: &Value, type_str: &str) -> bool {
424 match type_str {
425 "string" => value.is_string(),
426 "integer" => value.is_i64() || value.is_u64(),
427 "number" => value.is_number(),
428 "boolean" => value.is_boolean(),
429 "array" => value.is_array(),
430 "object" => value.is_object(),
431 _ => true, }
433}
434
435fn type_str_to_label(s: &str) -> &'static str {
436 match s {
437 "string" => "string",
438 "integer" => "integer",
439 "number" => "number",
440 "boolean" => "boolean",
441 "array" => "array",
442 "object" => "object",
443 _ => "unknown",
444 }
445}
446
447fn json_type_name(value: &Value) -> &'static str {
448 match value {
449 Value::Null => "null",
450 Value::Bool(_) => "boolean",
451 Value::Number(_) => "number",
452 Value::String(_) => "string",
453 Value::Array(_) => "array",
454 Value::Object(_) => "object",
455 }
456}
457
458fn value_as_f64(value: &Value) -> Option<f64> {
459 value.as_f64()
460}
461
462fn value_as_f64_ref(value: &Value) -> Option<f64> {
463 value.as_f64()
464}
465
466#[cfg(test)]
471mod tests {
472 use super::*;
473 use serde_json::json;
474
475 fn make_store(schemas_json: Value, gvk_json: Value) -> SchemaStore {
476 let store_json = json!({
477 "version": 2,
478 "gvk_index": gvk_json,
479 "schemas": schemas_json
480 });
481 SchemaStore::from_json(&store_json).unwrap()
482 }
483
484 fn simple_store() -> SchemaStore {
485 make_store(
486 json!({
487 "io.k8s.api.apps.v1.Deployment": {
488 "properties": {
489 "apiVersion": {"type": "string"},
490 "kind": {"type": "string"},
491 "metadata": {"$ref": "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},
492 "spec": {"$ref": "io.k8s.api.apps.v1.DeploymentSpec"}
493 },
494 "required": ["spec"]
495 },
496 "io.k8s.api.apps.v1.DeploymentSpec": {
497 "properties": {
498 "replicas": {"type": "integer"},
499 "selector": {"$ref": "io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector"},
500 "strategy": {"$ref": "io.k8s.api.apps.v1.DeploymentStrategy"},
501 "template": {"$ref": "io.k8s.api.core.v1.PodTemplateSpec"}
502 },
503 "required": ["selector"]
504 },
505 "io.k8s.api.apps.v1.DeploymentStrategy": {
506 "properties": {
507 "type": {
508 "type": "string",
509 "enum": ["Recreate", "RollingUpdate"]
510 }
511 }
512 },
513 "io.k8s.api.core.v1.PodTemplateSpec": {
514 "properties": {
515 "spec": {"$ref": "io.k8s.api.core.v1.PodSpec"}
516 }
517 },
518 "io.k8s.api.core.v1.PodSpec": {
519 "properties": {
520 "containers": {
521 "type": "array",
522 "items": {"$ref": "io.k8s.api.core.v1.Container"}
523 }
524 }
525 },
526 "io.k8s.api.core.v1.Container": {
527 "properties": {
528 "name": {"type": "string"},
529 "image": {"type": "string"},
530 "imagePullPolicy": {
531 "type": "string",
532 "enum": ["Always", "IfNotPresent", "Never"]
533 },
534 "ports": {
535 "type": "array",
536 "items": {"$ref": "io.k8s.api.core.v1.ContainerPort"}
537 },
538 "resources": {"$ref": "io.k8s.api.core.v1.ResourceRequirements"}
539 }
540 },
541 "io.k8s.api.core.v1.ContainerPort": {
542 "properties": {
543 "containerPort": {
544 "type": "integer",
545 "minimum": 1,
546 "maximum": 65535
547 },
548 "protocol": {
549 "type": "string",
550 "enum": ["TCP", "UDP", "SCTP"]
551 }
552 }
553 },
554 "io.k8s.api.core.v1.ResourceRequirements": {
555 "properties": {
556 "limits": {
557 "type": "object",
558 "additionalProperties": {"$ref": "io.k8s.apimachinery.pkg.api.resource.Quantity"}
559 },
560 "requests": {
561 "type": "object",
562 "additionalProperties": {"$ref": "io.k8s.apimachinery.pkg.api.resource.Quantity"}
563 }
564 }
565 },
566 "io.k8s.apimachinery.pkg.api.resource.Quantity": {
567 "type": "string",
568 "format": "quantity"
569 },
570 "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": {
571 "properties": {
572 "name": {"type": "string"},
573 "namespace": {"type": "string"},
574 "labels": {
575 "type": "object",
576 "additionalProperties": {"type": "string"}
577 }
578 }
579 },
580 "io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector": {
581 "properties": {
582 "matchLabels": {
583 "type": "object",
584 "additionalProperties": {"type": "string"}
585 }
586 }
587 }
588 }),
589 json!({
590 "apps/v1:Deployment": "io.k8s.api.apps.v1.Deployment"
591 }),
592 )
593 }
594
595 #[test]
598 fn type_mismatch_string_at_integer() {
599 let store = simple_store();
600 let doc = json!([{
601 "apiVersion": "apps/v1",
602 "kind": "Deployment",
603 "spec": {
604 "selector": {},
605 "replicas": "abc"
606 }
607 }]);
608 let errs = validate(&doc, Some(&store)).unwrap_err();
609 assert_eq!(errs.len(), 1);
610 assert!(errs[0].path.contains("replicas"));
611 assert!(matches!(
612 &errs[0].kind,
613 ValidationErrorKind::TypeMismatch {
614 expected: "integer",
615 ..
616 }
617 ));
618 assert!(errs[0].to_string().contains("expected type integer"));
619 assert!(errs[0].to_string().contains("string"));
620 }
621
622 #[test]
625 fn missing_required_field() {
626 let store = simple_store();
627 let doc = json!([{
628 "apiVersion": "apps/v1",
629 "kind": "Deployment",
630 "spec": {
631 "replicas": 3
632 }
633 }]);
634 let errs = validate(&doc, Some(&store)).unwrap_err();
635 assert!(errs.iter().any(|e| matches!(
636 &e.kind,
637 ValidationErrorKind::MissingRequired { field } if field == "selector"
638 )));
639 }
640
641 #[test]
644 fn invalid_enum_value() {
645 let store = simple_store();
646 let doc = json!([{
647 "apiVersion": "apps/v1",
648 "kind": "Deployment",
649 "spec": {
650 "selector": {},
651 "strategy": {
652 "type": "bluegreen"
653 }
654 }
655 }]);
656 let errs = validate(&doc, Some(&store)).unwrap_err();
657 assert_eq!(errs.len(), 1);
658 assert!(
659 matches!(&errs[0].kind, ValidationErrorKind::InvalidEnum { value, allowed }
660 if value == "bluegreen" && allowed.contains(&"Recreate".to_string())
661 )
662 );
663 assert!(errs[0].to_string().contains("bluegreen"));
664 }
665
666 #[test]
667 fn valid_enum_value() {
668 let store = simple_store();
669 let doc = json!([{
670 "apiVersion": "apps/v1",
671 "kind": "Deployment",
672 "spec": {
673 "selector": {},
674 "template": {
675 "spec": {
676 "containers": [{
677 "imagePullPolicy": "Always"
678 }]
679 }
680 }
681 }
682 }]);
683 assert!(validate(&doc, Some(&store)).is_ok());
684 }
685
686 #[test]
689 fn valid_quantity() {
690 let store = simple_store();
691 let doc = json!([{
692 "apiVersion": "apps/v1",
693 "kind": "Deployment",
694 "spec": {
695 "selector": {},
696 "template": {
697 "spec": {
698 "containers": [{
699 "resources": {
700 "requests": {"cpu": "500m", "memory": "1Gi"}
701 }
702 }]
703 }
704 }
705 }
706 }]);
707 assert!(validate(&doc, Some(&store)).is_ok());
708 }
709
710 #[test]
711 fn invalid_quantity() {
712 let store = simple_store();
713 let doc = json!([{
714 "apiVersion": "apps/v1",
715 "kind": "Deployment",
716 "spec": {
717 "selector": {},
718 "template": {
719 "spec": {
720 "containers": [{
721 "resources": {
722 "requests": {"cpu": "2gb"}
723 }
724 }]
725 }
726 }
727 }
728 }]);
729 let errs = validate(&doc, Some(&store)).unwrap_err();
730 assert_eq!(errs.len(), 1);
731 assert!(
732 matches!(&errs[0].kind, ValidationErrorKind::InvalidQuantity { value } if value == "2gb")
733 );
734 }
735
736 #[test]
737 fn number_at_quantity_is_valid() {
738 let store = simple_store();
739 let doc = json!([{
740 "apiVersion": "apps/v1",
741 "kind": "Deployment",
742 "spec": {
743 "selector": {},
744 "template": {
745 "spec": {
746 "containers": [{
747 "resources": {
748 "limits": {"cpu": 1}
749 }
750 }]
751 }
752 }
753 }
754 }]);
755 assert!(validate(&doc, Some(&store)).is_ok());
756 }
757
758 #[test]
761 fn pattern_match_ok() {
762 let store = make_store(
763 json!({
764 "test.Resource": {
765 "properties": {
766 "name": {
767 "type": "string",
768 "pattern": "^[a-z][a-z0-9-]*$"
769 }
770 }
771 }
772 }),
773 json!({ "v1:Test": "test.Resource" }),
774 );
775 let doc = json!([{
776 "apiVersion": "v1",
777 "kind": "Test",
778 "name": "my-resource-1"
779 }]);
780 assert!(validate(&doc, Some(&store)).is_ok());
781 }
782
783 #[test]
784 fn pattern_mismatch() {
785 let store = make_store(
786 json!({
787 "test.Resource": {
788 "properties": {
789 "name": {
790 "type": "string",
791 "pattern": "^[a-z][a-z0-9-]*$"
792 }
793 }
794 }
795 }),
796 json!({ "v1:Test": "test.Resource" }),
797 );
798 let doc = json!([{
799 "apiVersion": "v1",
800 "kind": "Test",
801 "name": "INVALID_NAME"
802 }]);
803 let errs = validate(&doc, Some(&store)).unwrap_err();
804 assert_eq!(errs.len(), 1);
805 assert!(matches!(
806 &errs[0].kind,
807 ValidationErrorKind::PatternMismatch { .. }
808 ));
809 }
810
811 #[test]
814 fn port_in_range() {
815 let store = simple_store();
816 let doc = json!([{
817 "apiVersion": "apps/v1",
818 "kind": "Deployment",
819 "spec": {
820 "selector": {},
821 "template": {
822 "spec": {
823 "containers": [{
824 "ports": [{"containerPort": 80}]
825 }]
826 }
827 }
828 }
829 }]);
830 assert!(validate(&doc, Some(&store)).is_ok());
831 }
832
833 #[test]
834 fn port_below_minimum() {
835 let store = simple_store();
836 let doc = json!([{
837 "apiVersion": "apps/v1",
838 "kind": "Deployment",
839 "spec": {
840 "selector": {},
841 "template": {
842 "spec": {
843 "containers": [{
844 "ports": [{"containerPort": 0}]
845 }]
846 }
847 }
848 }
849 }]);
850 let errs = validate(&doc, Some(&store)).unwrap_err();
851 assert!(
852 errs.iter()
853 .any(|e| matches!(&e.kind, ValidationErrorKind::BelowMinimum { .. }))
854 );
855 }
856
857 #[test]
858 fn port_above_maximum() {
859 let store = simple_store();
860 let doc = json!([{
861 "apiVersion": "apps/v1",
862 "kind": "Deployment",
863 "spec": {
864 "selector": {},
865 "template": {
866 "spec": {
867 "containers": [{
868 "ports": [{"containerPort": 70000}]
869 }]
870 }
871 }
872 }
873 }]);
874 let errs = validate(&doc, Some(&store)).unwrap_err();
875 assert!(
876 errs.iter()
877 .any(|e| matches!(&e.kind, ValidationErrorKind::AboveMaximum { .. }))
878 );
879 }
880
881 #[test]
884 fn int_or_string_number_ok() {
885 let store = make_store(
886 json!({
887 "test.Resource": {
888 "properties": {
889 "field": {"x-kubernetes-int-or-string": true}
890 }
891 }
892 }),
893 json!({ "v1:Test": "test.Resource" }),
894 );
895 let doc = json!([{ "apiVersion": "v1", "kind": "Test", "field": 42 }]);
896 assert!(validate(&doc, Some(&store)).is_ok());
897 }
898
899 #[test]
900 fn int_or_string_string_ok() {
901 let store = make_store(
902 json!({
903 "test.Resource": {
904 "properties": {
905 "field": {"x-kubernetes-int-or-string": true}
906 }
907 }
908 }),
909 json!({ "v1:Test": "test.Resource" }),
910 );
911 let doc = json!([{ "apiVersion": "v1", "kind": "Test", "field": "50%" }]);
912 assert!(validate(&doc, Some(&store)).is_ok());
913 }
914
915 #[test]
916 fn int_or_string_boolean_error() {
917 let store = make_store(
918 json!({
919 "test.Resource": {
920 "properties": {
921 "field": {"x-kubernetes-int-or-string": true}
922 }
923 }
924 }),
925 json!({ "v1:Test": "test.Resource" }),
926 );
927 let doc = json!([{ "apiVersion": "v1", "kind": "Test", "field": true }]);
928 let errs = validate(&doc, Some(&store)).unwrap_err();
929 assert_eq!(errs.len(), 1);
930 assert!(matches!(
931 &errs[0].kind,
932 ValidationErrorKind::TypeMismatch {
933 expected: "integer or string",
934 ..
935 }
936 ));
937 }
938
939 #[test]
942 fn allof_validates_all_sub_schemas() {
943 let store = make_store(
944 json!({
945 "test.Resource": {
946 "properties": {
947 "value": {
948 "allOf": [
949 {"type": "integer"},
950 {"minimum": 1, "maximum": 100}
951 ]
952 }
953 }
954 }
955 }),
956 json!({ "v1:Test": "test.Resource" }),
957 );
958
959 let doc = json!([{ "apiVersion": "v1", "kind": "Test", "value": 50 }]);
961 assert!(validate(&doc, Some(&store)).is_ok());
962
963 let doc = json!([{ "apiVersion": "v1", "kind": "Test", "value": 0 }]);
965 let errs = validate(&doc, Some(&store)).unwrap_err();
966 assert!(
967 errs.iter()
968 .any(|e| matches!(&e.kind, ValidationErrorKind::BelowMinimum { .. }))
969 );
970 }
971
972 #[test]
975 fn ref_resolution() {
976 let store = simple_store();
977 let doc = json!([{
978 "apiVersion": "apps/v1",
979 "kind": "Deployment",
980 "spec": {
981 "selector": {},
982 "template": {
983 "spec": {
984 "containers": [{
985 "name": 123
986 }]
987 }
988 }
989 }
990 }]);
991 let errs = validate(&doc, Some(&store)).unwrap_err();
992 assert!(errs.iter().any(|e| e.path.contains("name")
993 && matches!(
994 &e.kind,
995 ValidationErrorKind::TypeMismatch {
996 expected: "string",
997 ..
998 }
999 )));
1000 }
1001
1002 #[test]
1005 fn null_at_optional_skip() {
1006 let store = simple_store();
1007 let doc = json!([{
1008 "apiVersion": "apps/v1",
1009 "kind": "Deployment",
1010 "spec": {
1011 "selector": {},
1012 "replicas": null
1013 }
1014 }]);
1015 assert!(validate(&doc, Some(&store)).is_ok());
1016 }
1017
1018 #[test]
1021 fn depth_limit_no_stack_overflow() {
1022 let store = make_store(
1023 json!({
1024 "test.Recursive": {
1025 "properties": {
1026 "nested": {"$ref": "test.Recursive"}
1027 }
1028 }
1029 }),
1030 json!({ "v1:Test": "test.Recursive" }),
1031 );
1032
1033 let mut inner = json!({"val": 1});
1035 for _ in 0..10 {
1036 inner = json!({"nested": inner});
1037 }
1038 let doc = json!([{
1039 "apiVersion": "v1",
1040 "kind": "Test",
1041 "nested": inner
1042 }]);
1043 let _ = validate(&doc, Some(&store));
1045 }
1046
1047 #[test]
1050 fn fallback_no_schema_store() {
1051 let doc = json!([{
1052 "apiVersion": "apps/v1",
1053 "kind": "Deployment",
1054 "spec": {
1055 "template": {
1056 "spec": {
1057 "containers": [{
1058 "resources": {
1059 "requests": {"cpu": "2gb"}
1060 }
1061 }]
1062 }
1063 }
1064 }
1065 }]);
1066 let errs = validate(&doc, None).unwrap_err();
1067 assert_eq!(errs.len(), 1);
1068 assert!(matches!(
1069 &errs[0].kind,
1070 ValidationErrorKind::InvalidQuantity { .. }
1071 ));
1072 }
1073
1074 #[test]
1075 fn fallback_unknown_gvk() {
1076 let store = simple_store();
1077 let doc = json!([{
1078 "apiVersion": "unknown/v1",
1079 "kind": "Custom",
1080 "spec": {
1081 "resources": {
1082 "requests": {"cpu": "2gb"}
1083 }
1084 }
1085 }]);
1086 let errs = validate(&doc, Some(&store)).unwrap_err();
1087 assert_eq!(errs.len(), 1);
1088 assert!(matches!(
1089 &errs[0].kind,
1090 ValidationErrorKind::InvalidQuantity { .. }
1091 ));
1092 }
1093
1094 #[test]
1097 fn schema_store_from_json_wrong_version() {
1098 let json = json!({"version": 1, "gvk_index": {}, "schemas": {}});
1099 assert!(SchemaStore::from_json(&json).is_none());
1100 }
1101
1102 #[test]
1103 fn schema_store_from_json_valid() {
1104 let json = json!({"version": 2, "gvk_index": {"v1:Ns": "some.Schema"}, "schemas": {"some.Schema": {"type": "object"}}});
1105 let store = SchemaStore::from_json(&json).unwrap();
1106 assert!(store.resolve_ref("some.Schema").is_some());
1107 }
1108
1109 #[test]
1112 fn error_display_format() {
1113 let err = ValidationError {
1114 doc_index: 0,
1115 path: "$.spec.replicas".to_string(),
1116 kind: ValidationErrorKind::TypeMismatch {
1117 expected: "integer",
1118 got: "string".to_string(),
1119 },
1120 };
1121 let s = err.to_string();
1122 assert_eq!(
1123 s,
1124 "doc[0] at $.spec.replicas: expected type integer, got string"
1125 );
1126 }
1127
1128 #[test]
1129 fn error_display_enum() {
1130 let err = ValidationError {
1131 doc_index: 0,
1132 path: "$.spec.strategy.type".to_string(),
1133 kind: ValidationErrorKind::InvalidEnum {
1134 value: "bluegreen".to_string(),
1135 allowed: vec!["Recreate".to_string(), "RollingUpdate".to_string()],
1136 },
1137 };
1138 let s = err.to_string();
1139 assert_eq!(
1140 s,
1141 "doc[0] at $.spec.strategy.type: invalid value \"bluegreen\", expected one of: Recreate, RollingUpdate"
1142 );
1143 }
1144}