summaryrefslogtreecommitdiff
path: root/src/publish.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/publish.rs')
-rw-r--r--src/publish.rs488
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, &current_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", &timestamp).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", &timestamp).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(&current_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", &timestamp1).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", &timestamp2).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(&current_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", &timestamp).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", &timestamp).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",
+ &timestamp,
+ )
+ .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", &timestamp).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;
+ }
+}