msy 0.4.6

Modern musl rsync alternative - Fast, parallel file synchronization
Documentation
// Concurrent sync safety - file-based locking
//
// Prevents multiple sy processes from syncing the same directory pair simultaneously,
// which could lead to race conditions and data corruption.

use crate::error::Result;
use fs2::FileExt;
use std::fs::{self, File};
use std::path::{Path, PathBuf};

/// Lock guard for a sync pair
/// Lock is automatically released when guard is dropped
#[derive(Debug)]
pub struct SyncLock {
	_lock_file: File,
	lock_path: PathBuf,
}

impl SyncLock {
	/// Acquire exclusive lock for source/dest pair
	///
	/// Returns error if another process already holds the lock
	pub fn acquire(source: &Path, dest: &Path) -> Result<Self> {
		let lock_path = Self::get_lock_path(source, dest)?;

		// Create lock file
		let lock_file = fs::OpenOptions::new().create(true).write(true).truncate(true).open(&lock_path)?;

		// Try to acquire exclusive lock (non-blocking)
		match lock_file.try_lock_exclusive() {
			Ok(()) => {
				// Write PID to lock file for debugging
				use std::io::Write;
				let mut file_mut = &lock_file;
				let pid = std::process::id();
				writeln!(file_mut, "{}", pid)?;

				Ok(Self { _lock_file: lock_file, lock_path })
			}
			Err(_) => {
				// Lock is held by another process
				Err(crate::error::SyncError::SyncLocked {
					source_path: source.display().to_string(),
					dest_path: dest.display().to_string(),
					lock_file: lock_path.display().to_string(),
				})
			}
		}
	}

	/// Get lock file path for source/dest pair
	fn get_lock_path(source: &Path, dest: &Path) -> Result<PathBuf> {
		use std::collections::hash_map::DefaultHasher;
		use std::hash::{Hash, Hasher};

		let mut hasher = DefaultHasher::new();
		source.to_string_lossy().hash(&mut hasher);
		dest.to_string_lossy().hash(&mut hasher);
		let hash = format!("{:x}", hasher.finish());

		let cache_dir = if let Ok(xdg_cache) = std::env::var("XDG_CACHE_HOME") {
			PathBuf::from(xdg_cache)
		} else if let Ok(home) = std::env::var("HOME") {
			PathBuf::from(home).join(".cache")
		} else {
			return Err(crate::error::SyncError::Config("Cannot determine cache directory (HOME not set)".to_string()));
		};

		let lock_dir = cache_dir.join("sy").join("locks");
		fs::create_dir_all(&lock_dir)?;

		Ok(lock_dir.join(format!("{}.lock", hash)))
	}
}

impl Drop for SyncLock {
	fn drop(&mut self) {
		// Lock file is automatically unlocked when _lock_file is dropped
		// Clean up lock file on drop (best effort)
		let _ = fs::remove_file(&self.lock_path);
	}
}

#[cfg(test)]
mod tests {
	use super::*;
	use serial_test::serial;
	use std::thread;
	use std::time::Duration;
	use tempfile::TempDir;

	#[test]
	#[serial]
	fn test_acquire_lock() {
		let temp_dir = TempDir::new().unwrap();
		let source = temp_dir.path().join("source");
		let dest = temp_dir.path().join("dest");

		// Save original env var
		let original = std::env::var("XDG_CACHE_HOME").ok();

		// Set cache dir for test isolation
		let cache_dir = temp_dir.path().join("cache");
		unsafe {
			std::env::set_var("XDG_CACHE_HOME", &cache_dir);
		}

		let lock = SyncLock::acquire(&source, &dest).unwrap();

		// Verify lock file exists
		let lock_path = SyncLock::get_lock_path(&source, &dest).unwrap();
		assert!(lock_path.exists());

		drop(lock);

		// Verify lock file is cleaned up
		assert!(!lock_path.exists());

		// Restore original env var
		match original {
			Some(val) => unsafe { std::env::set_var("XDG_CACHE_HOME", val) },
			None => unsafe { std::env::remove_var("XDG_CACHE_HOME") },
		}
	}

