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