use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use ffsync::chunking::PipelineConfig;
use ffsync::protocol::tree_sync::{
TreeOutcome, TreeReceiveRequest, TreeSendRequest, tree_receive, tree_send,
};
use ffsync::tree::{EntryKind, TreeManifest};
struct TempTree(PathBuf);
impl TempTree {
fn new(name: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("ffsync_tree_test_{name}_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
TempTree(dir)
}
fn path(&self) -> &Path {
&self.0
}
fn write(&self, rel: &str, content: &[u8]) {
let p = self.0.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, content).unwrap();
}
fn read(&self, rel: &str) -> Option<Vec<u8>> {
fs::read(self.0.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))).ok()
}
fn exists(&self, rel: &str) -> bool {
self.0
.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
.exists()
}
}
impl Drop for TempTree {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn run_tree_session(
sender_root: &Path,
receiver_root: &Path,
checksum: bool,
no_delete: bool,
) -> (
Result<TreeOutcome, ffsync::protocol::sync::SyncError>,
Result<TreeOutcome, ffsync::protocol::sync::SyncError>,
) {
run_tree_session_ex(sender_root, receiver_root, checksum, no_delete, &[], &[])
}
fn run_tree_session_ex(
sender_root: &Path,
receiver_root: &Path,
checksum: bool,
no_delete: bool,
sender_excludes: &[&str],
receiver_excludes: &[&str],
) -> (
Result<TreeOutcome, ffsync::protocol::sync::SyncError>,
Result<TreeOutcome, ffsync::protocol::sync::SyncError>,
) {
run_tree_session_full(
sender_root,
receiver_root,
checksum,
no_delete,
sender_excludes,
receiver_excludes,
1,
false,
false,
)
}
#[allow(clippy::too_many_arguments)]
fn run_tree_session_full(
sender_root: &Path,
receiver_root: &Path,
checksum: bool,
no_delete: bool,
sender_excludes: &[&str],
receiver_excludes: &[&str],
parallel: u16,
dry_run: bool,
eager: bool,
) -> (
Result<TreeOutcome, ffsync::protocol::sync::SyncError>,
Result<TreeOutcome, ffsync::protocol::sync::SyncError>,
) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let listener2 = listener.try_clone().unwrap();
let recv_root = receiver_root.to_path_buf();
let recv_excludes: Vec<String> = receiver_excludes.iter().map(|s| s.to_string()).collect();
let server = std::thread::spawn(move || {
let (tcp, _peer) = listener.accept().unwrap();
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(20))).ok();
tree_receive(
&mut &tcp,
&TreeReceiveRequest {
root: recv_root,
force_checksum: checksum,
no_delete,
excludes: recv_excludes,
accept_worker: std::sync::Arc::new(move || {
let (tcp, _wpeer) = listener2
.accept()
.map_err(|e| std::io::Error::other(format!("cannot accept worker: {e}")))?;
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(20))).ok();
Ok(Box::new(tcp))
}),
dry_run,
eager,
},
&PipelineConfig::default(),
&mut |_p| {},
)
});
let tcp = std::net::TcpStream::connect(addr).unwrap();
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(20))).ok();
let send_addr = addr;
let send_excludes: Vec<String> = sender_excludes.iter().map(|s| s.to_string()).collect();
let send_res = tree_send(
&mut &tcp,
&TreeSendRequest {
root: sender_root.to_path_buf(),
force_checksum: checksum,
no_delete,
excludes: send_excludes,
parallel,
dry_run,
eager,
connect_worker: std::sync::Arc::new(move |_index: u16| {
let tcp = std::net::TcpStream::connect(send_addr)
.map_err(|e| std::io::Error::other(format!("cannot connect worker: {e}")))?;
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(20))).ok();
Ok(Box::new(tcp))
}),
},
&PipelineConfig::default(),
&mut |_p| {},
);
let recv_res = server.join().unwrap();
(send_res, recv_res)
}
fn set_mtime(path: &Path, secs: u64) {
let f = fs::OpenOptions::new().write(true).open(path).unwrap();
f.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
.unwrap();
}
#[test]
fn tree_converges_mixed_content() {
let sender = TempTree::new("conv_sender");
let receiver = TempTree::new("conv_receiver");
sender.write("big.bin", &vec![7u8; 3 * 1024 * 1024]);
sender.write("sub/medium.bin", &vec![1u8; 300 * 1024]);
sender.write("sub/deep/tiny.txt", b"hello tree");
sender.write("empty.txt", b"");
fs::create_dir_all(sender.path().join("empty_dir")).unwrap();
let (s, r) = run_tree_session(sender.path(), receiver.path(), false, false);
let s = s.expect("sender ok");
let r = r.expect("receiver ok");
assert_eq!(s.files, r.files);
assert!(s.transferred_bytes > 0);
assert_eq!(s.deleted, 0);
assert_eq!(receiver.read("big.bin"), sender.read("big.bin"));
assert_eq!(
receiver.read("sub/medium.bin"),
sender.read("sub/medium.bin")
);
assert_eq!(
receiver.read("sub/deep/tiny.txt"),
sender.read("sub/deep/tiny.txt")
);
assert_eq!(receiver.read("empty.txt"), Some(Vec::new()));
assert!(receiver.exists("empty_dir"));
assert!(receiver.exists("sub/deep"));
let (s2, r2) = run_tree_session(sender.path(), receiver.path(), false, false);
let s2 = s2.expect("sender2 ok");
let r2 = r2.expect("receiver2 ok");
assert_eq!(s2.files, 0, "second sync must skip all files (fast path)");
assert_eq!(r2.files, 0);
assert_eq!(s2.transferred_bytes, 0);
}
#[test]
fn tree_syncs_modified_and_new_files_only() {
let sender = TempTree::new("mod_sender");
let receiver = TempTree::new("mod_receiver");
sender.write("a.bin", &vec![1u8; 100 * 1024]);
sender.write("b.bin", &vec![2u8; 100 * 1024]);
let _ = run_tree_session(sender.path(), receiver.path(), false, false);
sender.write("a.bin", &vec![9u8; 100 * 1024]);
sender.write("c.bin", &vec![3u8; 50 * 1024]);
let (s, _r) = run_tree_session(sender.path(), receiver.path(), false, false);
let s = s.expect("sender ok");
assert_eq!(s.files, 2, "only changed/new files get conversations");
assert_eq!(receiver.read("a.bin"), sender.read("a.bin"));
assert_eq!(receiver.read("c.bin"), sender.read("c.bin"));
assert_eq!(receiver.read("b.bin"), sender.read("b.bin"));
}
#[test]
fn tree_deletes_receiver_only_files_after_success() {
let sender = TempTree::new("del_sender");
let receiver = TempTree::new("del_receiver");
sender.write("keep.bin", b"keep");
receiver.write("keep.bin", b"keep");
set_mtime(&sender.path().join("keep.bin"), 1600000000);
set_mtime(&receiver.path().join("keep.bin"), 1600000000);
receiver.write("stale.bin", b"stale content");
receiver.write("sub/old.bin", b"old");
let (s, r) = run_tree_session(sender.path(), receiver.path(), false, false);
let s = s.expect("sender ok");
let r = r.expect("receiver ok");
assert_eq!(s.files, 0, "no conversations needed");
assert_eq!(r.deleted, 3);
assert_eq!(r.deleted_bytes, 13 + 3, "dirs carry no bytes");
assert!(!receiver.exists("stale.bin"));
assert!(!receiver.exists("sub/old.bin"));
assert!(receiver.exists("keep.bin"));
assert!(!receiver.exists("sub"));
}
#[test]
fn tree_no_delete_keeps_receiver_only_files() {
let sender = TempTree::new("nodel_sender");
let receiver = TempTree::new("nodel_receiver");
sender.write("keep.bin", b"keep");
receiver.write("keep.bin", b"keep");
set_mtime(&sender.path().join("keep.bin"), 1600000000);
set_mtime(&receiver.path().join("keep.bin"), 1600000000);
receiver.write("stale.bin", b"stale");
let (s, r) = run_tree_session(sender.path(), receiver.path(), false, true);
let s = s.expect("sender ok");
let r = r.expect("receiver ok");
assert_eq!(s.files, 0);
assert_eq!(r.deleted, 0, "--no-delete must suppress deletions");
assert!(receiver.exists("stale.bin"));
}
#[test]
fn tree_checksum_mode_ignores_metadata() {
let sender = TempTree::new("csum_sender");
let receiver = TempTree::new("csum_receiver");
sender.write("a.bin", &vec![1u8; 64 * 1024]);
receiver.write("a.bin", &vec![2u8; 64 * 1024]);
set_mtime(&sender.path().join("a.bin"), 1600000000);
set_mtime(&receiver.path().join("a.bin"), 1600000000);
let (s, _r) = run_tree_session(sender.path(), receiver.path(), true, false);
let s = s.expect("sender ok");
assert_eq!(s.files, 1, "checksum mode must not trust metadata");
assert_eq!(receiver.read("a.bin"), sender.read("a.bin"));
}
#[test]
fn tree_empty_sender_file_converges_nonempty_receiver_file() {
let sender = TempTree::new("empty_sender");
let receiver = TempTree::new("empty_receiver");
sender.write("was_big.bin", b"");
receiver.write("was_big.bin", &vec![5u8; 4096]);
let (s, _r) = run_tree_session(sender.path(), receiver.path(), false, false);
let s = s.expect("sender ok");
assert_eq!(s.files, 1);
assert_eq!(receiver.read("was_big.bin"), Some(Vec::new()));
}
#[test]
fn tree_kind_conflict_resolves_sender_authoritative() {
let sender = TempTree::new("kind_sender");
let receiver = TempTree::new("kind_receiver");
sender.write("x", b"now a file");
sender.write("y/readme.txt", b"now a dir");
fs::create_dir_all(receiver.path().join("x/sub")).unwrap();
receiver.write("x/sub/junk.bin", b"junk");
receiver.write("y", b"now becomes a dir");
let (_s, r) = run_tree_session(sender.path(), receiver.path(), false, false);
let _r = r.expect("receiver ok");
assert_eq!(receiver.read("x"), Some(b"now a file".to_vec()));
assert_eq!(receiver.read("y/readme.txt"), Some(b"now a dir".to_vec()));
assert!(
!receiver.exists("x/sub"),
"receiver's conflicting dir content is gone"
);
}
#[test]
fn tree_v2_flow_rejects_tree_frames() {
let sender = TempTree::new("v2sim_sender");
let receiver = TempTree::new("v2sim_receiver");
sender.write("a.bin", b"data");
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let recv_root = receiver.path().to_path_buf();
let server = std::thread::spawn(move || {
let (tcp, _peer) = listener.accept().unwrap();
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
let req = ffsync::protocol::sync::ReceiveRequest {
path: recv_root.join("a.bin"),
local_source: recv_root.join("a.bin"),
force_checksum: false,
tree: false,
apply_mode: None,
apply_mtime: None,
eager: false,
extra_sources: Vec::new(),
};
ffsync::protocol::sync::receive(&mut &tcp, &req, &PipelineConfig::default(), &mut |_p| {})
});
let tcp = std::net::TcpStream::connect(addr).unwrap();
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
let send_res = tree_send(
&mut &tcp,
&TreeSendRequest {
root: sender.path().to_path_buf(),
force_checksum: false,
no_delete: false,
excludes: Vec::new(),
parallel: 1,
eager: false,
connect_worker: std::sync::Arc::new(|_| {
Err(std::io::Error::other("no workers in sequential sessions"))
}),
dry_run: false,
},
&PipelineConfig::default(),
&mut |_p| {},
);
let recv_res = server.join().unwrap();
assert!(
send_res.is_err() || recv_res.is_err(),
"tree frames against a non-tree peer must fail loudly: send={send_res:?} recv={recv_res:?}"
);
}
#[test]
fn tree_paths_reject_traversal() {
use ffsync::protocol::message::{TreeBegin, TreeEntryWire};
use std::io::Cursor;
let frame = TreeBegin {
flags: 0,
parallel: 1,
excludes: Vec::new(),
entries: vec![TreeEntryWire {
rel_path: "../escape.bin".to_string(),
kind: 0,
size: 1,
mtime_secs: 0,
mtime_nanos: 0,
mode: 0,
}],
}
.into_frame()
.unwrap();
let mut buf = Vec::new();
frame.encode(&mut buf).unwrap();
let decoded = TreeBegin::from_frame(
ffsync::protocol::framing::read_frame(&mut Cursor::new(buf)).unwrap(),
);
let err = ffsync::protocol::tree_sync::manifest_from_wire(&decoded.unwrap().entries);
assert!(err.is_err(), "traversal path must be rejected");
}
#[test]
fn sender_vanished_file_fails_loudly() {
let sender = TempTree::new("vanish_sender");
let receiver = TempTree::new("vanish_receiver");
sender.write("a.bin", &vec![1u8; 32 * 1024]);
sender.write("b.bin", &vec![2u8; 32 * 1024]);
receiver.write("stale.bin", b"stale");
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let recv_root = receiver.path().to_path_buf();
let del_target = sender.path().join("b.bin");
let server = std::thread::spawn(move || {
let (tcp, _peer) = listener.accept().unwrap();
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(20))).ok();
tree_receive(
&mut &tcp,
&TreeReceiveRequest {
root: recv_root,
force_checksum: false,
no_delete: false,
excludes: Vec::new(),
accept_worker: std::sync::Arc::new(|| {
Err(std::io::Error::other("no workers in sequential sessions"))
}),
dry_run: false,
eager: false,
},
&PipelineConfig::default(),
&mut |_p| {},
)
});
let tcp = std::net::TcpStream::connect(addr).unwrap();
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(20))).ok();
let racer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(2));
let _ = fs::remove_file(&del_target);
});
let send_res = tree_send(
&mut &tcp,
&TreeSendRequest {
root: sender.path().to_path_buf(),
force_checksum: false,
no_delete: false,
excludes: Vec::new(),
parallel: 1,
eager: false,
connect_worker: std::sync::Arc::new(|_| {
Err(std::io::Error::other("no workers in sequential sessions"))
}),
dry_run: false,
},
&PipelineConfig::default(),
&mut |_p| {},
);
let _ = racer.join();
let recv_res = server.join().unwrap();
match (&send_res, &recv_res) {
(Ok(_), Ok(_)) => {} (Err(_), Err(_)) => {} (Err(e), Ok(_)) => {
eprintln!("sender failed ({e}), receiver raced OK");
}
(Ok(_), Err(e)) => panic!("sender succeeded but receiver failed: {e}"),
}
assert!(
receiver.exists("stale.bin"),
"fail-closed: no deletions on a failed session"
);
}
#[test]
fn tree_excludes_protect_receiver_files_from_delete() {
let sender = TempTree::new("excl_protect_s");
sender.write("code.rs", b"fn main() {}");
let receiver = TempTree::new("excl_protect_r");
receiver.write("code.rs", b"fn main() {}");
receiver.write("secrets/api.key", b"hunter2");
receiver.write("secrets/notes.txt", b"private");
let (s, r) = run_tree_session_ex(
sender.path(),
receiver.path(),
false,
false,
&["secrets/"],
&[],
);
let s = s.expect("sender ok");
let r = r.expect("receiver ok");
assert_eq!(s.deleted, 0);
assert_eq!(r.deleted, 0);
assert!(
receiver.exists("secrets/api.key"),
"excluded receiver-only file must survive the delete pass"
);
assert!(receiver.exists("secrets/notes.txt"));
assert_eq!(receiver.read("code.rs").unwrap(), b"fn main() {}");
let (s2, r2) = run_tree_session(sender.path(), receiver.path(), false, false);
let s2 = s2.expect("sender ok");
let r2 = r2.expect("receiver ok");
assert_eq!(s2.deleted, 3);
assert_eq!(r2.deleted, 3);
assert!(!receiver.exists("secrets/api.key"));
}
#[test]
fn tree_sender_excludes_filter_its_own_tree() {
let sender = TempTree::new("excl_filter_s");
sender.write("app.bin", b"app");
sender.write("cache/tmp1.bin", b"junk1");
sender.write("cache/deep/tmp2.bin", b"junk2");
let receiver = TempTree::new("excl_filter_r");
receiver.write("cache/old.bin", b"old");
let (s, r) = run_tree_session_ex(
sender.path(),
receiver.path(),
false,
false,
&["cache"],
&[],
);
let s = s.expect("sender ok");
let r = r.expect("receiver ok");
assert_eq!(s.files, 1);
assert_eq!(r.files, 1);
assert!(receiver.exists("app.bin"));
assert!(receiver.exists("cache/old.bin"));
assert!(!receiver.exists("cache/tmp1.bin"));
assert_eq!(s.deleted, 0);
assert_eq!(r.deleted, 0);
}
#[test]
fn tree_excludes_deterministic_across_both_peers() {
let sender = TempTree::new("excl_det_s");
sender.write("a.bin", b"a");
sender.write("b.bin", b"b");
sender.write("skip1.bin", b"s1");
sender.write("sub/skip2.bin", b"s2");
sender.write("sub/keep.bin", b"k");
let receiver = TempTree::new("excl_det_r");
let (s, r) = run_tree_session_ex(
sender.path(),
receiver.path(),
false,
false,
&["skip*.bin"],
&[],
);
let s = s.expect("sender ok");
let r = r.expect("receiver ok");
assert_eq!(s.files, 3);
assert_eq!(r.files, 3);
assert!(receiver.exists("a.bin"));
assert!(receiver.exists("sub/keep.bin"));
assert!(!receiver.exists("skip1.bin"));
assert!(!receiver.exists("sub/skip2.bin"));
assert_eq!(r.transferred_bytes, 3); }
#[test]
fn tree_excludes_v3_peer_without_flag_interops() {
let sender = TempTree::new("excl_noflag_s");
sender.write("only.bin", b"x");
let receiver = TempTree::new("excl_noflag_r");
let (s, r) = run_tree_session(sender.path(), receiver.path(), false, false);
s.expect("sender ok");
r.expect("receiver ok");
assert!(receiver.exists("only.bin"));
}
#[test]
fn tree_parallel_converges_and_reports_all_files() {
let sender = TempTree::new("par_conv_s");
for i in 0..12 {
sender.write(&format!("f{i:02}.bin"), &vec![i as u8; 4096]);
}
sender.write("sub/nested.bin", b"nested");
let receiver = TempTree::new("par_conv_r");
let (s, r) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
4,
false,
false,
);
let s = s.unwrap_or_else(|e| panic!("sender failed: {e}"));
let r = r.unwrap_or_else(|e| panic!("receiver failed: {e}"));
assert_eq!(s.files, 13);
assert_eq!(r.files, 13);
assert_eq!(s.in_sync, 0);
assert_eq!(r.in_sync, 0);
for i in 0..12 {
let rel = format!("f{i:02}.bin");
assert_eq!(
receiver.read(&rel).unwrap(),
vec![i as u8; 4096],
"file {rel} did not converge"
);
}
assert_eq!(receiver.read("sub/nested.bin").unwrap(), b"nested");
let (s2, r2) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
4,
false,
false,
);
let s2 = s2.expect("sender ok");
let r2 = r2.expect("receiver ok");
assert_eq!(s2.files, 0, "second session has no conversations");
assert_eq!(r2.files, 0);
}
#[test]
fn tree_parallel_sequential_fallback_interops() {
let sender = TempTree::new("par_fb_s");
for i in 0..6 {
sender.write(&format!("g{i}.bin"), &vec![7u8; 2048]);
}
let receiver = TempTree::new("par_fb_r");
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let recv_root = receiver.path().to_path_buf();
let server = std::thread::spawn(move || {
let (tcp, _) = listener.accept().unwrap();
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(20))).ok();
tree_receive(
&mut &tcp,
&TreeReceiveRequest {
root: recv_root,
force_checksum: false,
no_delete: false,
excludes: Vec::new(),
accept_worker: std::sync::Arc::new(move || {
Err(std::io::Error::other("no worker connections accepted"))
}),
dry_run: false,
eager: false,
},
&PipelineConfig::default(),
&mut |_p| {},
)
});
let tcp = std::net::TcpStream::connect(addr).unwrap();
tcp.set_nodelay(true).ok();
tcp.set_read_timeout(Some(Duration::from_secs(20))).ok();
tcp.set_write_timeout(Some(Duration::from_secs(20))).ok();
let send_addr = addr;
let send_res = tree_send(
&mut &tcp,
&TreeSendRequest {
root: sender.path().to_path_buf(),
force_checksum: false,
no_delete: false,
excludes: Vec::new(),
parallel: 4,
eager: false,
connect_worker: std::sync::Arc::new(move |_index: u16| {
std::net::TcpStream::connect(send_addr)
.map(|t| Box::new(t) as Box<dyn ffsync::protocol::tree_sync::ReadWrite>)
.map_err(std::io::Error::other)
}),
dry_run: false,
},
&PipelineConfig::default(),
&mut |_p| {},
);
let recv_res = server.join().unwrap();
assert!(
send_res.is_err(),
"sender must see the receiver's rejection"
);
assert!(recv_res.is_err(), "receiver must fail on missing workers");
}
#[test]
fn tree_parallel_fail_closed_on_worker_error() {
let sender = TempTree::new("par_fail_s");
sender.write("a.bin", b"aaa");
sender.write("b.bin", b"bbb");
let receiver = TempTree::new("par_fail_r");
receiver.write("stale.bin", b"old");
let vanish_target = sender.path().join("a.bin");
let vanish = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(5));
let _ = std::fs::remove_file(&vanish_target);
});
let (s, r) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
2,
false,
false,
);
let _ = vanish.join();
match (&s, &r) {
(Ok(_), Ok(_)) => {}
(Err(_), Err(_)) | (Err(_), Ok(_)) => {
assert!(
receiver.exists("stale.bin"),
"fail-closed: a failed parallel session must not delete"
);
}
(Ok(_), Err(e)) => panic!("sender ok but receiver failed: {e}"),
}
}
#[test]
fn tree_converges_posix_mode_bits() {
let sender = TempTree::new("mode_sender");
let receiver = TempTree::new("mode_receiver");
sender.write("script.sh", b"#!/bin/sh\necho hi\n");
sender.write("data.bin", &vec![5u8; 64 * 1024]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let p = sender.path().join("script.sh");
std::fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
let p = sender.path().join("data.bin");
std::fs::set_permissions(&p, fs::Permissions::from_mode(0o640)).unwrap();
}
#[cfg(windows)]
{
let p = sender.path().join("data.bin");
let mut perms = fs::metadata(&p).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&p, perms).unwrap();
}
let (s, r) = run_tree_session(sender.path(), receiver.path(), false, false);
s.expect("sender ok");
r.expect("receiver ok");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let got = fs::metadata(receiver.path().join("script.sh"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(got, 0o755, "exec bits must converge");
let got = fs::metadata(receiver.path().join("data.bin"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(got, 0o640, "group-read/no-other must converge");
}
#[cfg(windows)]
{
let got = fs::metadata(receiver.path().join("data.bin"))
.unwrap()
.permissions()
.readonly();
assert!(
!got,
"windows sender sends no mode section; receiver attribute untouched"
);
}
let (s2, r2) = run_tree_session(sender.path(), receiver.path(), false, false);
let s2 = s2.expect("sender2 ok");
assert_eq!(s2.files, 0, "mode apply must not break the fast path");
let _r2 = r2.expect("receiver2 ok");
}
#[cfg(windows)]
#[test]
fn tree_windows_readonly_receiver_roundtrip() {
let sender = TempTree::new("ro_sender");
let receiver = TempTree::new("ro_receiver");
sender.write("doc.bin", &vec![1u8; 64 * 1024]);
let (s, r) = run_tree_session(sender.path(), receiver.path(), false, false);
s.expect("sender ok");
r.expect("receiver ok");
let rp = receiver.path().join("doc.bin");
let mut perms = fs::metadata(&rp).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&rp, perms).unwrap();
sender.write("doc.bin", &vec![2u8; 64 * 1024]);
let (s2, r2) = run_tree_session(sender.path(), receiver.path(), false, false);
let s2 = s2.expect("sender2 ok");
let _r2 = r2.expect("receiver2 ok");
assert_eq!(s2.files, 1);
assert_eq!(receiver.read("doc.bin"), sender.read("doc.bin"));
}
#[test]
fn scan_skips_dir_symlinks_and_dangling_links() {
let tree = TempTree::new("scan_sym");
tree.write("real.txt", b"content");
fs::create_dir_all(tree.path().join("sub")).unwrap();
tree.write("sub/inner.txt", b"inner");
#[cfg(windows)]
{
let out = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(tree.path().join("loop"))
.arg(tree.path())
.output()
.expect("mklink /J");
assert!(out.status.success(), "mklink /J failed: {:?}", out.stderr);
}
#[cfg(unix)]
std::os::unix::fs::symlink(tree.path(), tree.path().join("loop")).unwrap();
#[cfg(windows)]
{
let out = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(tree.path().join("dangling"))
.arg(tree.path().join("nope"))
.output()
.expect("mklink /J dangling");
assert!(out.status.success());
}
#[cfg(unix)]
std::os::unix::fs::symlink(tree.path().join("nope"), tree.path().join("dangling")).unwrap();
let manifest = TreeManifest::scan(tree.path()).expect("scan must terminate");
assert!(manifest.get("real.txt").is_some());
assert!(manifest.get("sub/inner.txt").is_some());
assert!(
manifest.get("loop").is_none(),
"dir symlink/junction must not be recursed or listed"
);
assert!(
manifest.get("dangling").is_none(),
"dangling link must be skipped"
);
}
#[test]
fn scan_follows_file_symlinks() {
let tree = TempTree::new("scan_follow");
tree.write("target.bin", &vec![9u8; 128 * 1024]);
#[cfg(windows)]
{
let out = std::process::Command::new("cmd")
.args(["/C", "mklink"])
.arg(tree.path().join("link.bin"))
.arg(tree.path().join("target.bin"))
.output()
.expect("mklink");
if !out.status.success() {
fs::hard_link(tree.path().join("target.bin"), tree.path().join("link.bin"))
.expect("hard_link fallback");
}
}
#[cfg(unix)]
std::os::unix::fs::symlink(tree.path().join("target.bin"), tree.path().join("link.bin"))
.unwrap();
let manifest = TreeManifest::scan(tree.path()).expect("scan ok");
let e = manifest.get("link.bin").expect("file link must be listed");
assert_eq!(e.size, 128 * 1024);
assert_eq!(e.kind, EntryKind::File);
}
#[test]
fn tree_exclude_negation_converges() {
let sender = TempTree::new("neg_sender");
let receiver = TempTree::new("neg_receiver");
sender.write("logs/a.log", &vec![1u8; 32 * 1024]);
sender.write("logs/keep.log", &vec![2u8; 32 * 1024]);
sender.write("main.bin", &vec![3u8; 32 * 1024]);
let excludes = ["!keep.log", "*.log"];
let (s, r) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&excludes, &[], 1,
false,
false,
);
let s = s.expect("sender ok");
let r = r.expect("receiver ok");
assert_eq!(s.files, 2, "keep.log + main.bin sync; a.log is invisible");
assert_eq!(receiver.read("logs/keep.log"), sender.read("logs/keep.log"));
assert_eq!(receiver.read("main.bin"), sender.read("main.bin"));
assert!(
!receiver.exists("logs/a.log"),
"excluded file never enters the session"
);
assert_eq!(r.deleted, 0);
receiver.write("logs/stray.log", b"stray");
let excludes2 = ["!keep.log", "*.log"];
let (s2, r2) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&excludes2,
&["*.log"], 1,
false,
false,
);
let _s2 = s2.expect("sender2 ok");
let r2 = r2.expect("receiver2 ok");
assert_eq!(
r2.deleted, 0,
"receiver's own *.log pattern protects stray.log"
);
assert!(receiver.exists("logs/stray.log"));
}
#[test]
fn tree_negation_free_session_interoperates() {
let sender = TempTree::new("noneg_sender");
let receiver = TempTree::new("noneg_receiver");
sender.write("a.bin", &vec![1u8; 16 * 1024]);
let excludes = ["*.log"];
let (s, r) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&excludes,
&[],
1,
false,
false,
);
let s = s.expect("sender ok");
let _r = r.expect("receiver ok");
assert_eq!(s.files, 1);
}
#[test]
fn tree_long_paths_over_260_chars_converge() {
let sender = TempTree::new("longpath_send");
let receiver = TempTree::new("longpath_recv");
let seg = "l".repeat(80);
let deep_rel = format!("{seg}/{seg}/{seg}/file.bin");
let data = vec![42u8; 3 * 1024 * 1024];
sender.write(&deep_rel, &data);
let (s, r) = run_tree_session(sender.path(), receiver.path(), false, false);
let s = s.expect("sender ok");
let r = r.expect("receiver ok");
assert_eq!(s.files, 1);
assert_eq!(r.files, 1);
assert_eq!(receiver.read(&deep_rel).unwrap(), data);
let p = receiver
.0
.join(deep_rel.replace('/', std::path::MAIN_SEPARATOR_STR));
assert!(
p.to_string_lossy().len() > 260,
"test setup must exceed MAX_PATH: {}",
p.display()
);
}
#[test]
fn tree_dry_run_reports_without_mutating_over_real_tcp() {
let sender = TempTree::new("dryrun_send");
let receiver = TempTree::new("dryrun_recv");
sender.write("a.bin", &vec![7u8; 512 * 1024]);
sender.write("sub/b.bin", &vec![7u8; 512 * 1024]);
receiver.write("stale.bin", b"old content");
let (s, r) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
1,
true,
false,
);
let s = s.expect("sender ok");
let r = r.expect("receiver ok");
assert_eq!(s.files, 2, "sender's triage counts the conversations");
assert_eq!(r.deleted, 1, "receiver reports the WOULD-delete");
assert_eq!(r.deleted_bytes, 11);
assert_eq!(receiver.read("stale.bin").unwrap(), b"old content");
assert!(!receiver.0.join("a.bin").exists());
assert!(
!receiver.0.join("sub").exists(),
"CreateDir must not run in a preview"
);
let (s2, r2) = run_tree_session(sender.path(), receiver.path(), false, false);
assert!(s2.is_ok() && r2.is_ok());
assert_eq!(receiver.read("a.bin").unwrap(), vec![7u8; 512 * 1024]);
assert_eq!(receiver.read("stale.bin"), None, "real session deletes it");
}
#[test]
fn tree_eager_converges_and_declines_loudly() {
let sender = TempTree::new("eager_tree_sender");
let receiver = TempTree::new("eager_tree_receiver");
sender.write("big.bin", &vec![7u8; 2 * 1024 * 1024]);
sender.write("sub/medium.bin", &vec![1u8; 300 * 1024]);
sender.write("sub/deep/tiny.txt", b"hello eager tree");
let (s, r) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
1,
false,
true,
);
let s = s.expect("eager sender ok");
let r = r.expect("eager receiver ok");
assert_eq!(s.files, r.files);
assert!(s.transferred_bytes > 0);
assert_eq!(receiver.read("big.bin"), sender.read("big.bin"));
assert_eq!(
receiver.read("sub/medium.bin"),
sender.read("sub/medium.bin")
);
assert_eq!(
receiver.read("sub/deep/tiny.txt"),
sender.read("sub/deep/tiny.txt")
);
let (s2, r2) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
1,
false,
true,
);
let s2 = s2.expect("eager sender2 ok");
let r2 = r2.expect("eager receiver2 ok");
assert_eq!(
s2.files, 0,
"second eager sync must skip all files (fast path)"
);
assert_eq!(r2.files, 0);
assert_eq!(s2.transferred_bytes, 0);
let receiver2 = TempTree::new("eager_tree_declining");
let (s3, r3) = run_tree_session_full(
sender.path(),
receiver2.path(),
false,
false,
&[],
&[],
1,
false,
false,
);
let s3 = s3.expect("lazy-fallback sender ok");
let _r3 = r3.expect("lazy-fallback receiver ok");
assert!(s3.transferred_bytes > 0);
assert_eq!(receiver2.read("big.bin"), sender.read("big.bin"));
let receiver3 = TempTree::new("eager_tree_receiver_only");
let (s4, r4) = run_tree_session_full(
sender.path(),
receiver3.path(),
false,
false,
&[],
&[],
1,
false,
false,
);
let _s4 = s4.expect("receiver-only-opt-in sender ok");
let _r4 = r4.expect("receiver-only-opt-in receiver ok");
}
#[test]
fn tree_eager_parallel_converges() {
let sender = TempTree::new("eager_par_sender");
let receiver = TempTree::new("eager_par_receiver");
for i in 0..12 {
sender.write(&format!("f{i:02}.bin"), &vec![i as u8; 400 * 1024]);
}
let (s, r) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
4,
false,
true,
);
let s = s.expect("eager parallel sender ok");
let r = r.expect("eager parallel receiver ok");
assert_eq!(s.files, r.files);
assert_eq!(s.files, 12);
for i in 0..12 {
assert_eq!(
receiver.read(&format!("f{i:02}.bin")),
sender.read(&format!("f{i:02}.bin"))
);
}
}
#[test]
fn tree_rename_moves_content_with_zero_wire_bytes() {
let sender = TempTree::new("ren_sender");
let receiver = TempTree::new("ren_receiver");
let mut data = vec![0u8; 2 * 1024 * 1024];
for (i, b) in data.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(71).wrapping_add((i / 4096) as u8);
}
sender.write("old/name.bin", &data);
let (s1, r1) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
1,
false,
false,
);
s1.expect("session 1 sender ok");
r1.expect("session 1 receiver ok");
assert_eq!(receiver.read("old/name.bin"), Some(data.clone()));
assert!(
ffsync::index::load_index(&receiver.0.join("old/name.bin")).is_some(),
"session 1 must leave the receiver's index (warm start + census)"
);
std::fs::create_dir_all(sender.0.join("new")).unwrap();
std::fs::rename(sender.0.join("old/name.bin"), sender.0.join("new/name.bin")).unwrap();
let (s2, r2) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
1,
false,
false,
);
let s2 = s2.expect("session 2 sender ok");
let r2 = r2.expect("session 2 receiver ok");
assert!(
matches!(
s2,
ffsync::protocol::tree_sync::TreeOutcome {
transferred_bytes: 0,
..
}
),
"sender must transfer 0 wire bytes for a pure rename: {s2:?}"
);
assert!(
matches!(
r2,
ffsync::protocol::tree_sync::TreeOutcome {
transferred_bytes: 0,
..
}
),
"receiver must write 0 wire bytes for a pure rename: {r2:?}"
);
assert_eq!(receiver.read("new/name.bin"), Some(data.clone()));
assert_eq!(receiver.read("old/name.bin"), None, "old name deleted");
assert!(
ffsync::index::load_index(&receiver.0.join("new/name.bin")).is_some(),
"the new name's index written at commit"
);
assert!(
ffsync::index::load_index(&receiver.0.join("old/name.bin")).is_none(),
"the old name's index removed by the delete pass"
);
let (s3, r3) = run_tree_session_full(
sender.path(),
receiver.path(),
false,
false,
&[],
&[],
1,
false,
false,
);
let s3 = s3.expect("session 3 sender ok");
let r3 = r3.expect("session 3 receiver ok");
assert_eq!(s3.files, 0, "third session fast-paths everything");
assert_eq!(r3.files, 0);
}