zinit 0.3.8

Process supervisor with dependency management
Documentation
//! Configuration validation utilities.

use super::config::{HealthDef, ServiceConfig, TargetConfig};
use super::signal;

/// Validate a service configuration.
///
/// Returns a list of validation errors. Empty list means valid.
pub fn validate_service(config: &ServiceConfig) -> Vec<String> {
    let mut errors = Vec::new();

    // Validate service name
    if config.service.name.is_empty() {
        errors.push("service.name cannot be empty".to_string());
    } else if !is_valid_name(&config.service.name) {
        errors.push(format!(
            "service.name '{}' contains invalid characters (use alphanumeric, dash, underscore)",
            config.service.name
        ));
    }

    // Validate exec
    if config.service.exec.is_empty() {
        errors.push("service.exec cannot be empty".to_string());
    }

    // Validate lifecycle timeouts
    if config.lifecycle.restart_delay_ms == 0 {
        errors.push("lifecycle.restart_delay_ms must be > 0".to_string());
    }
    if config.lifecycle.restart_delay_max_ms == 0 {
        errors.push("lifecycle.restart_delay_max_ms must be > 0".to_string());
    }
    if config.lifecycle.restart_delay_max_ms < config.lifecycle.restart_delay_ms {
        errors.push("lifecycle.restart_delay_max_ms must be >= restart_delay_ms".to_string());
    }
    if config.lifecycle.start_timeout_ms == 0 {
        errors.push("lifecycle.start_timeout_ms must be > 0".to_string());
    }
    if config.lifecycle.stop_timeout_ms == 0 {
        errors.push("lifecycle.stop_timeout_ms must be > 0".to_string());
    }

    // Validate stop signal
    if signal::parse(&config.lifecycle.stop_signal).is_none() {
        errors.push(format!(
            "lifecycle.stop_signal '{}' is not a valid signal",
            config.lifecycle.stop_signal
        ));
    }

    // Validate health check if present
    if let Some(health) = &config.health {
        let common = match health {
            HealthDef::Tcp { common, .. } => common,
            HealthDef::Http { common, .. } => common,
            HealthDef::Exec { common, .. } => common,
        };

        if common.retries == 0 {
            errors.push("health.retries must be > 0".to_string());
        }
        if common.interval_ms == 0 {
            errors.push("health.interval_ms must be > 0".to_string());
        }
        if common.timeout_ms == 0 {
            errors.push("health.timeout_ms must be > 0".to_string());
        }

        // Validate health check target
        match health {
            HealthDef::Tcp { target, .. } | HealthDef::Exec { target, .. } => {
                if target.is_empty() {
                    errors.push("health.target cannot be empty".to_string());
                }
            }
            HealthDef::Http {
                target,
                expect_status,
                ..
            } => {
                if target.is_empty() {
                    errors.push("health.target cannot be empty".to_string());
                }
                if *expect_status == 0 {
                    errors.push("health.expect_status must be > 0".to_string());
                }
            }
        }
    }

    // Validate logging
    if config.logging.buffer_lines == 0 {
        errors.push("logging.buffer_lines must be > 0".to_string());
    }

    // Validate dependency names
    for dep in &config.dependencies.requires {
        if !is_valid_name(dep) {
            errors.push(format!(
                "dependencies.requires contains invalid name '{}'",
                dep
            ));
        }
    }
    for dep in &config.dependencies.after {
        if !is_valid_name(dep) {
            errors.push(format!(
                "dependencies.after contains invalid name '{}'",
                dep
            ));
        }
    }
    for dep in &config.dependencies.wants {
        if !is_valid_name(dep) {
            errors.push(format!(
                "dependencies.wants contains invalid name '{}'",
                dep
            ));
        }
    }
    for dep in &config.dependencies.conflicts {
        if !is_valid_name(dep) {
            errors.push(format!(
                "dependencies.conflicts contains invalid name '{}'",
                dep
            ));
        }
    }

    errors
}

/// Validate a target configuration.
///
/// Returns a list of validation errors. Empty list means valid.
pub fn validate_target(config: &TargetConfig) -> Vec<String> {
    let mut errors = Vec::new();

    // Validate target name
    if config.target.name.is_empty() {
        errors.push("target.name cannot be empty".to_string());
    } else if !is_valid_name(&config.target.name) {
        errors.push(format!(
            "target.name '{}' contains invalid characters (use alphanumeric, dash, underscore)",
            config.target.name
        ));
    }

    // Validate dependency names
    for dep in &config.dependencies.requires {
        if !is_valid_name(dep) {
            errors.push(format!(
                "dependencies.requires contains invalid name '{}'",
                dep
            ));
        }
    }
    for dep in &config.dependencies.after {
        if !is_valid_name(dep) {
            errors.push(format!(
                "dependencies.after contains invalid name '{}'",
                dep
            ));
        }
    }
    for dep in &config.dependencies.wants {
        if !is_valid_name(dep) {
            errors.push(format!(
                "dependencies.wants contains invalid name '{}'",
                dep
            ));
        }
    }
    for dep in &config.dependencies.conflicts {
        if !is_valid_name(dep) {
            errors.push(format!(
                "dependencies.conflicts contains invalid name '{}'",
                dep
            ));
        }
    }

    errors
}

