From 064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Thu, 22 Jan 2026 22:07:32 +0100 Subject: witryna 0.1.0 — initial release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimalist Git-based static site deployment orchestrator. Webhook-triggered builds in Podman/Docker containers with atomic symlink publishing, SIGHUP hot-reload, and zero-downtime deploys. See README.md for usage, CHANGELOG.md for details. --- src/config.rs | 3041 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3041 insertions(+) create mode 100644 src/config.rs (limited to 'src/config.rs') diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..63f3447 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,3041 @@ +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() + ); + } +} -- cgit v1.2.3