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/// Convert a memory quantity string (`"512Mi"`, `"1Gi"`, `"2Ti"`, `"256Ki"`)
131/// into a byte count. Suffixes are binary (Ki = 1024, Mi = 1024², …).
132///
133/// Returns `None` if the value is malformed, lacks a recognized suffix, or
134/// overflows `u64`. The accepted grammar matches
135/// [`validate_memory_option_wrapper`]: a positive integer followed by one of
136/// `Ki` / `Mi` / `Gi` / `Ti`.
137#[must_use]
138pub fn memory_string_to_bytes(value: &str) -> Option<u64> {
139    const SUFFIXES: [(&str, u64); 4] = [
140        ("Ki", 1024),
141        ("Mi", 1024 * 1024),
142        ("Gi", 1024 * 1024 * 1024),
143        ("Ti", 1024 * 1024 * 1024 * 1024),
144    ];
145    for (suffix, mult) in SUFFIXES {
146        if let Some(numeric) = value.strip_suffix(suffix) {
147            let n = numeric.parse::<u64>().ok()?;
148            return n.checked_mul(mult);
149        }
150    }
151    None
152}
153
154/// Wrapper for `validate_port` for use with validator crate
155/// Note: validator crate passes primitive types by value for custom validators
156///
157/// # Errors
158///
159/// Returns a validation error if the port is 0.
160pub fn validate_port_wrapper(port: u16) -> Result<(), validator::ValidationError> {
161    if port >= 1 {
162        Ok(())
163    } else {
164        Err(make_validation_error(
165            "invalid_port",
166            "port must be between 1-65535",
167        ))
168    }
169}
170
171/// Validate scale range (min <= max) for `ScaleSpec`
172///
173/// # Errors
174///
175/// Returns a validation error if min > max.
176pub fn validate_scale_spec(scale: &ScaleSpec) -> Result<(), validator::ValidationError> {
177    if let ScaleSpec::Adaptive { min, max, .. } = scale {
178        if *min > *max {
179            return Err(make_validation_error(
180                "invalid_scale_range",
181                format!("scale min ({min}) cannot be greater than max ({max})"),
182            ));
183        }
184    }
185    Ok(())
186}
187
188/// Wrapper for `validate_cron_schedule` for use with validator crate
189/// Note: For `Option<String>` fields, validator crate unwraps and passes `&String`
190///
191/// # Errors
192///
193/// Returns a validation error if the cron expression is invalid.
194pub fn validate_schedule_wrapper(schedule: &String) -> Result<(), validator::ValidationError> {
195    Schedule::from_str(schedule).map(|_| ()).map_err(|e| {
196        make_validation_error(
197            "invalid_cron_schedule",
198            format!("invalid cron schedule '{schedule}': {e}"),
199        )
200    })
201}
202
203/// Validate a secret reference name format
204///
205/// Secret names must:
206/// - Start with a letter (a-z, A-Z)
207/// - Contain only alphanumeric characters, hyphens, and underscores
208/// - Optionally be prefixed with `@service/` for cross-service references
209///
210/// Examples of valid secret refs:
211/// - `$S:my-secret`
212/// - `$S:api_key`
213/// - `$S:@auth-service/jwt-secret`
214///
215/// # Errors
216///
217/// Returns a validation error if the secret reference format is invalid.
218///
219/// # Panics
220///
221/// Panics if the secret name is empty after validation (unreachable in practice).
222pub fn validate_secret_reference(value: &str) -> Result<(), validator::ValidationError> {
223    // Only validate values that start with $S:
224    if !value.starts_with("$S:") {
225        return Ok(());
226    }
227
228    let secret_ref = &value[3..]; // Remove "$S:" prefix
229
230    if secret_ref.is_empty() {
231        return Err(make_validation_error(
232            "invalid_secret_reference",
233            "secret reference cannot be empty after $S:",
234        ));
235    }
236
237    // Check for cross-service reference format: @service/secret-name
238    let secret_name = if let Some(rest) = secret_ref.strip_prefix('@') {
239        // Cross-service reference: @service/secret-name
240        let parts: Vec<&str> = rest.splitn(2, '/').collect();
241        if parts.len() != 2 {
242            return Err(make_validation_error(
243                "invalid_secret_reference",
244                format!(
245                    "cross-service secret reference '{value}' must have format @service/secret-name"
246                ),
247            ));
248        }
249
250        let service_name = parts[0];
251        let secret_name = parts[1];
252
253        // Validate service name part
254        if service_name.is_empty() {
255            return Err(make_validation_error(
256                "invalid_secret_reference",
257                format!("service name in secret reference '{value}' cannot be empty"),
258            ));
259        }
260
261        if !service_name.chars().next().unwrap().is_ascii_alphabetic() {
262            return Err(make_validation_error(
263                "invalid_secret_reference",
264                format!("service name in secret reference '{value}' must start with a letter"),
265            ));
266        }
267
268        for c in service_name.chars() {
269            if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
270                return Err(make_validation_error(
271                    "invalid_secret_reference",
272                    format!(
273                        "service name in secret reference '{value}' contains invalid character '{c}'"
274                    ),
275                ));
276            }
277        }
278
279        secret_name
280    } else {
281        secret_ref
282    };
283
284    // Validate the secret name
285    if secret_name.is_empty() {
286        return Err(make_validation_error(
287            "invalid_secret_reference",
288            format!("secret name in '{value}' cannot be empty"),
289        ));
290    }
291
292    // Must start with a letter
293    let first_char = secret_name.chars().next().unwrap();
294    if !first_char.is_ascii_alphabetic() {
295        return Err(make_validation_error(
296            "invalid_secret_reference",
297            format!("secret name in '{value}' must start with a letter, found '{first_char}'"),
298        ));
299    }
300
301    // All characters must be alphanumeric, hyphen, or underscore
302    for c in secret_name.chars() {
303        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
304            return Err(make_validation_error(
305                "invalid_secret_reference",
306                format!(
307                    "secret name in '{value}' contains invalid character '{c}' (only alphanumeric, hyphens, underscores allowed)"
308                ),
309            ));
310        }
311    }
312
313    Ok(())
314}
315
316/// Validate all environment variable values in a service spec
317///
318/// # Errors
319///
320/// Returns a validation error if any env var has an invalid secret reference.
321#[allow(clippy::implicit_hasher)]
322pub fn validate_env_vars(
323    service_name: &str,
324    env: &std::collections::HashMap<String, String>,
325) -> Result<(), crate::spec::error::ValidationError> {
326    for (key, value) in env {
327        if let Err(e) = validate_secret_reference(value) {
328            return Err(crate::spec::error::ValidationError {
329                kind: crate::spec::error::ValidationErrorKind::InvalidEnvVar {
330                    key: key.clone(),
331                    reason: e
332                        .message
333                        .map_or_else(|| "invalid secret reference".to_string(), |m| m.to_string()),
334                },
335                path: format!("services.{service_name}.env.{key}"),
336            });
337        }
338    }
339    Ok(())
340}
341
342/// Validate storage name format (lowercase alphanumeric with hyphens)
343///
344/// # Errors
345///
346/// Returns a validation error if the storage name format is invalid.
347///
348/// # Panics
349///
350/// Panics if the regex pattern is invalid (should never happen with a static pattern).
351pub fn validate_storage_name(name: &str) -> Result<(), validator::ValidationError> {
352    // Must be lowercase alphanumeric with hyphens, not starting/ending with hyphen
353    let re = regex::Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap();
354    if !re.is_match(name) || name.len() > 63 {
355        return Err(make_validation_error(
356            "invalid_storage_name",
357            format!("storage name '{name}' must be lowercase alphanumeric with hyphens, 1-63 chars, not starting/ending with hyphen"),
358        ));
359    }
360    Ok(())
361}
362
363/// Wrapper for `validate_storage_name` for use with validator crate
364///
365/// # Errors
366///
367/// Returns a validation error if the storage name format is invalid.
368pub fn validate_storage_name_wrapper(name: &str) -> Result<(), validator::ValidationError> {
369    validate_storage_name(name)
370}
371
372// =============================================================================
373// Cross-field validation functions (called from lib.rs)
374// =============================================================================
375
376/// Validate that all dependency service references exist
377///
378/// # Errors
379///
380/// Returns a validation error if a dependency references an unknown service.
381pub fn validate_dependencies(spec: &DeploymentSpec) -> Result<(), ValidationError> {
382    let service_names: HashSet<&str> = spec
383        .services
384        .keys()
385        .map(std::string::String::as_str)
386        .collect();
387
388    for (service_name, service_spec) in &spec.services {
389        for dep in &service_spec.depends {
390            if !service_names.contains(dep.service.as_str()) {
391                return Err(ValidationError {
392                    kind: ValidationErrorKind::UnknownDependency {
393                        service: dep.service.clone(),
394                    },
395                    path: format!("services.{service_name}.depends"),
396                });
397            }
398        }
399    }
400
401    Ok(())
402}
403
404/// Validate that each service has unique endpoint names
405///
406/// # Errors
407///
408/// Returns a validation error if any service has duplicate endpoint names.
409pub fn validate_unique_service_endpoints(spec: &DeploymentSpec) -> Result<(), ValidationError> {
410    for (service_name, service_spec) in &spec.services {
411        let mut seen = HashSet::new();
412        for endpoint in &service_spec.endpoints {
413            if !seen.insert(&endpoint.name) {
414                return Err(ValidationError {
415                    kind: ValidationErrorKind::DuplicateEndpoint {
416                        name: endpoint.name.clone(),
417                    },
418                    path: format!("services.{service_name}.endpoints"),
419                });
420            }
421        }
422    }
423
424    Ok(())
425}
426
427/// Validate schedule/rtype consistency for all services
428///
429/// # Errors
430///
431/// Returns a validation error if schedule/rtype are inconsistent.
432pub fn validate_cron_schedules(spec: &DeploymentSpec) -> Result<(), ValidationError> {
433    for (service_name, service_spec) in &spec.services {
434        validate_service_schedule(service_name, service_spec)?;
435    }
436    Ok(())
437}
438
439/// Validate schedule/rtype consistency for a single service
440///
441/// # Errors
442///
443/// Returns a validation error if a non-cron service has a schedule, or vice versa.
444pub fn validate_service_schedule(
445    service_name: &str,
446    spec: &ServiceSpec,
447) -> Result<(), ValidationError> {
448    // If schedule is set, rtype must be Cron
449    if spec.schedule.is_some() && spec.rtype != ResourceType::Cron {
450        return Err(ValidationError {
451            kind: ValidationErrorKind::ScheduleOnlyForCron,
452            path: format!("services.{service_name}.schedule"),
453        });
454    }
455
456    // If rtype is Cron, schedule must be set
457    if spec.rtype == ResourceType::Cron && spec.schedule.is_none() {
458        return Err(ValidationError {
459            kind: ValidationErrorKind::CronRequiresSchedule,
460            path: format!("services.{service_name}.schedule"),
461        });
462    }
463
464    Ok(())
465}
466
467// =============================================================================
468// Original validation functions (for direct use)
469// =============================================================================
470
471/// Validate that the version is "v1"
472///
473/// # Errors
474///
475/// Returns a validation error if the version is not "v1".
476pub fn validate_version(version: &str) -> Result<(), ValidationError> {
477    if version == "v1" {
478        Ok(())
479    } else {
480        Err(ValidationError {
481            kind: ValidationErrorKind::InvalidVersion {
482                found: version.to_string(),
483            },
484            path: "version".to_string(),
485        })
486    }
487}
488
489/// Validate a deployment name
490///
491/// Requirements:
492/// - 3-63 characters
493/// - Alphanumeric + hyphens only
494/// - Must start with alphanumeric character
495///
496/// # Errors
497///
498/// Returns a validation error if the deployment name is invalid.
499pub fn validate_deployment_name(name: &str) -> Result<(), ValidationError> {
500    // Check length
501    if name.len() < 3 || name.len() > 63 {
502        return Err(ValidationError {
503            kind: ValidationErrorKind::EmptyDeploymentName,
504            path: "deployment".to_string(),
505        });
506    }
507
508    // Check first character is alphanumeric
509    if let Some(first) = name.chars().next() {
510        if !first.is_ascii_alphanumeric() {
511            return Err(ValidationError {
512                kind: ValidationErrorKind::EmptyDeploymentName,
513                path: "deployment".to_string(),
514            });
515        }
516    }
517
518    // Check all characters are alphanumeric or hyphens
519    for c in name.chars() {
520        if !c.is_ascii_alphanumeric() && c != '-' {
521            return Err(ValidationError {
522                kind: ValidationErrorKind::EmptyDeploymentName,
523                path: "deployment".to_string(),
524            });
525        }
526    }
527
528    Ok(())
529}
530
531/// Validate an image name
532///
533/// Requirements:
534/// - Non-empty
535/// - Not whitespace-only
536///
537/// # Errors
538///
539/// Returns a validation error if the image name is empty or whitespace-only.
540pub fn validate_image_name(name: &str) -> Result<(), ValidationError> {
541    if name.is_empty() || name.trim().is_empty() {
542        Err(ValidationError {
543            kind: ValidationErrorKind::EmptyImageName,
544            path: "image.name".to_string(),
545        })
546    } else {
547        Ok(())
548    }
549}
550
551/// Validate CPU limit
552///
553/// Requirements:
554/// - Must be > 0
555///
556/// # Errors
557///
558/// Returns a validation error if CPU is <= 0.
559pub fn validate_cpu(cpu: &f64) -> Result<(), ValidationError> {
560    if *cpu > 0.0 {
561        Ok(())
562    } else {
563        Err(ValidationError {
564            kind: ValidationErrorKind::InvalidCpu { cpu: *cpu },
565            path: "resources.cpu".to_string(),
566        })
567    }
568}
569
570/// Validate memory format
571///
572/// Valid formats: number followed by Ki, Mi, Gi, or Ti suffix
573/// Examples: "512Mi", "1Gi", "2Ti", "256Ki"
574///
575/// # Errors
576///
577/// Returns a validation error if the memory format is invalid.
578pub fn validate_memory_format(value: &str) -> Result<(), ValidationError> {
579    // Valid suffixes
580    const VALID_SUFFIXES: [&str; 4] = ["Ki", "Mi", "Gi", "Ti"];
581
582    // Find which suffix is used, if any
583    let suffix_match = VALID_SUFFIXES
584        .iter()
585        .find(|&&suffix| value.ends_with(suffix));
586
587    match suffix_match {
588        Some(suffix) => {
589            // Extract the numeric part
590            let numeric_part = &value[..value.len() - suffix.len()];
591
592            // Check that numeric part is a valid positive number
593            match numeric_part.parse::<u64>() {
594                Ok(n) if n > 0 => Ok(()),
595                _ => Err(ValidationError {
596                    kind: ValidationErrorKind::InvalidMemoryFormat {
597                        value: value.to_string(),
598                    },
599                    path: "resources.memory".to_string(),
600                }),
601            }
602        }
603        None => Err(ValidationError {
604            kind: ValidationErrorKind::InvalidMemoryFormat {
605                value: value.to_string(),
606            },
607            path: "resources.memory".to_string(),
608        }),
609    }
610}
611
612/// Validate a port number
613///
614/// Requirements:
615/// - Must be 1-65535 (not 0)
616///
617/// # Errors
618///
619/// Returns a validation error if the port is 0.
620pub fn validate_port(port: &u16) -> Result<(), ValidationError> {
621    if *port >= 1 {
622        Ok(())
623    } else {
624        Err(ValidationError {
625            kind: ValidationErrorKind::InvalidPort {
626                port: u32::from(*port),
627            },
628            path: "endpoints[].port".to_string(),
629        })
630    }
631}
632
633/// Validate that endpoint names are unique within a service
634///
635/// # Errors
636///
637/// Returns a validation error if duplicate endpoint names are found.
638pub fn validate_unique_endpoints(endpoints: &[EndpointSpec]) -> Result<(), ValidationError> {
639    let mut seen = HashSet::new();
640
641    for endpoint in endpoints {
642        if !seen.insert(&endpoint.name) {
643            return Err(ValidationError {
644                kind: ValidationErrorKind::DuplicateEndpoint {
645                    name: endpoint.name.clone(),
646                },
647                path: "endpoints".to_string(),
648            });
649        }
650    }
651
652    Ok(())
653}
654
655/// Validate scale range
656///
657/// Requirements:
658/// - min <= max
659///
660/// # Errors
661///
662/// Returns a validation error if min > max.
663pub fn validate_scale_range(min: u32, max: u32) -> Result<(), ValidationError> {
664    if min <= max {
665        Ok(())
666    } else {
667        Err(ValidationError {
668            kind: ValidationErrorKind::InvalidScaleRange { min, max },
669            path: "scale".to_string(),
670        })
671    }
672}
673
674// =============================================================================
675// Tunnel validation functions
676// =============================================================================
677
678/// Validate tunnel TTL format (e.g., "4h", "30m", "1d")
679///
680/// # Errors
681///
682/// Returns a validation error if the TTL format is invalid.
683pub fn validate_tunnel_ttl(ttl: &str) -> Result<(), validator::ValidationError> {
684    humantime::parse_duration(ttl).map(|_| ()).map_err(|e| {
685        make_validation_error(
686            "invalid_tunnel_ttl",
687            format!("invalid TTL format '{ttl}': {e}"),
688        )
689    })
690}
691
692/// Validate a `TunnelAccessConfig`
693///
694/// # Errors
695///
696/// Returns a validation error if the TTL format is invalid.
697pub fn validate_tunnel_access_config(
698    config: &TunnelAccessConfig,
699    path: &str,
700) -> Result<(), ValidationError> {
701    if let Some(ref max_ttl) = config.max_ttl {
702        validate_tunnel_ttl(max_ttl).map_err(|e| ValidationError {
703            kind: ValidationErrorKind::InvalidTunnelTtl {
704                value: max_ttl.clone(),
705                reason: e
706                    .message
707                    .map_or_else(|| "invalid duration format".to_string(), |m| m.to_string()),
708            },
709            path: format!("{path}.access.max_ttl"),
710        })?;
711    }
712    Ok(())
713}
714
715/// Validate an `EndpointTunnelConfig`
716///
717/// # Errors
718///
719/// Returns a validation error if the access config has an invalid TTL.
720pub fn validate_endpoint_tunnel_config(
721    config: &EndpointTunnelConfig,
722    path: &str,
723) -> Result<(), ValidationError> {
724    // remote_port: 0 means auto-assign, otherwise must be valid port
725    // Note: u16 already constrains to 0-65535, so no additional check needed
726
727    // Validate access config if present
728    if let Some(ref access) = config.access {
729        validate_tunnel_access_config(access, path)?;
730    }
731
732    Ok(())
733}
734
735/// Validate a top-level `TunnelDefinition`
736///
737/// # Errors
738///
739/// Returns a validation error if any port is 0.
740pub fn validate_tunnel_definition(
741    name: &str,
742    tunnel: &TunnelDefinition,
743) -> Result<(), ValidationError> {
744    let path = format!("tunnels.{name}");
745
746    // Validate local_port (must be 1-65535, not 0)
747    if tunnel.local_port == 0 {
748        return Err(ValidationError {
749            kind: ValidationErrorKind::InvalidTunnelPort {
750                port: tunnel.local_port,
751                field: "local_port".to_string(),
752            },
753            path: format!("{path}.local_port"),
754        });
755    }
756
757    // Validate remote_port (must be 1-65535, not 0)
758    if tunnel.remote_port == 0 {
759        return Err(ValidationError {
760            kind: ValidationErrorKind::InvalidTunnelPort {
761                port: tunnel.remote_port,
762                field: "remote_port".to_string(),
763            },
764            path: format!("{path}.remote_port"),
765        });
766    }
767
768    Ok(())
769}
770
771/// Validate all tunnels in a deployment spec
772///
773/// # Errors
774///
775/// Returns a validation error if any tunnel configuration is invalid.
776pub fn validate_tunnels(spec: &DeploymentSpec) -> Result<(), ValidationError> {
777    // Validate top-level tunnels
778    for (name, tunnel) in &spec.tunnels {
779        validate_tunnel_definition(name, tunnel)?;
780    }
781
782    // Validate endpoint tunnels
783    for (service_name, service_spec) in &spec.services {
784        for (idx, endpoint) in service_spec.endpoints.iter().enumerate() {
785            if let Some(ref tunnel_config) = endpoint.tunnel {
786                let path = format!("services.{service_name}.endpoints[{idx}].tunnel");
787                validate_endpoint_tunnel_config(tunnel_config, &path)?;
788            }
789        }
790    }
791
792    Ok(())
793}
794
795// =============================================================================
796// WASM validation functions
797// =============================================================================
798
799/// Validate WASM configuration for all services in a deployment spec
800///
801/// # Errors
802///
803/// Returns a validation error if any WASM configuration is invalid.
804pub fn validate_wasm_configs(spec: &DeploymentSpec) -> Result<(), ValidationError> {
805    for (service_name, service_spec) in &spec.services {
806        validate_wasm_config(service_name, service_spec)?;
807    }
808    Ok(())
809}
810
811/// Validate WASM configuration for a single service
812///
813/// Checks:
814/// - WASM config should not be present on non-WASM service types
815/// - `min_instances` <= `max_instances`
816/// - `max_memory` format validation
817/// - Capability restriction validation (users can only restrict from defaults, not grant)
818/// - `WasmHttp` must have at least one HTTP endpoint (if endpoints exist)
819/// - Preopens must have non-empty source and target
820///
821/// # Errors
822///
823/// Returns a validation error if the WASM configuration is invalid.
824pub fn validate_wasm_config(service_name: &str, spec: &ServiceSpec) -> Result<(), ValidationError> {
825    // If service_type is NOT wasm but wasm config IS present, that's an error
826    if !spec.service_type.is_wasm() && spec.wasm.is_some() {
827        return Err(ValidationError {
828            kind: ValidationErrorKind::WasmConfigOnNonWasmType,
829            path: format!("services.{service_name}.wasm"),
830        });
831    }
832
833    if let Some(ref wasm) = spec.wasm {
834        validate_wasm_fields(service_name, wasm)?;
835        validate_wasm_capabilities(service_name, spec, wasm)?;
836        validate_wasm_http_endpoints(service_name, spec)?;
837        validate_wasm_preopens(service_name, wasm)?;
838    }
839
840    Ok(())
841}
842
843/// Validate basic WASM config fields (memory format, instance range).
844fn validate_wasm_fields(
845    service_name: &str,
846    wasm: &crate::spec::types::WasmConfig,
847) -> Result<(), ValidationError> {
848    if let Some(ref max_mem) = wasm.max_memory {
849        validate_memory_format(max_mem).map_err(|_| ValidationError {
850            kind: ValidationErrorKind::InvalidMemoryFormat {
851                value: max_mem.clone(),
852            },
853            path: format!("services.{service_name}.wasm.max_memory"),
854        })?;
855    }
856
857    if wasm.min_instances > wasm.max_instances {
858        return Err(ValidationError {
859            kind: ValidationErrorKind::InvalidWasmInstanceRange {
860                min: wasm.min_instances,
861                max: wasm.max_instances,
862            },
863            path: format!("services.{service_name}.wasm"),
864        });
865    }
866
867    Ok(())
868}
869
870/// Validate WASM capability restrictions against service type defaults.
871fn validate_wasm_capabilities(
872    service_name: &str,
873    spec: &ServiceSpec,
874    wasm: &crate::spec::types::WasmConfig,
875) -> Result<(), ValidationError> {
876    let Some(ref caps) = wasm.capabilities else {
877        return Ok(());
878    };
879    let Some(defaults) = spec.service_type.default_wasm_capabilities() else {
880        return Ok(());
881    };
882
883    let checks: &[(&str, bool, bool)] = &[
884        ("config", caps.config, defaults.config),
885        ("keyvalue", caps.keyvalue, defaults.keyvalue),
886        ("logging", caps.logging, defaults.logging),
887        ("secrets", caps.secrets, defaults.secrets),
888        ("metrics", caps.metrics, defaults.metrics),
889        ("http_client", caps.http_client, defaults.http_client),
890        ("cli", caps.cli, defaults.cli),
891        ("filesystem", caps.filesystem, defaults.filesystem),
892        ("sockets", caps.sockets, defaults.sockets),
893    ];
894
895    for &(cap_name, requested, default) in checks {
896        validate_capability_restriction(
897            service_name,
898            spec.service_type,
899            cap_name,
900            requested,
901            default,
902        )?;
903    }
904
905    Ok(())
906}
907
908/// Validate that `WasmHttp` services have at least one HTTP endpoint when endpoints exist.
909fn validate_wasm_http_endpoints(
910    service_name: &str,
911    spec: &ServiceSpec,
912) -> Result<(), ValidationError> {
913    if spec.service_type == ServiceType::WasmHttp && !spec.endpoints.is_empty() {
914        let has_http_endpoint = spec
915            .endpoints
916            .iter()
917            .any(|e| matches!(e.protocol, Protocol::Http | Protocol::Https));
918        if !has_http_endpoint {
919            return Err(ValidationError {
920                kind: ValidationErrorKind::WasmHttpMissingHttpEndpoint,
921                path: format!("services.{service_name}.endpoints"),
922            });
923        }
924    }
925    Ok(())
926}
927
928/// Validate that WASM preopens have non-empty source and target paths.
929fn validate_wasm_preopens(
930    service_name: &str,
931    wasm: &crate::spec::types::WasmConfig,
932) -> Result<(), ValidationError> {
933    for (i, preopen) in wasm.preopens.iter().enumerate() {
934        if preopen.source.is_empty() {
935            return Err(ValidationError {
936                kind: ValidationErrorKind::WasmPreopenEmpty {
937                    index: i,
938                    field: "source".to_string(),
939                },
940                path: format!("services.{service_name}.wasm.preopens[{i}].source"),
941            });
942        }
943        if preopen.target.is_empty() {
944            return Err(ValidationError {
945                kind: ValidationErrorKind::WasmPreopenEmpty {
946                    index: i,
947                    field: "target".to_string(),
948                },
949                path: format!("services.{service_name}.wasm.preopens[{i}].target"),
950            });
951        }
952    }
953    Ok(())
954}
955
956/// Validate that a requested capability does not exceed the default for the service type.
957///
958/// Users can only restrict capabilities from their defaults, not grant new ones.
959///
960/// # Errors
961///
962/// Returns a validation error if the user requests a capability that is not
963/// available for this WASM service type.
964fn validate_capability_restriction(
965    service_name: &str,
966    service_type: ServiceType,
967    cap_name: &str,
968    requested: bool,
969    default: bool,
970) -> Result<(), ValidationError> {
971    if requested && !default {
972        return Err(ValidationError {
973            kind: ValidationErrorKind::WasmCapabilityNotAvailable {
974                capability: cap_name.to_string(),
975                service_type: format!("{service_type:?}"),
976            },
977            path: format!("services.{service_name}.wasm.capabilities.{cap_name}"),
978        });
979    }
980    Ok(())
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986    use crate::spec::types::{ExposeType, Protocol};
987
988    // Version validation tests
989    #[test]
990    fn test_validate_version_valid() {
991        assert!(validate_version("v1").is_ok());
992    }
993
994    #[test]
995    fn test_validate_version_invalid_v2() {
996        let result = validate_version("v2");
997        assert!(result.is_err());
998        let err = result.unwrap_err();
999        assert!(matches!(
1000            err.kind,
1001            ValidationErrorKind::InvalidVersion { found } if found == "v2"
1002        ));
1003    }
1004
1005    #[test]
1006    fn test_validate_version_empty() {
1007        let result = validate_version("");
1008        assert!(result.is_err());
1009        let err = result.unwrap_err();
1010        assert!(matches!(
1011            err.kind,
1012            ValidationErrorKind::InvalidVersion { found } if found.is_empty()
1013        ));
1014    }
1015
1016    // Deployment name validation tests
1017    #[test]
1018    fn test_validate_deployment_name_valid() {
1019        assert!(validate_deployment_name("my-app").is_ok());
1020        assert!(validate_deployment_name("api").is_ok());
1021        assert!(validate_deployment_name("my-service-123").is_ok());
1022        assert!(validate_deployment_name("a1b").is_ok());
1023    }
1024
1025    #[test]
1026    fn test_validate_deployment_name_too_short() {
1027        assert!(validate_deployment_name("ab").is_err());
1028        assert!(validate_deployment_name("a").is_err());
1029        assert!(validate_deployment_name("").is_err());
1030    }
1031
1032    #[test]
1033    fn test_validate_deployment_name_too_long() {
1034        let long_name = "a".repeat(64);
1035        assert!(validate_deployment_name(&long_name).is_err());
1036    }
1037
1038    #[test]
1039    fn test_validate_deployment_name_invalid_chars() {
1040        assert!(validate_deployment_name("my_app").is_err()); // underscore
1041        assert!(validate_deployment_name("my.app").is_err()); // dot
1042        assert!(validate_deployment_name("my app").is_err()); // space
1043        assert!(validate_deployment_name("my@app").is_err()); // special char
1044    }
1045
1046    #[test]
1047    fn test_validate_deployment_name_must_start_alphanumeric() {
1048        assert!(validate_deployment_name("-myapp").is_err());
1049        assert!(validate_deployment_name("_myapp").is_err());
1050    }
1051
1052    // Image name validation tests
1053    #[test]
1054    fn test_validate_image_name_valid() {
1055        assert!(validate_image_name("nginx:latest").is_ok());
1056        assert!(validate_image_name("ghcr.io/org/api:v1.2.3").is_ok());
1057        assert!(validate_image_name("ubuntu").is_ok());
1058    }
1059
1060    #[test]
1061    fn test_validate_image_name_empty() {
1062        let result = validate_image_name("");
1063        assert!(result.is_err());
1064        assert!(matches!(
1065            result.unwrap_err().kind,
1066            ValidationErrorKind::EmptyImageName
1067        ));
1068    }
1069
1070    #[test]
1071    fn test_validate_image_name_whitespace_only() {
1072        assert!(validate_image_name("   ").is_err());
1073        assert!(validate_image_name("\t\n").is_err());
1074    }
1075
1076    // CPU validation tests
1077    #[test]
1078    fn test_validate_cpu_valid() {
1079        assert!(validate_cpu(&0.5).is_ok());
1080        assert!(validate_cpu(&1.0).is_ok());
1081        assert!(validate_cpu(&2.0).is_ok());
1082        assert!(validate_cpu(&0.001).is_ok());
1083    }
1084
1085    #[test]
1086    fn test_validate_cpu_zero() {
1087        let result = validate_cpu(&0.0);
1088        assert!(result.is_err());
1089        assert!(matches!(
1090            result.unwrap_err().kind,
1091            ValidationErrorKind::InvalidCpu { cpu } if cpu == 0.0
1092        ));
1093    }
1094
1095    #[test]
1096    #[allow(clippy::float_cmp)]
1097    fn test_validate_cpu_negative() {
1098        let result = validate_cpu(&-1.0);
1099        assert!(result.is_err());
1100        assert!(matches!(
1101            result.unwrap_err().kind,
1102            ValidationErrorKind::InvalidCpu { cpu } if cpu == -1.0
1103        ));
1104    }
1105
1106    // Memory format validation tests
1107    #[test]
1108    fn test_validate_memory_format_valid() {
1109        assert!(validate_memory_format("512Mi").is_ok());
1110        assert!(validate_memory_format("1Gi").is_ok());
1111        assert!(validate_memory_format("2Ti").is_ok());
1112        assert!(validate_memory_format("256Ki").is_ok());
1113        assert!(validate_memory_format("4096Mi").is_ok());
1114    }
1115
1116    #[test]
1117    fn test_validate_memory_format_invalid_suffix() {
1118        assert!(validate_memory_format("512MB").is_err());
1119        assert!(validate_memory_format("1GB").is_err());
1120        assert!(validate_memory_format("512").is_err());
1121        assert!(validate_memory_format("512m").is_err());
1122    }
1123
1124    #[test]
1125    fn test_validate_memory_format_no_number() {
1126        assert!(validate_memory_format("Mi").is_err());
1127        assert!(validate_memory_format("Gi").is_err());
1128    }
1129
1130    #[test]
1131    fn test_validate_memory_format_invalid_number() {
1132        assert!(validate_memory_format("-512Mi").is_err());
1133        assert!(validate_memory_format("0Mi").is_err());
1134        assert!(validate_memory_format("abcMi").is_err());
1135    }
1136
1137    // Port validation tests
1138    #[test]
1139    fn test_validate_port_valid() {
1140        assert!(validate_port(&1).is_ok());
1141        assert!(validate_port(&80).is_ok());
1142        assert!(validate_port(&443).is_ok());
1143        assert!(validate_port(&8080).is_ok());
1144        assert!(validate_port(&65535).is_ok());
1145    }
1146
1147    #[test]
1148    fn test_validate_port_zero() {
1149        let result = validate_port(&0);
1150        assert!(result.is_err());
1151        assert!(matches!(
1152            result.unwrap_err().kind,
1153            ValidationErrorKind::InvalidPort { port } if port == 0
1154        ));
1155    }
1156
1157    // Note: u16 cannot be negative, and max value 65535 is valid,
1158    // so we only need to test 0 as the invalid case
1159
1160    // Unique endpoints validation tests
1161    #[test]
1162    fn test_validate_unique_endpoints_valid() {
1163        let endpoints = vec![
1164            EndpointSpec {
1165                name: "http".to_string(),
1166                protocol: Protocol::Http,
1167                port: 8080,
1168                target_port: None,
1169                path: None,
1170                host: None,
1171                expose: ExposeType::Public,
1172                stream: None,
1173                target_role: None,
1174                tunnel: None,
1175            },
1176            EndpointSpec {
1177                name: "grpc".to_string(),
1178                protocol: Protocol::Tcp,
1179                port: 9090,
1180                target_port: None,
1181                path: None,
1182                host: None,
1183                expose: ExposeType::Internal,
1184                stream: None,
1185                target_role: None,
1186                tunnel: None,
1187            },
1188        ];
1189        assert!(validate_unique_endpoints(&endpoints).is_ok());
1190    }
1191
1192    #[test]
1193    fn test_validate_unique_endpoints_empty() {
1194        let endpoints: Vec<EndpointSpec> = vec![];
1195        assert!(validate_unique_endpoints(&endpoints).is_ok());
1196    }
1197
1198    #[test]
1199    fn test_validate_unique_endpoints_duplicates() {
1200        let endpoints = vec![
1201            EndpointSpec {
1202                name: "http".to_string(),
1203                protocol: Protocol::Http,
1204                port: 8080,
1205                target_port: None,
1206                path: None,
1207                host: None,
1208                expose: ExposeType::Public,
1209                stream: None,
1210                target_role: None,
1211                tunnel: None,
1212            },
1213            EndpointSpec {
1214                name: "http".to_string(), // duplicate name
1215                protocol: Protocol::Https,
1216                port: 8443,
1217                target_port: None,
1218                path: None,
1219                host: None,
1220                expose: ExposeType::Public,
1221                stream: None,
1222                target_role: None,
1223                tunnel: None,
1224            },
1225        ];
1226        let result = validate_unique_endpoints(&endpoints);
1227        assert!(result.is_err());
1228        assert!(matches!(
1229            result.unwrap_err().kind,
1230            ValidationErrorKind::DuplicateEndpoint { name } if name == "http"
1231        ));
1232    }
1233
1234    // Scale range validation tests
1235    #[test]
1236    fn test_validate_scale_range_valid() {
1237        assert!(validate_scale_range(1, 10).is_ok());
1238        assert!(validate_scale_range(1, 1).is_ok()); // min == max is valid
1239        assert!(validate_scale_range(0, 5).is_ok());
1240        assert!(validate_scale_range(5, 100).is_ok());
1241    }
1242
1243    #[test]
1244    fn test_validate_scale_range_min_greater_than_max() {
1245        let result = validate_scale_range(10, 5);
1246        assert!(result.is_err());
1247        let err = result.unwrap_err();
1248        assert!(matches!(
1249            err.kind,
1250            ValidationErrorKind::InvalidScaleRange { min: 10, max: 5 }
1251        ));
1252    }
1253
1254    #[test]
1255    fn test_validate_scale_range_large_gap() {
1256        // Large gap between min and max should still be valid
1257        assert!(validate_scale_range(1, 1000).is_ok());
1258    }
1259
1260    // Cron schedule validation tests
1261    // Note: The `cron` crate uses 7-field format: "sec min hour day-of-month month day-of-week year"
1262    #[test]
1263    fn test_validate_schedule_wrapper_valid() {
1264        // Valid 7-field cron expressions (sec min hour dom month dow year)
1265        assert!(validate_schedule_wrapper(&"0 0 0 * * * *".to_string()).is_ok()); // Daily at midnight
1266        assert!(validate_schedule_wrapper(&"0 */5 * * * * *".to_string()).is_ok()); // Every 5 minutes
1267        assert!(validate_schedule_wrapper(&"0 0 12 * * MON-FRI *".to_string()).is_ok()); // Weekdays at noon
1268        assert!(validate_schedule_wrapper(&"0 30 2 1 * * *".to_string()).is_ok()); // Monthly at 2:30am on 1st
1269        assert!(validate_schedule_wrapper(&"*/10 * * * * * *".to_string()).is_ok());
1270        // Every 10 seconds
1271    }
1272
1273    #[test]
1274    fn test_validate_schedule_wrapper_invalid() {
1275        // Invalid cron expressions
1276        assert!(validate_schedule_wrapper(&String::new()).is_err()); // Empty
1277        assert!(validate_schedule_wrapper(&"not a cron".to_string()).is_err()); // Plain text
1278        assert!(validate_schedule_wrapper(&"0 0 * * *".to_string()).is_err()); // 5-field (standard unix cron) not supported
1279        assert!(validate_schedule_wrapper(&"60 0 0 * * * *".to_string()).is_err());
1280        // Invalid second (60)
1281    }
1282
1283    // Secret reference validation tests
1284    #[test]
1285    fn test_validate_secret_reference_plain_values() {
1286        // Plain values should pass (not secret refs)
1287        assert!(validate_secret_reference("my-value").is_ok());
1288        assert!(validate_secret_reference("").is_ok());
1289        assert!(validate_secret_reference("some string").is_ok());
1290        assert!(validate_secret_reference("$E:MY_VAR").is_ok()); // Host env ref, not secret
1291    }
1292
1293    #[test]
1294    fn test_validate_secret_reference_valid() {
1295        // Valid secret references
1296        assert!(validate_secret_reference("$S:my-secret").is_ok());
1297        assert!(validate_secret_reference("$S:api_key").is_ok());
1298        assert!(validate_secret_reference("$S:MySecret123").is_ok());
1299        assert!(validate_secret_reference("$S:a").is_ok()); // Single letter is valid
1300    }
1301
1302    #[test]
1303    fn test_validate_secret_reference_cross_service() {
1304        // Valid cross-service references
1305        assert!(validate_secret_reference("$S:@auth-service/jwt-secret").is_ok());
1306        assert!(validate_secret_reference("$S:@my_service/api_key").is_ok());
1307        assert!(validate_secret_reference("$S:@svc/secret").is_ok());
1308    }
1309
1310    #[test]
1311    fn test_validate_secret_reference_empty_after_prefix() {
1312        // Empty after $S:
1313        assert!(validate_secret_reference("$S:").is_err());
1314    }
1315
1316    #[test]
1317    fn test_validate_secret_reference_must_start_with_letter() {
1318        // Secret name must start with letter
1319        assert!(validate_secret_reference("$S:123-secret").is_err());
1320        assert!(validate_secret_reference("$S:-my-secret").is_err());
1321        assert!(validate_secret_reference("$S:_underscore").is_err());
1322    }
1323
1324    #[test]
1325    fn test_validate_secret_reference_invalid_chars() {
1326        // Invalid characters in secret name
1327        assert!(validate_secret_reference("$S:my.secret").is_err());
1328        assert!(validate_secret_reference("$S:my secret").is_err());
1329        assert!(validate_secret_reference("$S:my@secret").is_err());
1330    }
1331
1332    #[test]
1333    fn test_validate_secret_reference_cross_service_invalid() {
1334        // Missing slash in cross-service ref
1335        assert!(validate_secret_reference("$S:@service").is_err());
1336        // Empty service name
1337        assert!(validate_secret_reference("$S:@/secret").is_err());
1338        // Empty secret name
1339        assert!(validate_secret_reference("$S:@service/").is_err());
1340        // Service name must start with letter
1341        assert!(validate_secret_reference("$S:@123-service/secret").is_err());
1342    }
1343
1344    // =========================================================================
1345    // Tunnel validation tests
1346    // =========================================================================
1347
1348    #[test]
1349    fn test_validate_tunnel_ttl_valid() {
1350        assert!(validate_tunnel_ttl("30m").is_ok());
1351        assert!(validate_tunnel_ttl("4h").is_ok());
1352        assert!(validate_tunnel_ttl("1d").is_ok());
1353        assert!(validate_tunnel_ttl("1h 30m").is_ok());
1354        assert!(validate_tunnel_ttl("2h30m").is_ok());
1355    }
1356
1357    #[test]
1358    fn test_validate_tunnel_ttl_invalid() {
1359        assert!(validate_tunnel_ttl("").is_err());
1360        assert!(validate_tunnel_ttl("invalid").is_err());
1361        assert!(validate_tunnel_ttl("30").is_err()); // Missing unit
1362        assert!(validate_tunnel_ttl("-1h").is_err()); // Negative
1363    }
1364
1365    #[test]
1366    fn test_validate_tunnel_definition_valid() {
1367        let tunnel = TunnelDefinition {
1368            from: "node-a".to_string(),
1369            to: "node-b".to_string(),
1370            local_port: 8080,
1371            remote_port: 9000,
1372            protocol: crate::spec::types::TunnelProtocol::Tcp,
1373            expose: ExposeType::Internal,
1374        };
1375        assert!(validate_tunnel_definition("test-tunnel", &tunnel).is_ok());
1376    }
1377
1378    #[test]
1379    fn test_validate_tunnel_definition_local_port_zero() {
1380        let tunnel = TunnelDefinition {
1381            from: "node-a".to_string(),
1382            to: "node-b".to_string(),
1383            local_port: 0,
1384            remote_port: 9000,
1385            protocol: crate::spec::types::TunnelProtocol::Tcp,
1386            expose: ExposeType::Internal,
1387        };
1388        let result = validate_tunnel_definition("test-tunnel", &tunnel);
1389        assert!(result.is_err());
1390        assert!(matches!(
1391            result.unwrap_err().kind,
1392            ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "local_port"
1393        ));
1394    }
1395
1396    #[test]
1397    fn test_validate_tunnel_definition_remote_port_zero() {
1398        let tunnel = TunnelDefinition {
1399            from: "node-a".to_string(),
1400            to: "node-b".to_string(),
1401            local_port: 8080,
1402            remote_port: 0,
1403            protocol: crate::spec::types::TunnelProtocol::Tcp,
1404            expose: ExposeType::Internal,
1405        };
1406        let result = validate_tunnel_definition("test-tunnel", &tunnel);
1407        assert!(result.is_err());
1408        assert!(matches!(
1409            result.unwrap_err().kind,
1410            ValidationErrorKind::InvalidTunnelPort { field, .. } if field == "remote_port"
1411        ));
1412    }
1413
1414    #[test]
1415    fn test_validate_endpoint_tunnel_config_valid() {
1416        let config = EndpointTunnelConfig {
1417            enabled: true,
1418            from: Some("node-1".to_string()),
1419            to: Some("ingress".to_string()),
1420            remote_port: 8080,
1421            expose: Some(ExposeType::Public),
1422            access: None,
1423        };
1424        assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1425    }
1426
1427    #[test]
1428    fn test_validate_endpoint_tunnel_config_with_access() {
1429        let config = EndpointTunnelConfig {
1430            enabled: true,
1431            from: None,
1432            to: None,
1433            remote_port: 0, // auto-assign
1434            expose: None,
1435            access: Some(TunnelAccessConfig {
1436                enabled: true,
1437                max_ttl: Some("4h".to_string()),
1438                audit: true,
1439            }),
1440        };
1441        assert!(validate_endpoint_tunnel_config(&config, "test.tunnel").is_ok());
1442    }
1443
1444    #[test]
1445    fn test_validate_endpoint_tunnel_config_invalid_ttl() {
1446        let config = EndpointTunnelConfig {
1447            enabled: true,
1448            from: None,
1449            to: None,
1450            remote_port: 0,
1451            expose: None,
1452            access: Some(TunnelAccessConfig {
1453                enabled: true,
1454                max_ttl: Some("invalid".to_string()),
1455                audit: false,
1456            }),
1457        };
1458        let result = validate_endpoint_tunnel_config(&config, "test.tunnel");
1459        assert!(result.is_err());
1460        assert!(matches!(
1461            result.unwrap_err().kind,
1462            ValidationErrorKind::InvalidTunnelTtl { .. }
1463        ));
1464    }
1465
1466    // =========================================================================
1467    // WASM validation tests
1468    // =========================================================================
1469
1470    #[test]
1471    fn test_validate_capability_restriction_allowed() {
1472        // Requesting a capability that defaults to true is fine
1473        let result = validate_capability_restriction(
1474            "test-svc",
1475            ServiceType::WasmHttp,
1476            "config",
1477            true,
1478            true,
1479        );
1480        assert!(result.is_ok());
1481    }
1482
1483    #[test]
1484    fn test_validate_capability_restriction_restricting_is_ok() {
1485        // Restricting a capability (setting false when default is true) is fine
1486        let result = validate_capability_restriction(
1487            "test-svc",
1488            ServiceType::WasmHttp,
1489            "config",
1490            false,
1491            true,
1492        );
1493        assert!(result.is_ok());
1494    }
1495
1496    #[test]
1497    fn test_validate_capability_restriction_granting_not_allowed() {
1498        // Requesting a capability that defaults to false is not allowed
1499        let result = validate_capability_restriction(
1500            "test-svc",
1501            ServiceType::WasmHttp,
1502            "secrets",
1503            true,
1504            false,
1505        );
1506        assert!(result.is_err());
1507        assert!(matches!(
1508            result.unwrap_err().kind,
1509            ValidationErrorKind::WasmCapabilityNotAvailable { ref capability, .. }
1510            if capability == "secrets"
1511        ));
1512    }
1513
1514    #[test]
1515    fn test_validate_capability_restriction_both_false_is_ok() {
1516        // Both false is fine (not requesting, not available)
1517        let result = validate_capability_restriction(
1518            "test-svc",
1519            ServiceType::WasmTransformer,
1520            "sockets",
1521            false,
1522            false,
1523        );
1524        assert!(result.is_ok());
1525    }
1526}