use anyhow::{Context as _, Result, bail}; use log::{debug, info, warn}; use std::path::{Path, PathBuf}; /// Result of a successful publish operation. #[derive(Debug)] pub struct PublishResult { /// Path to the timestamped build directory containing the published assets. pub build_dir: PathBuf, /// Timestamp used for the build directory name. pub timestamp: String, } /// Publish built assets with atomic symlink switching. /// /// # Arguments /// * `base_dir` - Base witryna directory (e.g., /var/lib/witryna) /// * `site_name` - The site name (already validated) /// * `clone_dir` - Path to the cloned repository /// * `public` - Relative path to built assets within `clone_dir` (e.g., "dist") /// * `timestamp` - Timestamp string for the build directory (format: %Y%m%d-%H%M%S-%f) /// /// # Errors /// /// Returns an error if the source directory doesn't exist, the asset copy /// fails, or the atomic symlink switch fails. /// /// # Workflow /// 1. Validate source directory exists /// 2. Create timestamped build directory: {`base_dir}/builds/{site_name}/{timestamp`} /// 3. Copy assets from {`clone_dir}/{public`}/ to the timestamped directory /// 4. Atomic symlink switch: update {`base_dir}/builds/{site_name}/current` pub async fn publish( base_dir: &Path, site_name: &str, clone_dir: &Path, public: &str, timestamp: &str, ) -> Result { // 1. Construct source path and validate it exists let source_dir = clone_dir.join(public); if !source_dir.exists() { bail!("public directory does not exist"); } if !source_dir.is_dir() { bail!("public path is not a directory"); } // 2. Create build directory with provided timestamp let site_builds_dir = base_dir.join("builds").join(site_name); let build_dir = site_builds_dir.join(timestamp); let current_link = site_builds_dir.join("current"); info!( "publishing assets: source={} destination={}", source_dir.display(), build_dir.display() ); // 3. Create builds directory structure tokio::fs::create_dir_all(&site_builds_dir) .await .with_context(|| { format!( "failed to create builds directory: {}", site_builds_dir.display() ) })?; // 4. Copy assets recursively copy_dir_contents(&source_dir, &build_dir) .await .context("failed to copy assets")?; // 5. Atomic symlink switch atomic_symlink_update(&build_dir, ¤t_link).await?; debug!( "publish completed: build_dir={} symlink={}", build_dir.display(), current_link.display() ); 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() { warn!("skipping symlink in build output: {}", entry_path.display()); 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. /// /// # Errors /// /// Returns an error if the temporary symlink cannot be created or the atomic rename fails. pub 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 tokio::fs; fn test_timestamp() -> String { crate::time::format_build_timestamp(std::time::SystemTime::now()) } #[tokio::test] async fn publish_copies_assets_to_timestamped_directory() { let base_dir = temp_dir("publish-test").await; let clone_dir = temp_dir("publish-test").await; // Create source assets let source = clone_dir.join("dist"); fs::create_dir_all(&source).await.unwrap(); fs::write(source.join("index.html"), "hello") .await .unwrap(); let timestamp = test_timestamp(); let result = publish(&base_dir, "my-site", &clone_dir, "dist", ×tamp).await; assert!(result.is_ok(), "publish should succeed: {result:?}"); let publish_result = result.unwrap(); // Verify timestamp is used for build directory assert_eq!(publish_result.timestamp, timestamp); // Verify assets were copied let copied_file = publish_result.build_dir.join("index.html"); assert!(copied_file.exists(), "copied file should exist"); let content = fs::read_to_string(&copied_file).await.unwrap(); assert_eq!(content, "hello"); cleanup(&base_dir).await; cleanup(&clone_dir).await; } #[tokio::test] async fn publish_creates_current_symlink() { let base_dir = temp_dir("publish-test").await; let clone_dir = temp_dir("publish-test").await; // Create source assets let source = clone_dir.join("public"); fs::create_dir_all(&source).await.unwrap(); fs::write(source.join("file.txt"), "content").await.unwrap(); let timestamp = test_timestamp(); let result = publish(&base_dir, "test-site", &clone_dir, "public", ×tamp).await; assert!(result.is_ok(), "publish should succeed: {result:?}"); let publish_result = result.unwrap(); // Verify current symlink exists and points to build dir let current_link = base_dir.join("builds/test-site/current"); assert!(current_link.exists(), "current symlink should exist"); let link_target = fs::read_link(¤t_link).await.unwrap(); assert_eq!(link_target, publish_result.build_dir); cleanup(&base_dir).await; cleanup(&clone_dir).await; } #[tokio::test] async fn publish_symlink_updated_on_second_publish() { let base_dir = temp_dir("publish-test").await; let clone_dir = temp_dir("publish-test").await; // Create source assets let source = clone_dir.join("dist"); fs::create_dir_all(&source).await.unwrap(); fs::write(source.join("file.txt"), "v1").await.unwrap(); // First publish let timestamp1 = "20260126-100000-000001".to_owned(); let result1 = publish(&base_dir, "my-site", &clone_dir, "dist", ×tamp1).await; assert!(result1.is_ok()); let publish1 = result1.unwrap(); // Update source and publish again with different timestamp fs::write(source.join("file.txt"), "v2").await.unwrap(); let timestamp2 = "20260126-100000-000002".to_owned(); let result2 = publish(&base_dir, "my-site", &clone_dir, "dist", ×tamp2).await; assert!(result2.is_ok()); let publish2 = result2.unwrap(); // Verify symlink points to second build let current_link = base_dir.join("builds/my-site/current"); let link_target = fs::read_link(¤t_link).await.unwrap(); assert_eq!(link_target, publish2.build_dir); // Verify both build directories still exist assert!( publish1.build_dir.exists(), "first build should still exist" ); assert!(publish2.build_dir.exists(), "second build should exist"); // Verify content is correct let content = fs::read_to_string(publish2.build_dir.join("file.txt")) .await .unwrap(); assert_eq!(content, "v2"); cleanup(&base_dir).await; cleanup(&clone_dir).await; } #[tokio::test] async fn publish_missing_source_returns_error() { let base_dir = temp_dir("publish-test").await; let clone_dir = temp_dir("publish-test").await; // Don't create source directory let timestamp = test_timestamp(); let result = publish(&base_dir, "my-site", &clone_dir, "nonexistent", ×tamp).await; assert!(result.is_err(), "publish should fail"); let err = result.unwrap_err().to_string(); assert!(err.contains("public directory does not exist")); cleanup(&base_dir).await; cleanup(&clone_dir).await; } #[tokio::test] async fn publish_source_is_file_returns_error() { let base_dir = temp_dir("publish-test").await; let clone_dir = temp_dir("publish-test").await; // Create a file instead of directory fs::write(clone_dir.join("dist"), "not a directory") .await .unwrap(); let timestamp = test_timestamp(); let result = publish(&base_dir, "my-site", &clone_dir, "dist", ×tamp).await; assert!(result.is_err(), "publish should fail"); let err = result.unwrap_err().to_string(); assert!(err.contains("public path is not a directory")); cleanup(&base_dir).await; cleanup(&clone_dir).await; } #[tokio::test] async fn publish_nested_public_directory() { let base_dir = temp_dir("publish-test").await; let clone_dir = temp_dir("publish-test").await; // Create nested source directory let source = clone_dir.join("build/output/dist"); fs::create_dir_all(&source).await.unwrap(); fs::write(source.join("app.js"), "console.log('hello')") .await .unwrap(); let timestamp = test_timestamp(); let result = publish( &base_dir, "my-site", &clone_dir, "build/output/dist", ×tamp, ) .await; assert!(result.is_ok(), "publish should succeed: {result:?}"); let publish_result = result.unwrap(); // Verify file was copied let copied_file = publish_result.build_dir.join("app.js"); assert!(copied_file.exists(), "copied file should exist"); cleanup(&base_dir).await; cleanup(&clone_dir).await; } #[tokio::test] async fn publish_preserves_directory_structure() { let base_dir = temp_dir("publish-test").await; let clone_dir = temp_dir("publish-test").await; // Create source with subdirectories let source = clone_dir.join("public"); fs::create_dir_all(source.join("css")).await.unwrap(); fs::create_dir_all(source.join("js")).await.unwrap(); fs::write(source.join("index.html"), "") .await .unwrap(); fs::write(source.join("css/style.css"), "body {}") .await .unwrap(); fs::write(source.join("js/app.js"), "// app").await.unwrap(); let timestamp = test_timestamp(); let result = publish(&base_dir, "my-site", &clone_dir, "public", ×tamp).await; assert!(result.is_ok(), "publish should succeed: {result:?}"); let publish_result = result.unwrap(); // Verify structure preserved assert!(publish_result.build_dir.join("index.html").exists()); assert!(publish_result.build_dir.join("css/style.css").exists()); assert!(publish_result.build_dir.join("js/app.js").exists()); cleanup(&base_dir).await; cleanup(&clone_dir).await; } #[tokio::test] async fn atomic_symlink_update_replaces_existing() { let temp = temp_dir("publish-test").await; // Create two target directories let target1 = temp.join("build-1"); let target2 = temp.join("build-2"); fs::create_dir_all(&target1).await.unwrap(); fs::create_dir_all(&target2).await.unwrap(); let link_path = temp.join("current"); // Create initial symlink atomic_symlink_update(&target1, &link_path).await.unwrap(); let link1 = fs::read_link(&link_path).await.unwrap(); assert_eq!(link1, target1); // Update symlink atomic_symlink_update(&target2, &link_path).await.unwrap(); let link2 = fs::read_link(&link_path).await.unwrap(); assert_eq!(link2, target2); cleanup(&temp).await; } #[tokio::test] async fn copy_dir_contents_skips_symlinks() { let src = temp_dir("publish-test").await; let dst = temp_dir("publish-test").await; // Create a normal file fs::write(src.join("real.txt"), "hello").await.unwrap(); // Create a symlink pointing outside the directory let outside = temp_dir("publish-test").await; fs::write(outside.join("secret.txt"), "secret") .await .unwrap(); tokio::fs::symlink(outside.join("secret.txt"), src.join("link.txt")) .await .unwrap(); // Run copy let dest = dst.join("output"); copy_dir_contents(&src, &dest).await.unwrap(); // Normal file should be copied assert!(dest.join("real.txt").exists(), "real file should be copied"); let content = fs::read_to_string(dest.join("real.txt")).await.unwrap(); assert_eq!(content, "hello"); // Symlink should NOT be copied assert!( !dest.join("link.txt").exists(), "symlink should not be copied" ); cleanup(&src).await; cleanup(&dst).await; cleanup(&outside).await; } #[tokio::test] async fn copy_dir_contents_preserves_permissions() { use std::os::unix::fs::PermissionsExt; let src = temp_dir("publish-test").await; let dst = temp_dir("publish-test").await; // Create a file with executable permissions (0o755) fs::write(src.join("script.sh"), "#!/bin/sh\necho hi") .await .unwrap(); let mut perms = fs::metadata(src.join("script.sh")) .await .unwrap() .permissions(); perms.set_mode(0o755); fs::set_permissions(src.join("script.sh"), perms) .await .unwrap(); // Create a file with restrictive permissions (0o644) fs::write(src.join("data.txt"), "data").await.unwrap(); let dest = dst.join("output"); copy_dir_contents(&src, &dest).await.unwrap(); // Verify executable permission preserved let copied_perms = fs::metadata(dest.join("script.sh")) .await .unwrap() .permissions(); assert_eq!( copied_perms.mode() & 0o777, 0o755, "executable permissions should be preserved" ); cleanup(&src).await; cleanup(&dst).await; } }