diff options
Diffstat (limited to 'src/publish.rs')
| -rw-r--r-- | src/publish.rs | 488 |
1 files changed, 488 insertions, 0 deletions
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, ¤t_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", ×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, "<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", ×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"), "<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", ×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; + } +} |
