1use crate::spec::error::{ValidationError, ValidationErrorKind};
6use crate::spec::types::{
7 DeploymentSpec, EndpointSpec, EndpointTunnelConfig, Protocol, ResourceType, ScaleSpec,
8 ServiceSpec, ServiceType, TunnelAccessConfig, TunnelDefinition,
9};
10use cron::Schedule;
11use std::collections::HashSet;
12use std::str::FromStr;
13
14fn make_validation_error(
21 code: &'static str,
22 message: impl Into<std::borrow::Cow<'static, str>>,
23) -> validator::ValidationError {
24 let mut err = validator::ValidationError::new(code);
25 err.message = Some(message.into());
26 err
27}
28
29pub fn validate_version_wrapper(version: &str) -> Result<(), validator::ValidationError> {
35 if version == "v1" {
36 Ok(())
37 } else {
38 Err(make_validation_error(
39 "invalid_version",
40 format!("version must be 'v1', found '{version}'"),
41 ))
42 }
43}
44
45pub fn validate_deployment_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
51 if name.len() < 3 || name.len() > 63 {
53 return Err(make_validation_error(
54 "invalid_deployment_name",
55 "deployment name must be 3-63 characters",
56 ));
57 }
58
59 if let Some(first) = name.chars().next() {
61 if !first.is_ascii_alphanumeric() {
62 return Err(make_validation_error(
63 "invalid_deployment_name",
64 "deployment name must start with alphanumeric character",
65 ));
66 }
67 }
68
69 for c in name.chars() {
71 if !c.is_ascii_alphanumeric() && c != '-' {
72 return Err(make_validation_error(
73 "invalid_deployment_name",
74 "deployment name can only contain alphanumeric characters and hyphens",
75 ));
76 }
77 }
78
79 Ok(())
80}
81
82pub fn validate_cpu_option_wrapper(cpu: f64) -> Result<(), validator::ValidationError> {
89 if cpu <= 0.0 {
90 Err(make_validation_error(
91 "invalid_cpu",
92 format!("CPU limit must be > 0, found {cpu}"),
93 ))
94 } else {
95 Ok(())
96 }
97}
98
99pub fn validate_memory_option_wrapper(value: &String) -> Result<(), validator::ValidationError> {
106 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
107
108 let suffix_match = VALID_SUFFIXES
109 .iter()
110 .find(|&&suffix| value.ends_with(suffix));
111
112 match suffix_match {
113 Some(suffix) => {
114 let numeric_part = &value[..value.len() - suffix.len()];
115 match numeric_part.parse::<u64>() {
116 Ok(n) if n > 0 => Ok(()),
117 _ => Err(make_validation_error(
118 "invalid_memory_format",
119 format!("invalid memory format: '{value}'"),
120 )),
121 }
122 }
123 None => Err(make_validation_error(
124 "invalid_memory_format",
125 format!("invalid memory format: '{value}' (use Ki, Mi, Gi, or Ti suffix)"),
126 )),
127 }
128}
129
130pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
137 if port >= 1 {
138 Ok(())
139 } else {
140 Err(make_validation_error(
141 "invalid_port",
142 "port must be between 1-65535",
143 ))
144 }
145}
146
147pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
153 if let ScaleSpec::Adaptive { min, max, .. } = scale {
154 if *min > *max {
155 return Err(make_validation_error(
156 "invalid_scale_range",
157 format!("scale min ({min}) cannot be greater than max ({max})"),
158 ));
159 }
160 }
161 Ok(())
162}
163
164pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
171 Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
172 make_validation_error(
173 "invalid_cron_schedule",
174 format!("invalid cron schedule '{schedule}': {e}"),
175 )
176 })
177}
178
179pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
199 if !value.starts_with("$S:") {
201 return Ok(());
202 }
203
204 let secret_ref = &value[3..]; if secret_ref.is_empty() {
207 return Err(make_validation_error(
208 "invalid_secret_reference",
209 "secret reference cannot be empty after $S:",
210 ));
211 }
212
213 let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
215 let parts: Vec<&str> = rest.splitn(2, '/').collect();
217 if parts.len() != 2 {
218 return Err(make_validation_error(
219 "invalid_secret_reference",
220 format!(
221 "cross-service secret reference '{value}' must have format @service/secret-name"
222 ),
223 ));
224 }
225
226 let service_name = parts[0];
227 let secret_name = parts[1];
228
229 if service_name.is_empty() {
231 return Err(make_validation_error(
232 "invalid_secret_reference",
233 format!("service name in secret reference '{value}' cannot be empty"),
234 ));
235 }
236
237 if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
238 return Err(make_validation_error(
239 "invalid_secret_reference",
240 format!("service name in secret reference '{value}' must start with a letter"),
241 ));
242 }
243
244 for c in service_name.chars() {
245 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
246 return Err(make_validation_error(
247 "invalid_secret_reference",
248 format!(
249 "service name in secret reference '{value}' contains invalid character '{c}'"
250 ),
251 ));
252 }
253 }
254
255 secret_name
256 } else {
257 secret_ref
258 };
259
260 if secret_name.is_empty() {
262 return Err(make_validation_error(
263 "invalid_secret_reference",
264 format!("secret name in '{value}' cannot be empty"),
265 ));
266 }
267
268 let first_char = secret_name.chars().next().unwrap();
270 if !first_char.is_ascii_alphabetic() {
271 return Err(make_validation_error(
272 "invalid_secret_reference",
273 format!("secret name in '{value}' must start with a letter, found '{first_char}'"),
274 ));
275 }
276
277 for c in secret_name.chars() {
279 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
280 return Err(make_validation_error(
281 "invalid_secret_reference",
282 format!(
283 "secret name in '{value}' contains invalid character '{c}' (only alphanumeric, hyphens, underscores allowed)"
284 ),
285 ));
286 }
287 }
288
289 Ok(())
290}
291
292#[allow(clippy::implicit_hasher)]
298pub fn validate_env_vars(
299 service_name: &str,
300 env: &std::collections::HashMap<String, String>,
301) -> Result<(), crate::spec::error::ValidationError> {
302 for (key, value) in env {
303 if let Err(e) = validate_secret_reference(value) {
304 return Err(crate::spec::error::ValidationError {
305 kind: crate::spec::error::ValidationErrorKind::InvalidEnvVar {
306 key: key.clone(),
307 reason: e
308 .message
309 .map_or_else(|| "invalid secret reference".to_string(), |m| m.to_string()),
310 },
311 path: format!("services.{service_name}.env.{key}"),
312 });
313 }
314 }
315 Ok(())
316}
317
318pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
328 let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
330 if !re.is_match(name) || name.len() > 63 {
331 return Err(make_validation_error(
332 "invalid_storage_name",
333 format!("storage name '{name}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen"),
334 ));
335 }
336 Ok(())
337}
338
339pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
345 validate_storage_name(name)
346}
347
348pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
358 let service_names: HashSet<&str> = spec
359 .services
360 .keys()
361 .map(std::string::String::as_str)
362 .collect();
363
364 for (service_name, service_spec) in &spec.services {
365 for dep in &service_spec.depends {
366 if !service_names.contains(dep.service.as_str()) {
367 return Err(ValidationError {
368 kind: ValidationErrorKind::UnknownDependency {
369 service: dep.service.clone(),
370 },
371 path: format!("services.{service_name}.depends"),
372 });
373 }
374 }
375 }
376
377 Ok(())
378}
379
380pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
386 for (service_name, service_spec) in &spec.services {
387 let mut seen = HashSet::new();
388 for endpoint in &service_spec.endpoints {
389 if !seen.insert(&endpoint.name) {
390 return Err(ValidationError {
391 kind: ValidationErrorKind::DuplicateEndpoint {
392 name: endpoint.name.clone(),
393 },
394 path: format!("services.{service_name}.endpoints"),
395 });
396 }
397 }
398 }
399
400 Ok(())
401}
402
403pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
409 for (service_name, service_spec) in &spec.services {
410 validate_service_schedule(service_name, service_spec)?;
411 }
412 Ok(())
413}
414
415pub fn validate_service_schedule(
421 service_name: &str,
422 spec: &ServiceSpec,
423) -> Result<(), ValidationError> {
424 if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
426 return Err(ValidationError {
427 kind: ValidationErrorKind::ScheduleOnlyForCron,
428 path: format!("services.{service_name}.schedule"),
429 });
430 }
431
432 if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
434 return Err(ValidationError {
435 kind: ValidationErrorKind::CronRequiresSchedule,
436 path: format!("services.{service_name}.schedule"),
437 });
438 }
439
440 Ok(())
441}
442
443pub fn validate_version(version: &str) -> Result<(), ValidationError> {
453 if version == "v1" {
454 Ok(())
455 } else {
456 Err(ValidationError {
457 kind: ValidationErrorKind::InvalidVersion {
458 found: version.to_string(),
459 },
460 path: "version".to_string(),
461 })
462 }
463}
464
465pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
476 if name.len() < 3 || name.len() > 63 {
478 return Err(ValidationError {
479 kind: ValidationErrorKind::EmptyDeploymentName,
480 path: "deployment".to_string(),
481 });
482 }
483
484 if let Some(first) = name.chars().next() {
486 if !first.is_ascii_alphanumeric() {
487 return Err(ValidationError {
488 kind: ValidationErrorKind::EmptyDeploymentName,
489 path: "deployment".to_string(),
490 });
491 }
492 }
493
494 for c in name.chars() {
496 if !c.is_ascii_alphanumeric() && c != '-' {
497 return Err(ValidationError {
498 kind: ValidationErrorKind::EmptyDeploymentName,
499 path: "deployment".to_string(),
500 });
501 }
502 }
503
504 Ok(())
505}
506
507pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
517 if name.is_empty() || name.trim().is_empty() {
518 Err(ValidationError {
519 kind: ValidationErrorKind::EmptyImageName,
520 path: "image.name".to_string(),
521 })
522 } else {
523 Ok(())
524 }
525}
526
527pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
536 if *cpu > 0.0 {
537 Ok(())
538 } else {
539 Err(ValidationError {
540 kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
541 path: "resources.cpu".to_string(),
542 })
543 }
544}
545
546pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
555 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
557
558 let suffix_match = VALID_SUFFIXES
560 .iter()
561 .find(|&&suffix| value.ends_with(suffix));
562
563 match suffix_match {
564 Some(suffix) => {
565 let numeric_part = &value[..value.len() - suffix.len()];
567
568 match numeric_part.parse::<u64>() {
570 Ok(n) if n > 0 => Ok(()),
571 _ => Err(ValidationError {
572 kind: ValidationErrorKind::InvalidMemoryFormat {
573 value: value.to_string(),
574 },
575 path: "resources.memory".to_string(),
576 }),
577 }
578 }
579 None => Err(ValidationError {
580 kind: ValidationErrorKind::InvalidMemoryFormat {
581 value: value.to_string(),
582 },
583 path: "resources.memory".to_string(),
584 }),
585 }
586}
587
588pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
597 if *port >= 1 {
598 Ok(())
599 } else {
600 Err(ValidationError {
601 kind: ValidationErrorKind::InvalidPort {
602 port: u32::from(*port),
603 },
604 path: "endpoints[].port".to_string(),
605 })
606 }
607}
608
609pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
615 let mut seen = HashSet::new();
616
617 for endpoint in endpoints {
618 if !seen.insert(&endpoint.name) {
619 return Err(ValidationError {
620 kind: ValidationErrorKind::DuplicateEndpoint {
621 name: endpoint.name.clone(),
622 },
623 path: "endpoints".to_string(),
624 });
625 }
626 }
627
628 Ok(())
629}
630
631pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
640 if min <= max {
641 Ok(())
642 } else {
643 Err(ValidationError {
644 kind: ValidationErrorKind::InvalidScaleRange { min, max },
645 path: "scale".to_string(),
646 })
647 }
648}
649
650pub fn validate_tunnel_ttl(ttl: &str) -> Result<(), validator::ValidationError> {
660 humantime::parse_duration(ttl).map(|_| ()).map_err(|e| {
661 make_validation_error(
662 "invalid_tunnel_ttl",
663 format!("invalid TTL format '{ttl}': {e}"),
664 )
665 })
666}
667
668pub fn validate_tunnel_access_config(
674 config: &TunnelAccessConfig,
675 path: &str,
676) -> Result<(), ValidationError> {
677 if let Some(ref max_ttl) = config.max_ttl {
678 validate_tunnel_ttl(max_ttl).map_err(|e| ValidationError {
679 kind: ValidationErrorKind::InvalidTunnelTtl {
680 value: max_ttl.clone(),
681 reason: e
682 .message
683 .map_or_else(|| "invalid duration format".to_string(), |m| m.to_string()),
684 },
685 path: format!("{path}.access.max_ttl"),
686 })?;
687 }
688 Ok(())
689}
690
691pub fn validate_endpoint_tunnel_config(
697 config: &EndpointTunnelConfig,
698 path: &str,
699) -> Result<(), ValidationError> {
700 if let Some(ref access) = config.access {
705 validate_tunnel_access_config(access, path)?;
706 }
707
708 Ok(())
709}
710
711pub fn validate_tunnel_definition(
717 name: &str,
718 tunnel: &TunnelDefinition,
719) -> Result<(), ValidationError> {
720 let path = format!("tunnels.{name}");
721
722 if tunnel.local_port == 0 {
724 return Err(ValidationError {
725 kind: ValidationErrorKind::InvalidTunnelPort {
726 port: tunnel.local_port,
727 field: "local_port".to_string(),
728 },
729 path: format!("{path}.local_port"),
730 });
731 }
732
733 if tunnel.remote_port == 0 {
735 return Err(ValidationError {
736 kind: ValidationErrorKind::InvalidTunnelPort {
737 port: tunnel.remote_port,
738 field: "remote_port".to_string(),
739 },
740 path: format!("{path}.remote_port"),
741 });
742 }
743
744 Ok(())
745}
746
747pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
753 for (name, tunnel) in &spec.tunnels {
755 validate_tunnel_definition(name, tunnel)?;
756 }
757
758 for (service_name, service_spec) in &spec.services {
760 for (idx, endpoint) in service_spec.endpoints.iter().enumerate() {
761 if let Some(ref tunnel_config) = endpoint.tunnel {
762 let path = format!("services.{service_name}.endpoints[{idx}].tunnel");
763 validate_endpoint_tunnel_config(tunnel_config, &path)?;
764 }
765 }
766 }
767
768 Ok(())
769}
770
771pub fn validate_wasm_configs(spec: &DeploymentSpec) -> Result<(), ValidationError> {
781 for (service_name, service_spec) in &spec.services {
782 validate_wasm_config(service_name, service_spec)?;
783 }
784 Ok(())
785}
786
787pub fn validate_wasm_config(service_name: &str, spec: &ServiceSpec) -> Result<(), ValidationError> {
801 if !spec.service_type.is_wasm() && spec.wasm.is_some() {
803 return Err(ValidationError {
804 kind: ValidationErrorKind::WasmConfigOnNonWasmType,
805 path: format!("services.{service_name}.wasm"),
806 });
807 }
808
809 if let Some(ref wasm) = spec.wasm {
810 validate_wasm_fields(service_name, wasm)?;
811 validate_wasm_capabilities(service_name, spec, wasm)?;
812 validate_wasm_http_endpoints(service_name, spec)?;
813 validate_wasm_preopens(service_name, wasm)?;
814 }
815
816 Ok(())
817}
818
819fn validate_wasm_fields(
821 service_name: &str,
822 wasm: &crate::spec::types::WasmConfig,
823) -> Result<(), ValidationError> {
824 if let Some(ref max_mem) = wasm.max_memory {
825 validate_memory_format(max_mem).map_err(|_| ValidationError {
826 kind: ValidationErrorKind::InvalidMemoryFormat {
827 value: max_mem.clone(),
828 },
829 path: format!("services.{service_name}.wasm.max_memory"),
830 })?;
831 }
832
833 if wasm.min_instances > wasm.max_instances {
834 return Err(ValidationError {
835 kind: ValidationErrorKind::InvalidWasmInstanceRange {
836 min: wasm.min_instances,
837 max: wasm.max_instances,
838 },
839 path: format!("services.{service_name}.wasm"),
840 });
841 }
842
843 Ok(())
844}
845
846fn validate_wasm_capabilities(
848 service_name: &str,
849 spec: &ServiceSpec,
850 wasm: &crate::spec::types::WasmConfig,
851) -> Result<(), ValidationError> {
852 let Some(ref caps) = wasm.capabilities else {
853 return Ok(());
854 };
855 let Some(defaults) = spec.service_type.default_wasm_capabilities() else {
856 return Ok(());
857 };
858
859 let checks: &[(&str, bool, bool)] = &[
860 ("config", caps.config, defaults.config),
861 ("keyvalue", caps.keyvalue, defaults.keyvalue),
862 ("logging", caps.logging, defaults.logging),
863 ("secrets", caps.secrets, defaults.secrets),
864 ("metrics", caps.metrics, defaults.metrics),
865 ("http_client", caps.http_client, defaults.http_client),
866 ("cli", caps.cli, defaults.cli),
867 ("filesystem", caps.filesystem, defaults.filesystem),
868 ("sockets", caps.sockets, defaults.sockets),
869 ];
870
871 for &(cap_name, requested, default) in checks {
872 validate_capability_restriction(
873 service_name,
874 spec.service_type,
875 cap_name,
876 requested,
877 default,
878 )?;
879 }
880
881 Ok(())
882}
883
884fn validate_wasm_http_endpoints(
886 service_name: &str,
887 spec: &ServiceSpec,
888) -> Result<(), ValidationError> {
889 if spec.service_type == ServiceType::WasmHttp && !spec.endpoints.is_empty() {
890 let has_http_endpoint = spec
891 .endpoints
892 .iter()
893 .any(|e| matches!(e.protocol, Protocol::Http | Protocol::Https));
894 if !has_http_endpoint {
895 return Err(ValidationError {
896 kind: ValidationErrorKind::WasmHttpMissingHttpEndpoint,
897 path: format!("services.{service_name}.endpoints"),
898 });
899 }
900 }
901 Ok(())
902}
903
904fn validate_wasm_preopens(
906 service_name: &str,
907 wasm: &crate::spec::types::WasmConfig,
908) -> Result<(), ValidationError> {
909 for (i, preopen) in wasm.preopens.iter().enumerate() {
910 if preopen.source.is_empty() {
911 return Err(ValidationError {
912 kind: ValidationErrorKind::WasmPreopenEmpty {
913 index: i,
914 field: "source".to_string(),
915 },
916 path: format!("services.{service_name}.wasm.preopens[{i}].source"),
917 });
918 }
919 if preopen.target.is_empty() {
920 return Err(ValidationError {
921 kind: ValidationErrorKind::WasmPreopenEmpty {
922 index: i,
923 field: "target".to_string(),
924 },
925 path: format!("services.{service_name}.wasm.preopens[{i}].target"),
926 });
927 }
928 }
929 Ok(())
930}
931
932fn validate_capability_restriction(
941 service_name: &str,
942 service_type: ServiceType,
943 cap_name: &str,
944 requested: bool,
945 default: bool,
946) -> Result<(), ValidationError> {
947 if requested && !default {
948 return Err(ValidationError {
949 kind: ValidationErrorKind::WasmCapabilityNotAvailable {
950 capability: cap_name.to_string(),
951 service_type: format!("{service_type:?}"),
952 },
953 path: format!("services.{service_name}.wasm.capabilities.{cap_name}"),
954 });
955 }
956 Ok(())
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use crate::spec::types::{ExposeType, Protocol};
963
964 #[test]
966 fn test_validate_version_valid() {
967 assert!(validate_version("v1").is_ok());
968 }
969
970 #[test]
971 fn test_validate_version_invalid_v2() {
972 let result = validate_version("v2");
973 assert!(result.is_err());
974 let err = result.unwrap_err();
975 assert!(matches!(
976 err.kind,
977 ValidationErrorKind::InvalidVersion { found } if found == "v2"
978 ));
979 }
980
981 #[test]
982 fn test_validate_version_empty() {
983 let result = validate_version("");
984 assert!(result.is_err());
985 let err = result.unwrap_err();
986 assert!(matches!(
987 err.kind,
988 ValidationErrorKind::InvalidVersion { found } if found.is_empty()
989 ));
990 }
991
992 #[test]
994 fn test_validate_deployment_name_valid() {
995 assert!(validate_deployment_name("my-app").is_ok());
996 assert!(validate_deployment_name("api").is_ok());
997 assert!(validate_deployment_name("my-service-123").is_ok());
998 assert!(validate_deployment_name("a1b").is_ok());
999 }
1000
1001 #[test]
1002 fn test_validate_deployment_name_too_short() {
1003 assert!(validate_deployment_name("ab").is_err());
1004 assert!(validate_deployment_name("a").is_err());
1005 assert!(validate_deployment_name("").is_err());
1006 }
1007
1008 #[test]
1009 fn test_validate_deployment_name_too_long() {
1010 let long_name = "a".repeat(64);
1011 assert!(validate_deployment_name(&long_name).is_err());
1012 }
1013
1014 #[test]
1015 fn test_validate_deployment_name_invalid_chars() {
1016 assert!(validate_deployment_name("my_app").is_err()); assert!(validate_deployment_name("my.app").is_err()); assert!(validate_deployment_name("my app").is_err()); assert!(validate_deployment_name("my@app").is_err()); }
1021
1022 #[test]
1023 fn test_validate_deployment_name_must_start_alphanumeric() {
1024 assert!(validate_deployment_name("-myapp").is_err());
1025 assert!(validate_deployment_name("_myapp").is_err());
1026 }
1027
1028 #[test]
1030 fn test_validate_image_name_valid() {
1031 assert!(validate_image_name("nginx:latest").is_ok());
1032 assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
1033 assert!(validate_image_name("ubuntu").is_ok());
1034 }
1035
1036 #[test]
1037 fn test_validate_image_name_empty() {
1038 let result = validate_image_name("");
1039 assert!(result.is_err());
1040 assert!(matches!(
1041 result.unwrap_err().kind,
1042 ValidationErrorKind::EmptyImageName
1043 ));
1044 }
1045
1046 #[test]
1047 fn test_validate_image_name_whitespace_only() {
1048 assert!(validate_image_name(" ").is_err());
1049 assert!(validate_image_name("\t\n").is_err());
1050 }
1051
1052 #[test]
1054 fn test_validate_cpu_valid() {
1055 assert!(validate_cpu(&0.5).is_ok());
1056 assert!(validate_cpu(&1.0).is_ok());
1057 assert!(validate_cpu(&2.0).is_ok());
1058 assert!(validate_cpu(&0.001).is_ok());
1059 }
1060
1061 #[test]
1062 fn test_validate_cpu_zero() {
1063 let result = validate_cpu(&0.0);
1064 assert!(result.is_err());
1065 assert!(matches!(
1066 result.unwrap_err().kind,
1067 ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
1068 ));
1069 }
1070
1071 #[test]
1072 #[allow(clippy::float_cmp)]
1073 fn test_validate_cpu_negative() {
1074 let result = validate_cpu(&-1.0);
1075 assert!(result.is_err());
1076 assert!(matches!(
1077 result.unwrap_err().kind,
1078 ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
1079 ));
1080 }
1081
1082 #[test]
1084 fn test_validate_memory_format_valid() {
1085 assert!(validate_memory_format("512Mi").is_ok());
1086 assert!(validate_memory_format("1Gi").is_ok());
1087 assert!(validate_memory_format("2Ti").is_ok());
1088 assert!(validate_memory_format("256Ki").is_ok());
1089 assert!(validate_memory_format("4096Mi").is_ok());
1090 }
1091
1092 #[test]
1093 fn test_validate_memory_format_invalid_suffix() {
1094 assert!(validate_memory_format("512MB").is_err());
1095 assert!(validate_memory_format("1GB").is_err());
1096 assert!(validate_memory_format("512").is_err());
1097 assert!(validate_memory_format("512m").is_err());
1098 }
1099
1100 #[test]
1101 fn test_validate_memory_format_no_number() {
1102 assert!(validate_memory_format("Mi").is_err());
1103 assert!(validate_memory_format("Gi").is_err());
1104 }
1105
1106 #[test]
1107 fn test_validate_memory_format_invalid_number() {
1108 assert!(validate_memory_format("-512Mi").is_err());
1109 assert!(validate_memory_format("0Mi").is_err());
1110 assert!(validate_memory_format("abcMi").is_err());
1111 }
1112
1113 #[test]
1115 fn test_validate_port_valid() {
1116 assert!(validate_port(&1).is_ok());
1117 assert!(validate_port(&80).is_ok());
1118 assert!(validate_port(&443).is_ok());
1119 assert!(validate_port(&8080).is_ok());
1120 assert!(validate_port(&65535).is_ok());
1121 }
1122
1123 #[test]
1124 fn test_validate_port_zero() {
1125 let result = validate_port(&0);
1126 assert!(result.is_err());
1127 assert!(matches!(
1128 result.unwrap_err().kind,
1129 ValidationErrorKind::InvalidPort { port } if port == 0
1130 ));
1131 }
1132
1133 #[test]
1138 fn test_validate_unique_endpoints_valid() {
1139 let endpoints = vec![
1140 EndpointSpec {
1141 name: "http".to_string(),
1142 protocol: Protocol::Http,
1143 port: 8080,
1144 target_port: None,
1145 path: None,
1146 host: None,
1147 expose: ExposeType::Public,
1148 stream: None,
1149 tunnel: None,
1150 },
1151 EndpointSpec {
1152 name: "grpc".to_string(),
1153 protocol: Protocol::Tcp,
1154 port: 9090,
1155 target_port: None,
1156 path: None,
1157 host: None,
1158 expose: ExposeType::Internal,
1159 stream: None,
1160 tunnel: None,
1161 },
1162 ];
1163 assert!(validate_unique_endpoints(&endpoints).is_ok());
1164 }
1165
1166 #[test]
1167 fn test_validate_unique_endpoints_empty() {
1168 let endpoints: Vec<EndpointSpec> = vec![];
1169 assert!(validate_unique_endpoints(&endpoints).is_ok());
1170 }
1171
1172 #[test]
1173 fn test_validate_unique_endpoints_duplicates() {
1174 let endpoints = vec![
1175 EndpointSpec {
1176 name: "http".to_string(),
1177 protocol: Protocol::Http,
1178 port: 8080,
1179 target_port: None,
1180 path: None,
1181 host: None,
1182 expose: ExposeType::Public,
1183 stream: None,
1184 tunnel: None,
1185 },
1186 EndpointSpec {
1187 name: "http".to_string(), protocol: Protocol::Https,
1189 port: 8443,
1190 target_port: None,
1191 path: None,
1192 host: None,
1193 expose: ExposeType::Public,
1194 stream: None,
1195 tunnel: None,
1196 },
1197 ];
1198 let result = validate_unique_endpoints(&endpoints);
1199 assert!(result.is_err());
1200 assert!(matches!(
1201 result.unwrap_err().kind,
1202 ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
1203 ));
1204 }
1205
1206 #[test]
1208 fn test_validate_scale_range_valid() {
1209 assert!(validate_scale_range(1, 10).is_ok());
1210 assert!(validate_scale_range(1, 1).is_ok()); assert!(validate_scale_range(0, 5).is_ok());
1212 assert!(validate_scale_range(5, 100).is_ok());
1213 }
1214
1215 #[test]
1216 fn test_validate_scale_range_min_greater_than_max() {
1217 let result = validate_scale_range(10, 5);
1218 assert!(result.is_err());
1219 let err = result.unwrap_err();
1220 assert!(matches!(
1221 err.kind,
1222 ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
1223 ));
1224 }
1225
1226 #[test]
1227 fn test_validate_scale_range_large_gap() {
1228 assert!(validate_scale_range(1, 1000).is_ok());
1230 }
1231
1232 #[test]
1235 fn test_validate_schedule_wrapper_valid() {
1236 assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
1242 }
1244
1245 #[test]
1246 fn test_validate_schedule_wrapper_invalid() {
1247 assert!(validate_schedule_wrapper(&String::new()).is_err()); assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
1252 }
1254
1255 #[test]
1257 fn test_validate_secret_reference_plain_values() {
1258 assert!(validate_secret_reference("my-value").is_ok());
1260 assert!(validate_secret_reference("").is_ok());
1261 assert!(validate_secret_reference("some string").is_ok());
1262 assert!(validate_secret_reference("$E:MY_VAR").is_ok()); }
1264
1265 #[test]
1266 fn test_validate_secret_reference_valid() {
1267 assert!(validate_secret_reference("$S:my-secret").is_ok());
1269 assert!(validate_secret_reference("$S:api_key").is_ok());
1270 assert!(validate_secret_reference("$S:MySecret123").is_ok());
1271 assert!(validate_secret_reference("$S:a").is_ok()); }
1273
1274 #[test]
1275 fn test_validate_secret_reference_cross_service() {
1276 assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
1278 assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
1279 assert!(validate_secret_reference("$S:@svc/secret").is_ok());
1280 }
1281
1282 #[test]
1283 fn test_validate_secret_reference_empty_after_prefix() {
1284 assert!(validate_secret_reference("$S:").is_err());
1286 }
1287
1288 #[test]
1289 fn test_validate_secret_reference_must_start_with_letter() {
1290 assert!(validate_secret_reference("$S:123-secret").is_err());
1292 assert!(validate_secret_reference("$S:-my-secret").is_err());
1293 assert!(validate_secret_reference("$S:_underscore").is_err());
1294 }
1295
1296 #[test]
1297 fn test_validate_secret_reference_invalid_chars() {
1298 assert!(validate_secret_reference("$S:my.secret").is_err());
1300 assert!(validate_secret_reference("$S:my secret").is_err());
1301 assert!(validate_secret_reference("$S:my@secret").is_err());
1302 }
1303
1304 #[test]
1305 fn test_validate_secret_reference_cross_service_invalid() {
1306 assert!(validate_secret_reference("$S:@service").is_err());
1308 assert!(validate_secret_reference("$S:@/secret").is_err());
1310 assert!(validate_secret_reference("$S:@service/").is_err());
1312 assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1314 }
1315
1316 #[test]
1321 fn test_validate_tunnel_ttl_valid() {
1322 assert!(validate_tunnel_ttl("30m").is_ok());
1323 assert!(validate_tunnel_ttl("4h").is_ok());
1324 assert!(validate_tunnel_ttl("1d").is_ok());
1325 assert!(validate_tunnel_ttl("1h 30m").is_ok());
1326 assert!(validate_tunnel_ttl("2h30m").is_ok());
1327 }
1328
1329 #[test]
1330 fn test_validate_tunnel_ttl_invalid() {
1331 assert!(validate_tunnel_ttl("").is_err());
1332 assert!(validate_tunnel_ttl("invalid").is_err());
1333 assert!(validate_tunnel_ttl("30").is_err()); assert!(validate_tunnel_ttl("-1h").is_err()); }
1336
1337 #[test]
1338 fn test_validate_tunnel_definition_valid() {
1339 let tunnel = TunnelDefinition {
1340 from: "node-a".to_string(),
1341 to: "node-b".to_string(),
1342 local_port: 8080,
1343 remote_port: 9000,
1344 protocol: crate::spec::types::TunnelProtocol::Tcp,
1345 expose: ExposeType::Internal,
1346 };
1347 assert!(validate_tunnel_definition("test-tunnel", &tunnel).is_ok());
1348 }
1349
1350 #[test]
1351 fn test_validate_tunnel_definition_local_port_zero() {
1352 let tunnel = TunnelDefinition {
1353 from: "node-a".to_string(),
1354 to: "node-b".to_string(),
1355 local_port: 0,
1356 remote_port: 9000,
1357 protocol: crate::spec::types::TunnelProtocol::Tcp,
1358 expose: ExposeType::Internal,
1359 };
1360 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1361 assert!(result.is_err());
1362 assert!(matches!(
1363 result.unwrap_err().kind,
1364 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "local_port"
1365 ));
1366 }
1367
1368 #[test]
1369 fn test_validate_tunnel_definition_remote_port_zero() {
1370 let tunnel = TunnelDefinition {
1371 from: "node-a".to_string(),
1372 to: "node-b".to_string(),
1373 local_port: 8080,
1374 remote_port: 0,
1375 protocol: crate::spec::types::TunnelProtocol::Tcp,
1376 expose: ExposeType::Internal,
1377 };
1378 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1379 assert!(result.is_err());
1380 assert!(matches!(
1381 result.unwrap_err().kind,
1382 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "remote_port"
1383 ));
1384 }
1385
1386 #[test]
1387 fn test_validate_endpoint_tunnel_config_valid() {
1388 let config = EndpointTunnelConfig {
1389 enabled: true,
1390 from: Some("node-1".to_string()),
1391 to: Some("ingress".to_string()),
1392 remote_port: 8080,
1393 expose: Some(ExposeType::Public),
1394 access: None,
1395 };
1396 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1397 }
1398
1399 #[test]
1400 fn test_validate_endpoint_tunnel_config_with_access() {
1401 let config = EndpointTunnelConfig {
1402 enabled: true,
1403 from: None,
1404 to: None,
1405 remote_port: 0, expose: None,
1407 access: Some(TunnelAccessConfig {
1408 enabled: true,
1409 max_ttl: Some("4h".to_string()),
1410 audit: true,
1411 }),
1412 };
1413 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1414 }
1415
1416 #[test]
1417 fn test_validate_endpoint_tunnel_config_invalid_ttl() {
1418 let config = EndpointTunnelConfig {
1419 enabled: true,
1420 from: None,
1421 to: None,
1422 remote_port: 0,
1423 expose: None,
1424 access: Some(TunnelAccessConfig {
1425 enabled: true,
1426 max_ttl: Some("invalid".to_string()),
1427 audit: false,
1428 }),
1429 };
1430 let result = validate_endpoint_tunnel_config(&config, "test.tunnel");
1431 assert!(result.is_err());
1432 assert!(matches!(
1433 result.unwrap_err().kind,
1434 ValidationErrorKind::InvalidTunnelTtl { .. }
1435 ));
1436 }
1437
1438 #[test]
1443 fn test_validate_capability_restriction_allowed() {
1444 let result = validate_capability_restriction(
1446 "test-svc",
1447 ServiceType::WasmHttp,
1448 "config",
1449 true,
1450 true,
1451 );
1452 assert!(result.is_ok());
1453 }
1454
1455 #[test]
1456 fn test_validate_capability_restriction_restricting_is_ok() {
1457 let result = validate_capability_restriction(
1459 "test-svc",
1460 ServiceType::WasmHttp,
1461 "config",
1462 false,
1463 true,
1464 );
1465 assert!(result.is_ok());
1466 }
1467
1468 #[test]
1469 fn test_validate_capability_restriction_granting_not_allowed() {
1470 let result = validate_capability_restriction(
1472 "test-svc",
1473 ServiceType::WasmHttp,
1474 "secrets",
1475 true,
1476 false,
1477 );
1478 assert!(result.is_err());
1479 assert!(matches!(
1480 result.unwrap_err().kind,
1481 ValidationErrorKind::WasmCapabilityNotAvailable { ref capability, .. }
1482 if capability == "secrets"
1483 ));
1484 }
1485
1486 #[test]
1487 fn test_validate_capability_restriction_both_false_is_ok() {
1488 let result = validate_capability_restriction(
1490 "test-svc",
1491 ServiceType::WasmTransformer,
1492 "sockets",
1493 false,
1494 false,
1495 );
1496 assert!(result.is_ok());
1497 }
1498}