Skip to main content

zlayer_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::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
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_image_name` for use with validator crate
83///
84/// # Errors
85///
86/// Returns a validation error if the image name is empty.
87pub 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
98/// Wrapper for `validate_cpu` for use with validator crate
99/// Note: For `Option<f64>` fields, validator crate unwraps and passes the inner `f64`
100///
101/// # Errors
102///
103/// Returns a validation error if CPU is <= 0.
104pub 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
115/// Wrapper for `validate_memory_format` for use with validator crate
116/// Note: For `Option<String>` fields, validator crate unwraps and passes `&String`
117///
118/// # Errors
119///
120/// Returns a validation error if the memory format is invalid.
121pub 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
146/// Wrapper for `validate_port` for use with validator crate
147/// Note: validator crate passes primitive types by value for custom validators
148///
149/// # Errors
150///
151/// Returns a validation error if the port is 0.
152pub 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
163/// Validate scale range (min <= max) for `ScaleSpec`
164///
165/// # Errors
166///
167/// Returns a validation error if min > max.
168pub 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
180/// Wrapper for `validate_cron_schedule` for use with validator crate
181/// Note: For `Option<String>` fields, validator crate unwraps and passes `&String`
182///
183/// # Errors
184///
185/// Returns a validation error if the cron expression is invalid.
186pub 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
195/// Validate a secret reference name format
196///
197/// Secret names must:
198/// - Start with a letter (a-z, A-Z)
199/// - Contain only alphanumeric characters, hyphens, and underscores
200/// - Optionally be prefixed with `@service/` for cross-service references
201///
202/// Examples of valid secret refs:
203/// - `$S:my-secret`
204/// - `$S:api_key`
205/// - `$S:@auth-service/jwt-secret`
206///
207/// # Errors
208///
209/// Returns a validation error if the secret reference format is invalid.
210///
211/// # Panics
212///
213/// Panics if the secret name is empty after validation (unreachable in practice).
214pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
215    // Only validate values that start with $S:
216    if !value.starts_with("$S:") {
217        return Ok(());
218    }
219
220    let secret_ref = &value[3..]; // Remove "$S:" prefix
221
222    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    // Check for cross-service reference format: @service/secret-name
230    let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
231        // Cross-service reference: @service/secret-name
232        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        // Validate service name part
246        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    // Validate the secret name
277    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    // Must start with a letter
285    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    // All characters must be alphanumeric, hyphen, or underscore
294    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/// Validate all environment variable values in a service spec
309///
310/// # Errors
311///
312/// Returns a validation error if any env var has an invalid secret reference.
313#[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
334/// Validate storage name format (lowercase alphanumeric with hyphens)
335///
336/// # Errors
337///
338/// Returns a validation error if the storage name format is invalid.
339///
340/// # Panics
341///
342/// Panics if the regex pattern is invalid (should never happen with a static pattern).
343pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
344    // Must be lowercase alphanumeric with hyphens, not starting/ending with hyphen
345    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
355/// Wrapper for `validate_storage_name` for use with validator crate
356///
357/// # Errors
358///
359/// Returns a validation error if the storage name format is invalid.
360pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
361    validate_storage_name(name)
362}
363
364// =============================================================================
365// Cross-field validation functions (called from lib.rs)
366// =============================================================================
367
368/// Validate that all dependency service references exist
369///
370/// # Errors
371///
372/// Returns a validation error if a dependency references an unknown service.
373pub 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
396/// Validate that each service has unique endpoint names
397///
398/// # Errors
399///
400/// Returns a validation error if any service has duplicate endpoint names.
401pub 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
419/// Validate schedule/rtype consistency for all services
420///
421/// # Errors
422///
423/// Returns a validation error if schedule/rtype are inconsistent.
424pub 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
431/// Validate schedule/rtype consistency for a single service
432///
433/// # Errors
434///
435/// Returns a validation error if a non-cron service has a schedule, or vice versa.
436pub fn validate_service_schedule(
437    service_name: &str,
438    spec: &ServiceSpec,
439) -> Result<(), ValidationError> {
440    // If schedule is set, rtype must be Cron
441    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 rtype is Cron, schedule must be set
449    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
459// =============================================================================
460// Original validation functions (for direct use)
461// =============================================================================
462
463/// Validate that the version is "v1"
464///
465/// # Errors
466///
467/// Returns a validation error if the version is not "v1".
468pub 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
481/// Validate a deployment name
482///
483/// Requirements:
484/// - 3-63 characters
485/// - Alphanumeric + hyphens only
486/// - Must start with alphanumeric character
487///
488/// # Errors
489///
490/// Returns a validation error if the deployment name is invalid.
491pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
492    // Check length
493    if name.len() < 3 || name.len() > 63 {
494        return Err(ValidationError {
495            kind: ValidationErrorKind::EmptyDeploymentName,
496            path: "deployment".to_string(),
497        });
498    }
499
500    // Check first character is alphanumeric
501    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    // Check all characters are alphanumeric or hyphens
511    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
523/// Validate an image name
524///
525/// Requirements:
526/// - Non-empty
527/// - Not whitespace-only
528///
529/// # Errors
530///
531/// Returns a validation error if the image name is empty or whitespace-only.
532pub 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
543/// Validate CPU limit
544///
545/// Requirements:
546/// - Must be > 0
547///
548/// # Errors
549///
550/// Returns a validation error if CPU is <= 0.
551pub 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
562/// Validate memory format
563///
564/// Valid formats: number followed by Ki, Mi, Gi, or Ti suffix
565/// Examples: "512Mi", "1Gi", "2Ti", "256Ki"
566///
567/// # Errors
568///
569/// Returns a validation error if the memory format is invalid.
570pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
571    // Valid suffixes
572    const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
573
574    // Find which suffix is used, if any
575    let suffix_match = VALID_SUFFIXES
576        .iter()
577        .find(|&&suffix| value.ends_with(suffix));
578
579    match suffix_match {
580        Some(suffix) => {
581            // Extract the numeric part
582            let numeric_part = &value[..value.len() - suffix.len()];
583
584            // Check that numeric part is a valid positive number
585            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
604/// Validate a port number
605///
606/// Requirements:
607/// - Must be 1-65535 (not 0)
608///
609/// # Errors
610///
611/// Returns a validation error if the port is 0.
612pub 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
625/// Validate that endpoint names are unique within a service
626///
627/// # Errors
628///
629/// Returns a validation error if duplicate endpoint names are found.
630pub 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
647/// Validate scale range
648///
649/// Requirements:
650/// - min <= max
651///
652/// # Errors
653///
654/// Returns a validation error if min > max.
655pub 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
666// =============================================================================
667// Tunnel validation functions
668// =============================================================================
669
670/// Validate tunnel TTL format (e.g., "4h", "30m", "1d")
671///
672/// # Errors
673///
674/// Returns a validation error if the TTL format is invalid.
675pub 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
684/// Validate a `TunnelAccessConfig`
685///
686/// # Errors
687///
688/// Returns a validation error if the TTL format is invalid.
689pub 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
707/// Validate an `EndpointTunnelConfig`
708///
709/// # Errors
710///
711/// Returns a validation error if the access config has an invalid TTL.
712pub fn validate_endpoint_tunnel_config(
713    config: &EndpointTunnelConfig,
714    path: &str,
715) -> Result<(), ValidationError> {
716    // remote_port: 0 means auto-assign, otherwise must be valid port
717    // Note: u16 already constrains to 0-65535, so no additional check needed
718
719    // Validate access config if present
720    if let Some(ref access) = config.access {
721        validate_tunnel_access_config(access, path)?;
722    }
723
724    Ok(())
725}
726
727/// Validate a top-level `TunnelDefinition`
728///
729/// # Errors
730///
731/// Returns a validation error if any port is 0.
732pub fn validate_tunnel_definition(
733    name: &str,
734    tunnel: &TunnelDefinition,
735) -> Result<(), ValidationError> {
736    let path = format!("tunnels.{name}");
737
738    // Validate local_port (must be 1-65535, not 0)
739    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    // Validate remote_port (must be 1-65535, not 0)
750    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
763/// Validate all tunnels in a deployment spec
764///
765/// # Errors
766///
767/// Returns a validation error if any tunnel configuration is invalid.
768pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
769    // Validate top-level tunnels
770    for (name, tunnel) in &spec.tunnels {
771        validate_tunnel_definition(name, tunnel)?;
772    }
773
774    // Validate endpoint tunnels
775    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
787// =============================================================================
788// WASM validation functions
789// =============================================================================
790
791/// Validate WASM configuration for all services in a deployment spec
792///
793/// # Errors
794///
795/// Returns a validation error if any WASM configuration is invalid.
796pub 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
803/// Validate WASM configuration for a single service
804///
805/// Checks:
806/// - WASM config should not be present on non-WASM service types
807/// - `min_instances` <= `max_instances`
808/// - `max_memory` format validation
809/// - Capability restriction validation (users can only restrict from defaults, not grant)
810/// - `WasmHttp` must have at least one HTTP endpoint (if endpoints exist)
811/// - Preopens must have non-empty source and target
812///
813/// # Errors
814///
815/// Returns a validation error if the WASM configuration is invalid.
816pub fn validate_wasm_config(service_name: &str, spec: &ServiceSpec) -> Result<(), ValidationError> {
817    // If service_type is NOT wasm but wasm config IS present, that's an error
818    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
835/// Validate basic WASM config fields (memory format, instance range).
836fn 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
862/// Validate WASM capability restrictions against service type defaults.
863fn 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
900/// Validate that `WasmHttp` services have at least one HTTP endpoint when endpoints exist.
901fn 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
920/// Validate that WASM preopens have non-empty source and target paths.
921fn 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
948/// Validate that a requested capability does not exceed the default for the service type.
949///
950/// Users can only restrict capabilities from their defaults, not grant new ones.
951///
952/// # Errors
953///
954/// Returns a validation error if the user requests a capability that is not
955/// available for this WASM service type.
956fn 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    // Version validation tests
981    #[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    // Deployment name validation tests
1009    #[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()); // underscore
1033        assert!(validate_deployment_name("my.app").is_err()); // dot
1034        assert!(validate_deployment_name("my app").is_err()); // space
1035        assert!(validate_deployment_name("my@app").is_err()); // special char
1036    }
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    // Image name validation tests
1045    #[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    // CPU validation tests
1069    #[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    // Memory format validation tests
1099    #[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    // Port validation tests
1130    #[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    // Note: u16 cannot be negative, and max value 65535 is valid,
1150    // so we only need to test 0 as the invalid case
1151
1152    // Unique endpoints validation tests
1153    #[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(), // duplicate name
1204                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    // Scale range validation tests
1223    #[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()); // min == max is valid
1227        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        // Large gap between min and max should still be valid
1245        assert!(validate_scale_range(1, 1000).is_ok());
1246    }
1247
1248    // Cron schedule validation tests
1249    // Note: The `cron` crate uses 7-field format: "sec min hour day-of-month month day-of-week year"
1250    #[test]
1251    fn test_validate_schedule_wrapper_valid() {
1252        // Valid 7-field cron expressions (sec min hour dom month dow year)
1253        assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); // Daily at midnight
1254        assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); // Every 5 minutes
1255        assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); // Weekdays at noon
1256        assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); // Monthly at 2:30am on 1st
1257        assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
1258        // Every 10 seconds
1259    }
1260
1261    #[test]
1262    fn test_validate_schedule_wrapper_invalid() {
1263        // Invalid cron expressions
1264        assert!(validate_schedule_wrapper(&String::new()).is_err()); // Empty
1265        assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); // Plain text
1266        assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); // 5-field (standard unix cron) not supported
1267        assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
1268        // Invalid second (60)
1269    }
1270
1271    // Secret reference validation tests
1272    #[test]
1273    fn test_validate_secret_reference_plain_values() {
1274        // Plain values should pass (not secret refs)
1275        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()); // Host env ref, not secret
1279    }
1280
1281    #[test]
1282    fn test_validate_secret_reference_valid() {
1283        // Valid secret references
1284        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()); // Single letter is valid
1288    }
1289
1290    #[test]
1291    fn test_validate_secret_reference_cross_service() {
1292        // Valid cross-service references
1293        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        // Empty after $S:
1301        assert!(validate_secret_reference("$S:").is_err());
1302    }
1303
1304    #[test]
1305    fn test_validate_secret_reference_must_start_with_letter() {
1306        // Secret name must start with letter
1307        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        // Invalid characters in secret name
1315        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        // Missing slash in cross-service ref
1323        assert!(validate_secret_reference("$S:@service").is_err());
1324        // Empty service name
1325        assert!(validate_secret_reference("$S:@/secret").is_err());
1326        // Empty secret name
1327        assert!(validate_secret_reference("$S:@service/").is_err());
1328        // Service name must start with letter
1329        assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1330    }
1331
1332    // =========================================================================
1333    // Tunnel validation tests
1334    // =========================================================================
1335
1336    #[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()); // Missing unit
1350        assert!(validate_tunnel_ttl("-1h").is_err()); // Negative
1351    }
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, // auto-assign
1422            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    // =========================================================================
1455    // WASM validation tests
1456    // =========================================================================
1457
1458    #[test]
1459    fn test_validate_capability_restriction_allowed() {
1460        // Requesting a capability that defaults to true is fine
1461        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        // Restricting a capability (setting false when default is true) is fine
1474        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        // Requesting a capability that defaults to false is not allowed
1487        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        // Both false is fine (not requesting, not available)
1505        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}