Skip to main content

zlayer_types/spec/
validate.rs

1//! Validation functions for `ZLayer` deployment specifications
2//!
3//! This module provides validators for all spec fields with proper error reporting.
4
5use 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
14// =============================================================================
15// Validator crate wrapper functions
16// =============================================================================
17// These functions match the signature expected by #[validate(custom(function = "..."))]
18// They return Result<(), validator::ValidationError>
19
20fn 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
29/// Wrapper for `validate_version` for use with validator crate
30///
31/// # Errors
32///
33/// Returns a validation error if the version is not "v1".
34pub 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
45/// Wrapper for `validate_deployment_name` for use with validator crate
46///
47/// # Errors
48///
49/// Returns a validation error if the name is invalid.
50pub fn validate_deployment_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
51    // Check length
52    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    // Check first character is alphanumeric
60    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    // Check all characters are alphanumeric or hyphens
70    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
82/// Wrapper for `validate_cpu` for use with validator crate
83/// Note: For `Option<f64>` fields, validator crate unwraps and passes the inner `f64`
84///
85/// # Errors
86///
87/// Returns a validation error if CPU is <= 0.
88pub 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
99/// Wrapper for `validate_memory_format` for use with validator crate
100/// Note: For `Option<String>` fields, validator crate unwraps and passes `&String`
101///
102/// # Errors
103///
104/// Returns a validation error if the memory format is invalid.
105pub 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/// Wrapper for `validate_port` for use with validator crate
131/// Note: validator crate passes primitive types by value for custom validators
132///
133/// # Errors
134///
135/// Returns a validation error if the port is 0.
136pub 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
147/// Validate scale range (min <= max) for `ScaleSpec`
148///
149/// # Errors
150///
151/// Returns a validation error if min > max.
152pub 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
164/// Wrapper for `validate_cron_schedule` for use with validator crate
165/// Note: For `Option<String>` fields, validator crate unwraps and passes `&String`
166///
167/// # Errors
168///
169/// Returns a validation error if the cron expression is invalid.
170pub 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
179/// Validate a secret reference name format
180///
181/// Secret names must:
182/// - Start with a letter (a-z, A-Z)
183/// - Contain only alphanumeric characters, hyphens, and underscores
184/// - Optionally be prefixed with `@service/` for cross-service references
185///
186/// Examples of valid secret refs:
187/// - `$S:my-secret`
188/// - `$S:api_key`
189/// - `$S:@auth-service/jwt-secret`
190///
191/// # Errors
192///
193/// Returns a validation error if the secret reference format is invalid.
194///
195/// # Panics
196///
197/// Panics if the secret name is empty after validation (unreachable in practice).
198pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
199    // Only validate values that start with $S:
200    if !value.starts_with("$S:") {
201        return Ok(());
202    }
203
204    let secret_ref = &value[3..]; // Remove "$S:" prefix
205
206    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    // Check for cross-service reference format: @service/secret-name
214    let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
215        // Cross-service reference: @service/secret-name
216        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        // Validate service name part
230        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    // Validate the secret name
261    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    // Must start with a letter
269    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    // All characters must be alphanumeric, hyphen, or underscore
278    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/// Validate all environment variable values in a service spec
293///
294/// # Errors
295///
296/// Returns a validation error if any env var has an invalid secret reference.
297#[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
318/// Validate storage name format (lowercase alphanumeric with hyphens)
319///
320/// # Errors
321///
322/// Returns a validation error if the storage name format is invalid.
323///
324/// # Panics
325///
326/// Panics if the regex pattern is invalid (should never happen with a static pattern).
327pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
328    // Must be lowercase alphanumeric with hyphens, not starting/ending with hyphen
329    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
339/// Wrapper for `validate_storage_name` for use with validator crate
340///
341/// # Errors
342///
343/// Returns a validation error if the storage name format is invalid.
344pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
345    validate_storage_name(name)
346}
347
348// =============================================================================
349// Cross-field validation functions (called from lib.rs)
350// =============================================================================
351
352/// Validate that all dependency service references exist
353///
354/// # Errors
355///
356/// Returns a validation error if a dependency references an unknown service.
357pub 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
380/// Validate that each service has unique endpoint names
381///
382/// # Errors
383///
384/// Returns a validation error if any service has duplicate endpoint names.
385pub 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
403/// Validate schedule/rtype consistency for all services
404///
405/// # Errors
406///
407/// Returns a validation error if schedule/rtype are inconsistent.
408pub 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
415/// Validate schedule/rtype consistency for a single service
416///
417/// # Errors
418///
419/// Returns a validation error if a non-cron service has a schedule, or vice versa.
420pub fn validate_service_schedule(
421    service_name: &str,
422    spec: &ServiceSpec,
423) -> Result<(), ValidationError> {
424    // If schedule is set, rtype must be Cron
425    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 rtype is Cron, schedule must be set
433    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
443// =============================================================================
444// Original validation functions (for direct use)
445// =============================================================================
446
447/// Validate that the version is "v1"
448///
449/// # Errors
450///
451/// Returns a validation error if the version is not "v1".
452pub 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
465/// Validate a deployment name
466///
467/// Requirements:
468/// - 3-63 characters
469/// - Alphanumeric + hyphens only
470/// - Must start with alphanumeric character
471///
472/// # Errors
473///
474/// Returns a validation error if the deployment name is invalid.
475pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
476    // Check length
477    if name.len() < 3 || name.len() > 63 {
478        return Err(ValidationError {
479            kind: ValidationErrorKind::EmptyDeploymentName,
480            path: "deployment".to_string(),
481        });
482    }
483
484    // Check first character is alphanumeric
485    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    // Check all characters are alphanumeric or hyphens
495    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
507/// Validate an image name
508///
509/// Requirements:
510/// - Non-empty
511/// - Not whitespace-only
512///
513/// # Errors
514///
515/// Returns a validation error if the image name is empty or whitespace-only.
516pub 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
527/// Validate CPU limit
528///
529/// Requirements:
530/// - Must be > 0
531///
532/// # Errors
533///
534/// Returns a validation error if CPU is <= 0.
535pub 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
546/// Validate memory format
547///
548/// Valid formats: number followed by Ki, Mi, Gi, or Ti suffix
549/// Examples: "512Mi", "1Gi", "2Ti", "256Ki"
550///
551/// # Errors
552///
553/// Returns a validation error if the memory format is invalid.
554pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
555    // Valid suffixes
556    const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
557
558    // Find which suffix is used, if any
559    let suffix_match = VALID_SUFFIXES
560        .iter()
561        .find(|&&suffix| value.ends_with(suffix));
562
563    match suffix_match {
564        Some(suffix) => {
565            // Extract the numeric part
566            let numeric_part = &value[..value.len() - suffix.len()];
567
568            // Check that numeric part is a valid positive number
569            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
588/// Validate a port number
589///
590/// Requirements:
591/// - Must be 1-65535 (not 0)
592///
593/// # Errors
594///
595/// Returns a validation error if the port is 0.
596pub 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
609/// Validate that endpoint names are unique within a service
610///
611/// # Errors
612///
613/// Returns a validation error if duplicate endpoint names are found.
614pub 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
631/// Validate scale range
632///
633/// Requirements:
634/// - min <= max
635///
636/// # Errors
637///
638/// Returns a validation error if min > max.
639pub 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
650// =============================================================================
651// Tunnel validation functions
652// =============================================================================
653
654/// Validate tunnel TTL format (e.g., "4h", "30m", "1d")
655///
656/// # Errors
657///
658/// Returns a validation error if the TTL format is invalid.
659pub 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
668/// Validate a `TunnelAccessConfig`
669///
670/// # Errors
671///
672/// Returns a validation error if the TTL format is invalid.
673pub 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
691/// Validate an `EndpointTunnelConfig`
692///
693/// # Errors
694///
695/// Returns a validation error if the access config has an invalid TTL.
696pub fn validate_endpoint_tunnel_config(
697    config: &EndpointTunnelConfig,
698    path: &str,
699) -> Result<(), ValidationError> {
700    // remote_port: 0 means auto-assign, otherwise must be valid port
701    // Note: u16 already constrains to 0-65535, so no additional check needed
702
703    // Validate access config if present
704    if let Some(ref access) = config.access {
705        validate_tunnel_access_config(access, path)?;
706    }
707
708    Ok(())
709}
710
711/// Validate a top-level `TunnelDefinition`
712///
713/// # Errors
714///
715/// Returns a validation error if any port is 0.
716pub fn validate_tunnel_definition(
717    name: &str,
718    tunnel: &TunnelDefinition,
719) -> Result<(), ValidationError> {
720    let path = format!("tunnels.{name}");
721
722    // Validate local_port (must be 1-65535, not 0)
723    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    // Validate remote_port (must be 1-65535, not 0)
734    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
747/// Validate all tunnels in a deployment spec
748///
749/// # Errors
750///
751/// Returns a validation error if any tunnel configuration is invalid.
752pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
753    // Validate top-level tunnels
754    for (name, tunnel) in &spec.tunnels {
755        validate_tunnel_definition(name, tunnel)?;
756    }
757
758    // Validate endpoint tunnels
759    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
771// =============================================================================
772// WASM validation functions
773// =============================================================================
774
775/// Validate WASM configuration for all services in a deployment spec
776///
777/// # Errors
778///
779/// Returns a validation error if any WASM configuration is invalid.
780pub 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
787/// Validate WASM configuration for a single service
788///
789/// Checks:
790/// - WASM config should not be present on non-WASM service types
791/// - `min_instances` <= `max_instances`
792/// - `max_memory` format validation
793/// - Capability restriction validation (users can only restrict from defaults, not grant)
794/// - `WasmHttp` must have at least one HTTP endpoint (if endpoints exist)
795/// - Preopens must have non-empty source and target
796///
797/// # Errors
798///
799/// Returns a validation error if the WASM configuration is invalid.
800pub fn validate_wasm_config(service_name: &str, spec: &ServiceSpec) -> Result<(), ValidationError> {
801    // If service_type is NOT wasm but wasm config IS present, that's an error
802    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
819/// Validate basic WASM config fields (memory format, instance range).
820fn 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
846/// Validate WASM capability restrictions against service type defaults.
847fn 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
884/// Validate that `WasmHttp` services have at least one HTTP endpoint when endpoints exist.
885fn 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
904/// Validate that WASM preopens have non-empty source and target paths.
905fn 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
932/// Validate that a requested capability does not exceed the default for the service type.
933///
934/// Users can only restrict capabilities from their defaults, not grant new ones.
935///
936/// # Errors
937///
938/// Returns a validation error if the user requests a capability that is not
939/// available for this WASM service type.
940fn 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    // Version validation tests
965    #[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    // Deployment name validation tests
993    #[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()); // underscore
1017        assert!(validate_deployment_name("my.app").is_err()); // dot
1018        assert!(validate_deployment_name("my app").is_err()); // space
1019        assert!(validate_deployment_name("my@app").is_err()); // special char
1020    }
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    // Image name validation tests
1029    #[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    // CPU validation tests
1053    #[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    // Memory format validation tests
1083    #[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    // Port validation tests
1114    #[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    // Note: u16 cannot be negative, and max value 65535 is valid,
1134    // so we only need to test 0 as the invalid case
1135
1136    // Unique endpoints validation tests
1137    #[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(), // duplicate name
1188                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    // Scale range validation tests
1207    #[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()); // min == max is valid
1211        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        // Large gap between min and max should still be valid
1229        assert!(validate_scale_range(1, 1000).is_ok());
1230    }
1231
1232    // Cron schedule validation tests
1233    // Note: The `cron` crate uses 7-field format: "sec min hour day-of-month month day-of-week year"
1234    #[test]
1235    fn test_validate_schedule_wrapper_valid() {
1236        // Valid 7-field cron expressions (sec min hour dom month dow year)
1237        assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); // Daily at midnight
1238        assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); // Every 5 minutes
1239        assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); // Weekdays at noon
1240        assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); // Monthly at 2:30am on 1st
1241        assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
1242        // Every 10 seconds
1243    }
1244
1245    #[test]
1246    fn test_validate_schedule_wrapper_invalid() {
1247        // Invalid cron expressions
1248        assert!(validate_schedule_wrapper(&String::new()).is_err()); // Empty
1249        assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); // Plain text
1250        assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); // 5-field (standard unix cron) not supported
1251        assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
1252        // Invalid second (60)
1253    }
1254
1255    // Secret reference validation tests
1256    #[test]
1257    fn test_validate_secret_reference_plain_values() {
1258        // Plain values should pass (not secret refs)
1259        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()); // Host env ref, not secret
1263    }
1264
1265    #[test]
1266    fn test_validate_secret_reference_valid() {
1267        // Valid secret references
1268        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()); // Single letter is valid
1272    }
1273
1274    #[test]
1275    fn test_validate_secret_reference_cross_service() {
1276        // Valid cross-service references
1277        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        // Empty after $S:
1285        assert!(validate_secret_reference("$S:").is_err());
1286    }
1287
1288    #[test]
1289    fn test_validate_secret_reference_must_start_with_letter() {
1290        // Secret name must start with letter
1291        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        // Invalid characters in secret name
1299        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        // Missing slash in cross-service ref
1307        assert!(validate_secret_reference("$S:@service").is_err());
1308        // Empty service name
1309        assert!(validate_secret_reference("$S:@/secret").is_err());
1310        // Empty secret name
1311        assert!(validate_secret_reference("$S:@service/").is_err());
1312        // Service name must start with letter
1313        assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1314    }
1315
1316    // =========================================================================
1317    // Tunnel validation tests
1318    // =========================================================================
1319
1320    #[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()); // Missing unit
1334        assert!(validate_tunnel_ttl("-1h").is_err()); // Negative
1335    }
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, // auto-assign
1406            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    // =========================================================================
1439    // WASM validation tests
1440    // =========================================================================
1441
1442    #[test]
1443    fn test_validate_capability_restriction_allowed() {
1444        // Requesting a capability that defaults to true is fine
1445        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        // Restricting a capability (setting false when default is true) is fine
1458        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        // Requesting a capability that defaults to false is not allowed
1471        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        // Both false is fine (not requesting, not available)
1489        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}