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/build.rs | 843 ++++++++++++++ src/build_guard.rs | 128 +++ src/cleanup.rs | 467 ++++++++ src/cli.rs | 134 +++ src/config.rs | 3041 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/git.rs | 1320 ++++++++++++++++++++++ src/hook.rs | 499 +++++++++ src/lib.rs | 21 + src/logs.rs | 919 ++++++++++++++++ src/main.rs | 422 +++++++ src/pipeline.rs | 328 ++++++ src/polling.rs | 242 ++++ src/publish.rs | 488 +++++++++ src/repo_config.rs | 523 +++++++++ src/server.rs | 1219 +++++++++++++++++++++ src/test_support.rs | 72 ++ 16 files changed, 10666 insertions(+) create mode 100644 src/build.rs create mode 100644 src/build_guard.rs create mode 100644 src/cleanup.rs create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/git.rs create mode 100644 src/hook.rs create mode 100644 src/lib.rs create mode 100644 src/logs.rs create mode 100644 src/main.rs create mode 100644 src/pipeline.rs create mode 100644 src/polling.rs create mode 100644 src/publish.rs create mode 100644 src/repo_config.rs create mode 100644 src/server.rs create mode 100644 src/test_support.rs (limited to 'src') diff --git a/src/build.rs b/src/build.rs new file mode 100644 index 0000000..e887f64 --- /dev/null +++ b/src/build.rs @@ -0,0 +1,843 @@ +use anyhow::{Context as _, Result}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncWrite, AsyncWriteExt as _, BufWriter}; +use tokio::process::Command; +use tracing::{debug, info}; + +use crate::repo_config::RepoConfig; + +/// Optional container resource limits and network mode. +/// +/// Passed from `SiteConfig` to `execute()` to inject `--memory`, `--cpus`, +/// `--pids-limit`, and `--network` flags into the container command. +#[derive(Debug)] +pub struct ContainerOptions { + pub memory: Option, + pub cpus: Option, + pub pids_limit: Option, + pub network: String, + pub workdir: Option, +} + +impl Default for ContainerOptions { + fn default() -> Self { + Self { + memory: None, + cpus: None, + pids_limit: None, + network: "bridge".to_owned(), + workdir: None, + } + } +} + +/// Default timeout for build operations. +pub const BUILD_TIMEOUT_DEFAULT: Duration = Duration::from_secs(600); // 10 minutes + +/// Size of the in-memory tail buffer for stderr (last 1 KB). +/// Used for `BuildFailure::Display` after streaming to disk. +const STDERR_TAIL_SIZE: usize = 1024; + +/// Result of a build execution. +/// +/// Stdout and stderr are streamed to temporary files on disk during the build. +/// Callers should pass these paths to `logs::save_build_log()` for composition. +#[derive(Debug)] +pub struct BuildResult { + pub stdout_file: PathBuf, + pub stderr_file: PathBuf, + pub duration: Duration, +} + +/// Error from a failed build command. +/// +/// Carries structured exit code and file paths to captured output. +/// `last_stderr` holds the last 1 KB of stderr for the `Display` impl. +#[derive(Debug)] +pub struct BuildFailure { + pub exit_code: i32, + pub stdout_file: PathBuf, + pub stderr_file: PathBuf, + pub last_stderr: String, + pub duration: Duration, +} + +impl std::fmt::Display for BuildFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "build failed with exit code {}: {}", + self.exit_code, + self.last_stderr.trim() + ) + } +} + +impl std::error::Error for BuildFailure {} + +/// Writer that duplicates all writes to both a primary and secondary writer. +/// +/// Used for `--verbose` mode: streams build output to both a temp file (primary) +/// and stderr (secondary) simultaneously. +pub(crate) struct TeeWriter { + primary: W, + secondary: tokio::io::Stderr, +} + +impl TeeWriter { + pub(crate) const fn new(primary: W, secondary: tokio::io::Stderr) -> Self { + Self { primary, secondary } + } +} + +impl AsyncWrite for TeeWriter { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + // Write to primary first + let poll = std::pin::Pin::new(&mut self.primary).poll_write(cx, buf); + if let std::task::Poll::Ready(Ok(n)) = &poll { + // Best-effort write to secondary (stderr) — same bytes + let _ = std::pin::Pin::new(&mut self.secondary).poll_write(cx, &buf[..*n]); + } + poll + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let _ = std::pin::Pin::new(&mut self.secondary).poll_flush(cx); + std::pin::Pin::new(&mut self.primary).poll_flush(cx) + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let _ = std::pin::Pin::new(&mut self.secondary).poll_shutdown(cx); + std::pin::Pin::new(&mut self.primary).poll_shutdown(cx) + } +} + +/// Execute a containerized build for a site. +/// +/// Stdout and stderr are streamed to the provided temporary files on disk +/// instead of being buffered in memory. This removes unbounded memory usage +/// for container builds. +/// +/// # Arguments +/// * `runtime` - Container runtime to use ("podman" or "docker") +/// * `clone_dir` - Path to the cloned repository +/// * `repo_config` - Build configuration from witryna.yaml +/// * `cache_volumes` - Pairs of (`container_path`, `host_path`) for persistent cache mounts +/// * `env` - User-defined environment variables to pass into the container via `--env` +/// * `options` - Optional container resource limits and network mode +/// * `stdout_file` - Temp file path for captured stdout +/// * `stderr_file` - Temp file path for captured stderr +/// * `timeout` - Maximum duration before killing the build +/// * `verbose` - When true, also stream build output to stderr in real-time +/// +/// # Errors +/// +/// Returns an error if the container command times out, fails to execute, +/// or exits with a non-zero status code (as a [`BuildFailure`]). +/// +/// # Security +/// - Uses typed arguments (no shell interpolation) per OWASP guidelines +/// - Mounts clone directory as read-write (needed for build output) +/// - Runs with minimal capabilities +#[allow(clippy::implicit_hasher, clippy::too_many_arguments)] +pub async fn execute( + runtime: &str, + clone_dir: &Path, + repo_config: &RepoConfig, + cache_volumes: &[(String, PathBuf)], + env: &HashMap, + options: &ContainerOptions, + stdout_file: &Path, + stderr_file: &Path, + timeout: Duration, + verbose: bool, +) -> Result { + info!( + image = %repo_config.image, + command = %repo_config.command, + path = %clone_dir.display(), + "executing container build" + ); + + let start = Instant::now(); + + // Build args dynamically to support optional cache volumes + let mut args = vec![ + "run".to_owned(), + "--rm".to_owned(), + "--volume".to_owned(), + format!("{}:/workspace:Z", clone_dir.display()), + ]; + + // Add cache volume mounts + for (container_path, host_path) in cache_volumes { + args.push("--volume".to_owned()); + args.push(format!("{}:{}:Z", host_path.display(), container_path)); + } + + // Add user-defined environment variables + for (key, value) in env { + args.push("--env".to_owned()); + args.push(format!("{key}={value}")); + } + + let workdir = match &options.workdir { + Some(subdir) => format!("/workspace/{subdir}"), + None => "/workspace".to_owned(), + }; + args.extend(["--workdir".to_owned(), workdir, "--cap-drop=ALL".to_owned()]); + + if runtime == "podman" { + args.push("--userns=keep-id".to_owned()); + } else { + // Docker: container runs as root but workspace is owned by host UID. + // DAC_OVERRIDE lets root bypass file permission checks. + // Podman doesn't need this because --userns=keep-id maps to the host UID. + args.push("--cap-add=DAC_OVERRIDE".to_owned()); + } + + // Resource limits + if let Some(memory) = &options.memory { + args.push("--memory".to_owned()); + args.push(memory.clone()); + } + if let Some(cpus) = options.cpus { + args.push("--cpus".to_owned()); + args.push(cpus.to_string()); + } + if let Some(pids) = options.pids_limit { + args.push("--pids-limit".to_owned()); + args.push(pids.to_string()); + } + + // Network mode + args.push(format!("--network={}", options.network)); + + args.extend([ + repo_config.image.clone(), + "sh".to_owned(), + "-c".to_owned(), + repo_config.command.clone(), + ]); + + // Spawn with piped stdout/stderr for streaming (OWASP: no shell interpolation) + let mut child = Command::new(runtime) + .args(&args) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn container build")?; + + let stdout_pipe = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("missing stdout pipe"))?; + let stderr_pipe = child + .stderr + .take() + .ok_or_else(|| anyhow::anyhow!("missing stderr pipe"))?; + + let stdout_file_writer = BufWriter::new( + tokio::fs::File::create(stdout_file) + .await + .with_context(|| format!("failed to create {}", stdout_file.display()))?, + ); + let stderr_file_writer = BufWriter::new( + tokio::fs::File::create(stderr_file) + .await + .with_context(|| format!("failed to create {}", stderr_file.display()))?, + ); + + if verbose { + let mut stdout_tee = TeeWriter::new(stdout_file_writer, tokio::io::stderr()); + let mut stderr_tee = TeeWriter::new(stderr_file_writer, tokio::io::stderr()); + run_build_process( + child, + stdout_pipe, + stderr_pipe, + &mut stdout_tee, + &mut stderr_tee, + start, + stdout_file, + stderr_file, + clone_dir, + "container", + timeout, + ) + .await + } else { + let mut stdout_writer = stdout_file_writer; + let mut stderr_writer = stderr_file_writer; + run_build_process( + child, + stdout_pipe, + stderr_pipe, + &mut stdout_writer, + &mut stderr_writer, + start, + stdout_file, + stderr_file, + clone_dir, + "container", + timeout, + ) + .await + } +} + +/// Copy from reader to writer, keeping the last `tail_size` bytes in memory. +/// Returns `(total_bytes_copied, tail_buffer)`. +/// +/// When `tail_size` is 0, skips tail tracking entirely (used for stdout +/// where we don't need a tail). The tail buffer is used to provide a +/// meaningful error message in `BuildFailure::Display` without reading +/// the entire stderr file back into memory. +#[allow(clippy::indexing_slicing)] // buf[..n] bounded by read() return value +pub(crate) async fn copy_with_tail( + mut reader: R, + mut writer: W, + tail_size: usize, +) -> std::io::Result<(u64, Vec)> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + use tokio::io::AsyncReadExt as _; + + let mut buf = [0_u8; 8192]; + let mut total: u64 = 0; + let mut tail: Vec = Vec::new(); + + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } + writer.write_all(&buf[..n]).await?; + total += n as u64; + + if tail_size > 0 { + tail.extend_from_slice(&buf[..n]); + if tail.len() > tail_size { + let excess = tail.len() - tail_size; + tail.drain(..excess); + } + } + } + + Ok((total, tail)) +} + +/// Shared build-process loop: stream stdout/stderr through writers, handle timeout and exit status. +#[allow(clippy::too_many_arguments)] +async fn run_build_process( + mut child: tokio::process::Child, + stdout_pipe: tokio::process::ChildStdout, + stderr_pipe: tokio::process::ChildStderr, + stdout_writer: &mut W1, + stderr_writer: &mut W2, + start: Instant, + stdout_file: &Path, + stderr_file: &Path, + clone_dir: &Path, + label: &str, + timeout: Duration, +) -> Result +where + W1: AsyncWrite + Unpin, + W2: AsyncWrite + Unpin, +{ + #[allow(clippy::large_futures)] + let Ok((stdout_res, stderr_res, wait_res)) = tokio::time::timeout(timeout, async { + let (stdout_res, stderr_res, wait_res) = tokio::join!( + copy_with_tail(stdout_pipe, &mut *stdout_writer, 0), + copy_with_tail(stderr_pipe, &mut *stderr_writer, STDERR_TAIL_SIZE), + child.wait(), + ); + (stdout_res, stderr_res, wait_res) + }) + .await + else { + let _ = child.kill().await; + anyhow::bail!("{label} build timed out after {}s", timeout.as_secs()); + }; + + stdout_res.context("failed to stream stdout")?; + let (_, stderr_tail) = stderr_res.context("failed to stream stderr")?; + stdout_writer.flush().await?; + stderr_writer.flush().await?; + + let status = wait_res.context(format!("{label} build I/O error"))?; + let last_stderr = String::from_utf8_lossy(&stderr_tail).into_owned(); + + if !status.success() { + let exit_code = status.code().unwrap_or(-1); + debug!(exit_code, "{label} build failed"); + return Err(BuildFailure { + exit_code, + stdout_file: stdout_file.to_path_buf(), + stderr_file: stderr_file.to_path_buf(), + last_stderr, + duration: start.elapsed(), + } + .into()); + } + + let duration = start.elapsed(); + debug!(path = %clone_dir.display(), ?duration, "{label} build completed"); + Ok(BuildResult { + stdout_file: stdout_file.to_path_buf(), + stderr_file: stderr_file.to_path_buf(), + duration, + }) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::large_futures, + clippy::print_stderr +)] +mod tests { + use super::*; + use crate::test_support::{cleanup, temp_dir}; + use tokio::fs; + use tokio::process::Command as TokioCommand; + + /// Check if a container runtime is available and its daemon is running. + async fn container_runtime_available(runtime: &str) -> bool { + TokioCommand::new(runtime) + .args(["info"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) + } + + /// Get the first available container runtime. + async fn get_runtime() -> Option { + for runtime in &["podman", "docker"] { + if container_runtime_available(runtime).await { + return Some((*runtime).to_owned()); + } + } + None + } + + // --- copy_with_tail() unit tests --- + + #[tokio::test] + async fn copy_with_tail_small_input() { + let input = b"hello"; + let mut output = Vec::new(); + let (total, tail) = copy_with_tail(&input[..], &mut output, 1024).await.unwrap(); + assert_eq!(total, 5); + assert_eq!(tail, b"hello"); + assert_eq!(output, b"hello"); + } + + #[tokio::test] + async fn copy_with_tail_large_input() { + // Input larger than tail_size — only last N bytes kept + let input: Vec = (0_u8..=255).cycle().take(2048).collect(); + let mut output = Vec::new(); + let (total, tail) = copy_with_tail(&input[..], &mut output, 512).await.unwrap(); + assert_eq!(total, 2048); + assert_eq!(tail.len(), 512); + assert_eq!(&tail[..], &input[2048 - 512..]); + assert_eq!(output, input); + } + + #[tokio::test] + async fn copy_with_tail_zero_tail() { + let input = b"data"; + let mut output = Vec::new(); + let (total, tail) = copy_with_tail(&input[..], &mut output, 0).await.unwrap(); + assert_eq!(total, 4); + assert!(tail.is_empty()); + assert_eq!(output, b"data"); + } + + // --- ContainerOptions workdir tests --- + + #[tokio::test] + async fn execute_custom_workdir_runs_from_subdir() { + let Some(runtime) = get_runtime().await else { + eprintln!("Skipping test: no container runtime available"); + return; + }; + + let temp = temp_dir("build-workdir-test").await; + let stdout_tmp = temp.join("stdout.tmp"); + let stderr_tmp = temp.join("stderr.tmp"); + + // Create a subdirectory with a marker file + let subdir = temp.join("packages").join("frontend"); + fs::create_dir_all(&subdir).await.unwrap(); + fs::write(subdir.join("marker.txt"), "subdir-marker") + .await + .unwrap(); + + let repo_config = RepoConfig { + image: "alpine:latest".to_owned(), + command: "cat marker.txt".to_owned(), + public: "dist".to_owned(), + }; + + let options = ContainerOptions { + workdir: Some("packages/frontend".to_owned()), + ..ContainerOptions::default() + }; + + let result = execute( + &runtime, + &temp, + &repo_config, + &[], + &HashMap::new(), + &options, + &stdout_tmp, + &stderr_tmp, + BUILD_TIMEOUT_DEFAULT, + false, + ) + .await; + + assert!(result.is_ok(), "build should succeed: {result:?}"); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + assert!( + stdout.contains("subdir-marker"), + "should read marker from subdir, got: {stdout}" + ); + + cleanup(&temp).await; + } + + // --- execute() container tests (Tier 2) --- + + #[tokio::test] + async fn execute_simple_command_success() { + let Some(runtime) = get_runtime().await else { + eprintln!("Skipping test: no container runtime available"); + return; + }; + + let temp = temp_dir("build-test").await; + let stdout_tmp = temp.join("stdout.tmp"); + let stderr_tmp = temp.join("stderr.tmp"); + + let repo_config = RepoConfig { + image: "alpine:latest".to_owned(), + command: "echo 'hello world'".to_owned(), + public: "dist".to_owned(), + }; + + let result = execute( + &runtime, + &temp, + &repo_config, + &[], + &HashMap::new(), + &ContainerOptions::default(), + &stdout_tmp, + &stderr_tmp, + BUILD_TIMEOUT_DEFAULT, + false, + ) + .await; + + assert!(result.is_ok(), "build should succeed: {result:?}"); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + assert!(stdout.contains("hello world")); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn execute_creates_output_files() { + let Some(runtime) = get_runtime().await else { + eprintln!("Skipping test: no container runtime available"); + return; + }; + + let temp = temp_dir("build-test").await; + let stdout_tmp = temp.join("stdout.tmp"); + let stderr_tmp = temp.join("stderr.tmp"); + + let repo_config = RepoConfig { + image: "alpine:latest".to_owned(), + command: "mkdir -p dist && echo 'content' > dist/index.html".to_owned(), + public: "dist".to_owned(), + }; + + let result = execute( + &runtime, + &temp, + &repo_config, + &[], + &HashMap::new(), + &ContainerOptions::default(), + &stdout_tmp, + &stderr_tmp, + BUILD_TIMEOUT_DEFAULT, + false, + ) + .await; + + assert!(result.is_ok(), "build should succeed: {result:?}"); + + // Verify output file was created + let output_file = temp.join("dist/index.html"); + assert!(output_file.exists(), "output file should exist"); + + let content = fs::read_to_string(&output_file).await.unwrap(); + assert!(content.contains("content")); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn execute_failing_command_returns_error() { + let Some(runtime) = get_runtime().await else { + eprintln!("Skipping test: no container runtime available"); + return; + }; + + let temp = temp_dir("build-test").await; + let stdout_tmp = temp.join("stdout.tmp"); + let stderr_tmp = temp.join("stderr.tmp"); + + let repo_config = RepoConfig { + image: "alpine:latest".to_owned(), + command: "exit 1".to_owned(), + public: "dist".to_owned(), + }; + + let result = execute( + &runtime, + &temp, + &repo_config, + &[], + &HashMap::new(), + &ContainerOptions::default(), + &stdout_tmp, + &stderr_tmp, + BUILD_TIMEOUT_DEFAULT, + false, + ) + .await; + + assert!(result.is_err(), "build should fail"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("exit code 1")); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn execute_command_with_stderr() { + let Some(runtime) = get_runtime().await else { + eprintln!("Skipping test: no container runtime available"); + return; + }; + + let temp = temp_dir("build-test").await; + let stdout_tmp = temp.join("stdout.tmp"); + let stderr_tmp = temp.join("stderr.tmp"); + + let repo_config = RepoConfig { + image: "alpine:latest".to_owned(), + command: "echo 'error message' >&2 && exit 1".to_owned(), + public: "dist".to_owned(), + }; + + let result = execute( + &runtime, + &temp, + &repo_config, + &[], + &HashMap::new(), + &ContainerOptions::default(), + &stdout_tmp, + &stderr_tmp, + BUILD_TIMEOUT_DEFAULT, + false, + ) + .await; + + assert!(result.is_err(), "build should fail"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("error message")); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn execute_invalid_image_returns_error() { + let Some(runtime) = get_runtime().await else { + eprintln!("Skipping test: no container runtime available"); + return; + }; + + let temp = temp_dir("build-test").await; + let stdout_tmp = temp.join("stdout.tmp"); + let stderr_tmp = temp.join("stderr.tmp"); + + let repo_config = RepoConfig { + image: "nonexistent-image-xyz-12345:latest".to_owned(), + command: "echo hello".to_owned(), + public: "dist".to_owned(), + }; + + let result = execute( + &runtime, + &temp, + &repo_config, + &[], + &HashMap::new(), + &ContainerOptions::default(), + &stdout_tmp, + &stderr_tmp, + BUILD_TIMEOUT_DEFAULT, + false, + ) + .await; + + assert!(result.is_err(), "build should fail for invalid image"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn execute_workdir_is_correct() { + let Some(runtime) = get_runtime().await else { + eprintln!("Skipping test: no container runtime available"); + return; + }; + + let temp = temp_dir("build-test").await; + let stdout_tmp = temp.join("stdout.tmp"); + let stderr_tmp = temp.join("stderr.tmp"); + + // Create a file in the temp dir to verify we can see it + fs::write(temp.join("marker.txt"), "test-marker") + .await + .unwrap(); + + let repo_config = RepoConfig { + image: "alpine:latest".to_owned(), + command: "cat marker.txt".to_owned(), + public: "dist".to_owned(), + }; + + let result = execute( + &runtime, + &temp, + &repo_config, + &[], + &HashMap::new(), + &ContainerOptions::default(), + &stdout_tmp, + &stderr_tmp, + BUILD_TIMEOUT_DEFAULT, + false, + ) + .await; + + assert!(result.is_ok(), "build should succeed: {result:?}"); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + assert!(stdout.contains("test-marker")); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn execute_invalid_runtime_returns_error() { + let temp = temp_dir("build-test").await; + let stdout_tmp = temp.join("stdout.tmp"); + let stderr_tmp = temp.join("stderr.tmp"); + + let repo_config = RepoConfig { + image: "alpine:latest".to_owned(), + command: "echo hello".to_owned(), + public: "dist".to_owned(), + }; + + let result = execute( + "nonexistent-runtime-xyz", + &temp, + &repo_config, + &[], + &HashMap::new(), + &ContainerOptions::default(), + &stdout_tmp, + &stderr_tmp, + BUILD_TIMEOUT_DEFAULT, + false, + ) + .await; + + assert!(result.is_err(), "build should fail for invalid runtime"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn execute_with_env_vars_passes_to_container() { + let Some(runtime) = get_runtime().await else { + eprintln!("Skipping test: no container runtime available"); + return; + }; + + let temp = temp_dir("build-test").await; + let stdout_tmp = temp.join("stdout.tmp"); + let stderr_tmp = temp.join("stderr.tmp"); + + let repo_config = RepoConfig { + image: "alpine:latest".to_owned(), + command: "printenv MY_VAR".to_owned(), + public: "dist".to_owned(), + }; + + let env = HashMap::from([("MY_VAR".to_owned(), "my_value".to_owned())]); + let result = execute( + &runtime, + &temp, + &repo_config, + &[], + &env, + &ContainerOptions::default(), + &stdout_tmp, + &stderr_tmp, + BUILD_TIMEOUT_DEFAULT, + false, + ) + .await; + + assert!(result.is_ok(), "build should succeed: {result:?}"); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + assert!( + stdout.contains("my_value"), + "stdout should contain env var value, got: {stdout}", + ); + + cleanup(&temp).await; + } +} diff --git a/src/build_guard.rs b/src/build_guard.rs new file mode 100644 index 0000000..0c7fed3 --- /dev/null +++ b/src/build_guard.rs @@ -0,0 +1,128 @@ +use dashmap::DashSet; +use std::sync::Arc; + +/// Manages per-site build scheduling: immediate execution and a depth-1 queue. +/// +/// When a build is already in progress, a single rebuild can be queued. +/// Subsequent requests while a rebuild is already queued are collapsed (no-op). +pub struct BuildScheduler { + pub in_progress: DashSet, + pub queued: DashSet, +} + +impl BuildScheduler { + #[must_use] + pub fn new() -> Self { + Self { + in_progress: DashSet::new(), + queued: DashSet::new(), + } + } + + /// Queue a rebuild for a site that is currently building. + /// Returns `true` if newly queued, `false` if already queued (collapse). + pub(crate) fn try_queue(&self, site_name: &str) -> bool { + self.queued.insert(site_name.to_owned()) + } + + /// Check and clear queued rebuild. Returns `true` if there was one. + pub(crate) fn take_queued(&self, site_name: &str) -> bool { + self.queued.remove(site_name).is_some() + } +} + +impl Default for BuildScheduler { + fn default() -> Self { + Self::new() + } +} + +/// RAII guard for per-site build exclusion. +/// Inserting into the scheduler's `in_progress` set acquires the lock; +/// dropping removes it. +pub(crate) struct BuildGuard { + site_name: String, + scheduler: Arc, +} + +impl BuildGuard { + pub(crate) fn try_acquire(site_name: String, scheduler: &Arc) -> Option { + if scheduler.in_progress.insert(site_name.clone()) { + Some(Self { + site_name, + scheduler: Arc::clone(scheduler), + }) + } else { + None + } + } +} + +impl Drop for BuildGuard { + fn drop(&mut self) { + self.scheduler.in_progress.remove(&self.site_name); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn build_guard_try_acquire_success() { + let scheduler = Arc::new(BuildScheduler::new()); + let guard = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); + assert!(guard.is_some()); + assert!(scheduler.in_progress.contains("my-site")); + } + + #[test] + fn build_guard_try_acquire_fails_when_held() { + let scheduler = Arc::new(BuildScheduler::new()); + let _guard = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); + let second = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); + assert!(second.is_none()); + } + + #[test] + fn build_guard_drop_releases_lock() { + let scheduler = Arc::new(BuildScheduler::new()); + { + let _guard = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); + assert!(scheduler.in_progress.contains("my-site")); + } + // Guard dropped — lock released + assert!(!scheduler.in_progress.contains("my-site")); + let again = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); + assert!(again.is_some()); + } + + #[test] + fn scheduler_try_queue_succeeds() { + let scheduler = BuildScheduler::new(); + assert!(scheduler.try_queue("my-site")); + assert!(scheduler.queued.contains("my-site")); + } + + #[test] + fn scheduler_try_queue_collapse() { + let scheduler = BuildScheduler::new(); + assert!(scheduler.try_queue("my-site")); + assert!(!scheduler.try_queue("my-site")); + } + + #[test] + fn scheduler_take_queued_clears_flag() { + let scheduler = BuildScheduler::new(); + scheduler.try_queue("my-site"); + assert!(scheduler.take_queued("my-site")); + assert!(!scheduler.queued.contains("my-site")); + } + + #[test] + fn scheduler_take_queued_returns_false_when_empty() { + let scheduler = BuildScheduler::new(); + assert!(!scheduler.take_queued("my-site")); + } +} diff --git a/src/cleanup.rs b/src/cleanup.rs new file mode 100644 index 0000000..ced8320 --- /dev/null +++ b/src/cleanup.rs @@ -0,0 +1,467 @@ +use anyhow::{Context as _, Result}; +use std::path::Path; +use tracing::{debug, info, warn}; + +/// Result of a cleanup operation. +#[derive(Debug, Default)] +pub struct CleanupResult { + /// Number of build directories removed. + pub builds_removed: u32, + /// Number of log files removed. + pub logs_removed: u32, +} + +/// Clean up old build directories and their corresponding log files. +/// +/// Keeps the `max_to_keep` most recent builds and removes older ones. +/// Also removes the corresponding log files for each removed build. +/// +/// # Arguments +/// * `base_dir` - Base witryna directory (e.g., /var/lib/witryna) +/// * `log_dir` - Log directory (e.g., /var/log/witryna) +/// * `site_name` - The site name +/// * `max_to_keep` - Maximum number of builds to keep (0 = keep all) +/// +/// # Errors +/// +/// Returns an error if the builds directory cannot be listed. Individual +/// removal failures are logged as warnings but do not cause the function +/// to return an error. +pub async fn cleanup_old_builds( + base_dir: &Path, + log_dir: &Path, + site_name: &str, + max_to_keep: u32, +) -> Result { + // If max_to_keep is 0, keep all builds + if max_to_keep == 0 { + debug!(%site_name, "max_builds_to_keep is 0, skipping cleanup"); + return Ok(CleanupResult::default()); + } + + let builds_dir = base_dir.join("builds").join(site_name); + let site_log_dir = log_dir.join(site_name); + + // Check if builds directory exists + if !builds_dir.exists() { + debug!(%site_name, "builds directory does not exist, skipping cleanup"); + return Ok(CleanupResult::default()); + } + + // List all build directories (excluding 'current' symlink) + let mut build_timestamps = list_build_timestamps(&builds_dir).await?; + + // Sort in descending order (newest first) + build_timestamps.sort_by(|a, b| b.cmp(a)); + + let mut result = CleanupResult::default(); + + // Calculate how many to remove + let to_remove = build_timestamps.len().saturating_sub(max_to_keep as usize); + if to_remove == 0 { + debug!(%site_name, count = build_timestamps.len(), max = max_to_keep, "no builds to remove"); + } + + // Remove oldest builds (they're at the end after reverse sort) + for timestamp in build_timestamps.iter().skip(max_to_keep as usize) { + let build_path = builds_dir.join(timestamp); + let log_path = site_log_dir.join(format!("{timestamp}.log")); + + // Remove build directory + match tokio::fs::remove_dir_all(&build_path).await { + Ok(()) => { + debug!(path = %build_path.display(), "removed old build"); + result.builds_removed += 1; + } + Err(e) => { + warn!(path = %build_path.display(), error = %e, "failed to remove old build"); + } + } + + // Remove corresponding log file (if exists) + if log_path.exists() { + match tokio::fs::remove_file(&log_path).await { + Ok(()) => { + debug!(path = %log_path.display(), "removed old log"); + result.logs_removed += 1; + } + Err(e) => { + warn!(path = %log_path.display(), error = %e, "failed to remove old log"); + } + } + } + + // Remove corresponding hook log file (if exists) + let hook_log_path = site_log_dir.join(format!("{timestamp}-hook.log")); + match tokio::fs::remove_file(&hook_log_path).await { + Ok(()) => { + debug!(path = %hook_log_path.display(), "removed old hook log"); + result.logs_removed += 1; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Not every build has a hook — silently skip + } + Err(e) => { + warn!(path = %hook_log_path.display(), error = %e, "failed to remove old hook log"); + } + } + } + + // Remove orphaned temp files (crash recovery) + if site_log_dir.exists() + && let Ok(mut entries) = tokio::fs::read_dir(&site_log_dir).await + { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + if name.to_string_lossy().ends_with(".tmp") { + let path = entry.path(); + match tokio::fs::remove_file(&path).await { + Ok(()) => { + debug!(path = %path.display(), "removed orphaned temp file"); + } + Err(e) => { + warn!(path = %path.display(), error = %e, "failed to remove orphaned temp file"); + } + } + } + } + } + + if result.builds_removed > 0 || result.logs_removed > 0 { + info!( + %site_name, + builds_removed = result.builds_removed, + logs_removed = result.logs_removed, + "cleanup completed" + ); + } + + Ok(result) +} + +/// List all build timestamps in a builds directory. +/// +/// Returns directory names that look like timestamps, excluding 'current' symlink. +async fn list_build_timestamps(builds_dir: &Path) -> Result> { + let mut timestamps = Vec::new(); + + let mut entries = tokio::fs::read_dir(builds_dir) + .await + .with_context(|| format!("failed to read builds directory: {}", builds_dir.display()))?; + + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip 'current' symlink and any other non-timestamp entries + if name_str == "current" { + continue; + } + + // Verify it's a directory (not a file or broken symlink) + let file_type = entry.file_type().await?; + if !file_type.is_dir() { + continue; + } + + // Basic timestamp format validation: YYYYMMDD-HHMMSS-... + if looks_like_timestamp(&name_str) { + timestamps.push(name_str.to_string()); + } + } + + Ok(timestamps) +} + +/// Check if a string looks like a valid timestamp format. +/// +/// Expected format: YYYYMMDD-HHMMSS-microseconds (e.g., 20260126-143000-123456) +fn looks_like_timestamp(s: &str) -> bool { + let parts: Vec<&str> = s.split('-').collect(); + let [date, time, micros, ..] = parts.as_slice() else { + return false; + }; + + // First part should be 8 digits (YYYYMMDD) + if date.len() != 8 || !date.chars().all(|c| c.is_ascii_digit()) { + return false; + } + + // Second part should be 6 digits (HHMMSS) + if time.len() != 6 || !time.chars().all(|c| c.is_ascii_digit()) { + return false; + } + + // Third part should be microseconds (digits) + if micros.is_empty() || !micros.chars().all(|c| c.is_ascii_digit()) { + return false; + } + + true +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::test_support::{cleanup, temp_dir}; + use tokio::fs; + + async fn create_build_and_log(base_dir: &Path, log_dir: &Path, site: &str, timestamp: &str) { + let build_dir = base_dir.join("builds").join(site).join(timestamp); + let site_log_dir = log_dir.join(site); + let log_file = site_log_dir.join(format!("{timestamp}.log")); + + fs::create_dir_all(&build_dir).await.unwrap(); + fs::create_dir_all(&site_log_dir).await.unwrap(); + fs::write(&log_file, "test log content").await.unwrap(); + fs::write(build_dir.join("index.html"), "") + .await + .unwrap(); + } + + #[tokio::test] + async fn cleanup_removes_old_builds_and_logs() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create 7 builds (keep 5, remove 2) + let timestamps = [ + "20260126-100000-000001", + "20260126-100000-000002", + "20260126-100000-000003", + "20260126-100000-000004", + "20260126-100000-000005", + "20260126-100000-000006", + "20260126-100000-000007", + ]; + + for ts in ×tamps { + create_build_and_log(&base_dir, &log_dir, site, ts).await; + } + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 5).await; + assert!(result.is_ok(), "cleanup should succeed: {result:?}"); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 2, "should remove 2 builds"); + assert_eq!(result.logs_removed, 2, "should remove 2 logs"); + + // Verify oldest 2 are gone + let builds_dir = base_dir.join("builds").join(site); + assert!(!builds_dir.join("20260126-100000-000001").exists()); + assert!(!builds_dir.join("20260126-100000-000002").exists()); + + // Verify newest 5 remain + assert!(builds_dir.join("20260126-100000-000003").exists()); + assert!(builds_dir.join("20260126-100000-000007").exists()); + + // Verify log cleanup + let site_logs = log_dir.join(site); + assert!(!site_logs.join("20260126-100000-000001.log").exists()); + assert!(site_logs.join("20260126-100000-000003.log").exists()); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_with_fewer_builds_than_max() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create only 3 builds (max is 5) + for ts in &[ + "20260126-100000-000001", + "20260126-100000-000002", + "20260126-100000-000003", + ] { + create_build_and_log(&base_dir, &log_dir, site, ts).await; + } + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 5).await; + assert!(result.is_ok()); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 0, "should not remove any builds"); + assert_eq!(result.logs_removed, 0, "should not remove any logs"); + + // Verify all builds remain + let builds_dir = base_dir.join("builds").join(site); + assert!(builds_dir.join("20260126-100000-000001").exists()); + assert!(builds_dir.join("20260126-100000-000002").exists()); + assert!(builds_dir.join("20260126-100000-000003").exists()); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_preserves_current_symlink() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create builds + create_build_and_log(&base_dir, &log_dir, site, "20260126-100000-000001").await; + create_build_and_log(&base_dir, &log_dir, site, "20260126-100000-000002").await; + create_build_and_log(&base_dir, &log_dir, site, "20260126-100000-000003").await; + + // Create 'current' symlink + let builds_dir = base_dir.join("builds").join(site); + let current = builds_dir.join("current"); + let target = builds_dir.join("20260126-100000-000003"); + tokio::fs::symlink(&target, ¤t).await.unwrap(); + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 2).await; + assert!(result.is_ok()); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 1, "should remove 1 build"); + + // Verify symlink still exists and points correctly + assert!(current.exists(), "current symlink should exist"); + let link_target = fs::read_link(¤t).await.unwrap(); + assert_eq!(link_target, target); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_handles_missing_logs_gracefully() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create builds but only some logs + let builds_dir = base_dir.join("builds").join(site); + fs::create_dir_all(builds_dir.join("20260126-100000-000001")) + .await + .unwrap(); + fs::create_dir_all(builds_dir.join("20260126-100000-000002")) + .await + .unwrap(); + fs::create_dir_all(builds_dir.join("20260126-100000-000003")) + .await + .unwrap(); + + // Only create log for one build + let site_logs = log_dir.join(site); + fs::create_dir_all(&site_logs).await.unwrap(); + fs::write(site_logs.join("20260126-100000-000001.log"), "log") + .await + .unwrap(); + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 2).await; + assert!(result.is_ok(), "should succeed even with missing logs"); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 1, "should remove 1 build"); + assert_eq!(result.logs_removed, 1, "should remove 1 log"); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_with_max_zero_keeps_all() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create builds + for ts in &[ + "20260126-100000-000001", + "20260126-100000-000002", + "20260126-100000-000003", + ] { + create_build_and_log(&base_dir, &log_dir, site, ts).await; + } + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 0).await; + assert!(result.is_ok()); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 0, "max 0 should keep all"); + assert_eq!(result.logs_removed, 0); + + // Verify all builds remain + let builds_dir = base_dir.join("builds").join(site); + assert!(builds_dir.join("20260126-100000-000001").exists()); + assert!(builds_dir.join("20260126-100000-000002").exists()); + assert!(builds_dir.join("20260126-100000-000003").exists()); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_nonexistent_builds_dir() { + let base_dir = temp_dir("cleanup-test").await; + let site = "nonexistent-site"; + + let log_dir = base_dir.join("logs"); + let result = cleanup_old_builds(&base_dir, &log_dir, site, 5).await; + assert!(result.is_ok(), "should succeed for nonexistent dir"); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 0); + assert_eq!(result.logs_removed, 0); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_removes_orphaned_tmp_files() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create a build so cleanup runs + create_build_and_log(&base_dir, &log_dir, site, "20260126-100000-000001").await; + + // Create orphaned temp files in site log dir + let site_log_dir = log_dir.join(site); + fs::write( + site_log_dir.join("20260126-100000-000001-stdout.tmp"), + "orphan", + ) + .await + .unwrap(); + fs::write( + site_log_dir.join("20260126-100000-000001-stderr.tmp"), + "orphan", + ) + .await + .unwrap(); + fs::write(site_log_dir.join("random.tmp"), "orphan") + .await + .unwrap(); + + assert!( + site_log_dir + .join("20260126-100000-000001-stdout.tmp") + .exists() + ); + + // Run cleanup (max_to_keep=5 means no builds removed, but tmp files should go) + let result = cleanup_old_builds(&base_dir, &log_dir, site, 5).await; + assert!(result.is_ok()); + + // Temp files should be gone + assert!( + !site_log_dir + .join("20260126-100000-000001-stdout.tmp") + .exists() + ); + assert!( + !site_log_dir + .join("20260126-100000-000001-stderr.tmp") + .exists() + ); + assert!(!site_log_dir.join("random.tmp").exists()); + + // Log file should still exist + assert!(site_log_dir.join("20260126-100000-000001.log").exists()); + + cleanup(&base_dir).await; + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..ab191a4 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,134 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +/// Witryna - minimalist Git-based static site deployment orchestrator +#[derive(Debug, Parser)] +#[command( + name = "witryna", + version, + author, + about = "Minimalist Git-based static site deployment orchestrator", + long_about = "Minimalist Git-based static site deployment orchestrator.\n\n\ + Witryna listens for webhook HTTP requests, pulls the corresponding Git \ + repository (with automatic Git LFS fetch and submodule initialization), \ + runs a user-defined build command inside an ephemeral container and \ + publishes the resulting assets via atomic symlink switching.\n\n\ + A health-check endpoint is available at GET /health (returns 200 OK).\n\n\ + Witryna does not serve files, terminate TLS, or manage DNS. \ + It is designed to sit behind a reverse proxy (Nginx, Caddy, etc.).", + subcommand_required = true, + arg_required_else_help = true +)] +pub struct Cli { + /// Path to the configuration file. + /// If not specified, searches: ./witryna.toml, $XDG_CONFIG_HOME/witryna/witryna.toml, /etc/witryna/witryna.toml + #[arg(long, global = true, value_name = "FILE")] + pub config: Option, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Start the deployment server (foreground) + Serve, + /// Validate configuration file and print summary + Validate, + /// Trigger a one-off build for a site (synchronous, no server) + Run { + /// Site name (as defined in witryna.toml) + site: String, + /// Stream full build output to stderr in real-time + #[arg(long, short)] + verbose: bool, + }, + /// Show deployment status for configured sites + Status { + /// Show last 10 deployments for a single site + #[arg(long, short)] + site: Option, + /// Output in JSON format + #[arg(long)] + json: bool, + }, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn run_parses_site_name() { + let cli = Cli::try_parse_from(["witryna", "run", "my-site"]).unwrap(); + match cli.command { + Command::Run { site, verbose } => { + assert_eq!(site, "my-site"); + assert!(!verbose); + } + _ => panic!("expected Run command"), + } + } + + #[test] + fn run_parses_verbose_flag() { + let cli = Cli::try_parse_from(["witryna", "run", "my-site", "--verbose"]).unwrap(); + match cli.command { + Command::Run { site, verbose } => { + assert_eq!(site, "my-site"); + assert!(verbose); + } + _ => panic!("expected Run command"), + } + } + + #[test] + fn status_parses_without_flags() { + let cli = Cli::try_parse_from(["witryna", "status"]).unwrap(); + match cli.command { + Command::Status { site, json } => { + assert!(site.is_none()); + assert!(!json); + } + _ => panic!("expected Status command"), + } + } + + #[test] + fn status_parses_site_filter() { + let cli = Cli::try_parse_from(["witryna", "status", "--site", "my-site"]).unwrap(); + match cli.command { + Command::Status { site, json } => { + assert_eq!(site.as_deref(), Some("my-site")); + assert!(!json); + } + _ => panic!("expected Status command"), + } + } + + #[test] + fn status_parses_json_flag() { + let cli = Cli::try_parse_from(["witryna", "status", "--json"]).unwrap(); + match cli.command { + Command::Status { site, json } => { + assert!(site.is_none()); + assert!(json); + } + _ => panic!("expected Status command"), + } + } + + #[test] + fn config_flag_is_optional() { + let cli = Cli::try_parse_from(["witryna", "status"]).unwrap(); + assert!(cli.config.is_none()); + } + + #[test] + fn config_flag_explicit_path() { + let cli = + Cli::try_parse_from(["witryna", "--config", "/etc/witryna.toml", "status"]).unwrap(); + assert_eq!(cli.config, Some(PathBuf::from("/etc/witryna.toml"))); + } +} 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() + ); + } +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..2193add --- /dev/null +++ b/src/git.rs @@ -0,0 +1,1320 @@ +use anyhow::{Context as _, Result, bail}; +use std::path::Path; +use std::time::Duration; +use tokio::process::Command; +use tracing::{debug, error, info, warn}; + +/// Default timeout for git operations (used when not configured). +pub const GIT_TIMEOUT_DEFAULT: Duration = Duration::from_secs(60); + +/// Default git clone depth (shallow clone with 1 commit). +pub const GIT_DEPTH_DEFAULT: u32 = 1; + +/// Timeout for LFS operations (longer due to large file downloads) +const LFS_TIMEOUT: Duration = Duration::from_secs(300); + +/// LFS pointer file signature (per Git LFS spec) +const LFS_POINTER_SIGNATURE: &str = "version https://git-lfs.github.com/spec/v1"; + +/// Maximum size for a valid LFS pointer file (per spec) +const LFS_POINTER_MAX_SIZE: u64 = 1024; + +/// Create a git Command with clean environment isolation. +/// +/// Strips `GIT_DIR`, `GIT_WORK_TREE`, and `GIT_INDEX_FILE` so that git +/// discovers the repository from the working directory set via +/// `.current_dir()`, not from inherited environment variables. +/// +/// This is defensive: in production these vars are never set, but it +/// prevents failures when tests run inside git hooks (e.g., a pre-commit +/// hook that invokes `cargo test`). +fn git_command() -> Command { + let mut cmd = Command::new("git"); + cmd.env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE"); + cmd +} + +/// Create a git Command that allows the file:// protocol. +/// +/// Git ≥ 2.38.1 disables file:// by default (CVE-2022-39253), but the +/// restriction targets local-clone hardlink attacks, not file:// transport. +/// Submodule URLs come from the trusted config, so this is safe. +/// Used only for submodule operations whose internal clones may use file://. +fn git_command_allow_file_transport() -> Command { + let mut cmd = git_command(); + cmd.env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "protocol.file.allow") + .env("GIT_CONFIG_VALUE_0", "always"); + cmd +} + +/// Run a git command with timeout and standard error handling. +/// +/// Builds a `git` `Command`, optionally sets the working directory, +/// enforces a timeout, and converts non-zero exit into an `anyhow` error. +async fn run_git(args: &[&str], dir: Option<&Path>, timeout: Duration, op: &str) -> Result<()> { + run_git_cmd(git_command(), args, dir, timeout, op).await +} + +/// Like [`run_git`] but uses a pre-built `Command` (e.g. one that allows +/// the file:// protocol for submodule clones). +async fn run_git_cmd( + mut cmd: Command, + args: &[&str], + dir: Option<&Path>, + timeout: Duration, + op: &str, +) -> Result<()> { + cmd.args(args); + if let Some(d) = dir { + cmd.current_dir(d); + } + + let output = tokio::time::timeout(timeout, cmd.output()) + .await + .with_context(|| format!("{op} timed out"))? + .with_context(|| format!("failed to execute {op}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("{op} failed: {}", stderr.trim()); + } + + Ok(()) +} + +/// Synchronize a Git repository: clone if not exists, pull if exists. +/// Automatically initializes submodules and fetches LFS objects if needed. +/// +/// # Errors +/// +/// Returns an error if the clone, pull, submodule init, or LFS fetch fails. +pub async fn sync_repo( + repo_url: &str, + branch: &str, + clone_dir: &Path, + timeout: Duration, + depth: u32, +) -> Result<()> { + let is_pull = clone_dir.exists(); + + if is_pull { + pull(clone_dir, branch, timeout, depth).await?; + } else if let Err(e) = clone(repo_url, branch, clone_dir, timeout, depth).await { + if clone_dir.exists() { + warn!(path = %clone_dir.display(), "cleaning up partial clone after failure"); + if let Err(cleanup_err) = tokio::fs::remove_dir_all(clone_dir).await { + error!(path = %clone_dir.display(), error = %cleanup_err, + "failed to clean up partial clone"); + } + } + return Err(e); + } + + // Initialize submodules before LFS (submodule files may contain LFS pointers) + maybe_init_submodules(clone_dir, timeout, depth, is_pull).await?; + + // Handle LFS after clone/pull + submodules + maybe_fetch_lfs(clone_dir).await?; + + Ok(()) +} + +/// Check if the remote branch has new commits compared to local HEAD. +/// Returns `Ok(true)` if new commits are available, `Ok(false)` if up-to-date. +/// +/// This function: +/// 1. Returns true if `clone_dir` doesn't exist (needs initial clone) +/// 2. Runs `git fetch` to update remote refs (with `--depth` if depth > 0) +/// 3. Compares local HEAD with `origin/{branch}` +/// 4. Does NOT modify the working directory (no reset/checkout) +/// +/// # Errors +/// +/// Returns an error if git fetch or rev-parse fails. +pub async fn has_remote_changes( + clone_dir: &Path, + branch: &str, + timeout: Duration, + depth: u32, +) -> Result { + // If clone directory doesn't exist, treat as "needs update" + if !clone_dir.exists() { + debug!(path = %clone_dir.display(), "clone directory does not exist, needs initial clone"); + return Ok(true); + } + + // Fetch from remote (update refs only, no working tree changes) + debug!(path = %clone_dir.display(), branch, "fetching remote refs"); + let depth_str = depth.to_string(); + let mut fetch_args = vec!["fetch"]; + if depth > 0 { + fetch_args.push("--depth"); + fetch_args.push(&depth_str); + } + fetch_args.extend_from_slice(&["origin", branch]); + run_git(&fetch_args, Some(clone_dir), timeout, "git fetch").await?; + + // Get local HEAD commit + let local_head = get_commit_hash(clone_dir, "HEAD").await?; + + // Get remote branch commit + let remote_ref = format!("origin/{branch}"); + let remote_head = get_commit_hash(clone_dir, &remote_ref).await?; + + debug!( + path = %clone_dir.display(), + local = %local_head, + remote = %remote_head, + "comparing commits" + ); + + Ok(local_head != remote_head) +} + +/// Get the full commit hash for a ref (HEAD, branch name, etc.) +async fn get_commit_hash(clone_dir: &Path, ref_name: &str) -> Result { + let output = git_command() + .args(["rev-parse", ref_name]) + .current_dir(clone_dir) + .output() + .await + .context("failed to execute git rev-parse")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("git rev-parse {} failed: {}", ref_name, stderr.trim()); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +async fn clone( + repo_url: &str, + branch: &str, + clone_dir: &Path, + timeout: Duration, + depth: u32, +) -> Result<()> { + info!(repo_url, branch, path = %clone_dir.display(), "cloning repository"); + + // Create parent directory if needed + if let Some(parent) = clone_dir.parent() { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; + } + + let clone_dir_str = clone_dir.display().to_string(); + let depth_str = depth.to_string(); + let mut args = vec!["clone", "--branch", branch, "--single-branch"]; + if depth > 0 { + args.push("--depth"); + args.push(&depth_str); + } + args.push(repo_url); + args.push(clone_dir_str.as_str()); + run_git(&args, None, timeout, "git clone").await?; + + debug!(path = %clone_dir.display(), "clone completed"); + Ok(()) +} + +async fn pull(clone_dir: &Path, branch: &str, timeout: Duration, depth: u32) -> Result<()> { + info!(branch, path = %clone_dir.display(), "pulling latest changes"); + + // Fetch from origin (shallow or full depending on depth) + let depth_str = depth.to_string(); + let mut fetch_args = vec!["fetch"]; + if depth > 0 { + fetch_args.push("--depth"); + fetch_args.push(&depth_str); + } + fetch_args.extend_from_slice(&["origin", branch]); + run_git(&fetch_args, Some(clone_dir), timeout, "git fetch").await?; + + // Reset to origin/branch to discard any local changes + let reset_ref = format!("origin/{branch}"); + run_git( + &["reset", "--hard", &reset_ref], + Some(clone_dir), + timeout, + "git reset", + ) + .await?; + + debug!(path = %clone_dir.display(), "pull completed"); + Ok(()) +} + +/// Check if the repository has LFS configured via .gitattributes. +async fn has_lfs_configured(clone_dir: &Path) -> bool { + let gitattributes = clone_dir.join(".gitattributes"); + + tokio::fs::read_to_string(&gitattributes) + .await + .is_ok_and(|content| content.contains("filter=lfs")) +} + +/// Scan repository for LFS pointer files. +/// Returns true if any tracked file matches the LFS pointer signature. +async fn has_lfs_pointers(clone_dir: &Path) -> Result { + // Use git ls-files to get tracked files + let output = git_command() + .args(["ls-files", "-z"]) // -z for null-separated output + .current_dir(clone_dir) + .output() + .await + .context("failed to list git files")?; + + if !output.status.success() { + // If ls-files fails, assume pointers might exist (conservative) + return Ok(true); + } + + let files_str = String::from_utf8_lossy(&output.stdout); + + for file_path in files_str.split('\0').filter(|s| !s.is_empty()) { + let full_path = clone_dir.join(file_path); + + // Check file size first (pointer files are < 1024 bytes) + let Ok(metadata) = tokio::fs::metadata(&full_path).await else { + continue; + }; + if metadata.len() >= LFS_POINTER_MAX_SIZE || !metadata.is_file() { + continue; + } + + // Read and check for LFS signature + let Ok(content) = tokio::fs::read_to_string(&full_path).await else { + continue; + }; + if content.starts_with(LFS_POINTER_SIGNATURE) { + debug!(file = %file_path, "found LFS pointer"); + return Ok(true); + } + } + + Ok(false) +} + +async fn is_lfs_available() -> bool { + git_command() + .args(["lfs", "version"]) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +async fn lfs_pull(clone_dir: &Path) -> Result<()> { + info!(path = %clone_dir.display(), "fetching LFS objects"); + + run_git( + &["lfs", "pull"], + Some(clone_dir), + LFS_TIMEOUT, + "git lfs pull", + ) + .await?; + + debug!(path = %clone_dir.display(), "LFS pull completed"); + Ok(()) +} + +/// Detect and fetch LFS objects if needed. +/// +/// Detection strategy: +/// 1. Check .gitattributes for `filter=lfs` +/// 2. If configured, scan for actual pointer files +/// 3. If pointers exist, verify git-lfs is available +/// 4. Run `git lfs pull` to fetch objects +async fn maybe_fetch_lfs(clone_dir: &Path) -> Result<()> { + // Step 1: Quick check for LFS configuration + if !has_lfs_configured(clone_dir).await { + debug!(path = %clone_dir.display(), "no LFS configuration found"); + return Ok(()); + } + + info!(path = %clone_dir.display(), "LFS configured, checking for pointers"); + + // Step 2: Scan for actual pointer files + match has_lfs_pointers(clone_dir).await { + Ok(true) => { + // Pointers found, need to fetch + } + Ok(false) => { + debug!(path = %clone_dir.display(), "no LFS pointers found"); + return Ok(()); + } + Err(e) => { + // If scan fails, try to fetch anyway (conservative approach) + debug!(error = %e, "LFS pointer scan failed, attempting fetch"); + } + } + + // Step 3: Verify git-lfs is available + if !is_lfs_available().await { + bail!("repository requires git-lfs but git-lfs is not installed"); + } + + // Step 4: Fetch LFS objects + lfs_pull(clone_dir).await +} + +/// Check if the repository has submodules configured via .gitmodules. +async fn has_submodules(clone_dir: &Path) -> bool { + let gitmodules = clone_dir.join(".gitmodules"); + tokio::fs::read_to_string(&gitmodules) + .await + .is_ok_and(|content| !content.trim().is_empty()) +} + +/// Detect and initialize submodules if needed. +/// +/// Detection: checks for `.gitmodules` (single stat call when absent). +/// On pull: runs `git submodule sync --recursive` first to handle URL changes. +/// Then: `git submodule update --init --recursive [--depth 1]`. +async fn maybe_init_submodules( + clone_dir: &Path, + timeout: Duration, + depth: u32, + is_pull: bool, +) -> Result<()> { + if !has_submodules(clone_dir).await { + debug!(path = %clone_dir.display(), "no submodules configured"); + return Ok(()); + } + + info!(path = %clone_dir.display(), "submodules detected, initializing"); + + // On pull, sync URLs first (handles upstream submodule URL changes) + if is_pull { + run_git( + &["submodule", "sync", "--recursive"], + Some(clone_dir), + timeout, + "git submodule sync", + ) + .await?; + } + + // Initialize and update submodules. + // Uses file-transport-allowing command because `git submodule update` + // internally clones each submodule, and URLs may use the file:// scheme. + let depth_str = depth.to_string(); + let mut args = vec!["submodule", "update", "--init", "--recursive"]; + if depth > 0 { + args.push("--depth"); + args.push(&depth_str); + } + run_git_cmd( + git_command_allow_file_transport(), + &args, + Some(clone_dir), + timeout, + "git submodule update", + ) + .await?; + + debug!(path = %clone_dir.display(), "submodule initialization completed"); + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::expect_used)] +mod tests { + use super::*; + use crate::test_support::{cleanup, temp_dir}; + use tokio::fs; + use tokio::process::Command; + + /// Alias for `git_command_allow_file_transport()` — tests use file:// + /// URLs for bare repos, so the file protocol must be allowed. + fn git_cmd() -> Command { + git_command_allow_file_transport() + } + + async fn configure_test_git_user(dir: &Path) { + git_cmd() + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .await + .unwrap(); + git_cmd() + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .await + .unwrap(); + } + + /// Create a local bare git repository with an initial commit on the specified branch. + /// Returns a file:// URL that works with git clone --depth 1. + async fn create_local_repo(temp: &Path, branch: &str) -> String { + let bare_repo = temp.join("origin.git"); + fs::create_dir_all(&bare_repo).await.unwrap(); + + // Initialize bare repo with explicit initial branch + let output = git_cmd() + .args(["init", "--bare", "--initial-branch", branch]) + .current_dir(&bare_repo) + .output() + .await + .unwrap(); + assert!(output.status.success(), "git init failed"); + + // Create a working copy to make initial commit + let work_dir = temp.join("work"); + let output = git_cmd() + .args([ + "clone", + bare_repo.to_str().unwrap(), + work_dir.to_str().unwrap(), + ]) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Configure git user for commit + configure_test_git_user(&work_dir).await; + + // Checkout the target branch (in case clone defaulted to something else) + let output = git_cmd() + .args(["checkout", "-B", branch]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git checkout failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Create initial commit + fs::write(work_dir.join("README.md"), "# Test Repo") + .await + .unwrap(); + let output = git_cmd() + .args(["add", "README.md"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!(output.status.success(), "git add failed"); + + let output = git_cmd() + .args(["commit", "-m", "Initial commit"]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Push to origin + let output = git_cmd() + .args(["push", "-u", "origin", branch]) + .current_dir(&work_dir) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git push failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Clean up working copy + let _ = fs::remove_dir_all(&work_dir).await; + + // Return file:// URL so --depth works correctly + format!("file://{}", bare_repo.to_str().unwrap()) + } + + #[tokio::test] + async fn clone_creates_directory_and_clones_repo() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + let clone_dir = temp.join("test-repo"); + + let result = clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1).await; + + assert!(result.is_ok(), "clone should succeed: {result:?}"); + assert!(clone_dir.exists(), "clone directory should exist"); + assert!( + clone_dir.join(".git").exists(), + ".git directory should exist" + ); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn clone_invalid_url_returns_error() { + let temp = temp_dir("git-test").await; + let clone_dir = temp.join("invalid-repo"); + + let result = clone( + "/nonexistent/path/to/repo.git", + "main", + &clone_dir, + GIT_TIMEOUT_DEFAULT, + 1, + ) + .await; + + assert!(result.is_err(), "clone should fail for invalid URL"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn clone_invalid_branch_returns_error() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + let clone_dir = temp.join("invalid-branch"); + + let result = clone( + &repo_url, + "nonexistent-branch-xyz", + &clone_dir, + GIT_TIMEOUT_DEFAULT, + 1, + ) + .await; + + assert!(result.is_err(), "clone should fail for invalid branch"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn pull_updates_existing_repo() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + let clone_dir = temp.join("pull-test"); + + // First clone + clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .expect("initial clone should succeed"); + + // Push a new commit to origin + let work_dir = temp.join("work-pull"); + push_new_commit(&repo_url, &work_dir, "pulled.txt", "pulled content").await; + + // Pull should fetch the new commit + pull(&clone_dir, "main", GIT_TIMEOUT_DEFAULT, 1) + .await + .expect("pull should succeed"); + + // Verify the new file appeared in the working copy + let pulled_file = clone_dir.join("pulled.txt"); + assert!(pulled_file.exists(), "pulled file should exist after pull"); + let content = fs::read_to_string(&pulled_file).await.unwrap(); + assert_eq!(content, "pulled content"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn pull_invalid_branch_returns_error() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + let clone_dir = temp.join("pull-invalid-branch"); + + // First clone + clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .expect("initial clone should succeed"); + + // Pull with invalid branch + let result = pull(&clone_dir, "nonexistent-branch-xyz", GIT_TIMEOUT_DEFAULT, 1).await; + + assert!(result.is_err(), "pull should fail for invalid branch"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn sync_repo_clones_when_not_exists() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + let clone_dir = temp.join("sync-clone"); + + let result = sync_repo(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1).await; + + assert!(result.is_ok(), "sync should succeed: {result:?}"); + assert!(clone_dir.exists(), "clone directory should exist"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn sync_repo_pulls_when_exists() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + let clone_dir = temp.join("sync-pull"); + + // First sync (clone) + sync_repo(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .expect("initial sync should succeed"); + + // Push a new commit to origin + let work_dir = temp.join("work-sync"); + push_new_commit(&repo_url, &work_dir, "synced.txt", "synced content").await; + + // Second sync should pull the new commit + sync_repo(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .expect("second sync should succeed"); + + // Verify the new file appeared + let synced_file = clone_dir.join("synced.txt"); + assert!(synced_file.exists(), "synced file should exist after pull"); + let content = fs::read_to_string(&synced_file).await.unwrap(); + assert_eq!(content, "synced content"); + + cleanup(&temp).await; + } + + // LFS tests + + #[tokio::test] + async fn has_lfs_configured_with_lfs() { + let temp = temp_dir("git-test").await; + fs::write( + temp.join(".gitattributes"), + "*.bin filter=lfs diff=lfs merge=lfs -text\n", + ) + .await + .unwrap(); + + assert!(has_lfs_configured(&temp).await); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn has_lfs_configured_without_lfs() { + let temp = temp_dir("git-test").await; + fs::write(temp.join(".gitattributes"), "*.txt text\n") + .await + .unwrap(); + + assert!(!has_lfs_configured(&temp).await); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn has_lfs_configured_no_file() { + let temp = temp_dir("git-test").await; + // No .gitattributes file + + assert!(!has_lfs_configured(&temp).await); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn has_lfs_pointers_detects_pointer() { + let temp = temp_dir("git-test").await; + + // Initialize git repo + init_git_repo(&temp).await; + + // Create LFS pointer file + let pointer_content = "version https://git-lfs.github.com/spec/v1\n\ + oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\n\ + size 12345\n"; + fs::write(temp.join("large.bin"), pointer_content) + .await + .unwrap(); + + // Stage the file + stage_file(&temp, "large.bin").await; + + let result = has_lfs_pointers(&temp).await; + assert!(result.is_ok()); + assert!(result.unwrap()); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn has_lfs_pointers_ignores_non_pointers() { + let temp = temp_dir("git-test").await; + + // Initialize git repo + init_git_repo(&temp).await; + + // Create normal small file + fs::write(temp.join("readme.txt"), "Hello World") + .await + .unwrap(); + stage_file(&temp, "readme.txt").await; + + let result = has_lfs_pointers(&temp).await; + assert!(result.is_ok()); + assert!(!result.unwrap()); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn has_lfs_pointers_ignores_large_files() { + let temp = temp_dir("git-test").await; + + init_git_repo(&temp).await; + + // Create large file that starts with LFS signature (edge case) + let mut content = String::from("version https://git-lfs.github.com/spec/v1\n"); + content.push_str(&"x".repeat(2000)); // > 1024 bytes + fs::write(temp.join("large.txt"), &content).await.unwrap(); + stage_file(&temp, "large.txt").await; + + let result = has_lfs_pointers(&temp).await; + assert!(result.is_ok()); + assert!(!result.unwrap()); // Should be ignored due to size + + cleanup(&temp).await; + } + + #[tokio::test] + async fn maybe_fetch_lfs_no_config() { + let temp = temp_dir("git-test").await; + init_git_repo(&temp).await; + + // No .gitattributes = no LFS + let result = maybe_fetch_lfs(&temp).await; + assert!(result.is_ok()); + + cleanup(&temp).await; + } + + // Helper functions for LFS tests + + async fn init_git_repo(dir: &Path) { + git_cmd() + .args(["init"]) + .current_dir(dir) + .output() + .await + .unwrap(); + configure_test_git_user(dir).await; + } + + async fn stage_file(dir: &Path, filename: &str) { + git_cmd() + .args(["add", filename]) + .current_dir(dir) + .output() + .await + .unwrap(); + } + + /// Clone a bare repo into `work_dir`, commit a new file, and push it. + async fn push_new_commit(repo_url: &str, work_dir: &Path, filename: &str, content: &str) { + git_cmd() + .args(["clone", repo_url, work_dir.to_str().unwrap()]) + .output() + .await + .unwrap(); + configure_test_git_user(work_dir).await; + + fs::write(work_dir.join(filename), content).await.unwrap(); + + git_cmd() + .args(["add", filename]) + .current_dir(work_dir) + .output() + .await + .unwrap(); + + git_cmd() + .args(["commit", "-m", "New commit"]) + .current_dir(work_dir) + .output() + .await + .unwrap(); + + git_cmd() + .args(["push"]) + .current_dir(work_dir) + .output() + .await + .unwrap(); + } + + // has_remote_changes tests + + #[tokio::test] + async fn has_remote_changes_nonexistent_dir_returns_true() { + let temp = temp_dir("git-test").await; + let nonexistent = temp.join("does-not-exist"); + + let result = has_remote_changes(&nonexistent, "main", GIT_TIMEOUT_DEFAULT, 1).await; + assert!(result.is_ok()); + assert!(result.unwrap(), "nonexistent directory should return true"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn has_remote_changes_up_to_date_returns_false() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + let clone_dir = temp.join("test-clone"); + + // Clone the repo + clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .unwrap(); + + // Check for changes - should be false since we just cloned + let result = has_remote_changes(&clone_dir, "main", GIT_TIMEOUT_DEFAULT, 1).await; + assert!(result.is_ok(), "has_remote_changes failed: {result:?}"); + assert!(!result.unwrap(), "freshly cloned repo should be up-to-date"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn has_remote_changes_detects_new_commits() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + let clone_dir = temp.join("test-clone"); + + // Clone the repo + clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .unwrap(); + + // Push a new commit to the origin + let work_dir = temp.join("work2"); + push_new_commit(&repo_url, &work_dir, "new-file.txt", "new content").await; + + // Now check for changes - should detect the new commit + let result = has_remote_changes(&clone_dir, "main", GIT_TIMEOUT_DEFAULT, 1).await; + assert!(result.is_ok(), "has_remote_changes failed: {result:?}"); + assert!(result.unwrap(), "should detect new commits on remote"); + + cleanup(&temp).await; + } + + // git_depth tests + + #[tokio::test] + async fn clone_full_depth_creates_complete_history() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + + // Push a second commit so we have more than 1 commit in history + let work_dir = temp.join("work-depth"); + push_new_commit(&repo_url, &work_dir, "second.txt", "second commit").await; + + let clone_dir = temp.join("full-clone"); + + // Clone with depth=0 (full clone) + clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 0) + .await + .expect("full clone should succeed"); + + // Verify we have more than 1 commit (full history) + let output = git_cmd() + .args(["rev-list", "--count", "HEAD"]) + .current_dir(&clone_dir) + .output() + .await + .unwrap(); + let count: u32 = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap(); + assert!( + count > 1, + "full clone should have multiple commits, got {count}" + ); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn sync_repo_full_depth_preserves_history() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + + // Push a second commit + let work_dir = temp.join("work-depth2"); + push_new_commit(&repo_url, &work_dir, "second.txt", "second").await; + + let clone_dir = temp.join("sync-full"); + + // sync_repo with depth=0 should do a full clone + sync_repo(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 0) + .await + .expect("sync with full depth should succeed"); + + let output = git_cmd() + .args(["rev-list", "--count", "HEAD"]) + .current_dir(&clone_dir) + .output() + .await + .unwrap(); + let count: u32 = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap(); + assert!( + count > 1, + "full sync should have multiple commits, got {count}" + ); + + cleanup(&temp).await; + } + + // Submodule tests + + #[tokio::test] + async fn has_submodules_with_gitmodules_file() { + let temp = temp_dir("git-test").await; + fs::write( + temp.join(".gitmodules"), + "[submodule \"lib\"]\n\tpath = lib\n\turl = ../lib.git\n", + ) + .await + .unwrap(); + + assert!(has_submodules(&temp).await); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn has_submodules_without_gitmodules() { + let temp = temp_dir("git-test").await; + + assert!(!has_submodules(&temp).await); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn has_submodules_empty_gitmodules() { + let temp = temp_dir("git-test").await; + fs::write(temp.join(".gitmodules"), "").await.unwrap(); + + assert!(!has_submodules(&temp).await); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn maybe_init_submodules_no_submodules_is_noop() { + let temp = temp_dir("git-test").await; + let repo_url = create_local_repo(&temp, "main").await; + let clone_dir = temp.join("no-submodules"); + + clone(&repo_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .expect("clone should succeed"); + + // No .gitmodules → should be a no-op + let result = maybe_init_submodules(&clone_dir, GIT_TIMEOUT_DEFAULT, 1, false).await; + assert!( + result.is_ok(), + "noop submodule init should succeed: {result:?}" + ); + + cleanup(&temp).await; + } + + /// Create a parent repo with a submodule wired up. + /// Returns (parent_url, submodule_url). + async fn create_repo_with_submodule(temp: &Path, branch: &str) -> (String, String) { + // 1. Create bare submodule repo with a file + let sub_bare = temp.join("sub.git"); + fs::create_dir_all(&sub_bare).await.unwrap(); + git_cmd() + .args(["init", "--bare", "--initial-branch", branch]) + .current_dir(&sub_bare) + .output() + .await + .unwrap(); + + let sub_work = temp.join("sub-work"); + git_cmd() + .args([ + "clone", + sub_bare.to_str().unwrap(), + sub_work.to_str().unwrap(), + ]) + .output() + .await + .unwrap(); + configure_test_git_user(&sub_work).await; + git_cmd() + .args(["checkout", "-B", branch]) + .current_dir(&sub_work) + .output() + .await + .unwrap(); + fs::write(sub_work.join("sub-file.txt"), "submodule content") + .await + .unwrap(); + git_cmd() + .args(["add", "sub-file.txt"]) + .current_dir(&sub_work) + .output() + .await + .unwrap(); + let output = git_cmd() + .args(["commit", "-m", "sub initial"]) + .current_dir(&sub_work) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "sub commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let output = git_cmd() + .args(["push", "-u", "origin", branch]) + .current_dir(&sub_work) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "sub push failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // 2. Create bare parent repo with a submodule reference + let parent_bare = temp.join("parent.git"); + fs::create_dir_all(&parent_bare).await.unwrap(); + git_cmd() + .args(["init", "--bare", "--initial-branch", branch]) + .current_dir(&parent_bare) + .output() + .await + .unwrap(); + + let parent_work = temp.join("parent-work"); + git_cmd() + .args([ + "clone", + parent_bare.to_str().unwrap(), + parent_work.to_str().unwrap(), + ]) + .output() + .await + .unwrap(); + configure_test_git_user(&parent_work).await; + git_cmd() + .args(["checkout", "-B", branch]) + .current_dir(&parent_work) + .output() + .await + .unwrap(); + fs::write(parent_work.join("README.md"), "# Parent") + .await + .unwrap(); + git_cmd() + .args(["add", "README.md"]) + .current_dir(&parent_work) + .output() + .await + .unwrap(); + + // Add submodule using file:// URL + let sub_url = format!("file://{}", sub_bare.to_str().unwrap()); + let output = git_cmd() + .args(["submodule", "add", &sub_url, "lib"]) + .current_dir(&parent_work) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git submodule add failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + git_cmd() + .args(["commit", "-m", "add submodule"]) + .current_dir(&parent_work) + .output() + .await + .unwrap(); + let output = git_cmd() + .args(["push", "-u", "origin", branch]) + .current_dir(&parent_work) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "git push failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let _ = fs::remove_dir_all(&sub_work).await; + let _ = fs::remove_dir_all(&parent_work).await; + + let parent_url = format!("file://{}", parent_bare.to_str().unwrap()); + (parent_url, sub_url) + } + + #[tokio::test] + async fn sync_repo_initializes_submodules() { + let temp = temp_dir("git-test").await; + let (parent_url, _sub_url) = create_repo_with_submodule(&temp, "main").await; + let clone_dir = temp.join("clone-with-sub"); + + sync_repo(&parent_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .expect("sync should succeed"); + + // Verify submodule content is present + let sub_file = clone_dir.join("lib").join("sub-file.txt"); + assert!(sub_file.exists(), "submodule file should exist after sync"); + let content = fs::read_to_string(&sub_file).await.unwrap(); + assert_eq!(content, "submodule content"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn sync_repo_updates_submodules_on_pull() { + let temp = temp_dir("git-test").await; + let (parent_url, sub_url) = create_repo_with_submodule(&temp, "main").await; + let clone_dir = temp.join("pull-sub"); + + // First sync (clone + submodule init) + sync_repo(&parent_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .expect("initial sync should succeed"); + + // Push a new commit to the submodule + let sub_work = temp.join("sub-update"); + git_cmd() + .args(["clone", &sub_url, sub_work.to_str().unwrap()]) + .output() + .await + .unwrap(); + configure_test_git_user(&sub_work).await; + fs::write(sub_work.join("new-sub-file.txt"), "updated submodule") + .await + .unwrap(); + git_cmd() + .args(["add", "new-sub-file.txt"]) + .current_dir(&sub_work) + .output() + .await + .unwrap(); + let output = git_cmd() + .args(["commit", "-m", "update sub"]) + .current_dir(&sub_work) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "sub commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let output = git_cmd() + .args(["push"]) + .current_dir(&sub_work) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "sub push failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Update parent to point to new submodule commit + let parent_work = temp.join("parent-update"); + let parent_bare = temp.join("parent.git"); + git_cmd() + .args([ + "clone", + parent_bare.to_str().unwrap(), + parent_work.to_str().unwrap(), + ]) + .output() + .await + .unwrap(); + configure_test_git_user(&parent_work).await; + // Init submodule in parent work copy, then update to latest + git_cmd() + .args(["submodule", "update", "--init", "--remote", "lib"]) + .current_dir(&parent_work) + .output() + .await + .unwrap(); + git_cmd() + .args(["add", "lib"]) + .current_dir(&parent_work) + .output() + .await + .unwrap(); + let output = git_cmd() + .args(["commit", "-m", "bump submodule"]) + .current_dir(&parent_work) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "parent bump commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let output = git_cmd() + .args(["push"]) + .current_dir(&parent_work) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "parent push failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Second sync (pull + submodule update) + sync_repo(&parent_url, "main", &clone_dir, GIT_TIMEOUT_DEFAULT, 1) + .await + .expect("second sync should succeed"); + + // Verify the new submodule content is present + let new_sub_file = clone_dir.join("lib").join("new-sub-file.txt"); + assert!( + new_sub_file.exists(), + "updated submodule file should exist after pull" + ); + let content = fs::read_to_string(&new_sub_file).await.unwrap(); + assert_eq!(content, "updated submodule"); + + cleanup(&temp).await; + } +} diff --git a/src/hook.rs b/src/hook.rs new file mode 100644 index 0000000..53e1e18 --- /dev/null +++ b/src/hook.rs @@ -0,0 +1,499 @@ +use crate::build::copy_with_tail; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::io::AsyncWriteExt as _; +use tokio::io::BufWriter; +use tokio::process::Command; +use tracing::debug; + +#[cfg(not(test))] +const HOOK_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(test)] +const HOOK_TIMEOUT: Duration = Duration::from_secs(2); + +/// Size of the in-memory tail buffer for stderr (last 256 bytes). +/// Used for error context in `HookResult` without reading the full file. +const STDERR_TAIL_SIZE: usize = 256; + +/// Result of a post-deploy hook execution. +/// +/// Stdout and stderr are streamed to temporary files on disk during execution. +/// Callers should pass these paths to `logs::save_hook_log()` for composition. +pub struct HookResult { + pub command: Vec, + pub stdout_file: PathBuf, + pub stderr_file: PathBuf, + pub last_stderr: String, + pub exit_code: Option, + pub duration: Duration, + pub success: bool, +} + +/// Execute a post-deploy hook command. +/// +/// Runs the command directly (no shell), with a minimal environment and a timeout. +/// Stdout and stderr are streamed to the provided temporary files. +/// Always returns a `HookResult` — never an `Err` — so callers can always log the outcome. +#[allow( + clippy::implicit_hasher, + clippy::large_futures, + clippy::too_many_arguments +)] +pub async fn run_post_deploy_hook( + command: &[String], + site_name: &str, + build_dir: &Path, + public_dir: &Path, + timestamp: &str, + env: &HashMap, + stdout_file: &Path, + stderr_file: &Path, +) -> HookResult { + let start = Instant::now(); + + let Some(executable) = command.first() else { + let _ = tokio::fs::File::create(stdout_file).await; + let _ = tokio::fs::File::create(stderr_file).await; + return HookResult { + command: command.to_vec(), + stdout_file: stdout_file.to_path_buf(), + stderr_file: stderr_file.to_path_buf(), + last_stderr: "empty command".to_owned(), + exit_code: None, + duration: start.elapsed(), + success: false, + }; + }; + let args = command.get(1..).unwrap_or_default(); + + let path_env = std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_owned()); + let home_env = std::env::var("HOME").unwrap_or_else(|_| "/nonexistent".to_owned()); + + let child = Command::new(executable) + .args(args) + .current_dir(build_dir) + .kill_on_drop(true) + .env_clear() + .envs(env) + .env("PATH", &path_env) + .env("HOME", &home_env) + .env("LANG", "C.UTF-8") + .env("WITRYNA_SITE", site_name) + .env("WITRYNA_BUILD_DIR", build_dir.as_os_str()) + .env("WITRYNA_PUBLIC_DIR", public_dir.as_os_str()) + .env("WITRYNA_BUILD_TIMESTAMP", timestamp) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + + let mut child = match child { + Ok(c) => c, + Err(e) => { + // Spawn failure — create empty temp files so log composition works + let _ = tokio::fs::File::create(stdout_file).await; + let _ = tokio::fs::File::create(stderr_file).await; + return HookResult { + command: command.to_vec(), + stdout_file: stdout_file.to_path_buf(), + stderr_file: stderr_file.to_path_buf(), + last_stderr: format!("failed to spawn hook: {e}"), + exit_code: None, + duration: start.elapsed(), + success: false, + }; + } + }; + + debug!(cmd = ?command, "hook process spawned"); + + let (last_stderr, exit_code, success) = + stream_hook_output(&mut child, stdout_file, stderr_file).await; + + HookResult { + command: command.to_vec(), + stdout_file: stdout_file.to_path_buf(), + stderr_file: stderr_file.to_path_buf(), + last_stderr, + exit_code, + duration: start.elapsed(), + success, + } +} + +/// Stream hook stdout/stderr to disk and wait for completion with timeout. +/// +/// Returns `(last_stderr, exit_code, success)`. On any setup or I/O failure, +/// returns an error description in `last_stderr` with `success = false`. +#[allow(clippy::large_futures)] +async fn stream_hook_output( + child: &mut tokio::process::Child, + stdout_file: &Path, + stderr_file: &Path, +) -> (String, Option, bool) { + let Some(stdout_pipe) = child.stdout.take() else { + let _ = tokio::fs::File::create(stdout_file).await; + let _ = tokio::fs::File::create(stderr_file).await; + return ("missing stdout pipe".to_owned(), None, false); + }; + let Some(stderr_pipe) = child.stderr.take() else { + let _ = tokio::fs::File::create(stdout_file).await; + let _ = tokio::fs::File::create(stderr_file).await; + return ("missing stderr pipe".to_owned(), None, false); + }; + + let stdout_writer = match tokio::fs::File::create(stdout_file).await { + Ok(f) => BufWriter::new(f), + Err(e) => { + let _ = tokio::fs::File::create(stderr_file).await; + return ( + format!("failed to create stdout temp file: {e}"), + None, + false, + ); + } + }; + let stderr_writer = match tokio::fs::File::create(stderr_file).await { + Ok(f) => BufWriter::new(f), + Err(e) => { + return ( + format!("failed to create stderr temp file: {e}"), + None, + false, + ); + } + }; + + let mut stdout_writer = stdout_writer; + let mut stderr_writer = stderr_writer; + + #[allow(clippy::large_futures)] + match tokio::time::timeout(HOOK_TIMEOUT, async { + let (stdout_res, stderr_res, wait_result) = tokio::join!( + copy_with_tail(stdout_pipe, &mut stdout_writer, 0), + copy_with_tail(stderr_pipe, &mut stderr_writer, STDERR_TAIL_SIZE), + child.wait(), + ); + (stdout_res, stderr_res, wait_result) + }) + .await + { + Ok((stdout_res, stderr_res, Ok(status))) => { + let _ = stdout_writer.flush().await; + let _ = stderr_writer.flush().await; + + let last_stderr = match stderr_res { + Ok((_, tail)) => String::from_utf8_lossy(&tail).into_owned(), + Err(_) => String::new(), + }; + // stdout_res error is non-fatal for hook result + let _ = stdout_res; + + (last_stderr, status.code(), status.success()) + } + Ok((_, _, Err(e))) => { + let _ = stdout_writer.flush().await; + let _ = stderr_writer.flush().await; + (format!("hook I/O error: {e}"), None, false) + } + Err(_) => { + // Timeout — kill the child + let _ = child.kill().await; + let _ = stdout_writer.flush().await; + let _ = stderr_writer.flush().await; + (String::new(), None, false) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::large_futures)] +mod tests { + use super::*; + use std::collections::HashMap; + use tempfile::TempDir; + use tokio::fs; + + fn cmd(args: &[&str]) -> Vec { + args.iter().map(std::string::ToString::to_string).collect() + } + + #[tokio::test] + async fn hook_success() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["echo", "hello"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + assert_eq!(result.exit_code, Some(0)); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + assert!(stdout.contains("hello")); + } + + #[tokio::test] + async fn hook_failure_exit_code() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["false"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(!result.success); + assert_eq!(result.exit_code, Some(1)); + } + + #[tokio::test] + async fn hook_timeout() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["sleep", "10"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(!result.success); + // Timeout path sets last_stderr to empty string — error context is in the log + assert!(result.last_stderr.is_empty()); + } + + #[tokio::test] + async fn hook_env_vars() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let env = HashMap::from([ + ("MY_VAR".to_owned(), "my_value".to_owned()), + ("DEPLOY_TARGET".to_owned(), "staging".to_owned()), + ]); + let public_dir = tmp.path().join("current"); + let result = run_post_deploy_hook( + &cmd(&["env"]), + "my-site", + tmp.path(), + &public_dir, + "20260202-120000-000000", + &env, + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + assert!(stdout.contains("WITRYNA_SITE=my-site")); + assert!(stdout.contains("WITRYNA_BUILD_TIMESTAMP=20260202-120000-000000")); + assert!(stdout.contains("WITRYNA_BUILD_DIR=")); + assert!(stdout.contains("WITRYNA_PUBLIC_DIR=")); + assert!(stdout.contains("PATH=")); + assert!(stdout.contains("HOME=")); + assert!(stdout.contains("LANG=C.UTF-8")); + assert!(stdout.contains("MY_VAR=my_value")); + assert!(stdout.contains("DEPLOY_TARGET=staging")); + + // Verify no unexpected env vars leak through + let lines: Vec<&str> = stdout.lines().collect(); + for line in &lines { + let key = line.split('=').next().unwrap_or(""); + assert!( + [ + "PATH", + "HOME", + "LANG", + "WITRYNA_SITE", + "WITRYNA_BUILD_DIR", + "WITRYNA_PUBLIC_DIR", + "WITRYNA_BUILD_TIMESTAMP", + "MY_VAR", + "DEPLOY_TARGET", + ] + .contains(&key), + "unexpected env var: {line}" + ); + } + } + + #[tokio::test] + async fn hook_nonexistent_command() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["/nonexistent-binary-xyz"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(!result.success); + assert!(result.last_stderr.contains("failed to spawn hook")); + } + + #[tokio::test] + async fn hook_large_output_streams_to_disk() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + // Generate output larger than old MAX_OUTPUT_BYTES (256 KB) — now unbounded to disk + let result = run_post_deploy_hook( + &cmd(&["sh", "-c", "yes | head -c 300000"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + // All 300000 bytes should be on disk (no truncation) + let stdout_len = fs::metadata(&stdout_tmp).await.unwrap().len(); + assert_eq!(stdout_len, 300_000); + } + + #[tokio::test] + async fn hook_current_dir() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["pwd"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + // Canonicalize to handle /tmp -> /private/tmp on macOS + let expected = std::fs::canonicalize(tmp.path()).unwrap(); + let actual = stdout.trim(); + let actual_canonical = std::fs::canonicalize(actual).unwrap_or_default(); + assert_eq!(actual_canonical, expected); + } + + #[tokio::test] + async fn hook_large_stdout_no_deadlock() { + // Writes 128 KB to stdout, exceeding the ~64 KB OS pipe buffer. + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["sh", "-c", "dd if=/dev/zero bs=1024 count=128 2>/dev/null"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stdout_len = fs::metadata(&stdout_tmp).await.unwrap().len(); + assert_eq!(stdout_len, 128 * 1024); + } + + #[tokio::test] + async fn hook_large_stderr_no_deadlock() { + // Writes 128 KB to stderr, covering the other pipe. + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&[ + "sh", + "-c", + "dd if=/dev/zero bs=1024 count=128 >&2 2>/dev/null", + ]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stderr_len = fs::metadata(&stderr_tmp).await.unwrap().len(); + assert_eq!(stderr_len, 128 * 1024); + } + + #[tokio::test] + async fn hook_user_env_does_not_override_reserved() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let env = HashMap::from([("PATH".to_owned(), "/should-not-appear".to_owned())]); + let result = run_post_deploy_hook( + &cmd(&["env"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &env, + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + // PATH should be the system value, not the user override + assert!(!stdout.contains("PATH=/should-not-appear")); + assert!(stdout.contains("PATH=")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a80b591 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,21 @@ +//! Internal library crate for witryna. +//! +//! This crate exposes modules for use by the binary and integration tests. +//! It is not intended for external consumption and has no stability guarantees. + +pub mod build; +pub mod build_guard; +pub mod cleanup; +pub mod cli; +pub mod config; +pub mod git; +pub mod hook; +pub mod logs; +pub mod pipeline; +pub mod polling; +pub mod publish; +pub mod repo_config; +pub mod server; + +#[cfg(any(test, feature = "integration"))] +pub mod test_support; diff --git a/src/logs.rs b/src/logs.rs new file mode 100644 index 0000000..bddcc9d --- /dev/null +++ b/src/logs.rs @@ -0,0 +1,919 @@ +use anyhow::{Context as _, Result}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::io::AsyncWriteExt as _; +use tokio::process::Command; +use tracing::{debug, warn}; + +use crate::hook::HookResult; + +/// Exit status of a build operation. +#[derive(Debug)] +pub enum BuildExitStatus { + Success, + Failed { + exit_code: Option, + error: String, + }, +} + +impl std::fmt::Display for BuildExitStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Success => write!(f, "success"), + Self::Failed { exit_code, error } => { + if let Some(code) = exit_code { + write!(f, "failed (exit code: {code}): {error}") + } else { + write!(f, "failed: {error}") + } + } + } + } +} + +/// Metadata about a build for logging purposes. +#[derive(Debug)] +pub struct BuildLogMeta { + pub site_name: String, + pub timestamp: String, + pub git_commit: Option, + pub container_image: String, + pub duration: Duration, + pub exit_status: BuildExitStatus, +} + +/// Save build log to disk via streaming composition. +/// +/// Writes the metadata header to the log file, then streams stdout and stderr +/// content from temporary files via `tokio::io::copy` (O(1) memory). +/// Deletes the temporary files after successful composition. +/// +/// Creates a log file at `{log_dir}/{site_name}/{timestamp}.log`. +/// +/// # Errors +/// +/// Returns an error if the log directory cannot be created, the log file +/// cannot be written, or the temp files cannot be read. +pub async fn save_build_log( + log_dir: &Path, + meta: &BuildLogMeta, + stdout_file: &Path, + stderr_file: &Path, +) -> Result { + let site_log_dir = log_dir.join(&meta.site_name); + let log_file = site_log_dir.join(format!("{}.log", meta.timestamp)); + + // Create logs directory if it doesn't exist + tokio::fs::create_dir_all(&site_log_dir) + .await + .with_context(|| { + format!( + "failed to create logs directory: {}", + site_log_dir.display() + ) + })?; + + // Write header + stream content from temp files + let mut log_writer = tokio::io::BufWriter::new( + tokio::fs::File::create(&log_file) + .await + .with_context(|| format!("failed to create log file: {}", log_file.display()))?, + ); + + let header = format_log_header(meta); + log_writer.write_all(header.as_bytes()).await?; + + // Append stdout section + log_writer.write_all(b"\n=== STDOUT ===\n").await?; + let mut stdout_reader = tokio::fs::File::open(stdout_file) + .await + .with_context(|| format!("failed to open {}", stdout_file.display()))?; + tokio::io::copy(&mut stdout_reader, &mut log_writer).await?; + + // Append stderr section + log_writer.write_all(b"\n\n=== STDERR ===\n").await?; + let mut stderr_reader = tokio::fs::File::open(stderr_file) + .await + .with_context(|| format!("failed to open {}", stderr_file.display()))?; + tokio::io::copy(&mut stderr_reader, &mut log_writer).await?; + log_writer.write_all(b"\n").await?; + + log_writer.flush().await?; + drop(log_writer); + + // Delete temp files (best-effort) + let _ = tokio::fs::remove_file(stdout_file).await; + let _ = tokio::fs::remove_file(stderr_file).await; + + debug!( + path = %log_file.display(), + site = %meta.site_name, + "build log saved" + ); + + Ok(log_file) +} + +/// Format a duration as a human-readable string (e.g., "45s" or "2m 30s"). +#[must_use] +pub fn format_duration(d: Duration) -> String { + let secs = d.as_secs(); + if secs >= 60 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{secs}s") + } +} + +/// Format the metadata header for a build log (without output sections). +fn format_log_header(meta: &BuildLogMeta) -> String { + let git_commit = meta.git_commit.as_deref().unwrap_or("unknown"); + let duration_str = format_duration(meta.duration); + + format!( + "=== BUILD LOG ===\n\ + Site: {}\n\ + Timestamp: {}\n\ + Git Commit: {}\n\ + Image: {}\n\ + Duration: {}\n\ + Status: {}", + meta.site_name, + meta.timestamp, + git_commit, + meta.container_image, + duration_str, + meta.exit_status, + ) +} + +/// Get the current git commit hash from a repository. +/// +/// Returns the short (7 character) commit hash, or None if the repository +/// is not a valid git repository or the command fails. +pub async fn get_git_commit(clone_dir: &Path) -> Option { + let mut cmd = Command::new("git"); + cmd.env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE"); + let output = cmd + .args(["rev-parse", "--short", "HEAD"]) + .current_dir(clone_dir) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + + let commit = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + + if commit.is_empty() { + None + } else { + Some(commit) + } +} + +/// Save hook log to disk via streaming composition. +/// +/// Writes the metadata header to the log file, then streams stdout and stderr +/// content from temporary files via `tokio::io::copy` (O(1) memory). +/// Deletes the temporary files after successful composition. +/// +/// Creates a log file at `{log_dir}/{site_name}/{timestamp}-hook.log`. +/// A log is written for every hook invocation regardless of outcome. +/// +/// # Errors +/// +/// Returns an error if the log directory cannot be created or the log file +/// cannot be written. +pub async fn save_hook_log( + log_dir: &Path, + site_name: &str, + timestamp: &str, + hook_result: &HookResult, +) -> Result { + let site_log_dir = log_dir.join(site_name); + let log_file = site_log_dir.join(format!("{timestamp}-hook.log")); + + tokio::fs::create_dir_all(&site_log_dir) + .await + .with_context(|| { + format!( + "failed to create logs directory: {}", + site_log_dir.display() + ) + })?; + + let mut log_writer = tokio::io::BufWriter::new( + tokio::fs::File::create(&log_file) + .await + .with_context(|| format!("failed to create hook log file: {}", log_file.display()))?, + ); + + let header = format_hook_log_header(site_name, timestamp, hook_result); + log_writer.write_all(header.as_bytes()).await?; + + // Append stdout section + log_writer.write_all(b"\n=== STDOUT ===\n").await?; + let mut stdout_reader = tokio::fs::File::open(&hook_result.stdout_file) + .await + .with_context(|| format!("failed to open {}", hook_result.stdout_file.display()))?; + tokio::io::copy(&mut stdout_reader, &mut log_writer).await?; + + // Append stderr section + log_writer.write_all(b"\n\n=== STDERR ===\n").await?; + let mut stderr_reader = tokio::fs::File::open(&hook_result.stderr_file) + .await + .with_context(|| format!("failed to open {}", hook_result.stderr_file.display()))?; + tokio::io::copy(&mut stderr_reader, &mut log_writer).await?; + log_writer.write_all(b"\n").await?; + + log_writer.flush().await?; + drop(log_writer); + + // Delete temp files (best-effort) + let _ = tokio::fs::remove_file(&hook_result.stdout_file).await; + let _ = tokio::fs::remove_file(&hook_result.stderr_file).await; + + debug!( + path = %log_file.display(), + site = %site_name, + "hook log saved" + ); + + Ok(log_file) +} + +/// Format the metadata header for a hook log (without output sections). +fn format_hook_log_header(site_name: &str, timestamp: &str, result: &HookResult) -> String { + let command_str = result.command.join(" "); + let duration_str = format_duration(result.duration); + + let status_str = if result.success { + "success".to_owned() + } else if let Some(code) = result.exit_code { + format!("failed (exit code {code})") + } else { + "failed (signal)".to_owned() + }; + + format!( + "=== HOOK LOG ===\n\ + Site: {site_name}\n\ + Timestamp: {timestamp}\n\ + Command: {command_str}\n\ + Duration: {duration_str}\n\ + Status: {status_str}" + ) +} + +/// Parsed header from a build log file. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ParsedLogHeader { + pub site_name: String, + pub timestamp: String, + pub git_commit: String, + pub image: String, + pub duration: String, + pub status: String, +} + +/// Combined deployment status (build + optional hook). +#[derive(Debug, Clone, serde::Serialize)] +pub struct DeploymentStatus { + pub site_name: String, + pub timestamp: String, + pub git_commit: String, + pub duration: String, + pub status: String, + pub log: String, +} + +/// Parse the header section of a build log file. +/// +/// Expects lines like: +/// ```text +/// === BUILD LOG === +/// Site: my-site +/// Timestamp: 20260126-143000-123456 +/// Git Commit: abc123d +/// Image: node:20-alpine +/// Duration: 45s +/// Status: success +/// ``` +/// +/// Returns `None` if the header is malformed. +#[must_use] +pub fn parse_log_header(content: &str) -> Option { + let mut site_name = None; + let mut timestamp = None; + let mut git_commit = None; + let mut image = None; + let mut duration = None; + let mut status = None; + + for line in content.lines().take(10) { + if let Some(val) = line.strip_prefix("Site: ") { + site_name = Some(val.to_owned()); + } else if let Some(val) = line.strip_prefix("Timestamp: ") { + timestamp = Some(val.to_owned()); + } else if let Some(val) = line.strip_prefix("Git Commit: ") { + git_commit = Some(val.to_owned()); + } else if let Some(val) = line.strip_prefix("Image: ") { + image = Some(val.to_owned()); + } else if let Some(val) = line.strip_prefix("Duration: ") { + duration = Some(val.to_owned()); + } else if let Some(val) = line.strip_prefix("Status: ") { + status = Some(val.to_owned()); + } + } + + Some(ParsedLogHeader { + site_name: site_name?, + timestamp: timestamp?, + git_commit: git_commit.unwrap_or_else(|| "unknown".to_owned()), + image: image.unwrap_or_else(|| "unknown".to_owned()), + duration: duration?, + status: status?, + }) +} + +/// Parse the status line from a hook log. +/// +/// Returns `Some(true)` for success, `Some(false)` for failure, +/// `None` if the content cannot be parsed. +#[must_use] +pub fn parse_hook_status(content: &str) -> Option { + for line in content.lines().take(10) { + if let Some(val) = line.strip_prefix("Status: ") { + return Some(val == "success"); + } + } + None +} + +/// List build log files for a site, sorted newest-first. +/// +/// Returns `(timestamp, path)` pairs. Excludes `*-hook.log` and `*.tmp` files. +/// +/// # Errors +/// +/// Returns an error if the directory cannot be read (except for not-found, +/// which returns an empty list). +pub async fn list_site_logs(log_dir: &Path, site_name: &str) -> Result> { + let site_log_dir = log_dir.join(site_name); + + if !site_log_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut entries = tokio::fs::read_dir(&site_log_dir) + .await + .with_context(|| format!("failed to read log directory: {}", site_log_dir.display()))?; + + let mut logs = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip hook logs and temp files + if name_str.ends_with("-hook.log") || name_str.ends_with(".tmp") { + continue; + } + + if let Some(timestamp) = name_str.strip_suffix(".log") { + logs.push((timestamp.to_owned(), entry.path())); + } + } + + // Sort descending (newest first) — timestamps are lexicographically sortable + logs.sort_by(|a, b| b.0.cmp(&a.0)); + + Ok(logs) +} + +/// Get the deployment status for a single build log. +/// +/// Reads the build log header and checks for an accompanying hook log +/// to determine overall deployment status. +/// +/// # Errors +/// +/// Returns an error if the build log cannot be read. +pub async fn get_deployment_status( + log_dir: &Path, + site_name: &str, + timestamp: &str, + log_path: &Path, +) -> Result { + let content = tokio::fs::read_to_string(log_path) + .await + .with_context(|| format!("failed to read build log: {}", log_path.display()))?; + + let header = parse_log_header(&content); + + let (git_commit, duration, build_status) = match &header { + Some(h) => (h.git_commit.clone(), h.duration.clone(), h.status.clone()), + None => { + warn!(path = %log_path.display(), "malformed build log header"); + ( + "unknown".to_owned(), + "-".to_owned(), + "(parse error)".to_owned(), + ) + } + }; + + // Check for accompanying hook log + let hook_log_path = log_dir + .join(site_name) + .join(format!("{timestamp}-hook.log")); + + let status = if hook_log_path.is_file() { + match tokio::fs::read_to_string(&hook_log_path).await { + Ok(hook_content) => match parse_hook_status(&hook_content) { + Some(true) => { + if build_status.starts_with("failed") { + build_status + } else { + "success".to_owned() + } + } + Some(false) => { + if build_status.starts_with("failed") { + build_status + } else { + "hook failed".to_owned() + } + } + None => build_status, + }, + Err(_) => build_status, + } + } else { + build_status + }; + + Ok(DeploymentStatus { + site_name: site_name.to_owned(), + timestamp: timestamp.to_owned(), + git_commit, + duration, + status, + log: log_path.to_string_lossy().to_string(), + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::test_support::{cleanup, temp_dir}; + use tokio::fs; + + /// Create a git Command isolated from parent git environment. + /// Prevents interference when tests run inside git hooks + /// (e.g., pre-commit hook running `cargo test`). + fn git_cmd() -> Command { + let mut cmd = Command::new("git"); + cmd.env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE"); + cmd + } + + #[tokio::test] + async fn save_build_log_creates_file_with_correct_content() { + let base_dir = temp_dir("logs-test").await; + let log_dir = base_dir.join("logs"); + + let meta = BuildLogMeta { + site_name: "test-site".to_owned(), + timestamp: "20260126-143000-123456".to_owned(), + git_commit: Some("abc123d".to_owned()), + container_image: "node:20-alpine".to_owned(), + duration: Duration::from_secs(45), + exit_status: BuildExitStatus::Success, + }; + + // Create temp files with content + let stdout_tmp = base_dir.join("stdout.tmp"); + let stderr_tmp = base_dir.join("stderr.tmp"); + fs::write(&stdout_tmp, "build output").await.unwrap(); + fs::write(&stderr_tmp, "warning message").await.unwrap(); + + let result = save_build_log(&log_dir, &meta, &stdout_tmp, &stderr_tmp).await; + + assert!(result.is_ok(), "save_build_log should succeed: {result:?}"); + let log_path = result.unwrap(); + + // Verify file exists at expected path + assert_eq!( + log_path, + log_dir.join("test-site/20260126-143000-123456.log") + ); + assert!(log_path.exists(), "log file should exist"); + + // Verify content + let content = fs::read_to_string(&log_path).await.unwrap(); + assert!(content.contains("=== BUILD LOG ===")); + assert!(content.contains("Site: test-site")); + assert!(content.contains("Timestamp: 20260126-143000-123456")); + assert!(content.contains("Git Commit: abc123d")); + assert!(content.contains("Image: node:20-alpine")); + assert!(content.contains("Duration: 45s")); + assert!(content.contains("Status: success")); + assert!(content.contains("=== STDOUT ===")); + assert!(content.contains("build output")); + assert!(content.contains("=== STDERR ===")); + assert!(content.contains("warning message")); + + // Verify temp files were deleted + assert!(!stdout_tmp.exists(), "stdout temp file should be deleted"); + assert!(!stderr_tmp.exists(), "stderr temp file should be deleted"); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn save_build_log_handles_empty_output() { + let base_dir = temp_dir("logs-test").await; + let log_dir = base_dir.join("logs"); + + let meta = BuildLogMeta { + site_name: "empty-site".to_owned(), + timestamp: "20260126-150000-000000".to_owned(), + git_commit: None, + container_image: "alpine:latest".to_owned(), + duration: Duration::from_secs(5), + exit_status: BuildExitStatus::Success, + }; + + let stdout_tmp = base_dir.join("stdout.tmp"); + let stderr_tmp = base_dir.join("stderr.tmp"); + fs::write(&stdout_tmp, "").await.unwrap(); + fs::write(&stderr_tmp, "").await.unwrap(); + + let result = save_build_log(&log_dir, &meta, &stdout_tmp, &stderr_tmp).await; + + assert!(result.is_ok(), "save_build_log should succeed: {result:?}"); + let log_path = result.unwrap(); + + let content = fs::read_to_string(&log_path).await.unwrap(); + assert!(content.contains("Git Commit: unknown")); + assert!(content.contains("=== STDOUT ===\n\n")); + assert!(content.contains("=== STDERR ===\n\n")); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn save_build_log_failed_status() { + let base_dir = temp_dir("logs-test").await; + let log_dir = base_dir.join("logs"); + + let meta = BuildLogMeta { + site_name: "failed-site".to_owned(), + timestamp: "20260126-160000-000000".to_owned(), + git_commit: Some("def456".to_owned()), + container_image: "node:18".to_owned(), + duration: Duration::from_secs(120), + exit_status: BuildExitStatus::Failed { + exit_code: Some(1), + error: "npm install failed".to_owned(), + }, + }; + + let stdout_tmp = base_dir.join("stdout.tmp"); + let stderr_tmp = base_dir.join("stderr.tmp"); + fs::write(&stdout_tmp, "").await.unwrap(); + fs::write(&stderr_tmp, "Error: ENOENT").await.unwrap(); + + let result = save_build_log(&log_dir, &meta, &stdout_tmp, &stderr_tmp).await; + + assert!(result.is_ok()); + let log_path = result.unwrap(); + + let content = fs::read_to_string(&log_path).await.unwrap(); + assert!(content.contains("Duration: 2m 0s")); + assert!(content.contains("Status: failed (exit code: 1): npm install failed")); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn save_build_log_deletes_temp_files() { + let base_dir = temp_dir("logs-test").await; + let log_dir = base_dir.join("logs"); + + let meta = BuildLogMeta { + site_name: "temp-test".to_owned(), + timestamp: "20260126-170000-000000".to_owned(), + git_commit: None, + container_image: "alpine:latest".to_owned(), + duration: Duration::from_secs(1), + exit_status: BuildExitStatus::Success, + }; + + let stdout_tmp = base_dir.join("stdout.tmp"); + let stderr_tmp = base_dir.join("stderr.tmp"); + fs::write(&stdout_tmp, "some output").await.unwrap(); + fs::write(&stderr_tmp, "some errors").await.unwrap(); + + assert!(stdout_tmp.exists()); + assert!(stderr_tmp.exists()); + + let result = save_build_log(&log_dir, &meta, &stdout_tmp, &stderr_tmp).await; + assert!(result.is_ok()); + + // Temp files must be gone + assert!(!stdout_tmp.exists(), "stdout temp file should be deleted"); + assert!(!stderr_tmp.exists(), "stderr temp file should be deleted"); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn get_git_commit_returns_short_hash() { + let temp = temp_dir("logs-test").await; + + // Initialize a git repo + git_cmd() + .args(["init"]) + .current_dir(&temp) + .output() + .await + .unwrap(); + + // Configure git user for commit + git_cmd() + .args(["config", "user.email", "test@test.com"]) + .current_dir(&temp) + .output() + .await + .unwrap(); + git_cmd() + .args(["config", "user.name", "Test"]) + .current_dir(&temp) + .output() + .await + .unwrap(); + + // Create a file and commit + fs::write(temp.join("file.txt"), "content").await.unwrap(); + git_cmd() + .args(["add", "."]) + .current_dir(&temp) + .output() + .await + .unwrap(); + git_cmd() + .args(["commit", "-m", "initial"]) + .current_dir(&temp) + .output() + .await + .unwrap(); + + let commit = get_git_commit(&temp).await; + + assert!(commit.is_some(), "should return commit hash"); + let hash = commit.unwrap(); + assert!(!hash.is_empty(), "hash should not be empty"); + assert!(hash.len() >= 7, "short hash should be at least 7 chars"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn get_git_commit_returns_none_for_non_repo() { + let temp = temp_dir("logs-test").await; + + // No git init - just an empty directory + let commit = get_git_commit(&temp).await; + + assert!(commit.is_none(), "should return None for non-git directory"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn save_hook_log_creates_file_with_correct_content() { + let base_dir = temp_dir("logs-test").await; + let log_dir = base_dir.join("logs"); + + let stdout_tmp = base_dir.join("hook-stdout.tmp"); + let stderr_tmp = base_dir.join("hook-stderr.tmp"); + fs::write(&stdout_tmp, "hook output").await.unwrap(); + fs::write(&stderr_tmp, "").await.unwrap(); + + let hook_result = HookResult { + command: vec!["touch".to_owned(), "marker".to_owned()], + stdout_file: stdout_tmp.clone(), + stderr_file: stderr_tmp.clone(), + last_stderr: String::new(), + exit_code: Some(0), + duration: Duration::from_secs(1), + success: true, + }; + + let result = save_hook_log( + &log_dir, + "test-site", + "20260202-120000-000000", + &hook_result, + ) + .await; + assert!(result.is_ok()); + let log_path = result.unwrap(); + + assert_eq!( + log_path, + log_dir.join("test-site/20260202-120000-000000-hook.log") + ); + assert!(log_path.exists()); + + let content = fs::read_to_string(&log_path).await.unwrap(); + assert!(content.contains("=== HOOK LOG ===")); + assert!(content.contains("Site: test-site")); + assert!(content.contains("Command: touch marker")); + assert!(content.contains("Status: success")); + assert!(content.contains("=== STDOUT ===")); + assert!(content.contains("hook output")); + + // Temp files should be deleted + assert!(!stdout_tmp.exists()); + assert!(!stderr_tmp.exists()); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn save_hook_log_failure_status() { + let base_dir = temp_dir("logs-test").await; + let log_dir = base_dir.join("logs"); + + let stdout_tmp = base_dir.join("hook-stdout.tmp"); + let stderr_tmp = base_dir.join("hook-stderr.tmp"); + fs::write(&stdout_tmp, "").await.unwrap(); + fs::write(&stderr_tmp, "error output").await.unwrap(); + + let hook_result = HookResult { + command: vec!["false".to_owned()], + stdout_file: stdout_tmp, + stderr_file: stderr_tmp, + last_stderr: "error output".to_owned(), + exit_code: Some(1), + duration: Duration::from_secs(0), + success: false, + }; + + let result = save_hook_log( + &log_dir, + "test-site", + "20260202-120000-000000", + &hook_result, + ) + .await; + assert!(result.is_ok()); + let log_path = result.unwrap(); + + let content = fs::read_to_string(&log_path).await.unwrap(); + assert!(content.contains("Status: failed (exit code 1)")); + assert!(content.contains("error output")); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn save_hook_log_signal_status() { + let base_dir = temp_dir("logs-test").await; + let log_dir = base_dir.join("logs"); + + let stdout_tmp = base_dir.join("hook-stdout.tmp"); + let stderr_tmp = base_dir.join("hook-stderr.tmp"); + fs::write(&stdout_tmp, "").await.unwrap(); + fs::write(&stderr_tmp, "post-deploy hook timed out after 30s") + .await + .unwrap(); + + let hook_result = HookResult { + command: vec!["sleep".to_owned(), "100".to_owned()], + stdout_file: stdout_tmp, + stderr_file: stderr_tmp, + last_stderr: String::new(), + exit_code: None, + duration: Duration::from_secs(30), + success: false, + }; + + let result = save_hook_log( + &log_dir, + "test-site", + "20260202-120000-000000", + &hook_result, + ) + .await; + assert!(result.is_ok()); + let log_path = result.unwrap(); + + let content = fs::read_to_string(&log_path).await.unwrap(); + assert!(content.contains("Status: failed (signal)")); + assert!(content.contains("timed out")); + + cleanup(&base_dir).await; + } + + // --- parse_log_header tests --- + + #[test] + fn parse_log_header_success() { + let content = "\ +=== BUILD LOG === +Site: my-site +Timestamp: 20260126-143000-123456 +Git Commit: abc123d +Image: node:20-alpine +Duration: 45s +Status: success + +=== STDOUT === +build output +"; + let header = parse_log_header(content).unwrap(); + assert_eq!(header.site_name, "my-site"); + assert_eq!(header.timestamp, "20260126-143000-123456"); + assert_eq!(header.git_commit, "abc123d"); + assert_eq!(header.image, "node:20-alpine"); + assert_eq!(header.duration, "45s"); + assert_eq!(header.status, "success"); + } + + #[test] + fn parse_log_header_failed_build() { + let content = "\ +=== BUILD LOG === +Site: fail-site +Timestamp: 20260126-160000-000000 +Git Commit: def456 +Image: node:18 +Duration: 2m 0s +Status: failed (exit code: 42): build error +"; + let header = parse_log_header(content).unwrap(); + assert_eq!(header.status, "failed (exit code: 42): build error"); + assert_eq!(header.duration, "2m 0s"); + } + + #[test] + fn parse_log_header_unknown_commit() { + let content = "\ +=== BUILD LOG === +Site: test-site +Timestamp: 20260126-150000-000000 +Git Commit: unknown +Image: alpine:latest +Duration: 5s +Status: success +"; + let header = parse_log_header(content).unwrap(); + assert_eq!(header.git_commit, "unknown"); + } + + #[test] + fn parse_log_header_malformed() { + let content = "This is not a valid log file\nSome random text\n"; + let header = parse_log_header(content); + assert!(header.is_none()); + } + + #[test] + fn parse_hook_status_success() { + let content = "\ +=== HOOK LOG === +Site: test-site +Timestamp: 20260202-120000-000000 +Command: touch marker +Duration: 1s +Status: success +"; + assert_eq!(parse_hook_status(content), Some(true)); + } + + #[test] + fn parse_hook_status_failed() { + let content = "\ +=== HOOK LOG === +Site: test-site +Timestamp: 20260202-120000-000000 +Command: false +Duration: 0s +Status: failed (exit code 1) +"; + assert_eq!(parse_hook_status(content), Some(false)); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b153297 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,422 @@ +use anyhow::{Context as _, Result, bail}; +use clap::Parser as _; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; +use witryna::cli::{Cli, Command}; +use witryna::config; +use witryna::logs::{self, DeploymentStatus}; +use witryna::{pipeline, server}; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let config_path = config::discover_config(cli.config.as_deref())?; + + match cli.command { + Command::Serve => run_serve(config_path).await, + Command::Validate => run_validate(config_path).await, + Command::Run { site, verbose } => run_run(config_path, site, verbose).await, + Command::Status { site, json } => run_status(config_path, site, json).await, + } +} + +async fn run_serve(config_path: std::path::PathBuf) -> Result<()> { + let config = config::Config::load(&config_path).await?; + + // Initialize tracing with configured log level + // RUST_LOG env var takes precedence if set + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(config.log_level_filter().to_string())); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + info!( + listen_address = %config.listen_address, + container_runtime = %config.container_runtime, + base_dir = %config.base_dir.display(), + log_dir = %config.log_dir.display(), + log_level = %config.log_level, + sites_count = config.sites.len(), + "loaded configuration" + ); + + for site in &config.sites { + if site.webhook_token.is_empty() { + warn!( + name = %site.name, + "webhook authentication disabled (no token configured)" + ); + } + if let Some(interval) = site.poll_interval { + info!( + name = %site.name, + repo_url = %site.repo_url, + branch = %site.branch, + poll_interval_secs = interval.as_secs(), + "configured site with polling" + ); + } else { + info!( + name = %site.name, + repo_url = %site.repo_url, + branch = %site.branch, + "configured site (webhook-only)" + ); + } + } + + server::run(config, config_path).await +} + +#[allow(clippy::print_stderr)] // CLI validation output goes to stderr +async fn run_validate(config_path: std::path::PathBuf) -> Result<()> { + let config = config::Config::load(&config_path).await?; + eprintln!("{}", format_validate_summary(&config, &config_path)); + Ok(()) +} + +#[allow(clippy::print_stderr)] // CLI output goes to stderr +async fn run_run(config_path: std::path::PathBuf, site_name: String, verbose: bool) -> Result<()> { + let config = config::Config::load(&config_path).await?; + + let site = config + .find_site(&site_name) + .with_context(|| { + format!( + "site '{}' not found in {}", + site_name, + config_path.display() + ) + })? + .clone(); + + // Initialize tracing: compact stderr, DEBUG when verbose + let level = if verbose { "debug" } else { "info" }; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .init(); + + eprintln!( + "Building site: {} (repo: {}, branch: {})", + site_name, site.repo_url, site.branch + ); + + let git_timeout = config + .git_timeout + .unwrap_or(witryna::git::GIT_TIMEOUT_DEFAULT); + + let result = pipeline::run_build( + &site_name, + &site, + &config.base_dir, + &config.log_dir, + &config.container_runtime, + config.max_builds_to_keep, + git_timeout, + verbose, + ) + .await?; + + eprintln!( + "Build succeeded in {} — {}", + logs::format_duration(result.duration), + result.build_dir.display(), + ); + eprintln!("Log: {}", result.log_file.display()); + + Ok(()) +} + +#[allow(clippy::print_stdout)] // CLI status output goes to stdout (pipeable) +async fn run_status( + config_path: std::path::PathBuf, + site_filter: Option, + json: bool, +) -> Result<()> { + let config = config::Config::load(&config_path).await?; + + if let Some(name) = &site_filter + && config.find_site(name).is_none() + { + bail!("site '{}' not found in {}", name, config_path.display()); + } + + let mut statuses: Vec = Vec::new(); + + match &site_filter { + Some(name) => { + // Show last 10 deployments for a single site + let site_logs = logs::list_site_logs(&config.log_dir, name).await?; + for (ts, path) in site_logs.into_iter().take(10) { + let ds = logs::get_deployment_status(&config.log_dir, name, &ts, &path).await?; + statuses.push(ds); + } + } + None => { + // Show latest deployment for each site + for site in &config.sites { + let site_logs = logs::list_site_logs(&config.log_dir, &site.name).await?; + if let Some((ts, path)) = site_logs.into_iter().next() { + let ds = logs::get_deployment_status(&config.log_dir, &site.name, &ts, &path) + .await?; + statuses.push(ds); + } else { + statuses.push(DeploymentStatus { + site_name: site.name.clone(), + timestamp: "-".to_owned(), + git_commit: "-".to_owned(), + duration: "-".to_owned(), + status: "-".to_owned(), + log: "(no builds)".to_owned(), + }); + } + } + } + } + + if json { + #[allow(clippy::expect_used)] // DeploymentStatus serialization cannot fail + let output = serde_json::to_string_pretty(&statuses) + .expect("DeploymentStatus serialization cannot fail"); + println!("{output}"); + } else { + print!("{}", format_status_table(&statuses)); + } + + Ok(()) +} + +fn format_status_table(statuses: &[DeploymentStatus]) -> String { + use std::fmt::Write as _; + + let site_width = statuses + .iter() + .map(|s| s.site_name.len()) + .max() + .unwrap_or(4) + .max(4); + + let mut out = String::new(); + let _ = writeln!( + out, + "{: String { + use std::fmt::Write as _; + let mut out = String::new(); + let _ = writeln!(out, "Configuration valid: {}", path.display()); + let _ = writeln!(out, " Listen: {}", config.listen_address); + let _ = writeln!(out, " Runtime: {}", config.container_runtime); + let _ = write!(out, " Sites: {}", config.sites.len()); + for site in &config.sites { + let _ = write!( + out, + "\n - {} ({}, branch: {})", + site.name, site.repo_url, site.branch + ); + } + out +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use std::path::PathBuf; + use witryna::config::{BuildOverrides, Config, SiteConfig}; + use witryna::logs::DeploymentStatus; + + fn test_config(sites: Vec) -> Config { + Config { + listen_address: "127.0.0.1:8080".to_owned(), + container_runtime: "podman".to_owned(), + base_dir: PathBuf::from("/var/lib/witryna"), + log_dir: PathBuf::from("/var/log/witryna"), + log_level: "info".to_owned(), + rate_limit_per_minute: 10, + max_builds_to_keep: 5, + git_timeout: None, + sites, + } + } + + fn test_site(name: &str, repo_url: &str, branch: &str) -> SiteConfig { + SiteConfig { + name: name.to_owned(), + repo_url: repo_url.to_owned(), + branch: branch.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: None, + container_memory: None, + container_cpus: None, + container_pids_limit: None, + container_network: "none".to_owned(), + git_depth: None, + container_workdir: None, + config_file: None, + } + } + + #[test] + fn validate_summary_single_site() { + let config = test_config(vec![test_site( + "my-site", + "https://github.com/user/my-site.git", + "main", + )]); + let output = format_validate_summary(&config, &PathBuf::from("witryna.toml")); + assert!(output.contains("Configuration valid: witryna.toml")); + assert!(output.contains("Listen: 127.0.0.1:8080")); + assert!(output.contains("Runtime: podman")); + assert!(output.contains("Sites: 1")); + assert!(output.contains("my-site (https://github.com/user/my-site.git, branch: main)")); + } + + #[test] + fn validate_summary_multiple_sites() { + let config = test_config(vec![ + test_site("site-one", "https://github.com/user/site-one.git", "main"), + test_site( + "site-two", + "https://github.com/user/site-two.git", + "develop", + ), + ]); + let output = format_validate_summary(&config, &PathBuf::from("/etc/witryna.toml")); + assert!(output.contains("Sites: 2")); + assert!(output.contains("site-one (https://github.com/user/site-one.git, branch: main)")); + assert!( + output.contains("site-two (https://github.com/user/site-two.git, branch: develop)") + ); + } + + #[test] + fn validate_summary_no_sites() { + let config = test_config(vec![]); + let output = format_validate_summary(&config, &PathBuf::from("witryna.toml")); + assert!(output.contains("Sites: 0")); + assert!(!output.contains(" -")); + } + + #[test] + fn validate_summary_runtime_shows_value() { + let config = test_config(vec![]); + let output = format_validate_summary(&config, &PathBuf::from("witryna.toml")); + assert!(output.contains("Runtime: podman")); + } + + // --- format_status_table tests --- + + fn test_deployment( + site_name: &str, + status: &str, + commit: &str, + duration: &str, + timestamp: &str, + log: &str, + ) -> DeploymentStatus { + DeploymentStatus { + site_name: site_name.to_owned(), + timestamp: timestamp.to_owned(), + git_commit: commit.to_owned(), + duration: duration.to_owned(), + status: status.to_owned(), + log: log.to_owned(), + } + } + + #[test] + fn format_status_table_single_site_success() { + let statuses = vec![test_deployment( + "my-site", + "success", + "abc123d", + "45s", + "20260126-143000-123456", + "/var/log/witryna/my-site/20260126-143000-123456.log", + )]; + let output = format_status_table(&statuses); + assert!(output.contains("SITE")); + assert!(output.contains("STATUS")); + assert!(output.contains("my-site")); + assert!(output.contains("success")); + assert!(output.contains("abc123d")); + assert!(output.contains("45s")); + } + + #[test] + fn format_status_table_no_builds() { + let statuses = vec![test_deployment( + "empty-site", + "-", + "-", + "-", + "-", + "(no builds)", + )]; + let output = format_status_table(&statuses); + assert!(output.contains("empty-site")); + assert!(output.contains("(no builds)")); + } + + #[test] + fn format_status_table_multiple_sites() { + let statuses = vec![ + test_deployment( + "site-one", + "success", + "abc123d", + "45s", + "20260126-143000-123456", + "/logs/site-one/20260126-143000-123456.log", + ), + test_deployment( + "site-two", + "failed", + "def456", + "2m 0s", + "20260126-160000-000000", + "/logs/site-two/20260126-160000-000000.log", + ), + ]; + let output = format_status_table(&statuses); + assert!(output.contains("site-one")); + assert!(output.contains("site-two")); + assert!(output.contains("success")); + assert!(output.contains("failed")); + } + + #[test] + fn format_status_table_hook_failed() { + let statuses = vec![test_deployment( + "hook-site", + "hook failed", + "abc123d", + "12s", + "20260126-143000-123456", + "/logs/hook-site/20260126-143000-123456.log", + )]; + let output = format_status_table(&statuses); + assert!(output.contains("hook failed")); + } +} diff --git a/src/pipeline.rs b/src/pipeline.rs new file mode 100644 index 0000000..5827ad7 --- /dev/null +++ b/src/pipeline.rs @@ -0,0 +1,328 @@ +use crate::config::SiteConfig; +use crate::logs::{BuildExitStatus, BuildLogMeta}; +use crate::{build, cleanup, git, hook, logs, publish, repo_config}; +use anyhow::Result; +use chrono::Utc; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tracing::{error, info, warn}; + +/// Result of a successful pipeline run. +pub struct PipelineResult { + pub build_dir: PathBuf, + pub log_file: PathBuf, + pub timestamp: String, + pub duration: Duration, +} + +/// Run the complete build pipeline: git sync → build → publish. +/// +/// This is the core pipeline logic shared by both the HTTP server and the CLI +/// `run` command. The server wraps this with `BuildGuard` for concurrency +/// control; the CLI calls it directly. +/// +/// # Errors +/// +/// Returns an error on git sync failure, config load failure, build failure, +/// or publish failure. Post-deploy hook and cleanup failures are non-fatal +/// (logged as warnings). +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub async fn run_build( + site_name: &str, + site: &SiteConfig, + base_dir: &Path, + log_dir: &Path, + container_runtime: &str, + max_builds_to_keep: u32, + git_timeout: Duration, + verbose: bool, +) -> Result { + let timestamp = Utc::now().format("%Y%m%d-%H%M%S-%f").to_string(); + let start_time = Instant::now(); + + let clone_dir = base_dir.join("clones").join(site_name); + + // 1. Sync git repository + info!(%site_name, "syncing repository"); + if let Err(e) = git::sync_repo( + &site.repo_url, + &site.branch, + &clone_dir, + git_timeout, + site.git_depth.unwrap_or(git::GIT_DEPTH_DEFAULT), + ) + .await + { + error!(%site_name, error = %e, "git sync failed"); + save_build_log_for_error( + log_dir, + site_name, + ×tamp, + start_time, + None, + "git-sync", + &e.to_string(), + ) + .await; + return Err(e.context("git sync failed")); + } + + // Get git commit hash for logging + let git_commit = logs::get_git_commit(&clone_dir).await; + + // 2. Load repo config (witryna.yaml) with overrides from witryna.toml + let repo_config = match repo_config::RepoConfig::load_with_overrides( + &clone_dir, + &site.build_overrides, + site.config_file.as_deref(), + ) + .await + { + Ok(config) => config, + Err(e) => { + error!(%site_name, error = %e, "failed to load repo config"); + save_build_log_for_error( + log_dir, + site_name, + ×tamp, + start_time, + git_commit, + "config-load", + &e.to_string(), + ) + .await; + return Err(e.context("failed to load repo config")); + } + }; + + // 3. Prepare cache volumes + let cache_volumes = match &site.cache_dirs { + Some(dirs) if !dirs.is_empty() => { + let mut volumes = Vec::with_capacity(dirs.len()); + for dir in dirs { + let sanitized = crate::config::sanitize_cache_dir_name(dir); + let host_path = base_dir.join("cache").join(site_name).join(&sanitized); + if let Err(e) = tokio::fs::create_dir_all(&host_path).await { + error!(%site_name, path = %host_path.display(), error = %e, "failed to create cache directory"); + anyhow::bail!("failed to create cache directory: {e}"); + } + volumes.push((dir.clone(), host_path)); + } + let mount_list: Vec<_> = volumes + .iter() + .map(|(c, h)| format!("{}:{}", h.display(), c)) + .collect(); + info!(%site_name, mounts = ?mount_list, "mounting cache volumes"); + volumes + } + _ => Vec::new(), + }; + + // 4. Execute build — stream output to temp files + let site_log_dir = log_dir.join(site_name); + if let Err(e) = tokio::fs::create_dir_all(&site_log_dir).await { + error!(%site_name, error = %e, "failed to create log directory"); + anyhow::bail!("failed to create log directory: {e}"); + } + let stdout_tmp = site_log_dir.join(format!("{timestamp}-stdout.tmp")); + let stderr_tmp = site_log_dir.join(format!("{timestamp}-stderr.tmp")); + + let env = site.env.clone().unwrap_or_default(); + let timeout = site.build_timeout.unwrap_or(build::BUILD_TIMEOUT_DEFAULT); + let options = build::ContainerOptions { + memory: site.container_memory.clone(), + cpus: site.container_cpus, + pids_limit: site.container_pids_limit, + network: site.container_network.clone(), + workdir: site.container_workdir.clone(), + }; + info!(%site_name, image = %repo_config.image, "running container build"); + let build_result = build::execute( + container_runtime, + &clone_dir, + &repo_config, + &cache_volumes, + &env, + &options, + &stdout_tmp, + &stderr_tmp, + timeout, + verbose, + ) + .await; + + // Determine exit status and extract temp file paths + let (exit_status, build_stdout_file, build_stderr_file, build_duration) = match &build_result { + Ok(result) => ( + BuildExitStatus::Success, + result.stdout_file.clone(), + result.stderr_file.clone(), + result.duration, + ), + Err(e) => e.downcast_ref::().map_or_else( + || { + ( + BuildExitStatus::Failed { + exit_code: None, + error: e.to_string(), + }, + stdout_tmp.clone(), + stderr_tmp.clone(), + start_time.elapsed(), + ) + }, + |failure| { + ( + BuildExitStatus::Failed { + exit_code: Some(failure.exit_code), + error: failure.to_string(), + }, + failure.stdout_file.clone(), + failure.stderr_file.clone(), + failure.duration, + ) + }, + ), + }; + + // Ensure temp files exist for save_build_log (spawn errors may not create them) + if !build_stdout_file.exists() { + let _ = tokio::fs::File::create(&build_stdout_file).await; + } + if !build_stderr_file.exists() { + let _ = tokio::fs::File::create(&build_stderr_file).await; + } + + // Save build log (always, success or failure) — streams from temp files + let meta = BuildLogMeta { + site_name: site_name.to_owned(), + timestamp: timestamp.clone(), + git_commit: git_commit.clone(), + container_image: repo_config.image.clone(), + duration: build_duration, + exit_status, + }; + + let log_file = + match logs::save_build_log(log_dir, &meta, &build_stdout_file, &build_stderr_file).await { + Ok(path) => path, + Err(e) => { + error!(%site_name, error = %e, "failed to save build log"); + let _ = tokio::fs::remove_file(&build_stdout_file).await; + let _ = tokio::fs::remove_file(&build_stderr_file).await; + // Non-fatal for log save — continue if build succeeded + log_dir.join(site_name).join(format!("{timestamp}.log")) + } + }; + + // If build failed, return error + if let Err(e) = build_result { + error!(%site_name, "build failed"); + return Err(e); + } + + // 5. Publish assets (with same timestamp as log) + info!(%site_name, public = %repo_config.public, "publishing assets"); + let publish_result = publish::publish( + base_dir, + site_name, + &clone_dir, + &repo_config.public, + ×tamp, + ) + .await?; + + info!( + %site_name, + build_dir = %publish_result.build_dir.display(), + timestamp = %publish_result.timestamp, + "deployment completed successfully" + ); + + // 6. Run post-deploy hook (non-fatal) + if let Some(hook_cmd) = &site.post_deploy { + info!(%site_name, "running post-deploy hook"); + let hook_stdout_tmp = site_log_dir.join(format!("{timestamp}-hook-stdout.tmp")); + let hook_stderr_tmp = site_log_dir.join(format!("{timestamp}-hook-stderr.tmp")); + let public_dir = base_dir.join("builds").join(site_name).join("current"); + + let hook_result = hook::run_post_deploy_hook( + hook_cmd, + site_name, + &publish_result.build_dir, + &public_dir, + ×tamp, + &env, + &hook_stdout_tmp, + &hook_stderr_tmp, + ) + .await; + + if let Err(e) = logs::save_hook_log(log_dir, site_name, ×tamp, &hook_result).await { + error!(%site_name, error = %e, "failed to save hook log"); + let _ = tokio::fs::remove_file(&hook_stdout_tmp).await; + let _ = tokio::fs::remove_file(&hook_stderr_tmp).await; + } + + if hook_result.success { + info!(%site_name, "post-deploy hook completed"); + } else { + warn!( + %site_name, + exit_code = ?hook_result.exit_code, + "post-deploy hook failed (non-fatal)" + ); + } + } + + // 7. Cleanup old builds (non-fatal if it fails) + if let Err(e) = + cleanup::cleanup_old_builds(base_dir, log_dir, site_name, max_builds_to_keep).await + { + warn!(%site_name, error = %e, "cleanup failed (non-fatal)"); + } + + let duration = start_time.elapsed(); + Ok(PipelineResult { + build_dir: publish_result.build_dir, + log_file, + timestamp, + duration, + }) +} + +/// Save a build log for errors that occur before the build starts. +async fn save_build_log_for_error( + log_dir: &Path, + site_name: &str, + timestamp: &str, + start_time: Instant, + git_commit: Option, + phase: &str, + error_msg: &str, +) { + let meta = BuildLogMeta { + site_name: site_name.to_owned(), + timestamp: timestamp.to_owned(), + git_commit, + container_image: format!("(failed at {phase})"), + duration: start_time.elapsed(), + exit_status: BuildExitStatus::Failed { + exit_code: None, + error: error_msg.to_owned(), + }, + }; + + let site_log_dir = log_dir.join(site_name); + let _ = tokio::fs::create_dir_all(&site_log_dir).await; + let stdout_tmp = site_log_dir.join(format!("{timestamp}-stdout.tmp")); + let stderr_tmp = site_log_dir.join(format!("{timestamp}-stderr.tmp")); + let _ = tokio::fs::File::create(&stdout_tmp).await; + let _ = tokio::fs::File::create(&stderr_tmp).await; + + if let Err(e) = logs::save_build_log(log_dir, &meta, &stdout_tmp, &stderr_tmp).await { + error!(site_name, error = %e, "failed to save build log"); + let _ = tokio::fs::remove_file(&stdout_tmp).await; + let _ = tokio::fs::remove_file(&stderr_tmp).await; + } +} diff --git a/src/polling.rs b/src/polling.rs new file mode 100644 index 0000000..6c25326 --- /dev/null +++ b/src/polling.rs @@ -0,0 +1,242 @@ +//! Polling manager for periodic repository change detection. +//! +//! Spawns background tasks for sites with `poll_interval` configured. +//! Integrates with SIGHUP reload to restart polling tasks on config change. + +use crate::build_guard::BuildGuard; +use crate::config::SiteConfig; +use crate::git; +use crate::server::AppState; +use std::collections::HashMap; +use std::hash::{Hash as _, Hasher as _}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info}; + +/// Manages polling tasks for all sites. +pub struct PollingManager { + /// Map of `site_name` -> cancellation token for active polling tasks + tasks: Arc>>, +} + +impl PollingManager { + #[must_use] + pub fn new() -> Self { + Self { + tasks: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Start polling tasks for sites with `poll_interval` configured. + /// Call this on startup and after SIGHUP reload. + pub async fn start_polling(&self, state: AppState) { + let config = state.config.read().await; + + for site in &config.sites { + if let Some(interval) = site.poll_interval { + self.spawn_poll_task(state.clone(), site.clone(), interval) + .await; + } + } + } + + /// Stop all currently running polling tasks. + /// Call this before starting new tasks on SIGHUP. + pub async fn stop_all(&self) { + let mut tasks = self.tasks.write().await; + + for (site_name, token) in tasks.drain() { + info!(site = %site_name, "stopping polling task"); + token.cancel(); + } + } + + /// Spawn a single polling task for a site. + async fn spawn_poll_task(&self, state: AppState, site: SiteConfig, interval: Duration) { + let site_name = site.name.clone(); + let token = CancellationToken::new(); + + // Store the cancellation token + { + let mut tasks = self.tasks.write().await; + tasks.insert(site_name.clone(), token.clone()); + } + + info!( + site = %site_name, + interval_secs = interval.as_secs(), + "starting polling task" + ); + + // Spawn the polling loop + let tasks = Arc::clone(&self.tasks); + tokio::spawn(async move { + #[allow(clippy::large_futures)] + poll_loop(state, site, interval, token.clone()).await; + + // Remove from active tasks when done + tasks.write().await.remove(&site_name); + debug!(site = %site_name, "polling task ended"); + }); + } +} + +impl Default for PollingManager { + fn default() -> Self { + Self::new() + } +} + +/// The main polling loop for a single site. +async fn poll_loop( + state: AppState, + site: SiteConfig, + interval: Duration, + cancel_token: CancellationToken, +) { + let site_name = &site.name; + + // Initial delay before first poll (avoid thundering herd on startup) + let initial_delay = calculate_initial_delay(site_name, interval); + debug!(site = %site_name, delay_secs = initial_delay.as_secs(), "initial poll delay"); + + tokio::select! { + () = tokio::time::sleep(initial_delay) => {} + () = cancel_token.cancelled() => return, + } + + loop { + debug!(site = %site_name, "polling for changes"); + + // 1. Acquire build lock before any git operation + let Some(guard) = BuildGuard::try_acquire(site_name.clone(), &state.build_scheduler) else { + debug!(site = %site_name, "build in progress, skipping poll cycle"); + tokio::select! { + () = tokio::time::sleep(interval) => {} + () = cancel_token.cancelled() => { + info!(site = %site_name, "polling cancelled"); + return; + } + } + continue; + }; + + // Get current config (might have changed via SIGHUP) + let (base_dir, git_timeout) = { + let config = state.config.read().await; + ( + config.base_dir.clone(), + config.git_timeout.unwrap_or(git::GIT_TIMEOUT_DEFAULT), + ) + }; + let clone_dir = base_dir.join("clones").join(site_name); + + // 2. Check for changes (guard held — no concurrent git ops possible) + let has_changes = match git::has_remote_changes( + &clone_dir, + &site.branch, + git_timeout, + site.git_depth.unwrap_or(git::GIT_DEPTH_DEFAULT), + ) + .await + { + Ok(changed) => changed, + Err(e) => { + error!(site = %site_name, error = %e, "failed to check for changes"); + false + } + }; + + if has_changes { + // 3a. Keep guard alive — move into build pipeline + info!(site = %site_name, "new commits detected, triggering build"); + #[allow(clippy::large_futures)] + crate::server::run_build_pipeline( + state.clone(), + site_name.clone(), + site.clone(), + guard, + ) + .await; + } else { + // 3b. Explicit drop BEFORE sleep — release lock immediately + drop(guard); + } + + // 4. Sleep (lock is NOT held here in either branch) + tokio::select! { + () = tokio::time::sleep(interval) => {} + () = cancel_token.cancelled() => { + info!(site = %site_name, "polling cancelled"); + return; + } + } + } +} + +/// Calculate staggered initial delay to avoid all sites polling at once. +/// Uses a simple hash of the site name to distribute start times. +fn calculate_initial_delay(site_name: &str, interval: Duration) -> Duration { + use std::collections::hash_map::DefaultHasher; + + let mut hasher = DefaultHasher::new(); + site_name.hash(&mut hasher); + let hash = hasher.finish(); + + // Spread across 0 to interval/2 + let max_delay_secs = interval.as_secs() / 2; + let delay_secs = if max_delay_secs > 0 { + hash % max_delay_secs + } else { + 0 + }; + + Duration::from_secs(delay_secs) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn initial_delay_zero_interval() { + // interval=0 → max_delay_secs=0 → delay=0 + let delay = calculate_initial_delay("site", Duration::from_secs(0)); + assert_eq!(delay, Duration::from_secs(0)); + } + + #[test] + fn initial_delay_one_second_interval() { + // interval=1s → max_delay_secs=0 → delay=0 + let delay = calculate_initial_delay("site", Duration::from_secs(1)); + assert_eq!(delay, Duration::from_secs(0)); + } + + #[test] + fn initial_delay_within_half_interval() { + let interval = Duration::from_secs(600); // 10 min + let delay = calculate_initial_delay("my-site", interval); + // Must be < interval/2 (300s) + assert!(delay < Duration::from_secs(300)); + } + + #[test] + fn initial_delay_deterministic() { + let interval = Duration::from_secs(600); + let d1 = calculate_initial_delay("my-site", interval); + let d2 = calculate_initial_delay("my-site", interval); + assert_eq!(d1, d2); + } + + #[test] + fn initial_delay_different_sites_differ() { + let interval = Duration::from_secs(3600); + let d1 = calculate_initial_delay("site-alpha", interval); + let d2 = calculate_initial_delay("site-beta", interval); + // Different names should (almost certainly) produce different delays + assert_ne!(d1, d2); + } +} diff --git a/src/publish.rs b/src/publish.rs new file mode 100644 index 0000000..338a136 --- /dev/null +++ b/src/publish.rs @@ -0,0 +1,488 @@ +use anyhow::{Context as _, Result, bail}; +use std::path::{Path, PathBuf}; +use tracing::{debug, info}; + +/// Result of a successful publish operation. +#[derive(Debug)] +pub struct PublishResult { + /// Path to the timestamped build directory containing the published assets. + pub build_dir: PathBuf, + /// Timestamp used for the build directory name. + pub timestamp: String, +} + +/// Publish built assets with atomic symlink switching. +/// +/// # Arguments +/// * `base_dir` - Base witryna directory (e.g., /var/lib/witryna) +/// * `site_name` - The site name (already validated) +/// * `clone_dir` - Path to the cloned repository +/// * `public` - Relative path to built assets within `clone_dir` (e.g., "dist") +/// * `timestamp` - Timestamp string for the build directory (format: %Y%m%d-%H%M%S-%f) +/// +/// # Errors +/// +/// Returns an error if the source directory doesn't exist, the asset copy +/// fails, or the atomic symlink switch fails. +/// +/// # Workflow +/// 1. Validate source directory exists +/// 2. Create timestamped build directory: {`base_dir}/builds/{site_name}/{timestamp`} +/// 3. Copy assets from {`clone_dir}/{public`}/ to the timestamped directory +/// 4. Atomic symlink switch: update {`base_dir}/builds/{site_name}/current` +pub async fn publish( + base_dir: &Path, + site_name: &str, + clone_dir: &Path, + public: &str, + timestamp: &str, +) -> Result { + // 1. Construct source path and validate it exists + let source_dir = clone_dir.join(public); + if !source_dir.exists() { + bail!("public directory does not exist"); + } + if !source_dir.is_dir() { + bail!("public path is not a directory"); + } + + // 2. Create build directory with provided timestamp + let site_builds_dir = base_dir.join("builds").join(site_name); + let build_dir = site_builds_dir.join(timestamp); + let current_link = site_builds_dir.join("current"); + + info!( + source = %source_dir.display(), + destination = %build_dir.display(), + "publishing assets" + ); + + // 3. Create builds directory structure + tokio::fs::create_dir_all(&site_builds_dir) + .await + .with_context(|| { + format!( + "failed to create builds directory: {}", + site_builds_dir.display() + ) + })?; + + // 4. Copy assets recursively + copy_dir_contents(&source_dir, &build_dir) + .await + .context("failed to copy assets")?; + + // 5. Atomic symlink switch + atomic_symlink_update(&build_dir, ¤t_link).await?; + + debug!( + build_dir = %build_dir.display(), + symlink = %current_link.display(), + "publish completed" + ); + + Ok(PublishResult { + build_dir, + timestamp: timestamp.to_owned(), + }) +} + +async fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> { + tokio::fs::create_dir_all(dst) + .await + .with_context(|| format!("failed to create directory: {}", dst.display()))?; + + // Preserve source directory permissions + let dir_metadata = tokio::fs::symlink_metadata(src).await?; + tokio::fs::set_permissions(dst, dir_metadata.permissions()) + .await + .with_context(|| format!("failed to set permissions on {}", dst.display()))?; + + let mut entries = tokio::fs::read_dir(src) + .await + .with_context(|| format!("failed to read directory: {}", src.display()))?; + + while let Some(entry) = entries.next_entry().await? { + let entry_path = entry.path(); + let dest_path = dst.join(entry.file_name()); + + // SEC-002: reject symlinks in build output to prevent symlink attacks + let metadata = tokio::fs::symlink_metadata(&entry_path).await?; + if metadata.file_type().is_symlink() { + tracing::warn!(path = %entry_path.display(), "skipping symlink in build output"); + continue; + } + + let file_type = entry.file_type().await?; + + if file_type.is_dir() { + Box::pin(copy_dir_contents(&entry_path, &dest_path)).await?; + } else { + tokio::fs::copy(&entry_path, &dest_path) + .await + .with_context(|| { + format!( + "failed to copy {} to {}", + entry_path.display(), + dest_path.display() + ) + })?; + // Preserve source file permissions + tokio::fs::set_permissions(&dest_path, metadata.permissions()) + .await + .with_context(|| format!("failed to set permissions on {}", dest_path.display()))?; + } + } + + Ok(()) +} + +/// Atomically update a symlink to point to a new target. +/// +/// Uses the temp-symlink + rename pattern for atomicity: +/// 1. Create temp symlink: {`link_path}.tmp` -> target +/// 2. Rename temp to final: {`link_path}.tmp` -> {`link_path`} +/// +/// The rename operation is atomic on POSIX filesystems. +async fn atomic_symlink_update(target: &Path, link_path: &Path) -> Result<()> { + let temp_link = link_path.with_extension("tmp"); + + // Remove any stale temp symlink from previous failed attempts + let _ = tokio::fs::remove_file(&temp_link).await; + + // Create temporary symlink pointing to target + tokio::fs::symlink(target, &temp_link) + .await + .with_context(|| "failed to create temporary symlink")?; + + // Atomically rename temp symlink to final location + tokio::fs::rename(&temp_link, link_path) + .await + .with_context(|| "failed to atomically update symlink")?; + + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::test_support::{cleanup, temp_dir}; + use chrono::Utc; + use tokio::fs; + + fn test_timestamp() -> String { + Utc::now().format("%Y%m%d-%H%M%S-%f").to_string() + } + + #[tokio::test] + async fn publish_copies_assets_to_timestamped_directory() { + let base_dir = temp_dir("publish-test").await; + let clone_dir = temp_dir("publish-test").await; + + // Create source assets + let source = clone_dir.join("dist"); + fs::create_dir_all(&source).await.unwrap(); + fs::write(source.join("index.html"), "hello") + .await + .unwrap(); + + let timestamp = test_timestamp(); + let result = publish(&base_dir, "my-site", &clone_dir, "dist", ×tamp).await; + + assert!(result.is_ok(), "publish should succeed: {result:?}"); + let publish_result = result.unwrap(); + + // Verify timestamp is used for build directory + assert_eq!(publish_result.timestamp, timestamp); + + // Verify assets were copied + let copied_file = publish_result.build_dir.join("index.html"); + assert!(copied_file.exists(), "copied file should exist"); + let content = fs::read_to_string(&copied_file).await.unwrap(); + assert_eq!(content, "hello"); + + cleanup(&base_dir).await; + cleanup(&clone_dir).await; + } + + #[tokio::test] + async fn publish_creates_current_symlink() { + let base_dir = temp_dir("publish-test").await; + let clone_dir = temp_dir("publish-test").await; + + // Create source assets + let source = clone_dir.join("public"); + fs::create_dir_all(&source).await.unwrap(); + fs::write(source.join("file.txt"), "content").await.unwrap(); + + let timestamp = test_timestamp(); + let result = publish(&base_dir, "test-site", &clone_dir, "public", ×tamp).await; + + assert!(result.is_ok(), "publish should succeed: {result:?}"); + let publish_result = result.unwrap(); + + // Verify current symlink exists and points to build dir + let current_link = base_dir.join("builds/test-site/current"); + assert!(current_link.exists(), "current symlink should exist"); + + let link_target = fs::read_link(¤t_link).await.unwrap(); + assert_eq!(link_target, publish_result.build_dir); + + cleanup(&base_dir).await; + cleanup(&clone_dir).await; + } + + #[tokio::test] + async fn publish_symlink_updated_on_second_publish() { + let base_dir = temp_dir("publish-test").await; + let clone_dir = temp_dir("publish-test").await; + + // Create source assets + let source = clone_dir.join("dist"); + fs::create_dir_all(&source).await.unwrap(); + fs::write(source.join("file.txt"), "v1").await.unwrap(); + + // First publish + let timestamp1 = "20260126-100000-000001".to_owned(); + let result1 = publish(&base_dir, "my-site", &clone_dir, "dist", ×tamp1).await; + assert!(result1.is_ok()); + let publish1 = result1.unwrap(); + + // Update source and publish again with different timestamp + fs::write(source.join("file.txt"), "v2").await.unwrap(); + + let timestamp2 = "20260126-100000-000002".to_owned(); + let result2 = publish(&base_dir, "my-site", &clone_dir, "dist", ×tamp2).await; + assert!(result2.is_ok()); + let publish2 = result2.unwrap(); + + // Verify symlink points to second build + let current_link = base_dir.join("builds/my-site/current"); + let link_target = fs::read_link(¤t_link).await.unwrap(); + assert_eq!(link_target, publish2.build_dir); + + // Verify both build directories still exist + assert!( + publish1.build_dir.exists(), + "first build should still exist" + ); + assert!(publish2.build_dir.exists(), "second build should exist"); + + // Verify content is correct + let content = fs::read_to_string(publish2.build_dir.join("file.txt")) + .await + .unwrap(); + assert_eq!(content, "v2"); + + cleanup(&base_dir).await; + cleanup(&clone_dir).await; + } + + #[tokio::test] + async fn publish_missing_source_returns_error() { + let base_dir = temp_dir("publish-test").await; + let clone_dir = temp_dir("publish-test").await; + + // Don't create source directory + + let timestamp = test_timestamp(); + let result = publish(&base_dir, "my-site", &clone_dir, "nonexistent", ×tamp).await; + + assert!(result.is_err(), "publish should fail"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("public directory does not exist")); + + cleanup(&base_dir).await; + cleanup(&clone_dir).await; + } + + #[tokio::test] + async fn publish_source_is_file_returns_error() { + let base_dir = temp_dir("publish-test").await; + let clone_dir = temp_dir("publish-test").await; + + // Create a file instead of directory + fs::write(clone_dir.join("dist"), "not a directory") + .await + .unwrap(); + + let timestamp = test_timestamp(); + let result = publish(&base_dir, "my-site", &clone_dir, "dist", ×tamp).await; + + assert!(result.is_err(), "publish should fail"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("public path is not a directory")); + + cleanup(&base_dir).await; + cleanup(&clone_dir).await; + } + + #[tokio::test] + async fn publish_nested_public_directory() { + let base_dir = temp_dir("publish-test").await; + let clone_dir = temp_dir("publish-test").await; + + // Create nested source directory + let source = clone_dir.join("build/output/dist"); + fs::create_dir_all(&source).await.unwrap(); + fs::write(source.join("app.js"), "console.log('hello')") + .await + .unwrap(); + + let timestamp = test_timestamp(); + let result = publish( + &base_dir, + "my-site", + &clone_dir, + "build/output/dist", + ×tamp, + ) + .await; + + assert!(result.is_ok(), "publish should succeed: {result:?}"); + let publish_result = result.unwrap(); + + // Verify file was copied + let copied_file = publish_result.build_dir.join("app.js"); + assert!(copied_file.exists(), "copied file should exist"); + + cleanup(&base_dir).await; + cleanup(&clone_dir).await; + } + + #[tokio::test] + async fn publish_preserves_directory_structure() { + let base_dir = temp_dir("publish-test").await; + let clone_dir = temp_dir("publish-test").await; + + // Create source with subdirectories + let source = clone_dir.join("public"); + fs::create_dir_all(source.join("css")).await.unwrap(); + fs::create_dir_all(source.join("js")).await.unwrap(); + fs::write(source.join("index.html"), "") + .await + .unwrap(); + fs::write(source.join("css/style.css"), "body {}") + .await + .unwrap(); + fs::write(source.join("js/app.js"), "// app").await.unwrap(); + + let timestamp = test_timestamp(); + let result = publish(&base_dir, "my-site", &clone_dir, "public", ×tamp).await; + + assert!(result.is_ok(), "publish should succeed: {result:?}"); + let publish_result = result.unwrap(); + + // Verify structure preserved + assert!(publish_result.build_dir.join("index.html").exists()); + assert!(publish_result.build_dir.join("css/style.css").exists()); + assert!(publish_result.build_dir.join("js/app.js").exists()); + + cleanup(&base_dir).await; + cleanup(&clone_dir).await; + } + + #[tokio::test] + async fn atomic_symlink_update_replaces_existing() { + let temp = temp_dir("publish-test").await; + + // Create two target directories + let target1 = temp.join("build-1"); + let target2 = temp.join("build-2"); + fs::create_dir_all(&target1).await.unwrap(); + fs::create_dir_all(&target2).await.unwrap(); + + let link_path = temp.join("current"); + + // Create initial symlink + atomic_symlink_update(&target1, &link_path).await.unwrap(); + let link1 = fs::read_link(&link_path).await.unwrap(); + assert_eq!(link1, target1); + + // Update symlink + atomic_symlink_update(&target2, &link_path).await.unwrap(); + let link2 = fs::read_link(&link_path).await.unwrap(); + assert_eq!(link2, target2); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn copy_dir_contents_skips_symlinks() { + let src = temp_dir("publish-test").await; + let dst = temp_dir("publish-test").await; + + // Create a normal file + fs::write(src.join("real.txt"), "hello").await.unwrap(); + + // Create a symlink pointing outside the directory + let outside = temp_dir("publish-test").await; + fs::write(outside.join("secret.txt"), "secret") + .await + .unwrap(); + tokio::fs::symlink(outside.join("secret.txt"), src.join("link.txt")) + .await + .unwrap(); + + // Run copy + let dest = dst.join("output"); + copy_dir_contents(&src, &dest).await.unwrap(); + + // Normal file should be copied + assert!(dest.join("real.txt").exists(), "real file should be copied"); + let content = fs::read_to_string(dest.join("real.txt")).await.unwrap(); + assert_eq!(content, "hello"); + + // Symlink should NOT be copied + assert!( + !dest.join("link.txt").exists(), + "symlink should not be copied" + ); + + cleanup(&src).await; + cleanup(&dst).await; + cleanup(&outside).await; + } + + #[tokio::test] + async fn copy_dir_contents_preserves_permissions() { + use std::os::unix::fs::PermissionsExt; + + let src = temp_dir("publish-test").await; + let dst = temp_dir("publish-test").await; + + // Create a file with executable permissions (0o755) + fs::write(src.join("script.sh"), "#!/bin/sh\necho hi") + .await + .unwrap(); + let mut perms = fs::metadata(src.join("script.sh")) + .await + .unwrap() + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(src.join("script.sh"), perms) + .await + .unwrap(); + + // Create a file with restrictive permissions (0o644) + fs::write(src.join("data.txt"), "data").await.unwrap(); + + let dest = dst.join("output"); + copy_dir_contents(&src, &dest).await.unwrap(); + + // Verify executable permission preserved + let copied_perms = fs::metadata(dest.join("script.sh")) + .await + .unwrap() + .permissions(); + assert_eq!( + copied_perms.mode() & 0o777, + 0o755, + "executable permissions should be preserved" + ); + + cleanup(&src).await; + cleanup(&dst).await; + } +} diff --git a/src/repo_config.rs b/src/repo_config.rs new file mode 100644 index 0000000..46c74b5 --- /dev/null +++ b/src/repo_config.rs @@ -0,0 +1,523 @@ +use crate::config::BuildOverrides; +use anyhow::{Context as _, Result, bail}; +use serde::Deserialize; +use std::path::{Component, Path}; + +/// Configuration for building a site, read from `witryna.yaml` or `witryna.yml` in the repository root. +#[derive(Debug, Deserialize)] +pub struct RepoConfig { + /// Container image to use for building (e.g., "node:20-alpine"). + pub image: String, + /// Command to execute inside the container (e.g., "npm install && npm run build") + pub command: String, + /// Directory containing built static assets, relative to repo root (e.g., "dist") + pub public: String, +} + +/// Validate container image name. +/// +/// # Errors +/// +/// Returns an error if the image name is empty or whitespace-only. +pub fn validate_image(image: &str) -> Result<()> { + if image.trim().is_empty() { + bail!("image cannot be empty"); + } + Ok(()) +} + +/// Validate build command. +/// +/// # Errors +/// +/// Returns an error if the command is empty or whitespace-only. +pub fn validate_command(command: &str) -> Result<()> { + if command.trim().is_empty() { + bail!("command cannot be empty"); + } + Ok(()) +} + +/// Validate public directory path. +/// +/// # Errors +/// +/// Returns an error if the path is empty, contains path traversal segments, +/// or is an absolute path. +pub fn validate_public(public: &str) -> Result<()> { + if public.trim().is_empty() { + bail!("public directory cannot be empty"); + } + + // OWASP: Reject absolute paths + if public.starts_with('/') { + bail!("invalid public directory '{public}': must be a relative path"); + } + + // OWASP: Reject real path traversal (Component::ParentDir) + // Allows names like "dist..v2" which contain ".." but are not traversal + if Path::new(public) + .components() + .any(|c| c == Component::ParentDir) + { + bail!("invalid public directory '{public}': path traversal not allowed"); + } + + Ok(()) +} + +impl RepoConfig { + /// Load repo configuration from the given repository directory. + /// + /// If `config_file` is `Some`, reads that specific path (relative to repo root). + /// Otherwise searches: `.witryna.yaml` -> `.witryna.yml` -> `witryna.yaml` -> `witryna.yml`. + /// + /// # Errors + /// + /// Returns an error if no config file is found, the file cannot be read + /// or parsed, or validation fails. + pub async fn load(repo_dir: &Path, config_file: Option<&str>) -> Result { + if let Some(custom) = config_file { + let path = repo_dir.join(custom); + let content = tokio::fs::read_to_string(&path) + .await + .with_context(|| format!("failed to read {}", path.display()))?; + let config: Self = serde_yaml_ng::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display()))?; + config.validate()?; + return Ok(config); + } + + let candidates = [ + ".witryna.yaml", + ".witryna.yml", + "witryna.yaml", + "witryna.yml", + ]; + for name in candidates { + let path = repo_dir.join(name); + if path.exists() { + let content = tokio::fs::read_to_string(&path) + .await + .with_context(|| format!("failed to read {}", path.display()))?; + let config: Self = serde_yaml_ng::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display()))?; + config.validate()?; + return Ok(config); + } + } + bail!( + "no build config found in {} (tried: {})", + repo_dir.display(), + candidates.join(", ") + ); + } + + fn validate(&self) -> Result<()> { + validate_image(&self.image)?; + validate_command(&self.command)?; + validate_public(&self.public)?; + Ok(()) + } + + /// Load repo configuration, applying overrides from witryna.toml. + /// + /// If all three override fields are specified, witryna.yaml is not loaded. + /// Otherwise, loads witryna.yaml and applies any partial overrides. + /// + /// # Errors + /// + /// Returns an error if the base config cannot be loaded (when overrides + /// are incomplete) or validation fails. + /// + /// # Panics + /// + /// Panics if `is_complete()` returns true but a required override + /// field is `None`. This is unreachable because `is_complete()` + /// checks all required fields. + #[allow(clippy::expect_used)] // fields verified by is_complete() + pub async fn load_with_overrides( + repo_dir: &Path, + overrides: &BuildOverrides, + config_file: Option<&str>, + ) -> Result { + // If all overrides are specified, skip loading witryna.yaml + if overrides.is_complete() { + let config = Self { + image: overrides.image.clone().expect("verified by is_complete"), + command: overrides.command.clone().expect("verified by is_complete"), + public: overrides.public.clone().expect("verified by is_complete"), + }; + // Validation already done in SiteConfig::validate(), but validate again for safety + config.validate()?; + return Ok(config); + } + + // Load base config from repo + let mut config = Self::load(repo_dir, config_file).await?; + + // Apply overrides (already validated in SiteConfig) + if let Some(image) = &overrides.image { + config.image.clone_from(image); + } + if let Some(command) = &overrides.command { + config.command.clone_from(command); + } + if let Some(public) = &overrides.public { + config.public.clone_from(public); + } + + Ok(config) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::test_support::{cleanup, temp_dir}; + + fn parse_yaml(yaml: &str) -> Result { + let config: RepoConfig = serde_yaml_ng::from_str(yaml)?; + config.validate()?; + Ok(config) + } + + #[test] + fn parse_valid_repo_config() { + let yaml = r#" +image: "node:20-alpine" +command: "npm install && npm run build" +public: "dist" +"#; + let config = parse_yaml(yaml).unwrap(); + assert_eq!(config.image, "node:20-alpine"); + assert_eq!(config.command, "npm install && npm run build"); + assert_eq!(config.public, "dist"); + } + + #[test] + fn missing_required_field() { + let yaml = r#" +image: "node:20-alpine" +command: "npm run build" +"#; + let result: Result = serde_yaml_ng::from_str(yaml); + assert!(result.is_err()); + } + + #[test] + fn empty_or_whitespace_image_rejected() { + for image in ["", " "] { + let yaml = + format!("image: \"{image}\"\ncommand: \"npm run build\"\npublic: \"dist\"\n"); + let result = parse_yaml(&yaml); + assert!(result.is_err(), "image '{image}' should be rejected"); + assert!(result.unwrap_err().to_string().contains("image")); + } + } + + #[test] + fn empty_command() { + let yaml = r#" +image: "node:20-alpine" +command: "" +public: "dist" +"#; + let result = parse_yaml(yaml); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("command")); + } + + #[test] + fn empty_public() { + let yaml = r#" +image: "node:20-alpine" +command: "npm run build" +public: "" +"#; + let result = parse_yaml(yaml); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("public")); + } + + #[test] + fn public_path_traversal() { + let invalid_paths = vec!["../dist", "build/../dist", "dist/..", ".."]; + + for path in invalid_paths { + let yaml = format!( + r#" +image: "node:20-alpine" +command: "npm run build" +public: "{path}" +"# + ); + let result = parse_yaml(&yaml); + assert!(result.is_err(), "public path '{path}' should be rejected"); + assert!(result.unwrap_err().to_string().contains("path traversal")); + } + } + + #[test] + fn public_absolute_path_unix() { + let invalid_paths = vec!["/dist", "/var/www/dist"]; + + for path in invalid_paths { + let yaml = format!( + r#" +image: "node:20-alpine" +command: "npm run build" +public: "{path}" +"# + ); + let result = parse_yaml(&yaml); + assert!(result.is_err(), "public path '{path}' should be rejected"); + assert!(result.unwrap_err().to_string().contains("relative path")); + } + } + + #[test] + fn valid_nested_public() { + let valid_paths = vec![ + "dist", + "build/dist", + "out/static", + "_site", + ".next/out", + "dist..v2", + "assets/..hidden", + "foo..bar/dist", + ]; + + for path in valid_paths { + let yaml = format!( + r#" +image: "node:20-alpine" +command: "npm run build" +public: "{path}" +"# + ); + let result = parse_yaml(&yaml); + assert!(result.is_ok(), "public path '{path}' should be valid"); + } + } + + #[test] + fn public_dot_segments_accepted() { + let valid = vec![".", "./dist", "dist/.", "dist//assets"]; + for path in valid { + assert!( + validate_public(path).is_ok(), + "path '{path}' should be valid" + ); + } + } + + // load_with_overrides tests + + #[tokio::test] + async fn load_with_overrides_complete_skips_file() { + // No need to create witryna.yaml since all overrides are provided + let temp = temp_dir("repo-config-test").await; + + let overrides = BuildOverrides { + image: Some("alpine:latest".to_owned()), + command: Some("echo hello".to_owned()), + public: Some("out".to_owned()), + }; + + let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; + + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.image, "alpine:latest"); + assert_eq!(config.command, "echo hello"); + assert_eq!(config.public, "out"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn load_with_overrides_partial_merges() { + let temp = temp_dir("repo-config-test").await; + + // Create witryna.yaml with base config + let yaml = r#" +image: "node:18" +command: "npm run build" +public: "dist" +"#; + tokio::fs::write(temp.join("witryna.yaml"), yaml) + .await + .unwrap(); + + // Override only the image + let overrides = BuildOverrides { + image: Some("node:20-alpine".to_owned()), + command: None, + public: None, + }; + + let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; + + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.image, "node:20-alpine"); // Overridden + assert_eq!(config.command, "npm run build"); // From yaml + assert_eq!(config.public, "dist"); // From yaml + + cleanup(&temp).await; + } + + #[tokio::test] + async fn load_with_overrides_none_loads_yaml() { + let temp = temp_dir("repo-config-test").await; + + // Create witryna.yaml + let yaml = r#" +image: "node:18" +command: "npm run build" +public: "dist" +"#; + tokio::fs::write(temp.join("witryna.yaml"), yaml) + .await + .unwrap(); + + let overrides = BuildOverrides::default(); + + let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; + + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.image, "node:18"); + assert_eq!(config.command, "npm run build"); + assert_eq!(config.public, "dist"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn load_with_overrides_missing_yaml_partial_fails() { + let temp = temp_dir("repo-config-test").await; + + // No witryna.yaml, partial overrides + let overrides = BuildOverrides { + image: Some("node:20-alpine".to_owned()), + command: None, // Missing, needs yaml + public: None, + }; + + let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no build config found") + ); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn load_with_overrides_incomplete_needs_yaml() { + let temp = temp_dir("repo-config-test").await; + + // Only command+public — incomplete (no image), no yaml file + let overrides = BuildOverrides { + image: None, + command: Some("npm run build".to_owned()), + public: Some("dist".to_owned()), + }; + + let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no build config found") + ); + + cleanup(&temp).await; + } + + // Discovery chain tests + + const VALID_YAML: &str = "image: \"node:20\"\ncommand: \"npm run build\"\npublic: \"dist\"\n"; + + #[tokio::test] + async fn load_finds_dot_witryna_yaml() { + let temp = temp_dir("repo-config-test").await; + tokio::fs::write(temp.join(".witryna.yaml"), VALID_YAML) + .await + .unwrap(); + + let config = RepoConfig::load(&temp, None).await.unwrap(); + assert_eq!(config.image, "node:20"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn load_finds_dot_witryna_yml() { + let temp = temp_dir("repo-config-test").await; + tokio::fs::write(temp.join(".witryna.yml"), VALID_YAML) + .await + .unwrap(); + + let config = RepoConfig::load(&temp, None).await.unwrap(); + assert_eq!(config.image, "node:20"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn load_precedence_dot_over_plain() { + let temp = temp_dir("repo-config-test").await; + let dot_yaml = "image: \"dot-image\"\ncommand: \"build\"\npublic: \"out\"\n"; + let plain_yaml = "image: \"plain-image\"\ncommand: \"build\"\npublic: \"out\"\n"; + tokio::fs::write(temp.join(".witryna.yaml"), dot_yaml) + .await + .unwrap(); + tokio::fs::write(temp.join("witryna.yaml"), plain_yaml) + .await + .unwrap(); + + let config = RepoConfig::load(&temp, None).await.unwrap(); + assert_eq!(config.image, "dot-image"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn load_custom_config_file() { + let temp = temp_dir("repo-config-test").await; + let subdir = temp.join("build"); + tokio::fs::create_dir_all(&subdir).await.unwrap(); + tokio::fs::write(subdir.join("config.yml"), VALID_YAML) + .await + .unwrap(); + + let config = RepoConfig::load(&temp, Some("build/config.yml")) + .await + .unwrap(); + assert_eq!(config.image, "node:20"); + + cleanup(&temp).await; + } + + #[tokio::test] + async fn load_custom_config_file_not_found_errors() { + let temp = temp_dir("repo-config-test").await; + + let result = RepoConfig::load(&temp, Some("nonexistent.yaml")).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("failed to read")); + + cleanup(&temp).await; + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..e31a1e4 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,1219 @@ +use crate::build_guard::{BuildGuard, BuildScheduler}; +use crate::config::{Config, SiteConfig}; +use crate::polling::PollingManager; +use anyhow::Result; +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Path, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, +}; +use governor::clock::DefaultClock; +use governor::state::keyed::DashMapStateStore; +use governor::{Quota, RateLimiter}; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::sync::Arc; +use subtle::ConstantTimeEq as _; +use tokio::net::TcpListener; +use tokio::signal::unix::{SignalKind, signal}; +use tokio::sync::RwLock; +use tracing::{error, info, warn}; + +#[derive(serde::Serialize)] +struct ErrorResponse { + error: &'static str, +} + +#[derive(serde::Serialize)] +struct QueuedResponse { + status: &'static str, +} + +#[derive(serde::Serialize)] +struct HealthResponse { + status: &'static str, +} + +fn error_response(status: StatusCode, error: &'static str) -> impl IntoResponse { + (status, Json(ErrorResponse { error })) +} + +type TokenRateLimiter = RateLimiter, DefaultClock>; + +#[derive(Clone)] +pub struct AppState { + pub config: Arc>, + pub config_path: Arc, + pub build_scheduler: Arc, + pub rate_limiter: Arc, + pub polling_manager: Arc, +} + +pub fn create_router(state: AppState) -> Router { + Router::new() + .route("/health", get(health_handler)) + .route("/{site_name}", post(deploy_handler)) + .layer(DefaultBodyLimit::max(1024 * 1024)) // 1MB limit + .with_state(state) +} + +async fn health_handler() -> impl IntoResponse { + Json(HealthResponse { status: "ok" }) +} + +/// Extract Bearer token from Authorization header. +fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) +} + +fn validate_token(provided: &str, expected: &str) -> bool { + let provided_bytes = provided.as_bytes(); + let expected_bytes = expected.as_bytes(); + + // Constant-time comparison - OWASP requirement + provided_bytes.ct_eq(expected_bytes).into() +} + +async fn deploy_handler( + State(state): State, + Path(site_name): Path, + headers: HeaderMap, +) -> impl IntoResponse { + info!(%site_name, "deployment request received"); + + // Find the site first to avoid information leakage + let site = { + let config = state.config.read().await; + if let Some(site) = config.find_site(&site_name) { + site.clone() + } else { + info!(%site_name, "site not found"); + return error_response(StatusCode::NOT_FOUND, "not_found").into_response(); + } + }; + + // Validate Bearer token (skip if auth disabled for this site) + if site.webhook_token.is_empty() { + // Auth disabled — rate limit by site name instead + if state.rate_limiter.check_key(&site_name).is_err() { + info!(%site_name, "rate limit exceeded"); + return error_response(StatusCode::TOO_MANY_REQUESTS, "rate_limit_exceeded") + .into_response(); + } + } else { + let Some(token) = extract_bearer_token(&headers) else { + info!(%site_name, "missing or malformed authorization header"); + return error_response(StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + }; + + if !validate_token(token, &site.webhook_token) { + info!(%site_name, "invalid token"); + return error_response(StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + + // Rate limit check (per token) + if state.rate_limiter.check_key(&token.to_owned()).is_err() { + info!(%site_name, "rate limit exceeded"); + return error_response(StatusCode::TOO_MANY_REQUESTS, "rate_limit_exceeded") + .into_response(); + } + } + + // Try immediate build + let Some(guard) = BuildGuard::try_acquire(site_name.clone(), &state.build_scheduler) else { + // Build in progress — try to queue + if state.build_scheduler.try_queue(&site_name) { + info!(%site_name, "build queued"); + return ( + StatusCode::ACCEPTED, + Json(QueuedResponse { status: "queued" }), + ) + .into_response(); + } + // Already queued — collapse + info!(%site_name, "build already queued, collapsing"); + return StatusCode::ACCEPTED.into_response(); + }; + + info!(%site_name, "deployment accepted"); + + // Spawn async build pipeline with queue drain loop + tokio::spawn(async move { + let mut current_site = site; + let mut current_guard = guard; + loop { + #[allow(clippy::large_futures)] + run_build_pipeline( + state.clone(), + site_name.clone(), + current_site.clone(), + current_guard, + ) + .await; + // Guard dropped here — build lock released + + if !state.build_scheduler.take_queued(&site_name) { + break; + } + info!(%site_name, "processing queued rebuild"); + let Some(new_site) = state.config.read().await.find_site(&site_name).cloned() else { + warn!(%site_name, "site removed from config, skipping queued rebuild"); + break; + }; + let Some(new_guard) = + BuildGuard::try_acquire(site_name.clone(), &state.build_scheduler) + else { + break; // someone else grabbed it + }; + current_site = new_site; + current_guard = new_guard; + } + }); + + StatusCode::ACCEPTED.into_response() +} + +/// Run the complete build pipeline: git sync → build → publish. +#[allow(clippy::large_futures)] +pub(crate) async fn run_build_pipeline( + state: AppState, + site_name: String, + site: SiteConfig, + _guard: BuildGuard, +) { + let (base_dir, log_dir, container_runtime, max_builds_to_keep, git_timeout) = { + let config = state.config.read().await; + ( + config.base_dir.clone(), + config.log_dir.clone(), + config.container_runtime.clone(), + config.max_builds_to_keep, + config + .git_timeout + .unwrap_or(crate::git::GIT_TIMEOUT_DEFAULT), + ) + }; + + match crate::pipeline::run_build( + &site_name, + &site, + &base_dir, + &log_dir, + &container_runtime, + max_builds_to_keep, + git_timeout, + false, + ) + .await + { + Ok(result) => { + info!( + %site_name, + build_dir = %result.build_dir.display(), + duration_secs = result.duration.as_secs(), + "pipeline completed" + ); + } + Err(e) => { + error!(%site_name, error = %e, "pipeline failed"); + } + } +} + +/// Setup SIGHUP signal handler for configuration hot-reload. +pub(crate) fn setup_sighup_handler(state: AppState) { + tokio::spawn(async move { + #[allow(clippy::expect_used)] // fatal: cannot proceed without signal handler + let mut sighup = + signal(SignalKind::hangup()).expect("failed to setup SIGHUP signal handler"); + + loop { + sighup.recv().await; + info!("SIGHUP received, reloading configuration"); + + let config_path = state.config_path.as_ref(); + match Config::load(config_path).await { + Ok(new_config) => { + let old_sites_count = state.config.read().await.sites.len(); + let new_sites_count = new_config.sites.len(); + + // Check for non-reloadable changes and capture old values + let (old_listen, old_base, old_log_dir, old_log_level) = { + let old_config = state.config.read().await; + if old_config.listen_address != new_config.listen_address { + warn!( + old = %old_config.listen_address, + new = %new_config.listen_address, + "listen_address changed but cannot be reloaded (restart required)" + ); + } + if old_config.base_dir != new_config.base_dir { + warn!( + old = %old_config.base_dir.display(), + new = %new_config.base_dir.display(), + "base_dir changed but cannot be reloaded (restart required)" + ); + } + if old_config.log_dir != new_config.log_dir { + warn!( + old = %old_config.log_dir.display(), + new = %new_config.log_dir.display(), + "log_dir changed but cannot be reloaded (restart required)" + ); + } + if old_config.log_level != new_config.log_level { + warn!( + old = %old_config.log_level, + new = %new_config.log_level, + "log_level changed but cannot be reloaded (restart required)" + ); + } + ( + old_config.listen_address.clone(), + old_config.base_dir.clone(), + old_config.log_dir.clone(), + old_config.log_level.clone(), + ) + }; + + // Preserve non-reloadable fields from the running config + let mut final_config = new_config; + final_config.listen_address = old_listen; + final_config.base_dir = old_base; + final_config.log_dir = old_log_dir; + final_config.log_level = old_log_level; + + // Apply the merged configuration + *state.config.write().await = final_config; + + // Restart polling tasks with new configuration + info!("restarting polling tasks"); + state.polling_manager.stop_all().await; + state.polling_manager.start_polling(state.clone()).await; + + info!( + old_sites_count, + new_sites_count, "configuration reloaded successfully" + ); + } + Err(e) => { + error!(error = %e, "failed to reload configuration, keeping current config"); + } + } + } + }); +} + +/// Start the server in production mode. +/// +/// # Errors +/// +/// Returns an error if the TCP listener cannot bind or the server encounters +/// a fatal I/O error. +/// +/// # Panics +/// +/// Panics if `rate_limit_per_minute` is zero. This is unreachable after +/// successful config validation. +pub async fn run(config: Config, config_path: PathBuf) -> Result<()> { + let addr = config.parsed_listen_address(); + + #[allow(clippy::expect_used)] // validated by Config::validate_rate_limit() + let quota = Quota::per_minute( + NonZeroU32::new(config.rate_limit_per_minute) + .expect("rate_limit_per_minute must be greater than 0"), + ); + let rate_limiter = Arc::new(RateLimiter::dashmap(quota)); + let polling_manager = Arc::new(PollingManager::new()); + + let state = AppState { + config: Arc::new(RwLock::new(config)), + config_path: Arc::new(config_path), + build_scheduler: Arc::new(BuildScheduler::new()), + rate_limiter, + polling_manager, + }; + + // Setup SIGHUP handler for configuration hot-reload + setup_sighup_handler(state.clone()); + + // Start polling tasks for sites with poll_interval configured + state.polling_manager.start_polling(state.clone()).await; + + let listener = TcpListener::bind(addr).await?; + info!(%addr, "server listening"); + + run_with_listener(state, listener, async { + let mut sigterm = signal(SignalKind::terminate()).expect("failed to setup SIGTERM handler"); + let mut sigint = signal(SignalKind::interrupt()).expect("failed to setup SIGINT handler"); + tokio::select! { + _ = sigterm.recv() => info!("received SIGTERM, shutting down"), + _ = sigint.recv() => info!("received SIGINT, shutting down"), + } + }) + .await +} + +/// Run the server on an already-bound listener with a custom shutdown signal. +/// +/// This is the core server loop used by both production (`run`) and integration tests. +/// Production delegates here after binding the listener and setting up SIGHUP handlers. +/// Tests call this via `test_support::run_server` with their own listener and shutdown channel. +pub(crate) async fn run_with_listener( + state: AppState, + listener: TcpListener, + shutdown_signal: impl std::future::Future + Send + 'static, +) -> Result<()> { + let router = create_router(state); + + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal) + .await?; + + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::expect_used)] +mod tests { + use super::*; + use crate::config::{BuildOverrides, SiteConfig}; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use axum::response::Response; + use std::path::PathBuf; + use tower::ServiceExt as _; + + fn test_state(config: Config) -> AppState { + test_state_with_rate_limit(config, 1000) // High limit for most tests + } + + fn test_state_with_rate_limit(config: Config, rate_limit: u32) -> AppState { + let quota = Quota::per_minute(NonZeroU32::new(rate_limit).unwrap()); + AppState { + config: Arc::new(RwLock::new(config)), + config_path: Arc::new(PathBuf::from("witryna.toml")), + build_scheduler: Arc::new(BuildScheduler::new()), + rate_limiter: Arc::new(RateLimiter::dashmap(quota)), + polling_manager: Arc::new(PollingManager::new()), + } + } + + fn test_config() -> Config { + Config { + listen_address: "127.0.0.1:8080".to_owned(), + container_runtime: "podman".to_owned(), + base_dir: PathBuf::from("/var/lib/witryna"), + log_dir: PathBuf::from("/var/log/witryna"), + log_level: "info".to_owned(), + rate_limit_per_minute: 10, + max_builds_to_keep: 5, + git_timeout: None, + sites: vec![], + } + } + + fn test_config_with_sites() -> Config { + Config { + sites: vec![SiteConfig { + name: "my-site".to_owned(), + repo_url: "https://github.com/user/my-site.git".to_owned(), + branch: "main".to_owned(), + webhook_token: "secret-token".to_owned(), + webhook_token_file: None, + + 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: "none".to_owned(), + git_depth: None, + container_workdir: None, + config_file: None, + }], + ..test_config() + } + } + + #[tokio::test] + async fn health_endpoint_returns_ok() { + let state = test_state(test_config_with_sites()); + let router = create_router(state); + + let response: Response = router + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn unknown_site_post_returns_not_found() { + let state = test_state(test_config()); + let router = create_router(state); + + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/nonexistent") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "not_found"); + } + + #[tokio::test] + async fn deploy_known_site_with_valid_token_returns_accepted() { + let state = test_state(test_config_with_sites()); + let router = create_router(state); + + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + assert!(body.is_empty()); + } + + #[tokio::test] + async fn deploy_missing_auth_header_returns_unauthorized() { + let state = test_state(test_config_with_sites()); + let router = create_router(state); + + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "unauthorized"); + } + + #[tokio::test] + async fn deploy_invalid_token_returns_unauthorized() { + let state = test_state(test_config_with_sites()); + let router = create_router(state); + + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer wrong-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "unauthorized"); + } + + #[tokio::test] + async fn deploy_malformed_auth_header_returns_unauthorized() { + let state = test_state(test_config_with_sites()); + let router = create_router(state); + + // Test without "Bearer " prefix + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "unauthorized"); + } + + #[tokio::test] + async fn deploy_basic_auth_returns_unauthorized() { + let state = test_state(test_config_with_sites()); + let router = create_router(state); + + // Test Basic auth instead of Bearer + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Basic dXNlcjpwYXNz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "unauthorized"); + } + + #[tokio::test] + async fn deploy_get_method_not_allowed() { + let state = test_state(test_config_with_sites()); + let router = create_router(state); + + let response: Response = router + .oneshot( + Request::builder() + .method("GET") + .uri("/my-site") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + } + + #[tokio::test] + async fn deploy_unknown_site_with_token_returns_not_found() { + let state = test_state(test_config_with_sites()); + let router = create_router(state); + + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/unknown-site") + .header("Authorization", "Bearer any-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // Returns 404 before checking token (site lookup first) + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "not_found"); + } + + fn test_config_with_two_sites() -> Config { + Config { + listen_address: "127.0.0.1:8080".to_owned(), + container_runtime: "podman".to_owned(), + base_dir: PathBuf::from("/var/lib/witryna"), + log_dir: PathBuf::from("/var/log/witryna"), + log_level: "info".to_owned(), + rate_limit_per_minute: 10, + max_builds_to_keep: 5, + git_timeout: None, + sites: vec![ + SiteConfig { + name: "site-one".to_owned(), + repo_url: "https://github.com/user/site-one.git".to_owned(), + branch: "main".to_owned(), + webhook_token: "token-one".to_owned(), + webhook_token_file: None, + + 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: "none".to_owned(), + git_depth: None, + container_workdir: None, + config_file: None, + }, + SiteConfig { + name: "site-two".to_owned(), + repo_url: "https://github.com/user/site-two.git".to_owned(), + branch: "main".to_owned(), + webhook_token: "token-two".to_owned(), + webhook_token_file: None, + + 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: "none".to_owned(), + git_depth: None, + container_workdir: None, + config_file: None, + }, + ], + } + } + + #[tokio::test] + async fn deploy_concurrent_same_site_gets_queued() { + let state = test_state(test_config_with_sites()); + let router = create_router(state.clone()); + + // First request should succeed (immediate build) + let response1: Response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response1.status(), StatusCode::ACCEPTED); + let body1 = axum::body::to_bytes(response1.into_body(), 1024) + .await + .unwrap(); + assert!(body1.is_empty()); + + // Second request to same site should be queued (202 with body) + let response2: Response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response2.status(), StatusCode::ACCEPTED); + let body2 = axum::body::to_bytes(response2.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body2).unwrap(); + assert_eq!(json["status"], "queued"); + + // Third request should be collapsed (202, no body) + let response3: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response3.status(), StatusCode::ACCEPTED); + let body3 = axum::body::to_bytes(response3.into_body(), 1024) + .await + .unwrap(); + assert!(body3.is_empty()); + } + + #[tokio::test] + async fn deploy_concurrent_different_sites_both_succeed() { + let state = test_state(test_config_with_two_sites()); + let router = create_router(state.clone()); + + // First site deployment + let response1: Response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/site-one") + .header("Authorization", "Bearer token-one") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response1.status(), StatusCode::ACCEPTED); + + // Second site deployment should also succeed + let response2: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/site-two") + .header("Authorization", "Bearer token-two") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response2.status(), StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn deploy_site_in_progress_checked_after_auth() { + let state = test_state(test_config_with_sites()); + + // Pre-mark site as building + state + .build_scheduler + .in_progress + .insert("my-site".to_owned()); + + let router = create_router(state); + + // Request with wrong token should return 401 (auth checked before build status) + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer wrong-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "unauthorized"); + } + + #[tokio::test] + async fn rate_limit_exceeded_returns_429() { + // Create state with rate limit of 2 per minute + let state = test_state_with_rate_limit(test_config_with_sites(), 2); + let router = create_router(state); + + // First request should succeed + let response1: Response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response1.status(), StatusCode::ACCEPTED); + + // Second request should succeed (or 409 if build in progress) + let response2: Response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Could be 202 or 409 depending on timing + assert!( + response2.status() == StatusCode::ACCEPTED + || response2.status() == StatusCode::CONFLICT + ); + + // Third request should hit rate limit + let response3: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response3.status(), StatusCode::TOO_MANY_REQUESTS); + let body = axum::body::to_bytes(response3.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "rate_limit_exceeded"); + } + + #[tokio::test] + async fn rate_limit_different_tokens_independent() { + // Create state with rate limit of 1 per minute + let state = test_state_with_rate_limit(test_config_with_two_sites(), 1); + let router = create_router(state); + + // First request with token-one should succeed + let response1: Response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/site-one") + .header("Authorization", "Bearer token-one") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response1.status(), StatusCode::ACCEPTED); + + // Second request with token-one should hit rate limit + let response2: Response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/site-one") + .header("Authorization", "Bearer token-one") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response2.status(), StatusCode::TOO_MANY_REQUESTS); + let body = axum::body::to_bytes(response2.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "rate_limit_exceeded"); + + // Request with different token should still succeed + let response3: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/site-two") + .header("Authorization", "Bearer token-two") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response3.status(), StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn rate_limit_checked_after_auth() { + // Create state with rate limit of 1 per minute + let state = test_state_with_rate_limit(test_config_with_sites(), 1); + let router = create_router(state); + + // First valid request exhausts rate limit + let response1: Response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer secret-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response1.status(), StatusCode::ACCEPTED); + + // Request with invalid token should return 401, not 429 + // (auth is checked before rate limit) + let response2: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/my-site") + .header("Authorization", "Bearer wrong-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response2.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(response2.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "unauthorized"); + } + + #[tokio::test] + async fn sighup_preserves_non_reloadable_fields() { + // Original config with specific non-reloadable values + let original = Config { + listen_address: "127.0.0.1:8080".to_owned(), + container_runtime: "podman".to_owned(), + base_dir: PathBuf::from("/var/lib/witryna"), + log_dir: PathBuf::from("/var/log/witryna"), + log_level: "info".to_owned(), + rate_limit_per_minute: 10, + max_builds_to_keep: 5, + git_timeout: None, + sites: vec![SiteConfig { + name: "old-site".to_owned(), + repo_url: "https://example.com/old.git".to_owned(), + branch: "main".to_owned(), + webhook_token: "old-token".to_owned(), + webhook_token_file: None, + + 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: "none".to_owned(), + git_depth: None, + container_workdir: None, + config_file: None, + }], + }; + + let state = test_state(original); + + // Simulate a new config loaded from disk with changed non-reloadable + // AND reloadable fields + let new_config = Config { + listen_address: "0.0.0.0:9999".to_owned(), + container_runtime: "docker".to_owned(), + base_dir: PathBuf::from("/tmp/new-base"), + log_dir: PathBuf::from("/tmp/new-logs"), + log_level: "debug".to_owned(), + rate_limit_per_minute: 20, + max_builds_to_keep: 10, + git_timeout: None, + sites: vec![SiteConfig { + name: "new-site".to_owned(), + repo_url: "https://example.com/new.git".to_owned(), + branch: "develop".to_owned(), + webhook_token: "new-token".to_owned(), + webhook_token_file: None, + + 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: "none".to_owned(), + git_depth: None, + container_workdir: None, + config_file: None, + }], + }; + + // Apply the same merge logic used in setup_sighup_handler + let (old_listen, old_base, old_log_dir, old_log_level) = { + let old_config = state.config.read().await; + ( + old_config.listen_address.clone(), + old_config.base_dir.clone(), + old_config.log_dir.clone(), + old_config.log_level.clone(), + ) + }; + + let mut final_config = new_config; + final_config.listen_address = old_listen; + final_config.base_dir = old_base; + final_config.log_dir = old_log_dir; + final_config.log_level = old_log_level; + + *state.config.write().await = final_config; + + // Verify non-reloadable fields are preserved + let config = state.config.read().await; + assert_eq!(config.listen_address, "127.0.0.1:8080"); + assert_eq!(config.base_dir, PathBuf::from("/var/lib/witryna")); + assert_eq!(config.log_dir, PathBuf::from("/var/log/witryna")); + assert_eq!(config.log_level, "info"); + + // Verify reloadable fields are updated + assert_eq!(config.container_runtime, "docker"); + assert_eq!(config.rate_limit_per_minute, 20); + assert_eq!(config.max_builds_to_keep, 10); + assert_eq!(config.sites.len(), 1); + assert_eq!(config.sites[0].name, "new-site"); + } + + fn test_config_with_disabled_auth() -> Config { + Config { + sites: vec![SiteConfig { + name: "open-site".to_owned(), + repo_url: "https://github.com/user/open-site.git".to_owned(), + branch: "main".to_owned(), + webhook_token: String::new(), + webhook_token_file: None, + 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: "none".to_owned(), + git_depth: None, + container_workdir: None, + config_file: None, + }], + ..test_config() + } + } + + #[tokio::test] + async fn deploy_disabled_auth_returns_accepted() { + let state = test_state(test_config_with_disabled_auth()); + let router = create_router(state); + + // Request without Authorization header should succeed + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/open-site") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn deploy_disabled_auth_ignores_token() { + let state = test_state(test_config_with_disabled_auth()); + let router = create_router(state); + + // Request WITH a Bearer token should also succeed (token ignored) + let response: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/open-site") + .header("Authorization", "Bearer any-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn deploy_disabled_auth_rate_limited_by_site_name() { + let state = test_state_with_rate_limit(test_config_with_disabled_auth(), 1); + let router = create_router(state); + + // First request should succeed + let response1: Response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/open-site") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response1.status(), StatusCode::ACCEPTED); + + // Second request should hit rate limit (keyed by site name) + let response2: Response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/open-site") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response2.status(), StatusCode::TOO_MANY_REQUESTS); + let body = axum::body::to_bytes(response2.into_body(), 1024) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "rate_limit_exceeded"); + } +} diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..8f2d2bf --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,72 @@ +//! Test support utilities shared between unit and integration tests. +//! +//! Gated behind `cfg(any(test, feature = "integration"))`. +//! Provides thin wrappers around `pub(crate)` server internals so integration +//! tests can start a real server on a random port without exposing internal APIs, +//! plus common helpers (temp dirs, cleanup) used across unit test modules. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::server::{AppState, run_with_listener}; +use anyhow::Result; +use std::path::{Path, PathBuf}; +use tokio::net::TcpListener; + +/// Start the HTTP server on the given listener, shutting down when `shutdown` resolves. +/// +/// The server behaves identically to production — same middleware, same handlers. +/// +/// # Errors +/// +/// Returns an error if the server encounters a fatal I/O error. +pub async fn run_server( + state: AppState, + listener: TcpListener, + shutdown: impl std::future::Future + Send + 'static, +) -> Result<()> { + run_with_listener(state, listener, shutdown).await +} + +/// Install the SIGHUP configuration-reload handler for `state`. +/// +/// Call this before sending SIGHUP in tests that exercise hot-reload. +/// It replaces the default signal disposition (terminate) with the production +/// reload handler, so the process stays alive after receiving the signal. +pub fn setup_sighup_handler(state: &AppState) { + crate::server::setup_sighup_handler(state.clone()); +} + +/// Generate a unique ID for test isolation (timestamp + counter). +/// +/// # Panics +/// +/// Panics if the system clock is before the Unix epoch. +pub fn uuid() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + let count = COUNTER.fetch_add(1, Ordering::SeqCst); + format!( + "{}-{}-{}", + duration.as_secs(), + duration.subsec_nanos(), + count + ) +} + +/// Create a unique temporary directory for a test. +/// +/// # Panics +/// +/// Panics if the directory cannot be created. +pub async fn temp_dir(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("witryna-{}-{}", prefix, uuid())); + tokio::fs::create_dir_all(&dir).await.unwrap(); + dir +} + +/// Remove a temporary directory (ignores errors). +pub async fn cleanup(dir: &Path) { + let _ = tokio::fs::remove_dir_all(dir).await; +} -- cgit v1.2.3