	#[test]
	#[serial]
	fn test_concurrent_lock_fails() {
		let temp_dir = TempDir::new().unwrap();
		let source = temp_dir.path().join("source");
		let dest = temp_dir.path().join("dest");

		// Save original env var
		let original = std::env::var("XDG_CACHE_HOME").ok();

		// Set cache dir for test isolation
		let cache_dir = temp_dir.path().join("cache");
		unsafe {
			std::env::set_var("XDG_CACHE_HOME", &cache_dir);
		};

		// Acquire first lock
		let _lock1 = SyncLock::acquire(&source, &dest).unwrap();

		// Second lock attempt should fail
		let result = SyncLock::acquire(&source, &dest);
		assert!(result.is_err());

		let err = result.unwrap_err();
		let err_str = format!("{}", err);
		assert!(err_str.contains("already in progress"));

		// Restore original env var
		match original {
			Some(val) => unsafe { std::env::set_var("XDG_CACHE_HOME", val) },
			None => unsafe { std::env::remove_var("XDG_CACHE_HOME") },
		}
	}

	#[test]
	#[serial]
	fn test_lock_released_on_drop() {
		let temp_dir = TempDir::new().unwrap();
		let source = temp_dir.path().join("source");
		let dest = temp_dir.path().join("dest");

		// Save original env var
		let original = std::env::var("XDG_CACHE_HOME").ok();

		// Set cache dir for test isolation
		let cache_dir = temp_dir.path().join("cache");
		unsafe {
			std::env::set_var("XDG_CACHE_HOME", &cache_dir);
		};

		{
			let _lock1 = SyncLock::acquire(&source, &dest).unwrap();
			// Lock is held here
		} // lock1 dropped, lock released

		// Should be able to acquire lock again
		let _lock2 = SyncLock::acquire(&source, &dest).unwrap();

		// Restore original env var
		match original {
			Some(val) => unsafe { std::env::set_var("XDG_CACHE_HOME", val) },
			None => unsafe { std::env::remove_var("XDG_CACHE_HOME") },
		}
	}

	#[test]
	#[serial]
	fn test_different_pairs_independent() {
		let temp_dir = TempDir::new().unwrap();
		let source1 = temp_dir.path().join("source1");
		let dest1 = temp_dir.path().join("dest1");
		let source2 = temp_dir.path().join("source2");
		let dest2 = temp_dir.path().join("dest2");

		// Save original env var
		let original = std::env::var("XDG_CACHE_HOME").ok();

		// Set cache dir for test isolation
		let cache_dir = temp_dir.path().join("cache");
		unsafe {
			std::env::set_var("XDG_CACHE_HOME", &cache_dir);
		};

		// Should be able to lock different pairs simultaneously
		let _lock1 = SyncLock::acquire(&source1, &dest1).unwrap();
		let _lock2 = SyncLock::acquire(&source2, &dest2).unwrap();

		// Restore original env var
		match original {
			Some(val) => unsafe { std::env::set_var("XDG_CACHE_HOME", val) },
			None => unsafe { std::env::remove_var("XDG_CACHE_HOME") },
		}
	}

	#[test]
	#[serial]
	fn test_lock_across_threads() {
		let temp_dir = TempDir::new().unwrap();
		let source = temp_dir.path().join("source");
		let dest = temp_dir.path().join("dest");

		// Save original env var
		let original = std::env::var("XDG_CACHE_HOME").ok();

		// Set cache dir for test isolation (before spawning thread)
		let cache_dir = temp_dir.path().join("cache");
		unsafe {
			std::env::set_var("XDG_CACHE_HOME", &cache_dir);
		};

		// Acquire lock in main thread
		let lock = SyncLock::acquire(&source, &dest).unwrap();

		let source_clone = source.clone();
		let dest_clone = dest.clone();

		// Spawn thread that tries to acquire same lock
		let handle = thread::spawn(move || SyncLock::acquire(&source_clone, &dest_clone));

		// Give thread time to attempt lock acquisition
		thread::sleep(Duration::from_millis(100));

		// Thread should have failed to acquire lock
		let result = handle.join().unwrap();
		assert!(result.is_err(), "Expected lock acquisition to fail while lock is held");

		// Release lock
		drop(lock);

		// Now should be able to acquire lock again
		let _lock2 = SyncLock::acquire(&source, &dest).unwrap();

		// Restore original env var
		match original {
			Some(val) => unsafe { std::env::set_var("XDG_CACHE_HOME", val) },
			None => unsafe { std::env::remove_var("XDG_CACHE_HOME") },
		}
	}
}