use crate::config::BuildOverrides; use anyhow::{Context as _, Result, bail}; use serde::Deserialize; use std::path::{Component, Path}; /// Configuration for building a site, read from `witryna.yaml` or `witryna.yml` in the repository root. #[derive(Debug, Deserialize)] pub struct RepoConfig { /// Container image to use for building (e.g., "node:20-alpine"). pub image: String, /// Command to execute inside the container (e.g., "npm install && npm run build") pub command: String, /// Directory containing built static assets, relative to repo root (e.g., "dist") pub public: String, } /// Validate container image name. /// /// # Errors /// /// Returns an error if the image name is empty or whitespace-only. pub fn validate_image(image: &str) -> Result<()> { if image.trim().is_empty() { bail!("image cannot be empty"); } Ok(()) } /// Validate build command. /// /// # Errors /// /// Returns an error if the command is empty or whitespace-only. pub fn validate_command(command: &str) -> Result<()> { if command.trim().is_empty() { bail!("command cannot be empty"); } Ok(()) } /// Validate public directory path. /// /// # Errors /// /// Returns an error if the path is empty, contains path traversal segments, /// or is an absolute path. pub fn validate_public(public: &str) -> Result<()> { if public.trim().is_empty() { bail!("public directory cannot be empty"); } // OWASP: Reject absolute paths if public.starts_with('/') { bail!("invalid public directory '{public}': must be a relative path"); } // OWASP: Reject real path traversal (Component::ParentDir) // Allows names like "dist..v2" which contain ".." but are not traversal if Path::new(public) .components() .any(|c| c == Component::ParentDir) { bail!("invalid public directory '{public}': path traversal not allowed"); } Ok(()) } impl RepoConfig { /// Load repo configuration from the given repository directory. /// /// If `config_file` is `Some`, reads that specific path (relative to repo root). /// Otherwise searches: `.witryna.yaml` -> `.witryna.yml` -> `witryna.yaml` -> `witryna.yml`. /// /// # Errors /// /// Returns an error if no config file is found, the file cannot be read /// or parsed, or validation fails. pub async fn load(repo_dir: &Path, config_file: Option<&str>) -> Result { if let Some(custom) = config_file { let path = repo_dir.join(custom); let content = tokio::fs::read_to_string(&path) .await .with_context(|| format!("failed to read {}", path.display()))?; let config: Self = serde_yaml_ng::from_str(&content) .with_context(|| format!("failed to parse {}", path.display()))?; config.validate()?; return Ok(config); } let candidates = [ ".witryna.yaml", ".witryna.yml", "witryna.yaml", "witryna.yml", ]; for name in candidates { let path = repo_dir.join(name); if path.exists() { let content = tokio::fs::read_to_string(&path) .await .with_context(|| format!("failed to read {}", path.display()))?; let config: Self = serde_yaml_ng::from_str(&content) .with_context(|| format!("failed to parse {}", path.display()))?; config.validate()?; return Ok(config); } } bail!( "no build config found in {} (tried: {})", repo_dir.display(), candidates.join(", ") ); } fn validate(&self) -> Result<()> { validate_image(&self.image)?; validate_command(&self.command)?; validate_public(&self.public)?; Ok(()) } /// Load repo configuration, applying overrides from witryna.toml. /// /// If all three override fields are specified, witryna.yaml is not loaded. /// Otherwise, loads witryna.yaml and applies any partial overrides. /// /// # Errors /// /// Returns an error if the base config cannot be loaded (when overrides /// are incomplete) or validation fails. /// /// # Panics /// /// Panics if `is_complete()` returns true but a required override /// field is `None`. This is unreachable because `is_complete()` /// checks all required fields. #[allow(clippy::expect_used)] // fields verified by is_complete() pub async fn load_with_overrides( repo_dir: &Path, overrides: &BuildOverrides, config_file: Option<&str>, ) -> Result { // If all overrides are specified, skip loading witryna.yaml if overrides.is_complete() { let config = Self { image: overrides.image.clone().expect("verified by is_complete"), command: overrides.command.clone().expect("verified by is_complete"), public: overrides.public.clone().expect("verified by is_complete"), }; // Validation already done in SiteConfig::validate(), but validate again for safety config.validate()?; return Ok(config); } // Load base config from repo let mut config = Self::load(repo_dir, config_file).await?; // Apply overrides (already validated in SiteConfig) if let Some(image) = &overrides.image { config.image.clone_from(image); } if let Some(command) = &overrides.command { config.command.clone_from(command); } if let Some(public) = &overrides.public { config.public.clone_from(public); } Ok(config) } } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::indexing_slicing)] mod tests { use super::*; use crate::test_support::{cleanup, temp_dir}; fn parse_yaml(yaml: &str) -> Result { let config: RepoConfig = serde_yaml_ng::from_str(yaml)?; config.validate()?; Ok(config) } #[test] fn parse_valid_repo_config() { let yaml = r#" image: "node:20-alpine" command: "npm install && npm run build" public: "dist" "#; let config = parse_yaml(yaml).unwrap(); assert_eq!(config.image, "node:20-alpine"); assert_eq!(config.command, "npm install && npm run build"); assert_eq!(config.public, "dist"); } #[test] fn missing_required_field() { let yaml = r#" image: "node:20-alpine" command: "npm run build" "#; let result: Result = serde_yaml_ng::from_str(yaml); assert!(result.is_err()); } #[test] fn empty_or_whitespace_image_rejected() { for image in ["", " "] { let yaml = format!("image: \"{image}\"\ncommand: \"npm run build\"\npublic: \"dist\"\n"); let result = parse_yaml(&yaml); assert!(result.is_err(), "image '{image}' should be rejected"); assert!(result.unwrap_err().to_string().contains("image")); } } #[test] fn empty_command() { let yaml = r#" image: "node:20-alpine" command: "" public: "dist" "#; let result = parse_yaml(yaml); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("command")); } #[test] fn empty_public() { let yaml = r#" image: "node:20-alpine" command: "npm run build" public: "" "#; let result = parse_yaml(yaml); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("public")); } #[test] fn public_path_traversal() { let invalid_paths = vec!["../dist", "build/../dist", "dist/..", ".."]; for path in invalid_paths { let yaml = format!( r#" image: "node:20-alpine" command: "npm run build" public: "{path}" "# ); let result = parse_yaml(&yaml); assert!(result.is_err(), "public path '{path}' should be rejected"); assert!(result.unwrap_err().to_string().contains("path traversal")); } } #[test] fn public_absolute_path_unix() { let invalid_paths = vec!["/dist", "/var/www/dist"]; for path in invalid_paths { let yaml = format!( r#" image: "node:20-alpine" command: "npm run build" public: "{path}" "# ); let result = parse_yaml(&yaml); assert!(result.is_err(), "public path '{path}' should be rejected"); assert!(result.unwrap_err().to_string().contains("relative path")); } } #[test] fn valid_nested_public() { let valid_paths = vec![ "dist", "build/dist", "out/static", "_site", ".next/out", "dist..v2", "assets/..hidden", "foo..bar/dist", ]; for path in valid_paths { let yaml = format!( r#" image: "node:20-alpine" command: "npm run build" public: "{path}" "# ); let result = parse_yaml(&yaml); assert!(result.is_ok(), "public path '{path}' should be valid"); } } #[test] fn public_dot_segments_accepted() { let valid = vec![".", "./dist", "dist/.", "dist//assets"]; for path in valid { assert!( validate_public(path).is_ok(), "path '{path}' should be valid" ); } } // load_with_overrides tests #[tokio::test] async fn load_with_overrides_complete_skips_file() { // No need to create witryna.yaml since all overrides are provided let temp = temp_dir("repo-config-test").await; let overrides = BuildOverrides { image: Some("alpine:latest".to_owned()), command: Some("echo hello".to_owned()), public: Some("out".to_owned()), }; let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; assert!(result.is_ok()); let config = result.unwrap(); assert_eq!(config.image, "alpine:latest"); assert_eq!(config.command, "echo hello"); assert_eq!(config.public, "out"); cleanup(&temp).await; } #[tokio::test] async fn load_with_overrides_partial_merges() { let temp = temp_dir("repo-config-test").await; // Create witryna.yaml with base config let yaml = r#" image: "node:18" command: "npm run build" public: "dist" "#; tokio::fs::write(temp.join("witryna.yaml"), yaml) .await .unwrap(); // Override only the image let overrides = BuildOverrides { image: Some("node:20-alpine".to_owned()), command: None, public: None, }; let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; assert!(result.is_ok()); let config = result.unwrap(); assert_eq!(config.image, "node:20-alpine"); // Overridden assert_eq!(config.command, "npm run build"); // From yaml assert_eq!(config.public, "dist"); // From yaml cleanup(&temp).await; } #[tokio::test] async fn load_with_overrides_none_loads_yaml() { let temp = temp_dir("repo-config-test").await; // Create witryna.yaml let yaml = r#" image: "node:18" command: "npm run build" public: "dist" "#; tokio::fs::write(temp.join("witryna.yaml"), yaml) .await .unwrap(); let overrides = BuildOverrides::default(); let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; assert!(result.is_ok()); let config = result.unwrap(); assert_eq!(config.image, "node:18"); assert_eq!(config.command, "npm run build"); assert_eq!(config.public, "dist"); cleanup(&temp).await; } #[tokio::test] async fn load_with_overrides_missing_yaml_partial_fails() { let temp = temp_dir("repo-config-test").await; // No witryna.yaml, partial overrides let overrides = BuildOverrides { image: Some("node:20-alpine".to_owned()), command: None, // Missing, needs yaml public: None, }; let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("no build config found") ); cleanup(&temp).await; } #[tokio::test] async fn load_with_overrides_incomplete_needs_yaml() { let temp = temp_dir("repo-config-test").await; // Only command+public — incomplete (no image), no yaml file let overrides = BuildOverrides { image: None, command: Some("npm run build".to_owned()), public: Some("dist".to_owned()), }; let result = RepoConfig::load_with_overrides(&temp, &overrides, None).await; assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("no build config found") ); cleanup(&temp).await; } // Discovery chain tests const VALID_YAML: &str = "image: \"node:20\"\ncommand: \"npm run build\"\npublic: \"dist\"\n"; #[tokio::test] async fn load_finds_dot_witryna_yaml() { let temp = temp_dir("repo-config-test").await; tokio::fs::write(temp.join(".witryna.yaml"), VALID_YAML) .await .unwrap(); let config = RepoConfig::load(&temp, None).await.unwrap(); assert_eq!(config.image, "node:20"); cleanup(&temp).await; } #[tokio::test] async fn load_finds_dot_witryna_yml() { let temp = temp_dir("repo-config-test").await; tokio::fs::write(temp.join(".witryna.yml"), VALID_YAML) .await .unwrap(); let config = RepoConfig::load(&temp, None).await.unwrap(); assert_eq!(config.image, "node:20"); cleanup(&temp).await; } #[tokio::test] async fn load_precedence_dot_over_plain() { let temp = temp_dir("repo-config-test").await; let dot_yaml = "image: \"dot-image\"\ncommand: \"build\"\npublic: \"out\"\n"; let plain_yaml = "image: \"plain-image\"\ncommand: \"build\"\npublic: \"out\"\n"; tokio::fs::write(temp.join(".witryna.yaml"), dot_yaml) .await .unwrap(); tokio::fs::write(temp.join("witryna.yaml"), plain_yaml) .await .unwrap(); let config = RepoConfig::load(&temp, None).await.unwrap(); assert_eq!(config.image, "dot-image"); cleanup(&temp).await; } #[tokio::test] async fn load_custom_config_file() { let temp = temp_dir("repo-config-test").await; let subdir = temp.join("build"); tokio::fs::create_dir_all(&subdir).await.unwrap(); tokio::fs::write(subdir.join("config.yml"), VALID_YAML) .await .unwrap(); let config = RepoConfig::load(&temp, Some("build/config.yml")) .await .unwrap(); assert_eq!(config.image, "node:20"); cleanup(&temp).await; } #[tokio::test] async fn load_custom_config_file_not_found_errors() { let temp = temp_dir("repo-config-test").await; let result = RepoConfig::load(&temp, Some("nonexistent.yaml")).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("failed to read")); cleanup(&temp).await; } }