1use crate::error::{ValidationError, ValidationErrorKind};
6use crate::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_image_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
88 if name.is_empty() || name.trim().is_empty() {
89 Err(make_validation_error(
90 "empty_image_name",
91 "image name cannot be empty",
92 ))
93 } else {
94 Ok(())
95 }
96}
97
98pub fn validate_cpu_option_wrapper(cpu: f64) -> Result<(), validator::ValidationError> {
105 if cpu <= 0.0 {
106 Err(make_validation_error(
107 "invalid_cpu",
108 format!("CPU limit must be > 0, found {cpu}"),
109 ))
110 } else {
111 Ok(())
112 }
113}
114
115pub fn validate_memory_option_wrapper(value: &String) -> Result<(), validator::ValidationError> {
122 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
123
124 let suffix_match = VALID_SUFFIXES
125 .iter()
126 .find(|&&suffix| value.ends_with(suffix));
127
128 match suffix_match {
129 Some(suffix) => {
130 let numeric_part = &value[..value.len() - suffix.len()];
131 match numeric_part.parse::<u64>() {
132 Ok(n) if n > 0 => Ok(()),
133 _ => Err(make_validation_error(
134 "invalid_memory_format",
135 format!("invalid memory format: '{value}'"),
136 )),
137 }
138 }
139 None => Err(make_validation_error(
140 "invalid_memory_format",
141 format!("invalid memory format: '{value}' (use Ki, Mi, Gi, or Ti suffix)"),
142 )),
143 }
144}
145
146pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
153 if port >= 1 {
154 Ok(())
155 } else {
156 Err(make_validation_error(
157 "invalid_port",
158 "port must be between 1-65535",
159 ))
160 }
161}
162
163pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
169 if let ScaleSpec::Adaptive { min, max, .. } = scale {
170 if *min > *max {
171 return Err(make_validation_error(
172 "invalid_scale_range",
173 format!("scale min ({min}) cannot be greater than max ({max})"),
174 ));
175 }
176 }
177 Ok(())
178}
179
180pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
187 Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
188 make_validation_error(
189 "invalid_cron_schedule",
190 format!("invalid cron schedule '{schedule}': {e}"),
191 )
192 })
193}
194
195pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
215 if !value.starts_with("$S:") {
217 return Ok(());
218 }
219
220 let secret_ref = &value[3..]; if secret_ref.is_empty() {
223 return Err(make_validation_error(
224 "invalid_secret_reference",
225 "secret reference cannot be empty after $S:",
226 ));
227 }
228
229 let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
231 let parts: Vec<&str> = rest.splitn(2, '/').collect();
233 if parts.len() != 2 {
234 return Err(make_validation_error(
235 "invalid_secret_reference",
236 format!(
237 "cross-service secret reference '{value}' must have format @service/secret-name"
238 ),
239 ));
240 }
241
242 let service_name = parts[0];
243 let secret_name = parts[1];
244
245 if service_name.is_empty() {
247 return Err(make_validation_error(
248 "invalid_secret_reference",
249 format!("service name in secret reference '{value}' cannot be empty"),
250 ));
251 }
252
253 if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
254 return Err(make_validation_error(
255 "invalid_secret_reference",
256 format!("service name in secret reference '{value}' must start with a letter"),
257 ));
258 }
259
260 for c in service_name.chars() {
261 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
262 return Err(make_validation_error(
263 "invalid_secret_reference",
264 format!(
265 "service name in secret reference '{value}' contains invalid character '{c}'"
266 ),
267 ));
268 }
269 }
270
271 secret_name
272 } else {
273 secret_ref
274 };
275
276 if secret_name.is_empty() {
278 return Err(make_validation_error(
279 "invalid_secret_reference",
280 format!("secret name in '{value}' cannot be empty"),
281 ));
282 }
283
284 let first_char = secret_name.chars().next().unwrap();
286 if !first_char.is_ascii_alphabetic() {
287 return Err(make_validation_error(
288 "invalid_secret_reference",
289 format!("secret name in '{value}' must start with a letter, found '{first_char}'"),
290 ));
291 }
292
293 for c in secret_name.chars() {
295 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
296 return Err(make_validation_error(
297 "invalid_secret_reference",
298 format!(
299 "secret name in '{value}' contains invalid character '{c}' (only alphanumeric, hyphens, underscores allowed)"
300 ),
301 ));
302 }
303 }
304
305 Ok(())
306}
307
308#[allow(clippy::implicit_hasher)]
314pub fn validate_env_vars(
315 service_name: &str,
316 env: &std::collections::HashMap<String, String>,
317) -> Result<(), crate::error::ValidationError> {
318 for (key, value) in env {
319 if let Err(e) = validate_secret_reference(value) {
320 return Err(crate::error::ValidationError {
321 kind: crate::error::ValidationErrorKind::InvalidEnvVar {
322 key: key.clone(),
323 reason: e
324 .message
325 .map_or_else(|| "invalid secret reference".to_string(), |m| m.to_string()),
326 },
327 path: format!("services.{service_name}.env.{key}"),
328 });
329 }
330 }
331 Ok(())
332}
333
334pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
344 let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
346 if !re.is_match(name) || name.len() > 63 {
347 return Err(make_validation_error(
348 "invalid_storage_name",
349 format!("storage name '{name}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen"),
350 ));
351 }
352 Ok(())
353}
354
355pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
361 validate_storage_name(name)
362}
363
364pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
374 let service_names: HashSet<&str> = spec
375 .services
376 .keys()
377 .map(std::string::String::as_str)
378 .collect();
379
380 for (service_name, service_spec) in &spec.services {
381 for dep in &service_spec.depends {
382 if !service_names.contains(dep.service.as_str()) {
383 return Err(ValidationError {
384 kind: ValidationErrorKind::UnknownDependency {
385 service: dep.service.clone(),
386 },
387 path: format!("services.{service_name}.depends"),
388 });
389 }
390 }
391 }
392
393 Ok(())
394}
395
396pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
402 for (service_name, service_spec) in &spec.services {
403 let mut seen = HashSet::new();
404 for endpoint in &service_spec.endpoints {
405 if !seen.insert(&endpoint.name) {
406 return Err(ValidationError {
407 kind: ValidationErrorKind::DuplicateEndpoint {
408 name: endpoint.name.clone(),
409 },
410 path: format!("services.{service_name}.endpoints"),
411 });
412 }
413 }
414 }
415
416 Ok(())
417}
418
419pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
425 for (service_name, service_spec) in &spec.services {
426 validate_service_schedule(service_name, service_spec)?;
427 }
428 Ok(())
429}
430
431pub fn validate_service_schedule(
437 service_name: &str,
438 spec: &ServiceSpec,
439) -> Result<(), ValidationError> {
440 if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
442 return Err(ValidationError {
443 kind: ValidationErrorKind::ScheduleOnlyForCron,
444 path: format!("services.{service_name}.schedule"),
445 });
446 }
447
448 if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
450 return Err(ValidationError {
451 kind: ValidationErrorKind::CronRequiresSchedule,
452 path: format!("services.{service_name}.schedule"),
453 });
454 }
455
456 Ok(())
457}
458
459pub fn validate_version(version: &str) -> Result<(), ValidationError> {
469 if version == "v1" {
470 Ok(())
471 } else {
472 Err(ValidationError {
473 kind: ValidationErrorKind::InvalidVersion {
474 found: version.to_string(),
475 },
476 path: "version".to_string(),
477 })
478 }
479}
480
481pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
492 if name.len() < 3 || name.len() > 63 {
494 return Err(ValidationError {
495 kind: ValidationErrorKind::EmptyDeploymentName,
496 path: "deployment".to_string(),
497 });
498 }
499
500 if let Some(first) = name.chars().next() {
502 if !first.is_ascii_alphanumeric() {
503 return Err(ValidationError {
504 kind: ValidationErrorKind::EmptyDeploymentName,
505 path: "deployment".to_string(),
506 });
507 }
508 }
509
510 for c in name.chars() {
512 if !c.is_ascii_alphanumeric() && c != '-' {
513 return Err(ValidationError {
514 kind: ValidationErrorKind::EmptyDeploymentName,
515 path: "deployment".to_string(),
516 });
517 }
518 }
519
520 Ok(())
521}
522
523pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
533 if name.is_empty() || name.trim().is_empty() {
534 Err(ValidationError {
535 kind: ValidationErrorKind::EmptyImageName,
536 path: "image.name".to_string(),
537 })
538 } else {
539 Ok(())
540 }
541}
542
543pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
552 if *cpu > 0.0 {
553 Ok(())
554 } else {
555 Err(ValidationError {
556 kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
557 path: "resources.cpu".to_string(),
558 })
559 }
560}
561
562pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
571 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
573
574 let suffix_match = VALID_SUFFIXES
576 .iter()
577 .find(|&&suffix| value.ends_with(suffix));
578
579 match suffix_match {
580 Some(suffix) => {
581 let numeric_part = &value[..value.len() - suffix.len()];
583
584 match numeric_part.parse::<u64>() {
586 Ok(n) if n > 0 => Ok(()),
587 _ => Err(ValidationError {
588 kind: ValidationErrorKind::InvalidMemoryFormat {
589 value: value.to_string(),
590 },
591 path: "resources.memory".to_string(),
592 }),
593 }
594 }
595 None => Err(ValidationError {
596 kind: ValidationErrorKind::InvalidMemoryFormat {
597 value: value.to_string(),
598 },
599 path: "resources.memory".to_string(),
600 }),
601 }
602}
603
604pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
613 if *port >= 1 {
614 Ok(())
615 } else {
616 Err(ValidationError {
617 kind: ValidationErrorKind::InvalidPort {
618 port: u32::from(*port),
619 },
620 path: "endpoints[].port".to_string(),
621 })
622 }
623}
624
625pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
631 let mut seen = HashSet::new();
632
633 for endpoint in endpoints {
634 if !seen.insert(&endpoint.name) {
635 return Err(ValidationError {
636 kind: ValidationErrorKind::DuplicateEndpoint {
637 name: endpoint.name.clone(),
638 },
639 path: "endpoints".to_string(),
640 });
641 }
642 }
643
644 Ok(())
645}
646
647pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
656 if min <= max {
657 Ok(())
658 } else {
659 Err(ValidationError {
660 kind: ValidationErrorKind::InvalidScaleRange { min, max },
661 path: "scale".to_string(),
662 })
663 }
664}
665
666pub fn validate_tunnel_ttl(ttl: &str) -> Result<(), validator::ValidationError> {
676 humantime::parse_duration(ttl).map(|_| ()).map_err(|e| {
677 make_validation_error(
678 "invalid_tunnel_ttl",
679 format!("invalid TTL format '{ttl}': {e}"),
680 )
681 })
682}
683
684pub fn validate_tunnel_access_config(
690 config: &TunnelAccessConfig,
691 path: &str,
692) -> Result<(), ValidationError> {
693 if let Some(ref max_ttl) = config.max_ttl {
694 validate_tunnel_ttl(max_ttl).map_err(|e| ValidationError {
695 kind: ValidationErrorKind::InvalidTunnelTtl {
696 value: max_ttl.clone(),
697 reason: e
698 .message
699 .map_or_else(|| "invalid duration format".to_string(), |m| m.to_string()),
700 },
701 path: format!("{path}.access.max_ttl"),
702 })?;
703 }
704 Ok(())
705}
706
707pub fn validate_endpoint_tunnel_config(
713 config: &EndpointTunnelConfig,
714 path: &str,
715) -> Result<(), ValidationError> {
716 if let Some(ref access) = config.access {
721 validate_tunnel_access_config(access, path)?;
722 }
723
724 Ok(())
725}
726
727pub fn validate_tunnel_definition(
733 name: &str,
734 tunnel: &TunnelDefinition,
735) -> Result<(), ValidationError> {
736 let path = format!("tunnels.{name}");
737
738 if tunnel.local_port == 0 {
740 return Err(ValidationError {
741 kind: ValidationErrorKind::InvalidTunnelPort {
742 port: tunnel.local_port,
743 field: "local_port".to_string(),
744 },
745 path: format!("{path}.local_port"),
746 });
747 }
748
749 if tunnel.remote_port == 0 {
751 return Err(ValidationError {
752 kind: ValidationErrorKind::InvalidTunnelPort {
753 port: tunnel.remote_port,
754 field: "remote_port".to_string(),
755 },
756 path: format!("{path}.remote_port"),
757 });
758 }
759
760 Ok(())
761}
762
763pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
769 for (name, tunnel) in &spec.tunnels {
771 validate_tunnel_definition(name, tunnel)?;
772 }
773
774 for (service_name, service_spec) in &spec.services {
776 for (idx, endpoint) in service_spec.endpoints.iter().enumerate() {
777 if let Some(ref tunnel_config) = endpoint.tunnel {
778 let path = format!("services.{service_name}.endpoints[{idx}].tunnel");
779 validate_endpoint_tunnel_config(tunnel_config, &path)?;
780 }
781 }
782 }
783
784 Ok(())
785}
786
787pub fn validate_wasm_configs(spec: &DeploymentSpec) -> Result<(), ValidationError> {
797 for (service_name, service_spec) in &spec.services {
798 validate_wasm_config(service_name, service_spec)?;
799 }
800 Ok(())
801}
802
803pub fn validate_wasm_config(service_name: &str, spec: &ServiceSpec) -> Result<(), ValidationError> {
817 if !spec.service_type.is_wasm() && spec.wasm.is_some() {
819 return Err(ValidationError {
820 kind: ValidationErrorKind::WasmConfigOnNonWasmType,
821 path: format!("services.{service_name}.wasm"),
822 });
823 }
824
825 if let Some(ref wasm) = spec.wasm {
826 validate_wasm_fields(service_name, wasm)?;
827 validate_wasm_capabilities(service_name, spec, wasm)?;
828 validate_wasm_http_endpoints(service_name, spec)?;
829 validate_wasm_preopens(service_name, wasm)?;
830 }
831
832 Ok(())
833}
834
835fn validate_wasm_fields(
837 service_name: &str,
838 wasm: &crate::types::WasmConfig,
839) -> Result<(), ValidationError> {
840 if let Some(ref max_mem) = wasm.max_memory {
841 validate_memory_format(max_mem).map_err(|_| ValidationError {
842 kind: ValidationErrorKind::InvalidMemoryFormat {
843 value: max_mem.clone(),
844 },
845 path: format!("services.{service_name}.wasm.max_memory"),
846 })?;
847 }
848
849 if wasm.min_instances > wasm.max_instances {
850 return Err(ValidationError {
851 kind: ValidationErrorKind::InvalidWasmInstanceRange {
852 min: wasm.min_instances,
853 max: wasm.max_instances,
854 },
855 path: format!("services.{service_name}.wasm"),
856 });
857 }
858
859 Ok(())
860}
861
862fn validate_wasm_capabilities(
864 service_name: &str,
865 spec: &ServiceSpec,
866 wasm: &crate::types::WasmConfig,
867) -> Result<(), ValidationError> {
868 let Some(ref caps) = wasm.capabilities else {
869 return Ok(());
870 };
871 let Some(defaults) = spec.service_type.default_wasm_capabilities() else {
872 return Ok(());
873 };
874
875 let checks: &[(&str, bool, bool)] = &[
876 ("config", caps.config, defaults.config),
877 ("keyvalue", caps.keyvalue, defaults.keyvalue),
878 ("logging", caps.logging, defaults.logging),
879 ("secrets", caps.secrets, defaults.secrets),
880 ("metrics", caps.metrics, defaults.metrics),
881 ("http_client", caps.http_client, defaults.http_client),
882 ("cli", caps.cli, defaults.cli),
883 ("filesystem", caps.filesystem, defaults.filesystem),
884 ("sockets", caps.sockets, defaults.sockets),
885 ];
886
887 for &(cap_name, requested, default) in checks {
888 validate_capability_restriction(
889 service_name,
890 spec.service_type,
891 cap_name,
892 requested,
893 default,
894 )?;
895 }
896
897 Ok(())
898}
899
900fn validate_wasm_http_endpoints(
902 service_name: &str,
903 spec: &ServiceSpec,
904) -> Result<(), ValidationError> {
905 if spec.service_type == ServiceType::WasmHttp && !spec.endpoints.is_empty() {
906 let has_http_endpoint = spec
907 .endpoints
908 .iter()
909 .any(|e| matches!(e.protocol, Protocol::Http | Protocol::Https));
910 if !has_http_endpoint {
911 return Err(ValidationError {
912 kind: ValidationErrorKind::WasmHttpMissingHttpEndpoint,
913 path: format!("services.{service_name}.endpoints"),
914 });
915 }
916 }
917 Ok(())
918}
919
920fn validate_wasm_preopens(
922 service_name: &str,
923 wasm: &crate::types::WasmConfig,
924) -> Result<(), ValidationError> {
925 for (i, preopen) in wasm.preopens.iter().enumerate() {
926 if preopen.source.is_empty() {
927 return Err(ValidationError {
928 kind: ValidationErrorKind::WasmPreopenEmpty {
929 index: i,
930 field: "source".to_string(),
931 },
932 path: format!("services.{service_name}.wasm.preopens[{i}].source"),
933 });
934 }
935 if preopen.target.is_empty() {
936 return Err(ValidationError {
937 kind: ValidationErrorKind::WasmPreopenEmpty {
938 index: i,
939 field: "target".to_string(),
940 },
941 path: format!("services.{service_name}.wasm.preopens[{i}].target"),
942 });
943 }
944 }
945 Ok(())
946}
947
948fn validate_capability_restriction(
957 service_name: &str,
958 service_type: ServiceType,
959 cap_name: &str,
960 requested: bool,
961 default: bool,
962) -> Result<(), ValidationError> {
963 if requested && !default {
964 return Err(ValidationError {
965 kind: ValidationErrorKind::WasmCapabilityNotAvailable {
966 capability: cap_name.to_string(),
967 service_type: format!("{service_type:?}"),
968 },
969 path: format!("services.{service_name}.wasm.capabilities.{cap_name}"),
970 });
971 }
972 Ok(())
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use crate::types::{ExposeType, Protocol};
979
980 #[test]
982 fn test_validate_version_valid() {
983 assert!(validate_version("v1").is_ok());
984 }
985
986 #[test]
987 fn test_validate_version_invalid_v2() {
988 let result = validate_version("v2");
989 assert!(result.is_err());
990 let err = result.unwrap_err();
991 assert!(matches!(
992 err.kind,
993 ValidationErrorKind::InvalidVersion { found } if found == "v2"
994 ));
995 }
996
997 #[test]
998 fn test_validate_version_empty() {
999 let result = validate_version("");
1000 assert!(result.is_err());
1001 let err = result.unwrap_err();
1002 assert!(matches!(
1003 err.kind,
1004 ValidationErrorKind::InvalidVersion { found } if found.is_empty()
1005 ));
1006 }
1007
1008 #[test]
1010 fn test_validate_deployment_name_valid() {
1011 assert!(validate_deployment_name("my-app").is_ok());
1012 assert!(validate_deployment_name("api").is_ok());
1013 assert!(validate_deployment_name("my-service-123").is_ok());
1014 assert!(validate_deployment_name("a1b").is_ok());
1015 }
1016
1017 #[test]
1018 fn test_validate_deployment_name_too_short() {
1019 assert!(validate_deployment_name("ab").is_err());
1020 assert!(validate_deployment_name("a").is_err());
1021 assert!(validate_deployment_name("").is_err());
1022 }
1023
1024 #[test]
1025 fn test_validate_deployment_name_too_long() {
1026 let long_name = "a".repeat(64);
1027 assert!(validate_deployment_name(&long_name).is_err());
1028 }
1029
1030 #[test]
1031 fn test_validate_deployment_name_invalid_chars() {
1032 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()); }
1037
1038 #[test]
1039 fn test_validate_deployment_name_must_start_alphanumeric() {
1040 assert!(validate_deployment_name("-myapp").is_err());
1041 assert!(validate_deployment_name("_myapp").is_err());
1042 }
1043
1044 #[test]
1046 fn test_validate_image_name_valid() {
1047 assert!(validate_image_name("nginx:latest").is_ok());
1048 assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
1049 assert!(validate_image_name("ubuntu").is_ok());
1050 }
1051
1052 #[test]
1053 fn test_validate_image_name_empty() {
1054 let result = validate_image_name("");
1055 assert!(result.is_err());
1056 assert!(matches!(
1057 result.unwrap_err().kind,
1058 ValidationErrorKind::EmptyImageName
1059 ));
1060 }
1061
1062 #[test]
1063 fn test_validate_image_name_whitespace_only() {
1064 assert!(validate_image_name(" ").is_err());
1065 assert!(validate_image_name("\t\n").is_err());
1066 }
1067
1068 #[test]
1070 fn test_validate_cpu_valid() {
1071 assert!(validate_cpu(&0.5).is_ok());
1072 assert!(validate_cpu(&1.0).is_ok());
1073 assert!(validate_cpu(&2.0).is_ok());
1074 assert!(validate_cpu(&0.001).is_ok());
1075 }
1076
1077 #[test]
1078 fn test_validate_cpu_zero() {
1079 let result = validate_cpu(&0.0);
1080 assert!(result.is_err());
1081 assert!(matches!(
1082 result.unwrap_err().kind,
1083 ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
1084 ));
1085 }
1086
1087 #[test]
1088 #[allow(clippy::float_cmp)]
1089 fn test_validate_cpu_negative() {
1090 let result = validate_cpu(&-1.0);
1091 assert!(result.is_err());
1092 assert!(matches!(
1093 result.unwrap_err().kind,
1094 ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
1095 ));
1096 }
1097
1098 #[test]
1100 fn test_validate_memory_format_valid() {
1101 assert!(validate_memory_format("512Mi").is_ok());
1102 assert!(validate_memory_format("1Gi").is_ok());
1103 assert!(validate_memory_format("2Ti").is_ok());
1104 assert!(validate_memory_format("256Ki").is_ok());
1105 assert!(validate_memory_format("4096Mi").is_ok());
1106 }
1107
1108 #[test]
1109 fn test_validate_memory_format_invalid_suffix() {
1110 assert!(validate_memory_format("512MB").is_err());
1111 assert!(validate_memory_format("1GB").is_err());
1112 assert!(validate_memory_format("512").is_err());
1113 assert!(validate_memory_format("512m").is_err());
1114 }
1115
1116 #[test]
1117 fn test_validate_memory_format_no_number() {
1118 assert!(validate_memory_format("Mi").is_err());
1119 assert!(validate_memory_format("Gi").is_err());
1120 }
1121
1122 #[test]
1123 fn test_validate_memory_format_invalid_number() {
1124 assert!(validate_memory_format("-512Mi").is_err());
1125 assert!(validate_memory_format("0Mi").is_err());
1126 assert!(validate_memory_format("abcMi").is_err());
1127 }
1128
1129 #[test]
1131 fn test_validate_port_valid() {
1132 assert!(validate_port(&1).is_ok());
1133 assert!(validate_port(&80).is_ok());
1134 assert!(validate_port(&443).is_ok());
1135 assert!(validate_port(&8080).is_ok());
1136 assert!(validate_port(&65535).is_ok());
1137 }
1138
1139 #[test]
1140 fn test_validate_port_zero() {
1141 let result = validate_port(&0);
1142 assert!(result.is_err());
1143 assert!(matches!(
1144 result.unwrap_err().kind,
1145 ValidationErrorKind::InvalidPort { port } if port == 0
1146 ));
1147 }
1148
1149 #[test]
1154 fn test_validate_unique_endpoints_valid() {
1155 let endpoints = vec![
1156 EndpointSpec {
1157 name: "http".to_string(),
1158 protocol: Protocol::Http,
1159 port: 8080,
1160 target_port: None,
1161 path: None,
1162 host: None,
1163 expose: ExposeType::Public,
1164 stream: None,
1165 tunnel: None,
1166 },
1167 EndpointSpec {
1168 name: "grpc".to_string(),
1169 protocol: Protocol::Tcp,
1170 port: 9090,
1171 target_port: None,
1172 path: None,
1173 host: None,
1174 expose: ExposeType::Internal,
1175 stream: None,
1176 tunnel: None,
1177 },
1178 ];
1179 assert!(validate_unique_endpoints(&endpoints).is_ok());
1180 }
1181
1182 #[test]
1183 fn test_validate_unique_endpoints_empty() {
1184 let endpoints: Vec<EndpointSpec> = vec![];
1185 assert!(validate_unique_endpoints(&endpoints).is_ok());
1186 }
1187
1188 #[test]
1189 fn test_validate_unique_endpoints_duplicates() {
1190 let endpoints = vec![
1191 EndpointSpec {
1192 name: "http".to_string(),
1193 protocol: Protocol::Http,
1194 port: 8080,
1195 target_port: None,
1196 path: None,
1197 host: None,
1198 expose: ExposeType::Public,
1199 stream: None,
1200 tunnel: None,
1201 },
1202 EndpointSpec {
1203 name: "http".to_string(), protocol: Protocol::Https,
1205 port: 8443,
1206 target_port: None,
1207 path: None,
1208 host: None,
1209 expose: ExposeType::Public,
1210 stream: None,
1211 tunnel: None,
1212 },
1213 ];
1214 let result = validate_unique_endpoints(&endpoints);
1215 assert!(result.is_err());
1216 assert!(matches!(
1217 result.unwrap_err().kind,
1218 ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
1219 ));
1220 }
1221
1222 #[test]
1224 fn test_validate_scale_range_valid() {
1225 assert!(validate_scale_range(1, 10).is_ok());
1226 assert!(validate_scale_range(1, 1).is_ok()); assert!(validate_scale_range(0, 5).is_ok());
1228 assert!(validate_scale_range(5, 100).is_ok());
1229 }
1230
1231 #[test]
1232 fn test_validate_scale_range_min_greater_than_max() {
1233 let result = validate_scale_range(10, 5);
1234 assert!(result.is_err());
1235 let err = result.unwrap_err();
1236 assert!(matches!(
1237 err.kind,
1238 ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
1239 ));
1240 }
1241
1242 #[test]
1243 fn test_validate_scale_range_large_gap() {
1244 assert!(validate_scale_range(1, 1000).is_ok());
1246 }
1247
1248 #[test]
1251 fn test_validate_schedule_wrapper_valid() {
1252 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());
1258 }
1260
1261 #[test]
1262 fn test_validate_schedule_wrapper_invalid() {
1263 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());
1268 }
1270
1271 #[test]
1273 fn test_validate_secret_reference_plain_values() {
1274 assert!(validate_secret_reference("my-value").is_ok());
1276 assert!(validate_secret_reference("").is_ok());
1277 assert!(validate_secret_reference("some string").is_ok());
1278 assert!(validate_secret_reference("$E:MY_VAR").is_ok()); }
1280
1281 #[test]
1282 fn test_validate_secret_reference_valid() {
1283 assert!(validate_secret_reference("$S:my-secret").is_ok());
1285 assert!(validate_secret_reference("$S:api_key").is_ok());
1286 assert!(validate_secret_reference("$S:MySecret123").is_ok());
1287 assert!(validate_secret_reference("$S:a").is_ok()); }
1289
1290 #[test]
1291 fn test_validate_secret_reference_cross_service() {
1292 assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
1294 assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
1295 assert!(validate_secret_reference("$S:@svc/secret").is_ok());
1296 }
1297
1298 #[test]
1299 fn test_validate_secret_reference_empty_after_prefix() {
1300 assert!(validate_secret_reference("$S:").is_err());
1302 }
1303
1304 #[test]
1305 fn test_validate_secret_reference_must_start_with_letter() {
1306 assert!(validate_secret_reference("$S:123-secret").is_err());
1308 assert!(validate_secret_reference("$S:-my-secret").is_err());
1309 assert!(validate_secret_reference("$S:_underscore").is_err());
1310 }
1311
1312 #[test]
1313 fn test_validate_secret_reference_invalid_chars() {
1314 assert!(validate_secret_reference("$S:my.secret").is_err());
1316 assert!(validate_secret_reference("$S:my secret").is_err());
1317 assert!(validate_secret_reference("$S:my@secret").is_err());
1318 }
1319
1320 #[test]
1321 fn test_validate_secret_reference_cross_service_invalid() {
1322 assert!(validate_secret_reference("$S:@service").is_err());
1324 assert!(validate_secret_reference("$S:@/secret").is_err());
1326 assert!(validate_secret_reference("$S:@service/").is_err());
1328 assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1330 }
1331
1332 #[test]
1337 fn test_validate_tunnel_ttl_valid() {
1338 assert!(validate_tunnel_ttl("30m").is_ok());
1339 assert!(validate_tunnel_ttl("4h").is_ok());
1340 assert!(validate_tunnel_ttl("1d").is_ok());
1341 assert!(validate_tunnel_ttl("1h 30m").is_ok());
1342 assert!(validate_tunnel_ttl("2h30m").is_ok());
1343 }
1344
1345 #[test]
1346 fn test_validate_tunnel_ttl_invalid() {
1347 assert!(validate_tunnel_ttl("").is_err());
1348 assert!(validate_tunnel_ttl("invalid").is_err());
1349 assert!(validate_tunnel_ttl("30").is_err()); assert!(validate_tunnel_ttl("-1h").is_err()); }
1352
1353 #[test]
1354 fn test_validate_tunnel_definition_valid() {
1355 let tunnel = TunnelDefinition {
1356 from: "node-a".to_string(),
1357 to: "node-b".to_string(),
1358 local_port: 8080,
1359 remote_port: 9000,
1360 protocol: crate::types::TunnelProtocol::Tcp,
1361 expose: ExposeType::Internal,
1362 };
1363 assert!(validate_tunnel_definition("test-tunnel", &tunnel).is_ok());
1364 }
1365
1366 #[test]
1367 fn test_validate_tunnel_definition_local_port_zero() {
1368 let tunnel = TunnelDefinition {
1369 from: "node-a".to_string(),
1370 to: "node-b".to_string(),
1371 local_port: 0,
1372 remote_port: 9000,
1373 protocol: crate::types::TunnelProtocol::Tcp,
1374 expose: ExposeType::Internal,
1375 };
1376 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1377 assert!(result.is_err());
1378 assert!(matches!(
1379 result.unwrap_err().kind,
1380 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "local_port"
1381 ));
1382 }
1383
1384 #[test]
1385 fn test_validate_tunnel_definition_remote_port_zero() {
1386 let tunnel = TunnelDefinition {
1387 from: "node-a".to_string(),
1388 to: "node-b".to_string(),
1389 local_port: 8080,
1390 remote_port: 0,
1391 protocol: crate::types::TunnelProtocol::Tcp,
1392 expose: ExposeType::Internal,
1393 };
1394 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1395 assert!(result.is_err());
1396 assert!(matches!(
1397 result.unwrap_err().kind,
1398 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "remote_port"
1399 ));
1400 }
1401
1402 #[test]
1403 fn test_validate_endpoint_tunnel_config_valid() {
1404 let config = EndpointTunnelConfig {
1405 enabled: true,
1406 from: Some("node-1".to_string()),
1407 to: Some("ingress".to_string()),
1408 remote_port: 8080,
1409 expose: Some(ExposeType::Public),
1410 access: None,
1411 };
1412 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1413 }
1414
1415 #[test]
1416 fn test_validate_endpoint_tunnel_config_with_access() {
1417 let config = EndpointTunnelConfig {
1418 enabled: true,
1419 from: None,
1420 to: None,
1421 remote_port: 0, expose: None,
1423 access: Some(TunnelAccessConfig {
1424 enabled: true,
1425 max_ttl: Some("4h".to_string()),
1426 audit: true,
1427 }),
1428 };
1429 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1430 }
1431
1432 #[test]
1433 fn test_validate_endpoint_tunnel_config_invalid_ttl() {
1434 let config = EndpointTunnelConfig {
1435 enabled: true,
1436 from: None,
1437 to: None,
1438 remote_port: 0,
1439 expose: None,
1440 access: Some(TunnelAccessConfig {
1441 enabled: true,
1442 max_ttl: Some("invalid".to_string()),
1443 audit: false,
1444 }),
1445 };
1446 let result = validate_endpoint_tunnel_config(&config, "test.tunnel");
1447 assert!(result.is_err());
1448 assert!(matches!(
1449 result.unwrap_err().kind,
1450 ValidationErrorKind::InvalidTunnelTtl { .. }
1451 ));
1452 }
1453
1454 #[test]
1459 fn test_validate_capability_restriction_allowed() {
1460 let result = validate_capability_restriction(
1462 "test-svc",
1463 ServiceType::WasmHttp,
1464 "config",
1465 true,
1466 true,
1467 );
1468 assert!(result.is_ok());
1469 }
1470
1471 #[test]
1472 fn test_validate_capability_restriction_restricting_is_ok() {
1473 let result = validate_capability_restriction(
1475 "test-svc",
1476 ServiceType::WasmHttp,
1477 "config",
1478 false,
1479 true,
1480 );
1481 assert!(result.is_ok());
1482 }
1483
1484 #[test]
1485 fn test_validate_capability_restriction_granting_not_allowed() {
1486 let result = validate_capability_restriction(
1488 "test-svc",
1489 ServiceType::WasmHttp,
1490 "secrets",
1491 true,
1492 false,
1493 );
1494 assert!(result.is_err());
1495 assert!(matches!(
1496 result.unwrap_err().kind,
1497 ValidationErrorKind::WasmCapabilityNotAvailable { ref capability, .. }
1498 if capability == "secrets"
1499 ));
1500 }
1501
1502 #[test]
1503 fn test_validate_capability_restriction_both_false_is_ok() {
1504 let result = validate_capability_restriction(
1506 "test-svc",
1507 ServiceType::WasmTransformer,
1508 "sockets",
1509 false,
1510 false,
1511 );
1512 assert!(result.is_ok());
1513 }
1514}