summaryrefslogtreecommitdiff
path: root/src/config.rs
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-01-22 22:07:32 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-02-10 18:44:26 +0100
commit064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch)
treea2023f9ccd297ed8a41a3a0cc5699c2add09244d /src/config.rs
witryna 0.1.0 — initial releasev0.1.0
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.
Diffstat (limited to 'src/config.rs')
-rw-r--r--src/config.rs3041
1 files changed, 3041 insertions, 0 deletions
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<Option<Duration>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let opt: Option<String> = 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<Duration>,
+ pub sites: Vec<SiteConfig>,
+}
+
+/// 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<String>,
+ /// Command to execute inside the container (overrides witryna.yaml)
+ pub command: Option<String>,
+ /// Directory containing built static assets (overrides witryna.yaml)
+ pub public: Option<String>,
+}
+
+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<PathBuf>,
+ /// 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<Duration>,
+ /// 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<Duration>,
+ /// 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<Vec<String>>,
+ /// Optional post-deploy hook command (array form, no shell).
+ /// Runs after successful symlink switch. Non-fatal on failure.
+ #[serde(default)]
+ pub post_deploy: Option<Vec<String>>,
+ /// 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<HashMap<String, String>>,
+ /// Container memory limit (e.g., "512m", "2g"). Passed as --memory to the container runtime.
+ #[serde(default)]
+ pub container_memory: Option<String>,
+ /// Container CPU limit (e.g., 0.5, 2.0). Passed as --cpus to the container runtime.
+ #[serde(default)]
+ pub container_cpus: Option<f64>,
+ /// Container PID limit (e.g., 100). Passed as --pids-limit to the container runtime.
+ #[serde(default)]
+ pub container_pids_limit: Option<u32>,
+ /// 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<u32>,
+ /// 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<String>,
+ /// 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<String>,
+}
+
+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<PathBuf> {
+ if let Some(path) = explicit {
+ if path.exists() {
+ return Ok(path.to_owned());
+ }
+ bail!("config file not found: {}", path.display());
+ }
+
+ let mut candidates: Vec<PathBuf> = 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::<Vec<_>>()
+ .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<Self> {
+ 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::<SocketAddr>()
+ .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<Config, _> = 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<Config, _> = 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<Config, _> = 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<Config, _> = 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()
+ );
+ }
+}