diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2026-01-22 22:07:32 +0100 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-10 18:44:26 +0100 |
| commit | 064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch) | |
| tree | a2023f9ccd297ed8a41a3a0cc5699c2add09244d /src/repo_config.rs | |
witryna 0.1.0 — initial releasev0.1.0
Minimalist Git-based static site deployment orchestrator.
Webhook-triggered builds in Podman/Docker containers with atomic
symlink publishing, SIGHUP hot-reload, and zero-downtime deploys.
See README.md for usage, CHANGELOG.md for details.
Diffstat (limited to 'src/repo_config.rs')
| -rw-r--r-- | src/repo_config.rs | 523 |
1 files changed, 523 insertions, 0 deletions
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; + } +} |
