use crate::cli::SymlinkMode;
use crate::error::Result;
use crate::sync::scanner::FileEntry;
use crate::transport::{TransferResult, Transport};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::sync::Notify;
#[derive(Clone, Debug)]
pub(crate) enum InodeState {
InProgress(Arc<Notify>),
Completed(PathBuf),
}
pub struct Transferrer<'a, T: Transport> {
transport: &'a T,
dry_run: bool,
diff_mode: bool, symlink_mode: SymlinkMode,
preserve_xattrs: bool,
preserve_hardlinks: bool,
preserve_acls: bool,
#[allow(dead_code)] preserve_flags: bool,
per_file_progress: bool, hardlink_map: Arc<Mutex<HashMap<u64, InodeState>>>, }
impl<'a, T: Transport> Transferrer<'a, T> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
transport: &'a T,
dry_run: bool,
diff_mode: bool,
symlink_mode: SymlinkMode,
preserve_xattrs: bool,
preserve_hardlinks: bool,
preserve_acls: bool,
preserve_flags: bool, per_file_progress: bool,
hardlink_map: Arc<Mutex<HashMap<u64, InodeState>>>,
) -> Self {
Self {
transport,
dry_run,
diff_mode,
symlink_mode,
preserve_xattrs,
preserve_hardlinks,
preserve_acls,
preserve_flags,
per_file_progress,
hardlink_map,
}
}
pub async fn create(&self, source: &FileEntry, dest_path: &Path) -> Result<Option<TransferResult>> {
if self.dry_run {
if self.diff_mode && !source.is_dir {
tracing::info!("Would create: {} ({})", dest_path.display(), Self::format_size(source.size));
} else {
tracing::info!("Would create: {}", dest_path.display());
}
return Ok(None);
}
if source.is_symlink {
return self.handle_symlink(source, dest_path).await;
}
if source.is_dir {
self.create_directory(dest_path).await?;
Ok(None)
} else {
if self.preserve_hardlinks
&& source.nlink > 1
&& let Some(inode) = source.inode
{
loop {
let state = {
let map = self.hardlink_map.lock().expect("hardlink map poisoned");
map.get(&inode).cloned()
};
match state {
Some(InodeState::Completed(first_path)) => {
tracing::debug!("Creating hardlink: {} -> {} (inode: {})", dest_path.display(), first_path.display(), inode);
self.transport.create_hardlink(&first_path, dest_path).await?;
return Ok(Some(TransferResult {
bytes_written: 0,
compression_used: false,
transferred_bytes: Some(0),
delta_operations: None,
literal_bytes: None,
}));
}
Some(InodeState::InProgress(notify)) => {
tracing::debug!("Waiting for inode {} to complete ({})", inode, source.path.display());
notify.notified().await;
continue;
}
None => {
let notify = Arc::new(Notify::new());
{
let mut map = self.hardlink_map.lock().expect("hardlink map poisoned");
if map.contains_key(&inode) {
continue; }
map.insert(inode, InodeState::InProgress(Arc::clone(¬ify)));
}
tracing::debug!("First occurrence of inode {}, copying {} to {}", inode, source.path.display(), dest_path.display());
let result = self.copy_file(&source.path, dest_path, source.size).await?;
self.write_xattrs(source, dest_path).await?;
self.write_acls(source, dest_path).await?;
self.write_bsd_flags(source, dest_path).await?;
{
let mut map = self.hardlink_map.lock().expect("hardlink map poisoned");
map.insert(inode, InodeState::Completed(dest_path.to_path_buf()));
}
notify.notify_waiters();
return Ok(Some(result));
}
}
}
}
let result = self.copy_file(&source.path, dest_path, source.size).await?;
self.write_xattrs(source, dest_path).await?;
self.write_acls(source, dest_path).await?;
self.write_bsd_flags(source, dest_path).await?;
Ok(Some(result))
}
}
pub async fn update(&self, source: &FileEntry, dest_path: &Path) -> Result<Option<TransferResult>> {
if self.dry_run {
if self.diff_mode && !source.is_dir {
tracing::info!("Would update: {} ({}, using delta sync)", dest_path.display(), Self::format_size(source.size));
} else {
tracing::info!("Would update: {}", dest_path.display());
}
return Ok(None);
}
if !source.is_dir {
let result = self.transport.sync_file_with_delta(&source.path, dest_path).await?;
self.write_xattrs(source, dest_path).await?;
self.write_acls(source, dest_path).await?;
self.write_bsd_flags(source, dest_path).await?;
tracing::info!("Updated: {} -> {}", source.path.display(), dest_path.display());
Ok(Some(result))
} else {
Ok(None)
}
}
pub async fn delete(&self, dest_path: &Path, is_dir: bool) -> Result<()> {
if self.dry_run {
tracing::info!("Would delete: {}", dest_path.display());
return Ok(());
}
self.transport.remove(dest_path, is_dir).await?;
tracing::info!("Deleted: {}", dest_path.display());
Ok(())
}
async fn create_directory(&self, path: &Path) -> Result<()> {
self.transport.create_dir_all(path).await?;
tracing::debug!("Created directory: {}", path.display());
Ok(())
}
async fn copy_file(&self, source: &Path, dest: &Path, file_size: u64) -> Result<TransferResult> {
use crate::sync::progress::{MIN_SIZE_FOR_PROGRESS, create_progress_callback};
if let Some(parent) = dest.parent() {
self.transport.create_dir_all(parent).await?;
}
let result = if self.per_file_progress && file_size >= MIN_SIZE_FOR_PROGRESS {
let progress_callback = create_progress_callback(source, file_size);
self.transport.copy_file_streaming(source, dest, Some(progress_callback)).await?
} else {
self.transport.copy_file(source, dest).await?
};
tracing::debug!("Copied: {} -> {}", source.display(), dest.display());
Ok(result)
}
async fn write_xattrs(&self, file_entry: &FileEntry, dest_path: &Path) -> Result<()> {
if !self.preserve_xattrs {
return Ok(());
}
#[cfg(unix)]
{
if let Some(ref xattrs) = file_entry.xattrs {
if xattrs.is_empty() {
return Ok(());
}
let xattrs_vec: Vec<(String, Vec<u8>)> = xattrs.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
self.transport.set_xattrs(dest_path, &xattrs_vec).await?;
}
}
#[cfg(not(unix))]
{
let _ = (file_entry, dest_path);
}
Ok(())
}
async fn write_acls(&self, file_entry: &FileEntry, dest_path: &Path) -> Result<()> {
if !self.preserve_acls {
return Ok(());
}
#[cfg(unix)]
{
if let Some(ref acls_bytes) = file_entry.acls {
if acls_bytes.is_empty() {
return Ok(());
}
let acls_text = match String::from_utf8(acls_bytes.clone()) {
Ok(text) => text,
Err(e) => {
tracing::warn!("Failed to parse ACL text for {}: {}", dest_path.display(), e);
return Ok(());
}
};
self.transport.set_acls(dest_path, &acls_text).await?;
}
}
#[cfg(not(unix))]
{
let _ = (file_entry, dest_path);
}
Ok(())
}
async fn write_bsd_flags(&self, file_entry: &FileEntry, dest_path: &Path) -> Result<()> {
#[cfg(not(target_os = "macos"))]
{
let _ = (file_entry, dest_path);
Ok(())
}
#[cfg(target_os = "macos")]
{
let flags_to_set = if self.preserve_flags {
file_entry.bsd_flags.unwrap_or(0)
} else {
0
};
self.transport.set_bsd_flags(dest_path, flags_to_set).await?;
Ok(())
}
}
async fn handle_symlink(&self, source: &FileEntry, dest_path: &Path) -> Result<Option<TransferResult>> {
match self.symlink_mode {
SymlinkMode::Skip => {
tracing::debug!("Skipping symlink: {}", source.path.display());
Ok(None)
}
SymlinkMode::Follow => {
if let Some(ref target) = source.symlink_target {
if !target.exists() {
tracing::warn!("Symlink target does not exist: {} -> {}", source.path.display(), target.display());
return Ok(None);
}
if target.is_dir() {
tracing::warn!("Skipping symlink to directory (not supported in follow mode): {}", source.path.display());
Ok(None)
} else {
let file_size = self.transport.file_info(target).await?.size;
let result = self.copy_file(target, dest_path, file_size).await?;
tracing::debug!("Followed symlink and copied target: {} -> {}", target.display(), dest_path.display());
Ok(Some(result))
}
} else {
tracing::warn!("Symlink has no target: {}", source.path.display());
Ok(None)
}
}
SymlinkMode::Preserve => {
if let Some(ref target) = source.symlink_target {
self.transport.create_symlink(target, dest_path).await?;
tracing::debug!("Created symlink: {} -> {}", dest_path.display(), target.display());
Ok(None)
} else {
tracing::warn!("Symlink has no target: {}", source.path.display());
Ok(None)
}
}
}
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
const TB: u64 = GB * 1024;
if bytes >= TB {
format!("{:.2} TB", bytes as f64 / TB as f64)
} else if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::local::LocalTransport;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;
use tempfile::TempDir;
#[tokio::test]
async fn test_copy_file() {
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let source_file = source_dir.path().join("test.txt");
fs::write(&source_file, "test content").unwrap();
let file_entry = FileEntry {
path: Arc::new(source_file),
relative_path: Arc::new(PathBuf::from("test.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
let dest_path = dest_dir.path().join("test.txt");
transferrer.create(&file_entry, &dest_path).await.unwrap();
assert!(dest_path.exists());
assert_eq!(fs::read_to_string(&dest_path).unwrap(), "test content");
}
#[tokio::test]
async fn test_dry_run() {
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let source_file = source_dir.path().join("test.txt");
fs::write(&source_file, "test content").unwrap();
let file_entry = FileEntry {
path: Arc::new(source_file),
relative_path: Arc::new(PathBuf::from("test.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, true, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map); let dest_path = dest_dir.path().join("test.txt");
transferrer.create(&file_entry, &dest_path).await.unwrap();
assert!(!dest_path.exists());
}
#[tokio::test]
async fn test_create_directory() {
let dest_dir = TempDir::new().unwrap();
let dir_entry = FileEntry {
path: Arc::new(PathBuf::from("/source/subdir")),
relative_path: Arc::new(PathBuf::from("subdir")),
size: 0,
modified: SystemTime::now(),
is_dir: true,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 0,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
let dest_path = dest_dir.path().join("subdir");
transferrer.create(&dir_entry, &dest_path).await.unwrap();
assert!(dest_path.exists());
assert!(dest_path.is_dir());
}
#[tokio::test]
#[cfg(unix)] async fn test_symlink_preserve() {
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let target_file = source_dir.path().join("target.txt");
fs::write(&target_file, "target content").unwrap();
let link_file = source_dir.path().join("link.txt");
std::os::unix::fs::symlink(&target_file, &link_file).unwrap();
let link_target = std::fs::read_link(&link_file).unwrap();
let file_entry = FileEntry {
path: Arc::new(link_file),
relative_path: Arc::new(PathBuf::from("link.txt")),
size: 0,
modified: SystemTime::now(),
is_dir: false,
is_symlink: true,
symlink_target: Some(Arc::new(link_target.clone())),
is_sparse: false,
allocated_size: 0,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
let dest_path = dest_dir.path().join("link.txt");
transferrer.create(&file_entry, &dest_path).await.unwrap();
assert!(dest_path.exists());
assert!(dest_path.is_symlink());
let dest_target = std::fs::read_link(&dest_path).unwrap();
assert_eq!(dest_target, link_target);
}
#[tokio::test]
#[cfg(unix)]
async fn test_symlink_follow() {
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let target_file = source_dir.path().join("target.txt");
fs::write(&target_file, "target content").unwrap();
let link_file = source_dir.path().join("link.txt");
std::os::unix::fs::symlink(&target_file, &link_file).unwrap();
let file_entry = FileEntry {
path: Arc::new(link_file),
relative_path: Arc::new(PathBuf::from("link.txt")),
size: 0,
modified: SystemTime::now(),
is_dir: false,
is_symlink: true,
symlink_target: Some(Arc::new(target_file)),
is_sparse: false,
allocated_size: 0,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Follow, false, false, false, false, false, hardlink_map);
let dest_path = dest_dir.path().join("link.txt");
transferrer.create(&file_entry, &dest_path).await.unwrap();
assert!(dest_path.exists());
assert!(!dest_path.is_symlink());
let content = fs::read_to_string(&dest_path).unwrap();
assert_eq!(content, "target content");
}
#[tokio::test]
#[cfg(unix)]
async fn test_symlink_skip() {
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let target_file = source_dir.path().join("target.txt");
fs::write(&target_file, "target content").unwrap();
let link_file = source_dir.path().join("link.txt");
std::os::unix::fs::symlink(&target_file, &link_file).unwrap();
let file_entry = FileEntry {
path: Arc::new(link_file),
relative_path: Arc::new(PathBuf::from("link.txt")),
size: 0,
modified: SystemTime::now(),
is_dir: false,
is_symlink: true,
symlink_target: Some(Arc::new(target_file)),
is_sparse: false,
allocated_size: 0,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Skip, false, false, false, false, false, hardlink_map);
let dest_path = dest_dir.path().join("link.txt");
transferrer.create(&file_entry, &dest_path).await.unwrap();
assert!(!dest_path.exists());
}
#[tokio::test]
#[cfg(unix)] async fn test_xattr_preservation() {
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let source_file = source_dir.path().join("test.txt");
fs::write(&source_file, "test content").unwrap();
xattr::set(&source_file, "user.test", b"value1").unwrap();
xattr::set(&source_file, "user.another", b"value2").unwrap();
let file_entry = FileEntry {
path: Arc::new(source_file),
relative_path: Arc::new(PathBuf::from("test.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: Some(
[("user.test".to_string(), b"value1".to_vec()), ("user.another".to_string(), b"value2".to_vec())]
.iter()
.cloned()
.collect(),
),
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, true, false, false, false, false, hardlink_map); let dest_path = dest_dir.path().join("test.txt");
transferrer.create(&file_entry, &dest_path).await.unwrap();
assert!(dest_path.exists());
let xattr1 = xattr::get(&dest_path, "user.test").unwrap().unwrap();
assert_eq!(xattr1, b"value1");
let xattr2 = xattr::get(&dest_path, "user.another").unwrap().unwrap();
assert_eq!(xattr2, b"value2");
}
#[tokio::test]
#[cfg(unix)]
async fn test_xattr_not_preserved_without_flag() {
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let source_file = source_dir.path().join("test.txt");
fs::write(&source_file, "test content").unwrap();
xattr::set(&source_file, "user.test", b"value1").unwrap();
let file_entry = FileEntry {
path: Arc::new(source_file),
relative_path: Arc::new(PathBuf::from("test.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: Some([("user.test".to_string(), b"value1".to_vec())].iter().cloned().collect()),
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map); let dest_path = dest_dir.path().join("test.txt");
transferrer.create(&file_entry, &dest_path).await.unwrap();
assert!(dest_path.exists());
let xattr = xattr::get(&dest_path, "user.test").unwrap();
assert!(xattr.is_none(), "Xattr should not be preserved when flag is false");
}
#[tokio::test]
#[cfg(unix)] async fn test_hardlink_preservation() {
use std::os::unix::fs::MetadataExt;
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let original_file = source_dir.path().join("original.txt");
fs::write(&original_file, "content").unwrap();
let link_file = source_dir.path().join("link.txt");
fs::hard_link(&original_file, &link_file).unwrap();
let original_meta = fs::metadata(&original_file).unwrap();
let inode = original_meta.ino();
let original_entry = FileEntry {
path: Arc::new(original_file),
relative_path: Arc::new(PathBuf::from("original.txt")),
size: 7,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 7,
xattrs: None,
inode: Some(inode),
nlink: 2,
acls: None,
bsd_flags: None,
};
let link_entry = FileEntry {
path: Arc::new(link_file),
relative_path: Arc::new(PathBuf::from("link.txt")),
size: 7,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 7,
xattrs: None,
inode: Some(inode),
nlink: 2,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(
&transport,
false,
false,
SymlinkMode::Preserve,
false,
true,
false,
false,
false, Arc::clone(&hardlink_map),
);
let dest_original = dest_dir.path().join("original.txt");
transferrer.create(&original_entry, &dest_original).await.unwrap();
let dest_link = dest_dir.path().join("link.txt");
transferrer.create(&link_entry, &dest_link).await.unwrap();
assert!(dest_original.exists());
assert!(dest_link.exists());
let dest_original_meta = fs::metadata(&dest_original).unwrap();
let dest_link_meta = fs::metadata(&dest_link).unwrap();
assert_eq!(dest_original_meta.ino(), dest_link_meta.ino(), "Destination files should be hardlinks (same inode)");
assert_eq!(dest_original_meta.nlink(), 2);
assert_eq!(dest_link_meta.nlink(), 2);
let map = hardlink_map.lock().unwrap();
assert!(map.contains_key(&inode), "Inode should be in hardlink map");
match map.get(&inode).unwrap() {
InodeState::Completed(path) => assert_eq!(path, &dest_original),
InodeState::InProgress(_) => panic!("Expected Completed state, got InProgress"),
}
}
#[tokio::test]
#[cfg(unix)]
async fn test_hardlink_not_preserved_without_flag() {
use std::os::unix::fs::MetadataExt;
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let original_file = source_dir.path().join("original.txt");
fs::write(&original_file, "content").unwrap();
let link_file = source_dir.path().join("link.txt");
fs::hard_link(&original_file, &link_file).unwrap();
let original_meta = fs::metadata(&original_file).unwrap();
let inode = original_meta.ino();
let original_entry = FileEntry {
path: Arc::new(original_file),
relative_path: Arc::new(PathBuf::from("original.txt")),
size: 7,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 7,
xattrs: None,
inode: Some(inode),
nlink: 2,
acls: None,
bsd_flags: None,
};
let link_entry = FileEntry {
path: Arc::new(link_file),
relative_path: Arc::new(PathBuf::from("link.txt")),
size: 7,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 7,
xattrs: None,
inode: Some(inode),
nlink: 2,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
let dest_original = dest_dir.path().join("original.txt");
transferrer.create(&original_entry, &dest_original).await.unwrap();
let dest_link = dest_dir.path().join("link.txt");
transferrer.create(&link_entry, &dest_link).await.unwrap();
assert!(dest_original.exists());
assert!(dest_link.exists());
let dest_original_meta = fs::metadata(&dest_original).unwrap();
let dest_link_meta = fs::metadata(&dest_link).unwrap();
assert_ne!(
dest_original_meta.ino(),
dest_link_meta.ino(),
"Destination files should NOT be hardlinks (different inodes) when flag is false"
);
assert_eq!(dest_original_meta.nlink(), 1);
assert_eq!(dest_link_meta.nlink(), 1);
}
#[tokio::test]
#[cfg(unix)]
async fn test_hardlink_three_files() {
use std::os::unix::fs::MetadataExt;
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let file1 = source_dir.path().join("file1.txt");
fs::write(&file1, "content").unwrap();
let file2 = source_dir.path().join("file2.txt");
let file3 = source_dir.path().join("file3.txt");
fs::hard_link(&file1, &file2).unwrap();
fs::hard_link(&file1, &file3).unwrap();
let inode = fs::metadata(&file1).unwrap().ino();
let entry1 = FileEntry {
path: Arc::new(file1),
relative_path: Arc::new(PathBuf::from("file1.txt")),
size: 7,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 7,
xattrs: None,
inode: Some(inode),
nlink: 3,
acls: None,
bsd_flags: None,
};
let entry2 = FileEntry {
path: Arc::new(file2),
relative_path: Arc::new(PathBuf::from("file2.txt")),
size: 7,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 7,
xattrs: None,
inode: Some(inode),
nlink: 3,
acls: None,
bsd_flags: None,
};
let entry3 = FileEntry {
path: Arc::new(file3),
relative_path: Arc::new(PathBuf::from("file3.txt")),
size: 7,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 7,
xattrs: None,
inode: Some(inode),
nlink: 3,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, true, false, false, false, hardlink_map);
let dest1 = dest_dir.path().join("file1.txt");
let dest2 = dest_dir.path().join("file2.txt");
let dest3 = dest_dir.path().join("file3.txt");
transferrer.create(&entry1, &dest1).await.unwrap();
transferrer.create(&entry2, &dest2).await.unwrap();
transferrer.create(&entry3, &dest3).await.unwrap();
assert!(dest1.exists());
assert!(dest2.exists());
assert!(dest3.exists());
let meta1 = fs::metadata(&dest1).unwrap();
let meta2 = fs::metadata(&dest2).unwrap();
let meta3 = fs::metadata(&dest3).unwrap();
assert_eq!(meta1.ino(), meta2.ino());
assert_eq!(meta1.ino(), meta3.ino());
assert_eq!(meta1.nlink(), 3);
assert_eq!(meta2.nlink(), 3);
assert_eq!(meta3.nlink(), 3);
}
#[tokio::test]
async fn test_create_file_nonexistent_source() {
let temp = tempfile::tempdir().unwrap();
let nonexistent = temp.path().join("nonexistent.txt");
let dest = temp.path().join("dest.txt");
let entry = FileEntry {
path: Arc::new(nonexistent),
relative_path: Arc::new(PathBuf::from("nonexistent.txt")),
size: 100,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 100,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
let result = transferrer.create(&entry, &dest).await;
assert!(result.is_err(), "Should fail when source file doesn't exist");
}
#[tokio::test]
#[cfg(unix)]
async fn test_create_file_permission_denied_dest() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source.txt");
fs::write(&source, b"test").unwrap();
let dest_dir = temp.path().join("readonly");
fs::create_dir(&dest_dir).unwrap();
let mut perms = fs::metadata(&dest_dir).unwrap().permissions();
perms.set_mode(0o444);
fs::set_permissions(&dest_dir, perms).unwrap();
let dest = dest_dir.join("dest.txt");
let entry = FileEntry {
path: Arc::new(source),
relative_path: Arc::new(PathBuf::from("source.txt")),
size: 4,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 4,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
let result = transferrer.create(&entry, &dest).await;
let mut perms = fs::metadata(&dest_dir).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&dest_dir, perms).unwrap();
assert!(result.is_err(), "Should fail when destination directory is read-only");
}
#[tokio::test]
async fn test_delete_nonexistent_file() {
let temp = tempfile::tempdir().unwrap();
let nonexistent = temp.path().join("nonexistent.txt");
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
let result = transferrer.delete(&nonexistent, false).await;
assert!(result.is_err(), "Should fail when trying to delete nonexistent file");
}
#[tokio::test]
async fn test_symlink_preserve_mode() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source.txt");
let link = temp.path().join("link.txt");
let dest = temp.path().join("dest.txt");
fs::write(&source, b"test").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&source, &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&source, &link).unwrap();
let entry = FileEntry {
path: Arc::new(link),
relative_path: Arc::new(PathBuf::from("link.txt")),
size: 4,
modified: SystemTime::now(),
is_dir: false,
is_symlink: true,
symlink_target: Some(Arc::new(source)),
is_sparse: false,
allocated_size: 4,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
transferrer.create(&entry, &dest).await.unwrap();
let meta = fs::symlink_metadata(&dest).unwrap();
assert!(meta.is_symlink(), "Destination should be a symlink");
}
#[tokio::test]
async fn test_symlink_follow_mode() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source.txt");
let link = temp.path().join("link.txt");
let dest = temp.path().join("dest.txt");
fs::write(&source, b"test content").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&source, &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&source, &link).unwrap();
let entry = FileEntry {
path: Arc::new(link),
relative_path: Arc::new(PathBuf::from("link.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: true,
symlink_target: Some(Arc::new(source)),
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Follow, false, false, false, false, false, hardlink_map);
transferrer.create(&entry, &dest).await.unwrap();
let meta = fs::symlink_metadata(&dest).unwrap();
assert!(!meta.is_symlink(), "Destination should be a regular file");
assert_eq!(fs::read_to_string(&dest).unwrap(), "test content");
}
#[tokio::test]
async fn test_dry_run_no_changes() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source.txt");
let dest = temp.path().join("dest.txt");
fs::write(&source, b"test").unwrap();
let entry = FileEntry {
path: Arc::new(source),
relative_path: Arc::new(PathBuf::from("source.txt")),
size: 4,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 4,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, true, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
let result = transferrer.create(&entry, &dest).await.unwrap();
assert!(result.is_none(), "Dry run should return None");
assert!(!dest.exists(), "Dry run should not create files");
}
#[tokio::test]
#[cfg(unix)]
async fn test_acl_detection() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source.txt");
let dest = temp.path().join("dest.txt");
fs::write(&source, b"test content").unwrap();
let acls_text = "user::rw-\ngroup::r--\nother::r--".to_string();
let entry = FileEntry {
path: Arc::new(source),
relative_path: Arc::new(PathBuf::from("source.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: Some(acls_text.into_bytes()),
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, true, false, false, hardlink_map);
transferrer.create(&entry, &dest).await.unwrap();
assert!(dest.exists());
}
#[tokio::test]
#[cfg(unix)]
async fn test_acl_not_preserved_without_flag() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source.txt");
let dest = temp.path().join("dest.txt");
fs::write(&source, b"test content").unwrap();
let acls_text = "user::rw-\ngroup::r--\nother::r--".to_string();
let entry = FileEntry {
path: Arc::new(source),
relative_path: Arc::new(PathBuf::from("source.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: Some(acls_text.into_bytes()),
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, false, false, false, hardlink_map);
transferrer.create(&entry, &dest).await.unwrap();
assert!(dest.exists());
}
#[tokio::test]
#[cfg(unix)]
async fn test_acl_empty_bytes() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source.txt");
let dest = temp.path().join("dest.txt");
fs::write(&source, b"test content").unwrap();
let entry = FileEntry {
path: Arc::new(source),
relative_path: Arc::new(PathBuf::from("source.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: Some(Vec::new()), bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, true, false, false, hardlink_map);
transferrer.create(&entry, &dest).await.unwrap();
assert!(dest.exists());
}
#[tokio::test]
#[cfg(all(unix, feature = "acl"))]
async fn test_acl_preservation_integration() {
use exacl::getfacl;
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source.txt");
let dest = temp.path().join("dest.txt");
fs::write(&source, b"test content").unwrap();
let source_acls = getfacl(&source, None).unwrap();
let source_acl_text: Vec<String> = source_acls.iter().map(|e| format!("{}", e)).collect();
if source_acl_text.is_empty() {
return;
}
let acls_bytes = source_acl_text.join("\n").into_bytes();
let entry = FileEntry {
path: Arc::new(source),
relative_path: Arc::new(PathBuf::from("source.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: Some(acls_bytes),
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, true, false, false, hardlink_map);
transferrer.create(&entry, &dest).await.unwrap();
assert!(dest.exists());
let dest_acls = getfacl(&dest, None).unwrap();
assert!(!dest_acls.is_empty(), "Destination should have ACLs after preservation");
}
#[tokio::test]
#[cfg(unix)]
async fn test_acl_parsing_robustness() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source.txt");
let dest = temp.path().join("dest.txt");
fs::write(&source, b"test content").unwrap();
let acls_text = "user::rw-\ninvalid_line_here\ngroup::r--\n\nanother_bad_line".to_string();
let entry = FileEntry {
path: Arc::new(source),
relative_path: Arc::new(PathBuf::from("source.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: Some(acls_text.into_bytes()),
bsd_flags: None,
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(&transport, false, false, SymlinkMode::Preserve, false, false, true, false, false, hardlink_map);
let result = transferrer.create(&entry, &dest).await;
assert!(result.is_ok(), "Should succeed despite invalid ACL entries");
assert!(dest.exists());
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_bsd_flags_preservation() {
use std::os::darwin::fs::MetadataExt;
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let source_file = source_dir.path().join("test.txt");
fs::write(&source_file, "test content").unwrap();
const UF_HIDDEN: u32 = 0x8000;
let c_path = std::ffi::CString::new(source_file.to_str().unwrap()).unwrap();
unsafe {
libc::chflags(c_path.as_ptr(), UF_HIDDEN as _);
}
let flags = fs::metadata(&source_file).unwrap().st_flags();
assert_eq!(flags & UF_HIDDEN, UF_HIDDEN, "Hidden flag should be set on source");
let file_entry = FileEntry {
path: Arc::new(source_file),
relative_path: Arc::new(PathBuf::from("test.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: Some(flags),
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(
&transport,
false,
false,
SymlinkMode::Preserve,
false,
false,
false,
true, false,
hardlink_map,
);
let dest_path = dest_dir.path().join("test.txt");
transferrer.create(&file_entry, &dest_path).await.unwrap();
assert!(dest_path.exists());
let dest_flags = fs::metadata(&dest_path).unwrap().st_flags();
assert_eq!(dest_flags & UF_HIDDEN, UF_HIDDEN, "Hidden flag should be preserved on dest");
}
#[tokio::test]
#[cfg(target_os = "macos")]
async fn test_bsd_flags_not_preserved_without_flag() {
use std::os::darwin::fs::MetadataExt;
let source_dir = TempDir::new().unwrap();
let dest_dir = TempDir::new().unwrap();
let source_file = source_dir.path().join("test.txt");
fs::write(&source_file, "test content").unwrap();
const UF_HIDDEN: u32 = 0x8000;
let c_path = std::ffi::CString::new(source_file.to_str().unwrap()).unwrap();
unsafe {
libc::chflags(c_path.as_ptr(), UF_HIDDEN as _);
}
let flags = fs::metadata(&source_file).unwrap().st_flags();
let file_entry = FileEntry {
path: Arc::new(source_file),
relative_path: Arc::new(PathBuf::from("test.txt")),
size: 12,
modified: SystemTime::now(),
is_dir: false,
is_symlink: false,
symlink_target: None,
is_sparse: false,
allocated_size: 12,
xattrs: None,
inode: None,
nlink: 1,
acls: None,
bsd_flags: Some(flags),
};
let transport = LocalTransport::new();
let hardlink_map = Arc::new(Mutex::new(std::collections::HashMap::new()));
let transferrer = Transferrer::new(
&transport,
false,
false,
SymlinkMode::Preserve,
false,
false,
false,
false, false,
hardlink_map,
);
let dest_path = dest_dir.path().join("test.txt");
transferrer.create(&file_entry, &dest_path).await.unwrap();
assert!(dest_path.exists());
let dest_flags = fs::metadata(&dest_path).unwrap().st_flags();
assert_eq!(dest_flags & UF_HIDDEN, 0, "Hidden flag should not be preserved when preserve_flags=false");
}
}