/// Check if a service/target name is valid.
///
/// Valid names contain only alphanumeric characters, dashes, underscores, and dots.
fn is_valid_name(name: &str) -> bool {
    !name.is_empty()
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sdk::config::{
        DependencyDef, HealthCommon, LifecycleDef, LoggingDef, ServiceDef, TargetDef,
    };
    use std::collections::HashMap;

    fn minimal_service() -> ServiceConfig {
        ServiceConfig {
            service: ServiceDef {
                name: "test".to_string(),
                exec: "/bin/test".to_string(),
                dir: None,
                oneshot: false,
                env: HashMap::new(),
                status: crate::Status::default(),
                class: crate::ServiceClass::default(),
                critical: false,
            },
            dependencies: DependencyDef::default(),
            lifecycle: LifecycleDef::default(),
            health: None,
            logging: LoggingDef::default(),
        }
    }

    #[test]
    fn test_valid_service() {
        let config = minimal_service();
        assert!(validate_service(&config).is_empty());
    }

    #[test]
    fn test_empty_name() {
        let mut config = minimal_service();
        config.service.name = "".to_string();
        let errors = validate_service(&config);
        assert!(errors.iter().any(|e| e.contains("name cannot be empty")));
    }

    #[test]
    fn test_invalid_name_chars() {
        let mut config = minimal_service();
        config.service.name = "test service".to_string(); // space is invalid
        let errors = validate_service(&config);
        assert!(errors.iter().any(|e| e.contains("invalid characters")));
    }

    #[test]
    fn test_valid_name_chars() {
        let mut config = minimal_service();
        config.service.name = "my-service_v1.0".to_string();
        assert!(validate_service(&config).is_empty());
    }

    #[test]
    fn test_empty_exec() {
        let mut config = minimal_service();
        config.service.exec = "".to_string();
        let errors = validate_service(&config);
        assert!(errors.iter().any(|e| e.contains("exec cannot be empty")));
    }

    #[test]
    fn test_invalid_stop_signal() {
        let mut config = minimal_service();
        config.lifecycle.stop_signal = "INVALID".to_string();
        let errors = validate_service(&config);
        assert!(errors.iter().any(|e| e.contains("not a valid signal")));
    }

    #[test]
    fn test_zero_timeout() {
        let mut config = minimal_service();
        config.lifecycle.start_timeout_ms = 0;
        let errors = validate_service(&config);
        assert!(errors.iter().any(|e| e.contains("start_timeout_ms")));
    }

    #[test]
    fn test_health_check_validation() {
        let mut config = minimal_service();
        config.health = Some(HealthDef::Http {
            target: "".to_string(),
            expect_status: 0,
            common: HealthCommon {
                interval_ms: 0,
                timeout_ms: 0,
                retries: 0,
                start_period_ms: 0,
            },
        });
        let errors = validate_service(&config);
        assert!(errors.iter().any(|e| e.contains("health.retries")));
        assert!(errors.iter().any(|e| e.contains("health.interval_ms")));
        assert!(errors.iter().any(|e| e.contains("health.target")));
        assert!(errors.iter().any(|e| e.contains("health.expect_status")));
    }

    #[test]
    fn test_invalid_dependency_name() {
        let mut config = minimal_service();
        config.dependencies.requires = vec!["valid".to_string(), "in valid".to_string()];
        let errors = validate_service(&config);
        assert!(errors.iter().any(|e| e.contains("invalid name")));
    }

    #[test]
    fn test_valid_target() {
        let config = TargetConfig {
            target: TargetDef {
                name: "multi-user".to_string(),
                critical: false,
            },
            dependencies: DependencyDef {
                requires: vec!["network".to_string()],
                ..Default::default()
            },
        };
        assert!(validate_target(&config).is_empty());
    }

    #[test]
    fn test_invalid_target_name() {
        let config = TargetConfig {
            target: TargetDef {
                name: "".to_string(),
                critical: false,
            },
            dependencies: DependencyDef::default(),
        };
        let errors = validate_target(&config);
        assert!(errors.iter().any(|e| e.contains("name cannot be empty")));
    }

    #[test]
    fn test_is_valid_name() {
        assert!(is_valid_name("test"));
        assert!(is_valid_name("my-service"));
        assert!(is_valid_name("my_service"));
        assert!(is_valid_name("my.service"));
        assert!(is_valid_name("service123"));
        assert!(!is_valid_name(""));
        assert!(!is_valid_name("my service"));
        assert!(!is_valid_name("service@host"));
    }
}