use crate::repo_config; use anyhow::{Context as _, Result, bail}; use serde::{Deserialize, Deserializer}; use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::path::{Component, PathBuf}; use std::time::Duration; use tracing::level_filters::LevelFilter; fn default_log_dir() -> PathBuf { PathBuf::from("/var/log/witryna") } const fn default_rate_limit() -> u32 { 10 } const fn default_max_builds_to_keep() -> u32 { 5 } /// Minimum poll interval to prevent DoS. /// Lowered to 1 second under the `integration` feature so tests run quickly. #[cfg(not(feature = "integration"))] const MIN_POLL_INTERVAL: Duration = Duration::from_secs(60); #[cfg(feature = "integration")] const MIN_POLL_INTERVAL: Duration = Duration::from_secs(1); /// Custom deserializer for optional humantime durations (e.g. `poll_interval`, `build_timeout`). fn deserialize_optional_duration<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let opt: Option = Option::deserialize(deserializer)?; opt.map_or_else( || Ok(None), |s| { humantime::parse_duration(&s) .map(Some) .map_err(serde::de::Error::custom) }, ) } #[derive(Debug, Deserialize)] pub struct Config { pub listen_address: String, pub container_runtime: String, pub base_dir: PathBuf, #[serde(default = "default_log_dir")] pub log_dir: PathBuf, pub log_level: String, #[serde(default = "default_rate_limit")] pub rate_limit_per_minute: u32, #[serde(default = "default_max_builds_to_keep")] pub max_builds_to_keep: u32, /// Optional global git operation timeout (e.g., "2m", "5m"). /// If not set, defaults to 60 seconds. #[serde(default, deserialize_with = "deserialize_optional_duration")] pub git_timeout: Option, pub sites: Vec, } /// Optional build configuration overrides that can be specified in witryna.toml. /// These values take precedence over corresponding values in the repository's witryna.yaml. #[derive(Debug, Clone, Default, Deserialize)] pub struct BuildOverrides { /// Container image to use for building (overrides witryna.yaml) pub image: Option, /// Command to execute inside the container (overrides witryna.yaml) pub command: Option, /// Directory containing built static assets (overrides witryna.yaml) pub public: Option, } impl BuildOverrides { /// Returns true if all three fields are specified. /// When complete, witryna.yaml becomes optional. #[must_use] pub const fn is_complete(&self) -> bool { self.image.is_some() && self.command.is_some() && self.public.is_some() } } #[derive(Debug, Clone, Deserialize)] pub struct SiteConfig { pub name: String, pub repo_url: String, pub branch: String, #[serde(default)] pub webhook_token: String, /// Path to a file containing the webhook token (e.g., Docker/K8s secrets). /// Mutually exclusive with `${VAR}` syntax in `webhook_token`. #[serde(default)] pub webhook_token_file: Option, /// Optional build configuration overrides from witryna.toml #[serde(flatten)] pub build_overrides: BuildOverrides, /// Optional polling interval for automatic builds (e.g., "30m", "1h") /// If not set, polling is disabled (webhook-only mode) #[serde(default, deserialize_with = "deserialize_optional_duration")] pub poll_interval: Option, /// Optional build timeout (e.g., "5m", "30m", "1h"). /// If not set, defaults to 10 minutes. #[serde(default, deserialize_with = "deserialize_optional_duration")] pub build_timeout: Option, /// Optional list of absolute container paths to persist as cache volumes across builds. /// Each path gets a dedicated host directory under `{base_dir}/cache/{site_name}/`. #[serde(default)] pub cache_dirs: Option>, /// Optional post-deploy hook command (array form, no shell). /// Runs after successful symlink switch. Non-fatal on failure. #[serde(default)] pub post_deploy: Option>, /// Optional environment variables passed to container builds and post-deploy hooks. /// Keys must not use the reserved `WITRYNA_*` prefix (case-insensitive). #[serde(default)] pub env: Option>, /// Container memory limit (e.g., "512m", "2g"). Passed as --memory to the container runtime. #[serde(default)] pub container_memory: Option, /// Container CPU limit (e.g., 0.5, 2.0). Passed as --cpus to the container runtime. #[serde(default)] pub container_cpus: Option, /// Container PID limit (e.g., 100). Passed as --pids-limit to the container runtime. #[serde(default)] pub container_pids_limit: Option, /// Container network mode. Defaults to "bridge" for compatibility. /// Set to "none" for maximum isolation (builds that don't need network). #[serde(default = "default_container_network")] pub container_network: String, /// Git clone depth. Default 1 (shallow). Set to 0 for full clone. #[serde(default)] pub git_depth: Option, /// Container working directory relative to repo root (e.g., "packages/frontend"). /// Translates to --workdir /workspace/{path}. Defaults to repo root (/workspace). #[serde(default)] pub container_workdir: Option, /// Path to a custom build config file in the repository (e.g., ".witryna.yaml", /// "build/config.yml"). Relative to repo root. If not set, witryna searches: /// .witryna.yaml -> .witryna.yml -> witryna.yaml -> witryna.yml #[serde(default)] pub config_file: Option, } fn default_container_network() -> String { "bridge".to_owned() } /// Check if a string is a valid environment variable name. /// Must start with ASCII uppercase letter or underscore, and contain only /// ASCII uppercase letters, digits, and underscores. fn is_valid_env_var_name(s: &str) -> bool { !s.is_empty() && s.bytes() .next() .is_some_and(|b| b.is_ascii_uppercase() || b == b'_') && s.bytes() .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') } /// Discover the configuration file path. /// /// Search order: /// 1. Explicit `--config` path (if provided) /// 2. `./witryna.toml` (current directory) /// 3. `$XDG_CONFIG_HOME/witryna/witryna.toml` (default: `~/.config/witryna/witryna.toml`) /// 4. `/etc/witryna/witryna.toml` /// /// Returns the first path that exists, or an error with all searched locations. /// /// # Errors /// /// Returns an error if no configuration file is found in any of the searched /// locations, or if an explicit path is provided but does not exist. pub fn discover_config(explicit: Option<&std::path::Path>) -> Result { if let Some(path) = explicit { if path.exists() { return Ok(path.to_owned()); } bail!("config file not found: {}", path.display()); } let mut candidates: Vec = vec![PathBuf::from("witryna.toml")]; candidates.push(xdg_config_path()); candidates.push(PathBuf::from("/etc/witryna/witryna.toml")); for path in &candidates { if path.exists() { return Ok(path.clone()); } } bail!( "no configuration file found\n searched: {}", candidates .iter() .map(|p| p.display().to_string()) .collect::>() .join(", ") ); } fn xdg_config_path() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { return PathBuf::from(xdg).join("witryna/witryna.toml"); } if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home).join(".config/witryna/witryna.toml"); } // No HOME set — fall back to /etc path (will be checked by the caller anyway) PathBuf::from("/etc/witryna/witryna.toml") } impl Config { /// # Errors /// /// Returns an error if the config file cannot be read, parsed, or fails /// validation. pub async fn load(path: &std::path::Path) -> Result { let content = tokio::fs::read_to_string(path) .await .with_context(|| format!("failed to read config file: {}", path.display()))?; let mut config: Self = toml::from_str(&content).with_context(|| "failed to parse config file")?; config.resolve_secrets().await?; config.validate()?; Ok(config) } /// Resolve secret references in webhook tokens. /// /// Supports two mechanisms: /// - `webhook_token = "${VAR_NAME}"` — resolved from environment /// - `webhook_token_file = "/path/to/file"` — read from file /// # Errors /// /// Returns an error if an environment variable is missing or a token file /// cannot be read. pub async fn resolve_secrets(&mut self) -> Result<()> { for site in &mut self.sites { let is_env_ref = site .webhook_token .strip_prefix("${") .and_then(|s| s.strip_suffix('}')) .filter(|name| is_valid_env_var_name(name)); if is_env_ref.is_some() && site.webhook_token_file.is_some() { bail!( "site '{}': webhook_token uses ${{VAR}} syntax and webhook_token_file \ are mutually exclusive", site.name ); } if let Some(var_name) = is_env_ref { let var_name = var_name.to_owned(); site.webhook_token = std::env::var(&var_name).with_context(|| { format!( "site '{}': environment variable '{}' not set", site.name, var_name ) })?; } else if let Some(path) = &site.webhook_token_file { site.webhook_token = tokio::fs::read_to_string(path) .await .with_context(|| { format!( "site '{}': failed to read webhook_token_file '{}'", site.name, path.display() ) })? .trim() .to_owned(); } } Ok(()) } fn validate(&self) -> Result<()> { self.validate_listen_address()?; self.validate_log_level()?; self.validate_rate_limit()?; self.validate_git_timeout()?; self.validate_container_runtime()?; self.validate_sites()?; Ok(()) } fn validate_git_timeout(&self) -> Result<()> { if let Some(timeout) = self.git_timeout { if timeout < MIN_GIT_TIMEOUT { bail!( "git_timeout is too short ({:?}): minimum is {}s", timeout, MIN_GIT_TIMEOUT.as_secs() ); } if timeout > MAX_GIT_TIMEOUT { bail!("git_timeout is too long ({:?}): maximum is 1h", timeout,); } } Ok(()) } fn validate_container_runtime(&self) -> Result<()> { if self.container_runtime.trim().is_empty() { bail!("container_runtime must not be empty"); } Ok(()) } fn validate_listen_address(&self) -> Result<()> { self.listen_address .parse::() .with_context(|| format!("invalid listen_address: {}", self.listen_address))?; Ok(()) } fn validate_log_level(&self) -> Result<()> { const VALID_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"]; if !VALID_LEVELS.contains(&self.log_level.to_lowercase().as_str()) { bail!( "invalid log_level '{}': must be one of {:?}", self.log_level, VALID_LEVELS ); } Ok(()) } fn validate_rate_limit(&self) -> Result<()> { if self.rate_limit_per_minute == 0 { bail!("rate_limit_per_minute must be greater than 0"); } Ok(()) } fn validate_sites(&self) -> Result<()> { let mut seen_names = HashSet::new(); for site in &self.sites { site.validate()?; if !seen_names.insert(&site.name) { bail!("duplicate site name: {}", site.name); } } Ok(()) } #[must_use] /// # Panics /// /// Panics if `listen_address` is not a valid socket address. This is /// unreachable after successful validation. #[allow(clippy::expect_used)] // value validated by validate_listen_address() pub fn parsed_listen_address(&self) -> SocketAddr { self.listen_address .parse() .expect("listen_address already validated") } #[must_use] pub fn log_level_filter(&self) -> LevelFilter { match self.log_level.to_lowercase().as_str() { "trace" => LevelFilter::TRACE, "debug" => LevelFilter::DEBUG, "warn" => LevelFilter::WARN, "error" => LevelFilter::ERROR, // Catch-all: covers "info" and the unreachable default after validation. _ => LevelFilter::INFO, } } /// Find a site configuration by name. #[must_use] pub fn find_site(&self, name: &str) -> Option<&SiteConfig> { self.sites.iter().find(|s| s.name == name) } } /// Sanitize a container path for use as a host directory name. /// /// Percent-encodes `_` → `%5F`, then replaces `/` → `%2F`, and strips the leading `%2F`. /// This prevents distinct container paths from mapping to the same host directory. #[must_use] pub fn sanitize_cache_dir_name(container_path: &str) -> String { let encoded = container_path.replace('_', "%5F").replace('/', "%2F"); encoded.strip_prefix("%2F").unwrap_or(&encoded).to_owned() } /// Minimum allowed git timeout. const MIN_GIT_TIMEOUT: Duration = Duration::from_secs(5); /// Maximum allowed git timeout (1 hour). const MAX_GIT_TIMEOUT: Duration = Duration::from_secs(3600); /// Minimum allowed build timeout. const MIN_BUILD_TIMEOUT: Duration = Duration::from_secs(10); /// Maximum allowed build timeout (24 hours). const MAX_BUILD_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); /// Maximum number of `cache_dirs` entries per site. const MAX_CACHE_DIRS: usize = 20; /// Maximum number of elements in a `post_deploy` command array. const MAX_POST_DEPLOY_ARGS: usize = 64; /// Maximum number of environment variables per site. const MAX_ENV_VARS: usize = 64; impl SiteConfig { fn validate(&self) -> Result<()> { self.validate_name()?; self.validate_webhook_token()?; self.validate_build_overrides()?; self.validate_poll_interval()?; self.validate_build_timeout()?; self.validate_cache_dirs()?; self.validate_post_deploy()?; self.validate_env()?; self.validate_resource_limits()?; self.validate_container_network()?; self.validate_container_workdir()?; self.validate_config_file()?; Ok(()) } fn validate_poll_interval(&self) -> Result<()> { if let Some(interval) = self.poll_interval && interval < MIN_POLL_INTERVAL { bail!( "poll_interval for site '{}' is too short ({:?}): minimum is 1 minute", self.name, interval ); } Ok(()) } fn validate_build_timeout(&self) -> Result<()> { if let Some(timeout) = self.build_timeout { if timeout < MIN_BUILD_TIMEOUT { bail!( "build_timeout for site '{}' is too short ({:?}): minimum is {}s", self.name, timeout, MIN_BUILD_TIMEOUT.as_secs() ); } if timeout > MAX_BUILD_TIMEOUT { bail!( "build_timeout for site '{}' is too long ({:?}): maximum is 24h", self.name, timeout, ); } } Ok(()) } fn validate_build_overrides(&self) -> Result<()> { if let Some(image) = &self.build_overrides.image { repo_config::validate_image(image) .with_context(|| format!("site '{}': invalid image override", self.name))?; } if let Some(command) = &self.build_overrides.command { repo_config::validate_command(command) .with_context(|| format!("site '{}': invalid command override", self.name))?; } if let Some(public) = &self.build_overrides.public { repo_config::validate_public(public) .with_context(|| format!("site '{}': invalid public override", self.name))?; } Ok(()) } fn validate_name(&self) -> Result<()> { if self.name.is_empty() { bail!("site name cannot be empty"); } // OWASP: Validate site names contain only safe characters // Allows alphanumeric characters, hyphens, and underscores let is_valid = self .name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') && !self.name.starts_with('-') && !self.name.ends_with('-') && !self.name.contains("--") && !self.name.starts_with('_') && !self.name.ends_with('_') && !self.name.contains("__"); if !is_valid { bail!( "invalid site name '{}': must contain only alphanumeric characters, hyphens, and underscores, \ cannot start/end with hyphen or underscore, or contain consecutive hyphens or underscores", self.name ); } // OWASP: Reject path traversal attempts if self.name.contains("..") || self.name.contains('/') { bail!( "invalid site name '{}': path traversal characters not allowed", self.name ); } Ok(()) } fn validate_webhook_token(&self) -> Result<()> { if !self.webhook_token.is_empty() { if self.webhook_token.trim().is_empty() { bail!( "site '{}': webhook_token is whitespace-only \ (omit it entirely to disable authentication)", self.name ); } if self.webhook_token.contains('\0') { bail!("site '{}': webhook_token contains null byte", self.name); } } Ok(()) } fn validate_cache_dirs(&self) -> Result<()> { let Some(dirs) = &self.cache_dirs else { return Ok(()); }; if dirs.len() > MAX_CACHE_DIRS { bail!( "site '{}': too many cache_dirs ({}, max {})", self.name, dirs.len(), MAX_CACHE_DIRS ); } let mut seen = HashSet::new(); for (i, raw) in dirs.iter().enumerate() { if raw.is_empty() { bail!("site '{}': cache_dirs[{}] is empty", self.name, i); } // Normalize through std::path::Path to resolve //, /./, trailing slashes let path = std::path::Path::new(raw); let normalized: PathBuf = path.components().collect(); let normalized_str = normalized.to_string_lossy().to_string(); // Must be absolute if !normalized.is_absolute() { bail!( "site '{}': cache_dirs[{}] ('{}') must be an absolute path", self.name, i, raw ); } // No parent directory components (path traversal) if normalized.components().any(|c| c == Component::ParentDir) { bail!( "site '{}': cache_dirs[{}] ('{}') contains path traversal (..)", self.name, i, raw ); } // No duplicates after normalization if !seen.insert(normalized_str.clone()) { bail!( "site '{}': duplicate cache_dirs entry '{}'", self.name, normalized_str ); } } Ok(()) } fn validate_post_deploy(&self) -> Result<()> { let Some(cmd) = &self.post_deploy else { return Ok(()); }; if cmd.is_empty() { bail!( "site '{}': post_deploy must not be an empty array", self.name ); } if cmd.len() > MAX_POST_DEPLOY_ARGS { bail!( "site '{}': post_deploy has too many elements ({}, max {})", self.name, cmd.len(), MAX_POST_DEPLOY_ARGS ); } // First element is the executable let Some(exe) = cmd.first() else { // Already checked cmd.is_empty() above unreachable!() }; if exe.trim().is_empty() { bail!( "site '{}': post_deploy executable must not be empty", self.name ); } // No element may contain null bytes for (i, arg) in cmd.iter().enumerate() { if arg.contains('\0') { bail!( "site '{}': post_deploy[{}] contains null byte", self.name, i ); } } Ok(()) } fn validate_resource_limits(&self) -> Result<()> { if let Some(memory) = &self.container_memory && (!memory .as_bytes() .last() .is_some_and(|c| matches!(c, b'k' | b'm' | b'g' | b'K' | b'M' | b'G')) || memory.len() < 2 || !memory[..memory.len() - 1] .chars() .all(|c| c.is_ascii_digit())) { bail!( "site '{}': invalid container_memory '{}': must be digits followed by k, m, or g", self.name, memory ); } if let Some(cpus) = self.container_cpus && cpus <= 0.0 { bail!( "site '{}': container_cpus must be greater than 0.0", self.name ); } if let Some(pids) = self.container_pids_limit && pids == 0 { bail!( "site '{}': container_pids_limit must be greater than 0", self.name ); } Ok(()) } fn validate_container_network(&self) -> Result<()> { const ALLOWED: &[&str] = &["none", "bridge", "host", "slirp4netns"]; if !ALLOWED.contains(&self.container_network.as_str()) { bail!( "site '{}': invalid container_network '{}': must be one of {:?}", self.name, self.container_network, ALLOWED ); } Ok(()) } fn validate_container_workdir(&self) -> Result<()> { let Some(workdir) = &self.container_workdir else { return Ok(()); }; if workdir.trim().is_empty() { bail!("site '{}': container_workdir cannot be empty", self.name); } if workdir.starts_with('/') { bail!( "site '{}': container_workdir '{}' must be a relative path", self.name, workdir ); } if std::path::Path::new(workdir) .components() .any(|c| c == Component::ParentDir) { bail!( "site '{}': container_workdir '{}': path traversal not allowed", self.name, workdir ); } Ok(()) } fn validate_config_file(&self) -> Result<()> { let Some(cf) = &self.config_file else { return Ok(()); }; if cf.trim().is_empty() { bail!("site '{}': config_file cannot be empty", self.name); } if cf.starts_with('/') { bail!( "site '{}': config_file '{}' must be a relative path", self.name, cf ); } if std::path::Path::new(cf) .components() .any(|c| c == Component::ParentDir) { bail!( "site '{}': config_file '{}': path traversal not allowed", self.name, cf ); } Ok(()) } fn validate_env(&self) -> Result<()> { let Some(env_vars) = &self.env else { return Ok(()); }; if env_vars.len() > MAX_ENV_VARS { bail!( "site '{}': too many env vars ({}, max {})", self.name, env_vars.len(), MAX_ENV_VARS ); } for (key, value) in env_vars { if key.is_empty() { bail!("site '{}': env var key cannot be empty", self.name); } if key.contains('=') { bail!( "site '{}': env var key '{}' contains '=' character", self.name, key ); } // Case-insensitive check: block witryna_, Witryna_, WITRYNA_, etc. if key.to_ascii_uppercase().starts_with("WITRYNA_") { bail!( "site '{}': env var key '{}' uses reserved prefix 'WITRYNA_'", self.name, key ); } if key.contains('\0') { bail!( "site '{}': env var key '{}' contains null byte", self.name, key ); } if value.contains('\0') { bail!( "site '{}': env var value for '{}' contains null byte", self.name, key ); } } Ok(()) } } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::expect_used)] mod tests { use super::*; fn valid_config_toml() -> &'static str { r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/my-site.git" branch = "main" webhook_token = "secret-token-123" "# } #[test] fn parse_valid_config() { let config: Config = toml::from_str(valid_config_toml()).unwrap(); assert_eq!(config.listen_address, "127.0.0.1:8080"); assert_eq!(config.sites.len(), 1); assert_eq!(config.sites[0].name, "my-site"); } #[test] fn parse_multiple_sites() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "site-one" repo_url = "https://github.com/user/site-one.git" branch = "main" webhook_token = "token-1" [[sites]] name = "site-two" repo_url = "https://github.com/user/site-two.git" branch = "develop" webhook_token = "token-2" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.sites.len(), 2); } #[test] fn missing_required_field() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" "#; let result: Result = toml::from_str(toml); assert!(result.is_err()); } #[test] fn invalid_listen_address() { let toml = r#" listen_address = "not-a-valid-address" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" sites = [] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("listen_address")); } #[test] fn invalid_log_level() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "invalid" sites = [] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("log_level")); } #[test] fn valid_log_levels() { for level in &["trace", "debug", "info", "warn", "error", "INFO", "Debug"] { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "{level}" sites = [] "# ); let config: Config = toml::from_str(&toml).unwrap(); assert!( config.validate().is_ok(), "log_level '{level}' should be valid" ); } } #[test] fn zero_rate_limit_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" rate_limit_per_minute = 0 sites = [] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("rate_limit_per_minute") ); } #[test] fn duplicate_site_names() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "duplicate" repo_url = "https://github.com/user/site1.git" branch = "main" webhook_token = "token-1" [[sites]] name = "duplicate" repo_url = "https://github.com/user/site2.git" branch = "main" webhook_token = "token-2" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("duplicate")); } #[test] fn invalid_site_name_with_path_traversal() { let invalid_names = vec!["../etc", "foo/../bar", "..site", "site..", "foo/bar"]; for name in invalid_names { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "{name}" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" "# ); let config: Config = toml::from_str(&toml).unwrap(); let result = config.validate(); assert!(result.is_err(), "site name '{name}' should be invalid"); } } #[test] fn invalid_site_name_special_chars() { let invalid_names = vec![ "site@name", "site name", "-start", "end-", "a--b", "_start", "end_", "a__b", ]; for name in invalid_names { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "{name}" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" "# ); let config: Config = toml::from_str(&toml).unwrap(); let result = config.validate(); assert!(result.is_err(), "site name '{name}' should be invalid"); } } #[test] fn valid_site_names() { let valid_names = vec![ "site", "my-site", "site123", "123site", "a-b-c", "site-1-test", "site_name", "my_site", "my-site_v2", "a_b-c", ]; for name in valid_names { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "{name}" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" "# ); let config: Config = toml::from_str(&toml).unwrap(); assert!( config.validate().is_ok(), "site name '{name}' should be valid" ); } } #[test] fn empty_site_name() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("empty")); } // BuildOverrides tests #[test] fn parse_site_with_image_override() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/my-site.git" branch = "main" webhook_token = "token" image = "node:20-alpine" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!( config.sites[0].build_overrides.image, Some("node:20-alpine".to_owned()) ); } #[test] fn parse_site_with_all_overrides() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/my-site.git" branch = "main" webhook_token = "token" image = "node:20-alpine" command = "npm ci && npm run build" public = "dist" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert!(config.sites[0].build_overrides.is_complete()); assert_eq!( config.sites[0].build_overrides.image, Some("node:20-alpine".to_owned()) ); assert_eq!( config.sites[0].build_overrides.command, Some("npm ci && npm run build".to_owned()) ); assert_eq!( config.sites[0].build_overrides.public, Some("dist".to_owned()) ); } #[test] fn invalid_image_override_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/my-site.git" branch = "main" webhook_token = "token" image = " " "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("image")); } #[test] fn invalid_command_override_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/my-site.git" branch = "main" webhook_token = "token" command = " " "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("command")); } #[test] fn invalid_public_override_path_traversal() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/my-site.git" branch = "main" webhook_token = "token" public = "../etc" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); let err = result.unwrap_err(); // Use alternate format {:#} to get full error chain let err_str = format!("{err:#}"); assert!( err_str.contains("path traversal"), "Expected 'path traversal' in error: {err_str}" ); } #[test] fn invalid_public_override_absolute_path() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/my-site.git" branch = "main" webhook_token = "token" public = "/var/www/html" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); let err = result.unwrap_err(); // Use alternate format {:#} to get full error chain let err_str = format!("{err:#}"); assert!( err_str.contains("relative path"), "Expected 'relative path' in error: {err_str}" ); } // Poll interval tests #[test] fn parse_site_with_poll_interval() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "polled-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" poll_interval = "30m" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!( config.sites[0].poll_interval, Some(Duration::from_secs(30 * 60)) ); } #[test] #[cfg(not(feature = "integration"))] fn poll_interval_too_short_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "test-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" poll_interval = "30s" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("too short")); } #[test] fn poll_interval_invalid_format_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "test-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" poll_interval = "invalid" "#; let result: Result = toml::from_str(toml); assert!(result.is_err()); } // Git timeout tests #[test] fn parse_config_with_git_timeout() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" git_timeout = "2m" sites = [] "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.git_timeout, Some(Duration::from_secs(120))); } #[test] fn git_timeout_not_set_defaults_to_none() { let config: Config = toml::from_str(valid_config_toml()).unwrap(); config.validate().unwrap(); assert!(config.git_timeout.is_none()); } #[test] fn git_timeout_too_short_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" git_timeout = "3s" sites = [] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("too short")); } #[test] fn git_timeout_too_long_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" git_timeout = "2h" sites = [] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("too long")); } #[test] fn git_timeout_boundary_values_accepted() { // 5 seconds (minimum) let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" git_timeout = "5s" sites = [] "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.git_timeout, Some(Duration::from_secs(5))); // 1 hour (maximum) let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" git_timeout = "1h" sites = [] "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.git_timeout, Some(Duration::from_secs(3600))); } #[test] fn git_timeout_invalid_format_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" git_timeout = "invalid" sites = [] "#; let result: Result = toml::from_str(toml); assert!(result.is_err()); } // Build timeout tests #[test] fn parse_site_with_build_timeout() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" build_timeout = "5m" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!( config.sites[0].build_timeout, Some(Duration::from_secs(300)) ); } #[test] fn build_timeout_not_set_defaults_to_none() { let config: Config = toml::from_str(valid_config_toml()).unwrap(); config.validate().unwrap(); assert!(config.sites[0].build_timeout.is_none()); } #[test] fn build_timeout_too_short_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" build_timeout = "5s" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("too short")); } #[test] fn build_timeout_too_long_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" build_timeout = "25h" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("too long")); } #[test] fn build_timeout_invalid_format_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" build_timeout = "invalid" "#; let result: Result = toml::from_str(toml); assert!(result.is_err()); } #[test] fn build_timeout_boundary_values_accepted() { // 10 seconds (minimum) let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" build_timeout = "10s" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.sites[0].build_timeout, Some(Duration::from_secs(10))); // 24 hours (maximum) let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" build_timeout = "24h" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!( config.sites[0].build_timeout, Some(Duration::from_secs(24 * 60 * 60)) ); } // Cache dirs tests #[test] fn parse_site_with_cache_dirs() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" cache_dirs = ["/root/.npm", "/root/.cache/pip"] "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!( config.sites[0].cache_dirs, Some(vec!["/root/.npm".to_owned(), "/root/.cache/pip".to_owned()]) ); } #[test] fn cache_dirs_relative_path_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" cache_dirs = ["relative/path"] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("absolute path")); } #[test] fn cache_dirs_path_traversal_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" cache_dirs = ["/root/../etc/passwd"] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("path traversal")); } #[test] fn cache_dirs_empty_path_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" cache_dirs = [""] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("empty")); } #[test] fn cache_dirs_normalized_paths_accepted() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" cache_dirs = ["/root//.npm", "/root/./cache", "/root/pip/"] "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); } #[test] fn cache_dirs_duplicate_after_normalization_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" cache_dirs = ["/root/.npm", "/root/.npm/"] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("duplicate")); } #[test] fn sanitize_cache_dir_name_no_collisions() { // /a_b/c and /a/b_c must produce different host directory names let a = sanitize_cache_dir_name("/a_b/c"); let b = sanitize_cache_dir_name("/a/b_c"); assert_ne!(a, b, "sanitized names must not collide"); } #[test] fn sanitize_cache_dir_name_examples() { assert_eq!(sanitize_cache_dir_name("/root/.npm"), "root%2F.npm"); assert_eq!( sanitize_cache_dir_name("/root/.cache/pip"), "root%2F.cache%2Fpip" ); } #[test] fn sanitize_cache_dir_name_with_underscores() { assert_eq!( sanitize_cache_dir_name("/home/user_name/.cache"), "home%2Fuser%5Fname%2F.cache" ); } // Post-deploy hook tests #[test] fn post_deploy_valid() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" post_deploy = ["cmd", "arg1", "arg2"] "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!( config.sites[0].post_deploy, Some(vec!["cmd".to_owned(), "arg1".to_owned(), "arg2".to_owned()]) ); } #[test] fn post_deploy_empty_array_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" post_deploy = [] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("empty array")); } #[test] fn post_deploy_empty_executable_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" post_deploy = [""] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("executable")); } #[test] fn post_deploy_whitespace_executable_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" post_deploy = [" "] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("executable")); } #[test] fn empty_webhook_token_disables_auth() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = "" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.validate().is_ok()); assert!(config.sites[0].webhook_token.is_empty()); } #[test] fn absent_webhook_token_disables_auth() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.validate().is_ok()); assert!(config.sites[0].webhook_token.is_empty()); } #[test] fn whitespace_only_webhook_token_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = " " "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("whitespace-only")); } #[test] fn webhook_token_with_null_byte_rejected() { let site = SiteConfig { name: "my-site".to_owned(), repo_url: "https://example.com/repo.git".to_owned(), branch: "main".to_owned(), webhook_token: "tok\0en".to_owned(), webhook_token_file: None, build_overrides: BuildOverrides { image: None, command: None, public: None, }, poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: None, container_memory: None, container_cpus: None, container_pids_limit: None, container_network: default_container_network(), git_depth: None, container_workdir: None, config_file: None, }; let result = site.validate_webhook_token(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("null byte")); } #[test] fn valid_webhook_token_accepted() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = "secret-token" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.validate().is_ok()); } #[test] fn whitespace_padded_webhook_token_accepted() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = " secret-token " "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.validate().is_ok()); } // Env var tests #[test] fn env_vars_valid() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" [sites.env] DEPLOY_TOKEN = "abc123" NODE_ENV = "production" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); let env = config.sites[0].env.as_ref().unwrap(); assert_eq!(env.get("DEPLOY_TOKEN").unwrap(), "abc123"); assert_eq!(env.get("NODE_ENV").unwrap(), "production"); } #[test] fn env_vars_none_accepted() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert!(config.sites[0].env.is_none()); } #[test] fn env_vars_empty_key_rejected() { let site = SiteConfig { name: "my-site".to_owned(), repo_url: "https://example.com/repo.git".to_owned(), branch: "main".to_owned(), webhook_token: "token".to_owned(), webhook_token_file: None, build_overrides: BuildOverrides::default(), poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: Some(HashMap::from([(String::new(), "val".to_owned())])), container_memory: None, container_cpus: None, container_pids_limit: None, container_network: default_container_network(), git_depth: None, container_workdir: None, config_file: None, }; let result = site.validate(); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("key cannot be empty") ); } #[test] fn env_vars_equals_in_key_rejected() { let site = SiteConfig { name: "my-site".to_owned(), repo_url: "https://example.com/repo.git".to_owned(), branch: "main".to_owned(), webhook_token: "token".to_owned(), webhook_token_file: None, build_overrides: BuildOverrides::default(), poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: Some(HashMap::from([("FOO=BAR".to_owned(), "val".to_owned())])), container_memory: None, container_cpus: None, container_pids_limit: None, container_network: default_container_network(), git_depth: None, container_workdir: None, config_file: None, }; let result = site.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("contains '='")); } #[test] fn env_vars_null_byte_in_key_rejected() { let site = SiteConfig { name: "my-site".to_owned(), repo_url: "https://example.com/repo.git".to_owned(), branch: "main".to_owned(), webhook_token: "token".to_owned(), webhook_token_file: None, build_overrides: BuildOverrides::default(), poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: Some(HashMap::from([("FOO\0".to_owned(), "val".to_owned())])), container_memory: None, container_cpus: None, container_pids_limit: None, container_network: default_container_network(), git_depth: None, container_workdir: None, config_file: None, }; let result = site.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("null byte")); } #[test] fn env_vars_null_byte_in_value_rejected() { let site = SiteConfig { name: "my-site".to_owned(), repo_url: "https://example.com/repo.git".to_owned(), branch: "main".to_owned(), webhook_token: "token".to_owned(), webhook_token_file: None, build_overrides: BuildOverrides::default(), poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: Some(HashMap::from([("FOO".to_owned(), "val\0ue".to_owned())])), container_memory: None, container_cpus: None, container_pids_limit: None, container_network: default_container_network(), git_depth: None, container_workdir: None, config_file: None, }; let result = site.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("null byte")); } #[test] fn env_vars_reserved_prefix_rejected() { // Test case-insensitive prefix blocking for key in &["WITRYNA_SITE", "witryna_foo", "Witryna_Bar"] { let site = SiteConfig { name: "my-site".to_owned(), repo_url: "https://example.com/repo.git".to_owned(), branch: "main".to_owned(), webhook_token: "token".to_owned(), webhook_token_file: None, build_overrides: BuildOverrides::default(), poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: Some(HashMap::from([((*key).to_owned(), "val".to_owned())])), container_memory: None, container_cpus: None, container_pids_limit: None, container_network: default_container_network(), git_depth: None, container_workdir: None, config_file: None, }; let result = site.validate(); assert!(result.is_err(), "key '{key}' should be rejected"); assert!( result.unwrap_err().to_string().contains("reserved prefix"), "key '{key}' error should mention reserved prefix" ); } } #[test] fn env_vars_too_many_rejected() { let mut env = HashMap::new(); for i in 0..65 { env.insert(format!("VAR{i}"), format!("val{i}")); } let site = SiteConfig { name: "my-site".to_owned(), repo_url: "https://example.com/repo.git".to_owned(), branch: "main".to_owned(), webhook_token: "token".to_owned(), webhook_token_file: None, build_overrides: BuildOverrides::default(), poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: Some(env), container_memory: None, container_cpus: None, container_pids_limit: None, container_network: default_container_network(), git_depth: None, container_workdir: None, config_file: None, }; let result = site.validate(); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("too many env vars") ); } // BuildOverrides.is_complete tests #[test] fn build_overrides_complete_requires_all_three() { let full = BuildOverrides { image: Some("node:20".to_owned()), command: Some("npm run build".to_owned()), public: Some("dist".to_owned()), }; assert!(full.is_complete()); let no_image = BuildOverrides { image: None, command: Some("npm run build".to_owned()), public: Some("dist".to_owned()), }; assert!(!no_image.is_complete()); } // container_runtime validation tests #[test] fn container_runtime_empty_string_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = " " base_dir = "/var/lib/witryna" log_level = "info" sites = [] "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("container_runtime must not be empty") ); } #[test] fn container_runtime_accepted() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); } #[test] fn cache_dirs_with_site_accepted() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" cache_dirs = ["/root/.npm"] "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); } // Resource limits tests #[test] fn container_memory_valid_values() { for val in &["512m", "2g", "1024k", "512M", "2G", "1024K"] { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_memory = "{val}" "# ); let config: Config = toml::from_str(&toml).unwrap(); assert!( config.validate().is_ok(), "container_memory '{val}' should be valid" ); } } #[test] fn container_memory_invalid_values() { for val in &["512", "abc", "m", "512mb", "2 g", ""] { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_memory = "{val}" "# ); let config: Config = toml::from_str(&toml).unwrap(); let result = config.validate(); assert!( result.is_err(), "container_memory '{val}' should be invalid" ); assert!( result .unwrap_err() .to_string() .contains("invalid container_memory"), "error for '{val}' should mention container_memory" ); } } #[test] fn container_cpus_valid() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_cpus = 0.5 "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.sites[0].container_cpus, Some(0.5)); } #[test] fn container_cpus_zero_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_cpus = 0.0 "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("container_cpus must be greater than 0.0") ); } #[test] fn container_cpus_negative_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_cpus = -1.0 "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("container_cpus must be greater than 0.0") ); } #[test] fn container_pids_limit_valid() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_pids_limit = 100 "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.sites[0].container_pids_limit, Some(100)); } #[test] fn container_pids_limit_zero_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_pids_limit = 0 "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("container_pids_limit must be greater than 0") ); } #[test] fn resource_limits_not_set_by_default() { let config: Config = toml::from_str(valid_config_toml()).unwrap(); config.validate().unwrap(); assert!(config.sites[0].container_memory.is_none()); assert!(config.sites[0].container_cpus.is_none()); assert!(config.sites[0].container_pids_limit.is_none()); } // Container network tests #[test] fn container_network_defaults_to_bridge() { let config: Config = toml::from_str(valid_config_toml()).unwrap(); config.validate().unwrap(); assert_eq!(config.sites[0].container_network, "bridge"); } #[test] fn container_network_valid_values() { for val in &["none", "bridge", "host", "slirp4netns"] { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_network = "{val}" "# ); let config: Config = toml::from_str(&toml).unwrap(); assert!( config.validate().is_ok(), "container_network '{val}' should be valid" ); } } #[test] fn container_network_invalid_rejected() { for val in &["custom", "vpn", "", "pasta"] { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_network = "{val}" "# ); let config: Config = toml::from_str(&toml).unwrap(); let result = config.validate(); assert!( result.is_err(), "container_network '{val}' should be invalid" ); assert!( result .unwrap_err() .to_string() .contains("invalid container_network"), "error for '{val}' should mention container_network" ); } } // container_workdir tests #[test] fn container_workdir_valid_relative_paths() { for path in &["packages/frontend", "apps/web", "src"] { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_workdir = "{path}" "# ); let config: Config = toml::from_str(&toml).unwrap(); assert!( config.validate().is_ok(), "container_workdir '{path}' should be valid" ); } } #[test] fn container_workdir_defaults_to_none() { let config: Config = toml::from_str(valid_config_toml()).unwrap(); config.validate().unwrap(); assert!(config.sites[0].container_workdir.is_none()); } #[test] fn container_workdir_absolute_path_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_workdir = "/packages/frontend" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("relative path")); } #[test] fn container_workdir_path_traversal_rejected() { for path in &["../packages", "packages/../etc"] { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_workdir = "{path}" "# ); let config: Config = toml::from_str(&toml).unwrap(); let result = config.validate(); assert!( result.is_err(), "container_workdir '{path}' should be rejected" ); assert!( result.unwrap_err().to_string().contains("path traversal"), "error for '{path}' should mention path traversal" ); } } #[test] fn container_workdir_empty_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" container_workdir = "" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("cannot be empty")); } // git_depth tests #[test] fn parse_config_with_git_depth() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" git_depth = 10 "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.sites[0].git_depth, Some(10)); } #[test] fn git_depth_not_set_defaults_to_none() { let config: Config = toml::from_str(valid_config_toml()).unwrap(); config.validate().unwrap(); assert!(config.sites[0].git_depth.is_none()); } #[test] fn git_depth_zero_accepted() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" git_depth = 0 "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.sites[0].git_depth, Some(0)); } #[test] fn git_depth_large_value_accepted() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" git_depth = 1000 "#; let config: Config = toml::from_str(toml).unwrap(); config.validate().unwrap(); assert_eq!(config.sites[0].git_depth, Some(1000)); } // is_valid_env_var_name tests #[test] fn valid_env_var_names() { assert!(is_valid_env_var_name("FOO")); assert!(is_valid_env_var_name("_FOO")); assert!(is_valid_env_var_name("FOO_BAR")); assert!(is_valid_env_var_name("WITRYNA_TOKEN")); assert!(is_valid_env_var_name("A1")); } #[test] fn invalid_env_var_names() { assert!(!is_valid_env_var_name("")); assert!(!is_valid_env_var_name("foo")); assert!(!is_valid_env_var_name("1FOO")); assert!(!is_valid_env_var_name("FOO-BAR")); assert!(!is_valid_env_var_name("FOO BAR")); assert!(!is_valid_env_var_name("foo_bar")); } // resolve_secrets tests #[tokio::test] async fn resolve_secrets_env_var() { // Use a unique env var name to avoid test races let var_name = "WITRYNA_TEST_TOKEN_RESOLVE_01"; // SAFETY: test-only, single-threaded tokio test unsafe { std::env::set_var(var_name, "resolved-secret") }; let mut config: Config = toml::from_str(&format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = "${{{var_name}}}" "# )) .unwrap(); config.resolve_secrets().await.unwrap(); assert_eq!(config.sites[0].webhook_token, "resolved-secret"); // SAFETY: test-only cleanup unsafe { std::env::remove_var(var_name) }; } #[tokio::test] async fn resolve_secrets_env_var_missing() { let var_name = "WITRYNA_TEST_TOKEN_RESOLVE_02_MISSING"; // SAFETY: test-only, single-threaded tokio test unsafe { std::env::remove_var(var_name) }; let mut config: Config = toml::from_str(&format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = "${{{var_name}}}" "# )) .unwrap(); let result = config.resolve_secrets().await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not set")); } #[tokio::test] async fn resolve_secrets_file() { let dir = tempfile::tempdir().unwrap(); let token_path = dir.path().join("token"); std::fs::write(&token_path, " file-secret\n ").unwrap(); let mut config: Config = toml::from_str(&format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token_file = "{}" "#, token_path.display() )) .unwrap(); config.resolve_secrets().await.unwrap(); assert_eq!(config.sites[0].webhook_token, "file-secret"); } #[tokio::test] async fn resolve_secrets_file_missing() { let mut config: Config = toml::from_str( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token_file = "/nonexistent/path/token" "#, ) .unwrap(); let result = config.resolve_secrets().await; assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("failed to read webhook_token_file") ); } #[tokio::test] async fn resolve_secrets_mutual_exclusivity() { let var_name = "WITRYNA_TEST_TOKEN_RESOLVE_03"; // SAFETY: test-only, single-threaded tokio test unsafe { std::env::set_var(var_name, "val") }; let mut config: Config = toml::from_str(&format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = "${{{var_name}}}" webhook_token_file = "/run/secrets/token" "# )) .unwrap(); let result = config.resolve_secrets().await; assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("mutually exclusive") ); // SAFETY: test-only cleanup unsafe { std::env::remove_var(var_name) }; } #[test] fn webhook_token_file_only_passes_validation() { // When webhook_token_file is set and webhook_token is empty (default), // validation should not complain about empty webhook_token let site = SiteConfig { name: "my-site".to_owned(), repo_url: "https://example.com/repo.git".to_owned(), branch: "main".to_owned(), webhook_token: String::new(), webhook_token_file: Some(PathBuf::from("/run/secrets/token")), build_overrides: BuildOverrides::default(), poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: None, container_memory: None, container_cpus: None, container_pids_limit: None, container_network: default_container_network(), git_depth: None, container_workdir: None, config_file: None, }; // validate_webhook_token should pass because webhook_token_file is set site.validate_webhook_token().unwrap(); } #[test] fn literal_dollar_brace_not_treated_as_env_ref() { // Invalid env var names should be treated as literal tokens let toml_str = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = "${lowercase}" "#; let config: Config = toml::from_str(toml_str).unwrap(); // "lowercase" is not a valid env var name (not uppercase), so treated as literal assert_eq!(config.sites[0].webhook_token, "${lowercase}"); config.validate().unwrap(); } #[test] fn partial_interpolation_treated_as_literal() { let toml_str = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = "prefix-${VAR}" "#; let config: Config = toml::from_str(toml_str).unwrap(); // Not a full-value ${VAR}, so treated as literal assert_eq!(config.sites[0].webhook_token, "prefix-${VAR}"); config.validate().unwrap(); } #[test] fn empty_braces_treated_as_literal() { let toml_str = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://example.com/repo.git" branch = "main" webhook_token = "${}" "#; let config: Config = toml::from_str(toml_str).unwrap(); // "${}" has empty var name — not valid, treated as literal assert_eq!(config.sites[0].webhook_token, "${}"); // But validation will reject it (non-empty after trim but still weird chars) config.validate().unwrap(); } // config_file validation tests #[test] fn config_file_path_traversal_rejected() { for path in &["../config.yaml", "build/../etc/passwd"] { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" config_file = "{path}" "# ); let config: Config = toml::from_str(&toml).unwrap(); let result = config.validate(); assert!(result.is_err(), "config_file '{path}' should be rejected"); assert!(result.unwrap_err().to_string().contains("path traversal")); } } #[test] fn config_file_absolute_path_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" config_file = "/etc/witryna.yaml" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("relative path")); } #[test] fn config_file_empty_rejected() { let toml = r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" config_file = "" "#; let config: Config = toml::from_str(toml).unwrap(); let result = config.validate(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("config_file")); } #[test] fn config_file_valid_paths_accepted() { for path in &[".witryna.yaml", "build/config.yml", "ci/witryna.yaml"] { let toml = format!( r#" listen_address = "127.0.0.1:8080" container_runtime = "podman" base_dir = "/var/lib/witryna" log_level = "info" [[sites]] name = "my-site" repo_url = "https://github.com/user/site.git" branch = "main" webhook_token = "token" config_file = "{path}" "# ); let config: Config = toml::from_str(&toml).unwrap(); assert!( config.validate().is_ok(), "config_file '{path}' should be valid" ); } } // discover_config tests #[test] fn discover_config_explicit_path_found() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("witryna.toml"); std::fs::write(&path, "").unwrap(); let result = super::discover_config(Some(&path)); assert!(result.is_ok()); assert_eq!(result.unwrap(), path); } #[test] fn discover_config_explicit_path_not_found_errors() { let result = super::discover_config(Some(std::path::Path::new( "/tmp/nonexistent-witryna-test-12345/witryna.toml", ))); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("config file not found") ); } #[test] fn xdg_config_path_returns_valid_path() { // xdg_config_path uses env vars internally; just verify it returns a // path ending with the expected suffix regardless of environment. let result = super::xdg_config_path(); assert!( result.ends_with("witryna/witryna.toml"), "expected path ending with witryna/witryna.toml, got: {}", result.display() ); } }