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
130#[must_use]
138pub fn memory_string_to_bytes(value: &str) -> Option<u64> {
139 const SUFFIXES: [(&str, u64); 4] = [
140 ("Ki", 1024),
141 ("Mi", 1024 * 1024),
142 ("Gi", 1024 * 1024 * 1024),
143 ("Ti", 1024 * 1024 * 1024 * 1024),
144 ];
145 for (suffix, mult) in SUFFIXES {
146 if let Some(numeric) = value.strip_suffix(suffix) {
147 let n = numeric.parse::<u64>().ok()?;
148 return n.checked_mul(mult);
149 }
150 }
151 None
152}
153
154pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
161 if port >= 1 {
162 Ok(())
163 } else {
164 Err(make_validation_error(
165 "invalid_port",
166 "port must be between 1-65535",
167 ))
168 }
169}
170
171pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
177 if let ScaleSpec::Adaptive { min, max, .. } = scale {
178 if *min > *max {
179 return Err(make_validation_error(
180 "invalid_scale_range",
181 format!("scale min ({min}) cannot be greater than max ({max})"),
182 ));
183 }
184 }
185 Ok(())
186}
187
188pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
195 Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
196 make_validation_error(
197 "invalid_cron_schedule",
198 format!("invalid cron schedule '{schedule}': {e}"),
199 )
200 })
201}
202
203pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
223 if !value.starts_with("$S:") {
225 return Ok(());
226 }
227
228 let secret_ref = &value[3..]; if secret_ref.is_empty() {
231 return Err(make_validation_error(
232 "invalid_secret_reference",
233 "secret reference cannot be empty after $S:",
234 ));
235 }
236
237 let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
239 let parts: Vec<&str> = rest.splitn(2, '/').collect();
241 if parts.len() != 2 {
242 return Err(make_validation_error(
243 "invalid_secret_reference",
244 format!(
245 "cross-service secret reference '{value}' must have format @service/secret-name"
246 ),
247 ));
248 }
249
250 let service_name = parts[0];
251 let secret_name = parts[1];
252
253 if service_name.is_empty() {
255 return Err(make_validation_error(
256 "invalid_secret_reference",
257 format!("service name in secret reference '{value}' cannot be empty"),
258 ));
259 }
260
261 if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
262 return Err(make_validation_error(
263 "invalid_secret_reference",
264 format!("service name in secret reference '{value}' must start with a letter"),
265 ));
266 }
267
268 for c in service_name.chars() {
269 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
270 return Err(make_validation_error(
271 "invalid_secret_reference",
272 format!(
273 "service name in secret reference '{value}' contains invalid character '{c}'"
274 ),
275 ));
276 }
277 }
278
279 secret_name
280 } else {
281 secret_ref
282 };
283
284 if secret_name.is_empty() {
286 return Err(make_validation_error(
287 "invalid_secret_reference",
288 format!("secret name in '{value}' cannot be empty"),
289 ));
290 }
291
292 let first_char = secret_name.chars().next().unwrap();
294 if !first_char.is_ascii_alphabetic() {
295 return Err(make_validation_error(
296 "invalid_secret_reference",
297 format!("secret name in '{value}' must start with a letter, found '{first_char}'"),
298 ));
299 }
300
301 for c in secret_name.chars() {
303 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
304 return Err(make_validation_error(
305 "invalid_secret_reference",
306 format!(
307 "secret name in '{value}' contains invalid character '{c}' (only alphanumeric, hyphens, underscores allowed)"
308 ),
309 ));
310 }
311 }
312
313 Ok(())
314}
315
316#[allow(clippy::implicit_hasher)]
322pub fn validate_env_vars(
323 service_name: &str,
324 env: &std::collections::HashMap<String, String>,
325) -> Result<(), crate::spec::error::ValidationError> {
326 for (key, value) in env {
327 if let Err(e) = validate_secret_reference(value) {
328 return Err(crate::spec::error::ValidationError {
329 kind: crate::spec::error::ValidationErrorKind::InvalidEnvVar {
330 key: key.clone(),
331 reason: e
332 .message
333 .map_or_else(|| "invalid secret reference".to_string(), |m| m.to_string()),
334 },
335 path: format!("services.{service_name}.env.{key}"),
336 });
337 }
338 }
339 Ok(())
340}
341
342pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
352 let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
354 if !re.is_match(name) || name.len() > 63 {
355 return Err(make_validation_error(
356 "invalid_storage_name",
357 format!("storage name '{name}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen"),
358 ));
359 }
360 Ok(())
361}
362
363pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
369 validate_storage_name(name)
370}
371
372pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
382 let service_names: HashSet<&str> = spec
383 .services
384 .keys()
385 .map(std::string::String::as_str)
386 .collect();
387
388 for (service_name, service_spec) in &spec.services {
389 for dep in &service_spec.depends {
390 if !service_names.contains(dep.service.as_str()) {
391 return Err(ValidationError {
392 kind: ValidationErrorKind::UnknownDependency {
393 service: dep.service.clone(),
394 },
395 path: format!("services.{service_name}.depends"),
396 });
397 }
398 }
399 }
400
401 Ok(())
402}
403
404pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
410 for (service_name, service_spec) in &spec.services {
411 let mut seen = HashSet::new();
412 for endpoint in &service_spec.endpoints {
413 if !seen.insert(&endpoint.name) {
414 return Err(ValidationError {
415 kind: ValidationErrorKind::DuplicateEndpoint {
416 name: endpoint.name.clone(),
417 },
418 path: format!("services.{service_name}.endpoints"),
419 });
420 }
421 }
422 }
423
424 Ok(())
425}
426
427pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
433 for (service_name, service_spec) in &spec.services {
434 validate_service_schedule(service_name, service_spec)?;
435 }
436 Ok(())
437}
438
439pub fn validate_service_schedule(
445 service_name: &str,
446 spec: &ServiceSpec,
447) -> Result<(), ValidationError> {
448 if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
450 return Err(ValidationError {
451 kind: ValidationErrorKind::ScheduleOnlyForCron,
452 path: format!("services.{service_name}.schedule"),
453 });
454 }
455
456 if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
458 return Err(ValidationError {
459 kind: ValidationErrorKind::CronRequiresSchedule,
460 path: format!("services.{service_name}.schedule"),
461 });
462 }
463
464 Ok(())
465}
466
467pub fn validate_version(version: &str) -> Result<(), ValidationError> {
477 if version == "v1" {
478 Ok(())
479 } else {
480 Err(ValidationError {
481 kind: ValidationErrorKind::InvalidVersion {
482 found: version.to_string(),
483 },
484 path: "version".to_string(),
485 })
486 }
487}
488
489pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
500 if name.len() < 3 || name.len() > 63 {
502 return Err(ValidationError {
503 kind: ValidationErrorKind::EmptyDeploymentName,
504 path: "deployment".to_string(),
505 });
506 }
507
508 if let Some(first) = name.chars().next() {
510 if !first.is_ascii_alphanumeric() {
511 return Err(ValidationError {
512 kind: ValidationErrorKind::EmptyDeploymentName,
513 path: "deployment".to_string(),
514 });
515 }
516 }
517
518 for c in name.chars() {
520 if !c.is_ascii_alphanumeric() && c != '-' {
521 return Err(ValidationError {
522 kind: ValidationErrorKind::EmptyDeploymentName,
523 path: "deployment".to_string(),
524 });
525 }
526 }
527
528 Ok(())
529}
530
531pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
541 if name.is_empty() || name.trim().is_empty() {
542 Err(ValidationError {
543 kind: ValidationErrorKind::EmptyImageName,
544 path: "image.name".to_string(),
545 })
546 } else {
547 Ok(())
548 }
549}
550
551pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
560 if *cpu > 0.0 {
561 Ok(())
562 } else {
563 Err(ValidationError {
564 kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
565 path: "resources.cpu".to_string(),
566 })
567 }
568}
569
570pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
579 const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
581
582 let suffix_match = VALID_SUFFIXES
584 .iter()
585 .find(|&&suffix| value.ends_with(suffix));
586
587 match suffix_match {
588 Some(suffix) => {
589 let numeric_part = &value[..value.len() - suffix.len()];
591
592 match numeric_part.parse::<u64>() {
594 Ok(n) if n > 0 => Ok(()),
595 _ => Err(ValidationError {
596 kind: ValidationErrorKind::InvalidMemoryFormat {
597 value: value.to_string(),
598 },
599 path: "resources.memory".to_string(),
600 }),
601 }
602 }
603 None => Err(ValidationError {
604 kind: ValidationErrorKind::InvalidMemoryFormat {
605 value: value.to_string(),
606 },
607 path: "resources.memory".to_string(),
608 }),
609 }
610}
611
612pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
621 if *port >= 1 {
622 Ok(())
623 } else {
624 Err(ValidationError {
625 kind: ValidationErrorKind::InvalidPort {
626 port: u32::from(*port),
627 },
628 path: "endpoints[].port".to_string(),
629 })
630 }
631}
632
633pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
639 let mut seen = HashSet::new();
640
641 for endpoint in endpoints {
642 if !seen.insert(&endpoint.name) {
643 return Err(ValidationError {
644 kind: ValidationErrorKind::DuplicateEndpoint {
645 name: endpoint.name.clone(),
646 },
647 path: "endpoints".to_string(),
648 });
649 }
650 }
651
652 Ok(())
653}
654
655pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
664 if min <= max {
665 Ok(())
666 } else {
667 Err(ValidationError {
668 kind: ValidationErrorKind::InvalidScaleRange { min, max },
669 path: "scale".to_string(),
670 })
671 }
672}
673
674pub fn validate_tunnel_ttl(ttl: &str) -> Result<(), validator::ValidationError> {
684 humantime::parse_duration(ttl).map(|_| ()).map_err(|e| {
685 make_validation_error(
686 "invalid_tunnel_ttl",
687 format!("invalid TTL format '{ttl}': {e}"),
688 )
689 })
690}
691
692pub fn validate_tunnel_access_config(
698 config: &TunnelAccessConfig,
699 path: &str,
700) -> Result<(), ValidationError> {
701 if let Some(ref max_ttl) = config.max_ttl {
702 validate_tunnel_ttl(max_ttl).map_err(|e| ValidationError {
703 kind: ValidationErrorKind::InvalidTunnelTtl {
704 value: max_ttl.clone(),
705 reason: e
706 .message
707 .map_or_else(|| "invalid duration format".to_string(), |m| m.to_string()),
708 },
709 path: format!("{path}.access.max_ttl"),
710 })?;
711 }
712 Ok(())
713}
714
715pub fn validate_endpoint_tunnel_config(
721 config: &EndpointTunnelConfig,
722 path: &str,
723) -> Result<(), ValidationError> {
724 if let Some(ref access) = config.access {
729 validate_tunnel_access_config(access, path)?;
730 }
731
732 Ok(())
733}
734
735pub fn validate_tunnel_definition(
741 name: &str,
742 tunnel: &TunnelDefinition,
743) -> Result<(), ValidationError> {
744 let path = format!("tunnels.{name}");
745
746 if tunnel.local_port == 0 {
748 return Err(ValidationError {
749 kind: ValidationErrorKind::InvalidTunnelPort {
750 port: tunnel.local_port,
751 field: "local_port".to_string(),
752 },
753 path: format!("{path}.local_port"),
754 });
755 }
756
757 if tunnel.remote_port == 0 {
759 return Err(ValidationError {
760 kind: ValidationErrorKind::InvalidTunnelPort {
761 port: tunnel.remote_port,
762 field: "remote_port".to_string(),
763 },
764 path: format!("{path}.remote_port"),
765 });
766 }
767
768 Ok(())
769}
770
771pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
777 for (name, tunnel) in &spec.tunnels {
779 validate_tunnel_definition(name, tunnel)?;
780 }
781
782 for (service_name, service_spec) in &spec.services {
784 for (idx, endpoint) in service_spec.endpoints.iter().enumerate() {
785 if let Some(ref tunnel_config) = endpoint.tunnel {
786 let path = format!("services.{service_name}.endpoints[{idx}].tunnel");
787 validate_endpoint_tunnel_config(tunnel_config, &path)?;
788 }
789 }
790 }
791
792 Ok(())
793}
794
795pub fn validate_wasm_configs(spec: &DeploymentSpec) -> Result<(), ValidationError> {
805 for (service_name, service_spec) in &spec.services {
806 validate_wasm_config(service_name, service_spec)?;
807 }
808 Ok(())
809}
810
811pub fn validate_wasm_config(service_name: &str, spec: &ServiceSpec) -> Result<(), ValidationError> {
825 if !spec.service_type.is_wasm() && spec.wasm.is_some() {
827 return Err(ValidationError {
828 kind: ValidationErrorKind::WasmConfigOnNonWasmType,
829 path: format!("services.{service_name}.wasm"),
830 });
831 }
832
833 if let Some(ref wasm) = spec.wasm {
834 validate_wasm_fields(service_name, wasm)?;
835 validate_wasm_capabilities(service_name, spec, wasm)?;
836 validate_wasm_http_endpoints(service_name, spec)?;
837 validate_wasm_preopens(service_name, wasm)?;
838 }
839
840 Ok(())
841}
842
843fn validate_wasm_fields(
845 service_name: &str,
846 wasm: &crate::spec::types::WasmConfig,
847) -> Result<(), ValidationError> {
848 if let Some(ref max_mem) = wasm.max_memory {
849 validate_memory_format(max_mem).map_err(|_| ValidationError {
850 kind: ValidationErrorKind::InvalidMemoryFormat {
851 value: max_mem.clone(),
852 },
853 path: format!("services.{service_name}.wasm.max_memory"),
854 })?;
855 }
856
857 if wasm.min_instances > wasm.max_instances {
858 return Err(ValidationError {
859 kind: ValidationErrorKind::InvalidWasmInstanceRange {
860 min: wasm.min_instances,
861 max: wasm.max_instances,
862 },
863 path: format!("services.{service_name}.wasm"),
864 });
865 }
866
867 Ok(())
868}
869
870fn validate_wasm_capabilities(
872 service_name: &str,
873 spec: &ServiceSpec,
874 wasm: &crate::spec::types::WasmConfig,
875) -> Result<(), ValidationError> {
876 let Some(ref caps) = wasm.capabilities else {
877 return Ok(());
878 };
879 let Some(defaults) = spec.service_type.default_wasm_capabilities() else {
880 return Ok(());
881 };
882
883 let checks: &[(&str, bool, bool)] = &[
884 ("config", caps.config, defaults.config),
885 ("keyvalue", caps.keyvalue, defaults.keyvalue),
886 ("logging", caps.logging, defaults.logging),
887 ("secrets", caps.secrets, defaults.secrets),
888 ("metrics", caps.metrics, defaults.metrics),
889 ("http_client", caps.http_client, defaults.http_client),
890 ("cli", caps.cli, defaults.cli),
891 ("filesystem", caps.filesystem, defaults.filesystem),
892 ("sockets", caps.sockets, defaults.sockets),
893 ];
894
895 for &(cap_name, requested, default) in checks {
896 validate_capability_restriction(
897 service_name,
898 spec.service_type,
899 cap_name,
900 requested,
901 default,
902 )?;
903 }
904
905 Ok(())
906}
907
908fn validate_wasm_http_endpoints(
910 service_name: &str,
911 spec: &ServiceSpec,
912) -> Result<(), ValidationError> {
913 if spec.service_type == ServiceType::WasmHttp && !spec.endpoints.is_empty() {
914 let has_http_endpoint = spec
915 .endpoints
916 .iter()
917 .any(|e| matches!(e.protocol, Protocol::Http | Protocol::Https));
918 if !has_http_endpoint {
919 return Err(ValidationError {
920 kind: ValidationErrorKind::WasmHttpMissingHttpEndpoint,
921 path: format!("services.{service_name}.endpoints"),
922 });
923 }
924 }
925 Ok(())
926}
927
928fn validate_wasm_preopens(
930 service_name: &str,
931 wasm: &crate::spec::types::WasmConfig,
932) -> Result<(), ValidationError> {
933 for (i, preopen) in wasm.preopens.iter().enumerate() {
934 if preopen.source.is_empty() {
935 return Err(ValidationError {
936 kind: ValidationErrorKind::WasmPreopenEmpty {
937 index: i,
938 field: "source".to_string(),
939 },
940 path: format!("services.{service_name}.wasm.preopens[{i}].source"),
941 });
942 }
943 if preopen.target.is_empty() {
944 return Err(ValidationError {
945 kind: ValidationErrorKind::WasmPreopenEmpty {
946 index: i,
947 field: "target".to_string(),
948 },
949 path: format!("services.{service_name}.wasm.preopens[{i}].target"),
950 });
951 }
952 }
953 Ok(())
954}
955
956fn validate_capability_restriction(
965 service_name: &str,
966 service_type: ServiceType,
967 cap_name: &str,
968 requested: bool,
969 default: bool,
970) -> Result<(), ValidationError> {
971 if requested && !default {
972 return Err(ValidationError {
973 kind: ValidationErrorKind::WasmCapabilityNotAvailable {
974 capability: cap_name.to_string(),
975 service_type: format!("{service_type:?}"),
976 },
977 path: format!("services.{service_name}.wasm.capabilities.{cap_name}"),
978 });
979 }
980 Ok(())
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986 use crate::spec::types::{ExposeType, Protocol};
987
988 #[test]
990 fn test_validate_version_valid() {
991 assert!(validate_version("v1").is_ok());
992 }
993
994 #[test]
995 fn test_validate_version_invalid_v2() {
996 let result = validate_version("v2");
997 assert!(result.is_err());
998 let err = result.unwrap_err();
999 assert!(matches!(
1000 err.kind,
1001 ValidationErrorKind::InvalidVersion { found } if found == "v2"
1002 ));
1003 }
1004
1005 #[test]
1006 fn test_validate_version_empty() {
1007 let result = validate_version("");
1008 assert!(result.is_err());
1009 let err = result.unwrap_err();
1010 assert!(matches!(
1011 err.kind,
1012 ValidationErrorKind::InvalidVersion { found } if found.is_empty()
1013 ));
1014 }
1015
1016 #[test]
1018 fn test_validate_deployment_name_valid() {
1019 assert!(validate_deployment_name("my-app").is_ok());
1020 assert!(validate_deployment_name("api").is_ok());
1021 assert!(validate_deployment_name("my-service-123").is_ok());
1022 assert!(validate_deployment_name("a1b").is_ok());
1023 }
1024
1025 #[test]
1026 fn test_validate_deployment_name_too_short() {
1027 assert!(validate_deployment_name("ab").is_err());
1028 assert!(validate_deployment_name("a").is_err());
1029 assert!(validate_deployment_name("").is_err());
1030 }
1031
1032 #[test]
1033 fn test_validate_deployment_name_too_long() {
1034 let long_name = "a".repeat(64);
1035 assert!(validate_deployment_name(&long_name).is_err());
1036 }
1037
1038 #[test]
1039 fn test_validate_deployment_name_invalid_chars() {
1040 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()); }
1045
1046 #[test]
1047 fn test_validate_deployment_name_must_start_alphanumeric() {
1048 assert!(validate_deployment_name("-myapp").is_err());
1049 assert!(validate_deployment_name("_myapp").is_err());
1050 }
1051
1052 #[test]
1054 fn test_validate_image_name_valid() {
1055 assert!(validate_image_name("nginx:latest").is_ok());
1056 assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
1057 assert!(validate_image_name("ubuntu").is_ok());
1058 }
1059
1060 #[test]
1061 fn test_validate_image_name_empty() {
1062 let result = validate_image_name("");
1063 assert!(result.is_err());
1064 assert!(matches!(
1065 result.unwrap_err().kind,
1066 ValidationErrorKind::EmptyImageName
1067 ));
1068 }
1069
1070 #[test]
1071 fn test_validate_image_name_whitespace_only() {
1072 assert!(validate_image_name(" ").is_err());
1073 assert!(validate_image_name("\t\n").is_err());
1074 }
1075
1076 #[test]
1078 fn test_validate_cpu_valid() {
1079 assert!(validate_cpu(&0.5).is_ok());
1080 assert!(validate_cpu(&1.0).is_ok());
1081 assert!(validate_cpu(&2.0).is_ok());
1082 assert!(validate_cpu(&0.001).is_ok());
1083 }
1084
1085 #[test]
1086 fn test_validate_cpu_zero() {
1087 let result = validate_cpu(&0.0);
1088 assert!(result.is_err());
1089 assert!(matches!(
1090 result.unwrap_err().kind,
1091 ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
1092 ));
1093 }
1094
1095 #[test]
1096 #[allow(clippy::float_cmp)]
1097 fn test_validate_cpu_negative() {
1098 let result = validate_cpu(&-1.0);
1099 assert!(result.is_err());
1100 assert!(matches!(
1101 result.unwrap_err().kind,
1102 ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
1103 ));
1104 }
1105
1106 #[test]
1108 fn test_validate_memory_format_valid() {
1109 assert!(validate_memory_format("512Mi").is_ok());
1110 assert!(validate_memory_format("1Gi").is_ok());
1111 assert!(validate_memory_format("2Ti").is_ok());
1112 assert!(validate_memory_format("256Ki").is_ok());
1113 assert!(validate_memory_format("4096Mi").is_ok());
1114 }
1115
1116 #[test]
1117 fn test_validate_memory_format_invalid_suffix() {
1118 assert!(validate_memory_format("512MB").is_err());
1119 assert!(validate_memory_format("1GB").is_err());
1120 assert!(validate_memory_format("512").is_err());
1121 assert!(validate_memory_format("512m").is_err());
1122 }
1123
1124 #[test]
1125 fn test_validate_memory_format_no_number() {
1126 assert!(validate_memory_format("Mi").is_err());
1127 assert!(validate_memory_format("Gi").is_err());
1128 }
1129
1130 #[test]
1131 fn test_validate_memory_format_invalid_number() {
1132 assert!(validate_memory_format("-512Mi").is_err());
1133 assert!(validate_memory_format("0Mi").is_err());
1134 assert!(validate_memory_format("abcMi").is_err());
1135 }
1136
1137 #[test]
1139 fn test_validate_port_valid() {
1140 assert!(validate_port(&1).is_ok());
1141 assert!(validate_port(&80).is_ok());
1142 assert!(validate_port(&443).is_ok());
1143 assert!(validate_port(&8080).is_ok());
1144 assert!(validate_port(&65535).is_ok());
1145 }
1146
1147 #[test]
1148 fn test_validate_port_zero() {
1149 let result = validate_port(&0);
1150 assert!(result.is_err());
1151 assert!(matches!(
1152 result.unwrap_err().kind,
1153 ValidationErrorKind::InvalidPort { port } if port == 0
1154 ));
1155 }
1156
1157 #[test]
1162 fn test_validate_unique_endpoints_valid() {
1163 let endpoints = vec![
1164 EndpointSpec {
1165 name: "http".to_string(),
1166 protocol: Protocol::Http,
1167 port: 8080,
1168 target_port: None,
1169 path: None,
1170 host: None,
1171 expose: ExposeType::Public,
1172 stream: None,
1173 target_role: None,
1174 tunnel: None,
1175 },
1176 EndpointSpec {
1177 name: "grpc".to_string(),
1178 protocol: Protocol::Tcp,
1179 port: 9090,
1180 target_port: None,
1181 path: None,
1182 host: None,
1183 expose: ExposeType::Internal,
1184 stream: None,
1185 target_role: None,
1186 tunnel: None,
1187 },
1188 ];
1189 assert!(validate_unique_endpoints(&endpoints).is_ok());
1190 }
1191
1192 #[test]
1193 fn test_validate_unique_endpoints_empty() {
1194 let endpoints: Vec<EndpointSpec> = vec![];
1195 assert!(validate_unique_endpoints(&endpoints).is_ok());
1196 }
1197
1198 #[test]
1199 fn test_validate_unique_endpoints_duplicates() {
1200 let endpoints = vec![
1201 EndpointSpec {
1202 name: "http".to_string(),
1203 protocol: Protocol::Http,
1204 port: 8080,
1205 target_port: None,
1206 path: None,
1207 host: None,
1208 expose: ExposeType::Public,
1209 stream: None,
1210 target_role: None,
1211 tunnel: None,
1212 },
1213 EndpointSpec {
1214 name: "http".to_string(), protocol: Protocol::Https,
1216 port: 8443,
1217 target_port: None,
1218 path: None,
1219 host: None,
1220 expose: ExposeType::Public,
1221 stream: None,
1222 target_role: None,
1223 tunnel: None,
1224 },
1225 ];
1226 let result = validate_unique_endpoints(&endpoints);
1227 assert!(result.is_err());
1228 assert!(matches!(
1229 result.unwrap_err().kind,
1230 ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
1231 ));
1232 }
1233
1234 #[test]
1236 fn test_validate_scale_range_valid() {
1237 assert!(validate_scale_range(1, 10).is_ok());
1238 assert!(validate_scale_range(1, 1).is_ok()); assert!(validate_scale_range(0, 5).is_ok());
1240 assert!(validate_scale_range(5, 100).is_ok());
1241 }
1242
1243 #[test]
1244 fn test_validate_scale_range_min_greater_than_max() {
1245 let result = validate_scale_range(10, 5);
1246 assert!(result.is_err());
1247 let err = result.unwrap_err();
1248 assert!(matches!(
1249 err.kind,
1250 ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
1251 ));
1252 }
1253
1254 #[test]
1255 fn test_validate_scale_range_large_gap() {
1256 assert!(validate_scale_range(1, 1000).is_ok());
1258 }
1259
1260 #[test]
1263 fn test_validate_schedule_wrapper_valid() {
1264 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());
1270 }
1272
1273 #[test]
1274 fn test_validate_schedule_wrapper_invalid() {
1275 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());
1280 }
1282
1283 #[test]
1285 fn test_validate_secret_reference_plain_values() {
1286 assert!(validate_secret_reference("my-value").is_ok());
1288 assert!(validate_secret_reference("").is_ok());
1289 assert!(validate_secret_reference("some string").is_ok());
1290 assert!(validate_secret_reference("$E:MY_VAR").is_ok()); }
1292
1293 #[test]
1294 fn test_validate_secret_reference_valid() {
1295 assert!(validate_secret_reference("$S:my-secret").is_ok());
1297 assert!(validate_secret_reference("$S:api_key").is_ok());
1298 assert!(validate_secret_reference("$S:MySecret123").is_ok());
1299 assert!(validate_secret_reference("$S:a").is_ok()); }
1301
1302 #[test]
1303 fn test_validate_secret_reference_cross_service() {
1304 assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
1306 assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
1307 assert!(validate_secret_reference("$S:@svc/secret").is_ok());
1308 }
1309
1310 #[test]
1311 fn test_validate_secret_reference_empty_after_prefix() {
1312 assert!(validate_secret_reference("$S:").is_err());
1314 }
1315
1316 #[test]
1317 fn test_validate_secret_reference_must_start_with_letter() {
1318 assert!(validate_secret_reference("$S:123-secret").is_err());
1320 assert!(validate_secret_reference("$S:-my-secret").is_err());
1321 assert!(validate_secret_reference("$S:_underscore").is_err());
1322 }
1323
1324 #[test]
1325 fn test_validate_secret_reference_invalid_chars() {
1326 assert!(validate_secret_reference("$S:my.secret").is_err());
1328 assert!(validate_secret_reference("$S:my secret").is_err());
1329 assert!(validate_secret_reference("$S:my@secret").is_err());
1330 }
1331
1332 #[test]
1333 fn test_validate_secret_reference_cross_service_invalid() {
1334 assert!(validate_secret_reference("$S:@service").is_err());
1336 assert!(validate_secret_reference("$S:@/secret").is_err());
1338 assert!(validate_secret_reference("$S:@service/").is_err());
1340 assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1342 }
1343
1344 #[test]
1349 fn test_validate_tunnel_ttl_valid() {
1350 assert!(validate_tunnel_ttl("30m").is_ok());
1351 assert!(validate_tunnel_ttl("4h").is_ok());
1352 assert!(validate_tunnel_ttl("1d").is_ok());
1353 assert!(validate_tunnel_ttl("1h 30m").is_ok());
1354 assert!(validate_tunnel_ttl("2h30m").is_ok());
1355 }
1356
1357 #[test]
1358 fn test_validate_tunnel_ttl_invalid() {
1359 assert!(validate_tunnel_ttl("").is_err());
1360 assert!(validate_tunnel_ttl("invalid").is_err());
1361 assert!(validate_tunnel_ttl("30").is_err()); assert!(validate_tunnel_ttl("-1h").is_err()); }
1364
1365 #[test]
1366 fn test_validate_tunnel_definition_valid() {
1367 let tunnel = TunnelDefinition {
1368 from: "node-a".to_string(),
1369 to: "node-b".to_string(),
1370 local_port: 8080,
1371 remote_port: 9000,
1372 protocol: crate::spec::types::TunnelProtocol::Tcp,
1373 expose: ExposeType::Internal,
1374 };
1375 assert!(validate_tunnel_definition("test-tunnel", &tunnel).is_ok());
1376 }
1377
1378 #[test]
1379 fn test_validate_tunnel_definition_local_port_zero() {
1380 let tunnel = TunnelDefinition {
1381 from: "node-a".to_string(),
1382 to: "node-b".to_string(),
1383 local_port: 0,
1384 remote_port: 9000,
1385 protocol: crate::spec::types::TunnelProtocol::Tcp,
1386 expose: ExposeType::Internal,
1387 };
1388 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1389 assert!(result.is_err());
1390 assert!(matches!(
1391 result.unwrap_err().kind,
1392 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "local_port"
1393 ));
1394 }
1395
1396 #[test]
1397 fn test_validate_tunnel_definition_remote_port_zero() {
1398 let tunnel = TunnelDefinition {
1399 from: "node-a".to_string(),
1400 to: "node-b".to_string(),
1401 local_port: 8080,
1402 remote_port: 0,
1403 protocol: crate::spec::types::TunnelProtocol::Tcp,
1404 expose: ExposeType::Internal,
1405 };
1406 let result = validate_tunnel_definition("test-tunnel", &tunnel);
1407 assert!(result.is_err());
1408 assert!(matches!(
1409 result.unwrap_err().kind,
1410 ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "remote_port"
1411 ));
1412 }
1413
1414 #[test]
1415 fn test_validate_endpoint_tunnel_config_valid() {
1416 let config = EndpointTunnelConfig {
1417 enabled: true,
1418 from: Some("node-1".to_string()),
1419 to: Some("ingress".to_string()),
1420 remote_port: 8080,
1421 expose: Some(ExposeType::Public),
1422 access: None,
1423 };
1424 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1425 }
1426
1427 #[test]
1428 fn test_validate_endpoint_tunnel_config_with_access() {
1429 let config = EndpointTunnelConfig {
1430 enabled: true,
1431 from: None,
1432 to: None,
1433 remote_port: 0, expose: None,
1435 access: Some(TunnelAccessConfig {
1436 enabled: true,
1437 max_ttl: Some("4h".to_string()),
1438 audit: true,
1439 }),
1440 };
1441 assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1442 }
1443
1444 #[test]
1445 fn test_validate_endpoint_tunnel_config_invalid_ttl() {
1446 let config = EndpointTunnelConfig {
1447 enabled: true,
1448 from: None,
1449 to: None,
1450 remote_port: 0,
1451 expose: None,
1452 access: Some(TunnelAccessConfig {
1453 enabled: true,
1454 max_ttl: Some("invalid".to_string()),
1455 audit: false,
1456 }),
1457 };
1458 let result = validate_endpoint_tunnel_config(&config, "test.tunnel");
1459 assert!(result.is_err());
1460 assert!(matches!(
1461 result.unwrap_err().kind,
1462 ValidationErrorKind::InvalidTunnelTtl { .. }
1463 ));
1464 }
1465
1466 #[test]
1471 fn test_validate_capability_restriction_allowed() {
1472 let result = validate_capability_restriction(
1474 "test-svc",
1475 ServiceType::WasmHttp,
1476 "config",
1477 true,
1478 true,
1479 );
1480 assert!(result.is_ok());
1481 }
1482
1483 #[test]
1484 fn test_validate_capability_restriction_restricting_is_ok() {
1485 let result = validate_capability_restriction(
1487 "test-svc",
1488 ServiceType::WasmHttp,
1489 "config",
1490 false,
1491 true,
1492 );
1493 assert!(result.is_ok());
1494 }
1495
1496 #[test]
1497 fn test_validate_capability_restriction_granting_not_allowed() {
1498 let result = validate_capability_restriction(
1500 "test-svc",
1501 ServiceType::WasmHttp,
1502 "secrets",
1503 true,
1504 false,
1505 );
1506 assert!(result.is_err());
1507 assert!(matches!(
1508 result.unwrap_err().kind,
1509 ValidationErrorKind::WasmCapabilityNotAvailable { ref capability, .. }
1510 if capability == "secrets"
1511 ));
1512 }
1513
1514 #[test]
1515 fn test_validate_capability_restriction_both_false_is_ok() {
1516 let result = validate_capability_restriction(
1518 "test-svc",
1519 ServiceType::WasmTransformer,
1520 "sockets",
1521 false,
1522 false,
1523 );
1524 assert!(result.is_ok());
1525 }
1526}