summaryrefslogtreecommitdiff
path: root/src/repo_config.rs
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-01-22 22:07:32 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-02-10 18:44:26 +0100
commit064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch)
treea2023f9ccd297ed8a41a3a0cc5699c2add09244d /src/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.rs523
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;
+ }
+}