diff --git a/Cargo.lock b/Cargo.lock index 2e2e993..f755003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -625,6 +625,7 @@ dependencies = [ "crossbeam-channel", "dirs", "env_logger", + "fs2", "glib 0.21.5", "gtk", "iced", @@ -1566,6 +1567,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.32" diff --git a/crates/filesync/Cargo.toml b/crates/filesync/Cargo.toml index 0d0083d..e09f25d 100644 --- a/crates/filesync/Cargo.toml +++ b/crates/filesync/Cargo.toml @@ -32,6 +32,7 @@ gtk = "0.18.2" rfd = "0.17.2" env_logger.workspace = true dirs = "6.0.0" +fs2 = "0.4" toml.workspace = true tray-icon = "0.21.3" iced = { version = "0.14.0", features = ["tokio"] } diff --git a/crates/filesync/src/client.rs b/crates/filesync/src/client.rs index 66e9b02..38d7a01 100644 --- a/crates/filesync/src/client.rs +++ b/crates/filesync/src/client.rs @@ -406,6 +406,50 @@ impl Client { ); } + // ── Preemptive disk-space check (client) ────────────────────────────── + // Simulate the server's send-list computation (is_server = true) to + // predict exactly which bytes will arrive. We do this *before* sending + // our ManifestExchange so that, if we are out of space, the server is + // notified before it starts transmitting anything. + let bytes_incoming: u64 = manifest::compute_send_list(&remote, &local, true) + .iter() + .filter_map(|p| remote.files.get(p)) + .filter(|m| !m.is_dir) + .map(|m| m.size) + .sum(); + if bytes_incoming > 0 { + match common::check_disk_space(self.engine.root(), bytes_incoming) { + Ok(avail) => { + debug!( + "filesync session: disk-space check OK — {} B available, {} B required", + avail, bytes_incoming + ); + } + Err(_) => { + let avail = common::available_disk_space(self.engine.root()).unwrap_or(0); + error!( + "filesync: not enough disk space on client — \ + {} B available, {} B required; aborting sync", + avail, bytes_incoming + ); + // Notify the server before closing so it can surface the + // real reason rather than a generic connection error. + let _ = conn.send(&Message::InsufficientDiskSpace { + available_bytes: avail, + required_bytes: bytes_incoming, + }); + return Err(io::Error::new( + io::ErrorKind::StorageFull, + format!( + "filesync: client out of disk space: \ + {} B available, {} B required", + avail, bytes_incoming + ), + )); + } + } + } + debug!("filesync session: sending local ManifestExchange"); conn.send(&Message::ManifestExchange(local.clone()))?; @@ -463,7 +507,7 @@ impl Client { b.bundle_id, n_files, n_dirs, bundle_bytes, bytes_received + bundle_bytes ); - let n = self.engine.apply_bundle(&b)?; + let n = self.engine.apply_bundle(&b)?.written; for fd in &b.files { if fd.metadata.is_dir { dirs_received += 1; @@ -542,6 +586,21 @@ impl Client { ); info!("filesync: initial sync large file committed {path:?}"); } + crate::sync_engine::FinishResult::CommittedWithConflict(ci) => { + files_received += 1; + if let Some(ref gs) = self.gui_state { + gs.write().files_received = files_received as u64; + } + debug!( + "filesync session: large file committed {path:?} (files_received={})", + files_received + ); + warn!( + "filesync: initial sync large file committed {path:?} \ + (conflict copy: {:?})", + ci.conflict_copy_path + ); + } crate::sync_engine::FinishResult::MissingChunks(indices) => { warn!( "filesync: initial sync {path:?} missing {} chunk(s), requesting retransmit", @@ -565,6 +624,24 @@ impl Client { ); break; } + Message::InsufficientDiskSpace { + available_bytes, + required_bytes, + } => { + error!( + "filesync session: server reports insufficient disk space — \ + {} B available, {} B required; aborting sync", + available_bytes, required_bytes + ); + return Err(io::Error::new( + io::ErrorKind::StorageFull, + format!( + "filesync: server out of disk space: \ + {} B available, {} B required", + available_bytes, required_bytes + ), + )); + } other => { warn!("filesync session: unexpected message during initial-sync recv phase"); debug!("filesync session: unexpected message variant: {other:?}"); @@ -806,6 +883,16 @@ fn recv_loop(engine: Arc, conn: Arc, bus: Option { + error!( + "{prefix}: server reports insufficient disk space — \ + {available_bytes} B available, {required_bytes} B required; disconnecting" + ); + return; + } Ok(other) => { warn!("{prefix}: unexpected message in live sync phase — possible protocol issue"); debug!("{prefix}: unexpected message variant: {other:?}"); diff --git a/crates/filesync/src/common.rs b/crates/filesync/src/common.rs index ae61b5f..b23d439 100644 --- a/crates/filesync/src/common.rs +++ b/crates/filesync/src/common.rs @@ -1,10 +1,14 @@ //! Shared helpers used by both the filesync **client** and **server**. //! +//! Includes preemptive disk-space checking via [`check_disk_space`] / +//! [`available_disk_space`], which are called after a manifest exchange to +//! abort the sync early when the local filesystem has insufficient room. +//! //! Everything that was duplicated between `client.rs` and `server.rs` lives //! here so that bug-fixes and behavioural changes only need to happen once. use crate::protocol::*; -use crate::sync_engine::{ChunkResult, FinishResult, SyncEngine}; +use crate::sync_engine::{ChunkResult, ConflictInfo, FinishResult, SyncEngine}; use crate::transport::Connection; use crate::watcher::FsEvent; @@ -17,6 +21,36 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; +// ── Disk-space helpers ──────────────────────────────────────────────────── + +/// Returns the number of bytes currently available on the filesystem that +/// contains `path`. Thin wrapper over [`fs2::available_space`]. +pub fn available_disk_space(path: &Path) -> io::Result { + fs2::available_space(path) +} + +/// Asserts that at least `required_bytes` are available on the filesystem +/// that contains `path`. +/// +/// * `Ok(available)` – there is enough space; `available` is the free-byte +/// count at the time of the check. +/// * `Err(StorageFull)` – not enough space; the error message includes both +/// the available and required byte counts. +pub fn check_disk_space(path: &Path, required_bytes: u64) -> io::Result { + let available = available_disk_space(path)?; + if available < required_bytes { + Err(io::Error::new( + io::ErrorKind::StorageFull, + format!( + "insufficient disk space: {} B available, {} B required", + available, required_bytes + ), + )) + } else { + Ok(available) + } +} + // ── Pending-changes accumulator ────────────────────────────────────────── const MAX_BATCH_MS: u64 = 2_000; @@ -256,6 +290,8 @@ pub struct BundleApplied { pub dirs_count: usize, /// Total bytes across all entries in the bundle. pub bytes: u64, + /// Conflict copies created while applying this bundle. + pub conflicts: Vec, } /// Outcome of processing a `LargeFileChunk` message. @@ -290,15 +326,36 @@ pub fn handle_recv_bundle( bus: &Option>, log_prefix: &str, ) -> io::Result { - let applied = engine.apply_bundle(bundle)?; + let apply_result = engine.apply_bundle(bundle)?; let files_count = bundle.files.iter().filter(|f| !f.metadata.is_dir).count(); let dirs_count = bundle.files.iter().filter(|f| f.metadata.is_dir).count(); let bytes: u64 = bundle.files.iter().map(|f| f.metadata.size).sum(); + let applied = apply_result.written; + info!("{log_prefix}: +{applied} file(s) from {peer}"); publish_changed(bus, bundle, peer); + for ci in &apply_result.conflicts { + warn!( + "{log_prefix}: conflict copy from {peer}: {:?} → {:?}", + ci.original_path, ci.conflict_copy_path + ); + if let Some(ref bus) = bus { + bus.publish( + "filesync", + "filesync.conflict_copy", + serde_json::json!({ + "original": ci.original_path, + "conflict_copy": ci.conflict_copy_path, + "peer": peer, + "node": engine.node_id(), + }), + ); + } + } + if files_count > 0 { if let Some(ref bus) = bus { bus.publish( @@ -320,6 +377,7 @@ pub fn handle_recv_bundle( files_count, dirs_count, bytes, + conflicts: apply_result.conflicts, }) } @@ -351,10 +409,19 @@ pub fn handle_recv_large_file_chunk( ) -> io::Result { match engine.receive_large_file_chunk(path, chunk_index, data)? { ChunkResult::ReadyToCommit(hash) => { - engine.commit_large_file(path, hash).map_err(|e| { + match engine.commit_large_file(path, hash).map_err(|e| { error!("{log_prefix}: large file commit after retransmit from {peer}: {e}"); e - })?; + })? { + FinishResult::Committed => {} + FinishResult::CommittedWithConflict(ci) => { + warn!( + "{log_prefix}: conflict copy during retransmit commit: {:?} → {:?}", + ci.original_path, ci.conflict_copy_path + ); + } + FinishResult::MissingChunks(_) => {} + } info!("{log_prefix}: large file committed {path:?} from {peer} (after retransmit)"); Ok(ChunkOutcome::Committed) } @@ -392,6 +459,40 @@ pub fn handle_recv_large_file_end( } Ok(LargeFileEndOutcome::Committed) } + FinishResult::CommittedWithConflict(ci) => { + info!( + "{log_prefix}: large file committed {path:?} from {peer} \ + (conflict copy: {:?})", + ci.conflict_copy_path + ); + if let Some(ref bus) = bus { + bus.publish( + "filesync", + "filesync.conflict_copy", + serde_json::json!({ + "original": ci.original_path, + "conflict_copy": ci.conflict_copy_path, + "peer": peer, + "node": engine.node_id(), + }), + ); + bus.publish( + "filesync", + "filesync.file_changed", + serde_json::json!({ "path": path, "node": peer }), + ); + bus.publish( + "filesync", + "filesync.incremental_stats", + serde_json::json!({ + "node": engine.node_id(), + "peer": peer, + "files_changed": 1, + }), + ); + } + Ok(LargeFileEndOutcome::Committed) + } FinishResult::MissingChunks(indices) => { warn!( "{log_prefix}: {path:?} from {peer} missing {} chunk(s), requesting retransmit", diff --git a/crates/filesync/src/protocol.rs b/crates/filesync/src/protocol.rs index 3d6d80d..08f17f3 100644 --- a/crates/filesync/src/protocol.rs +++ b/crates/filesync/src/protocol.rs @@ -98,6 +98,16 @@ pub enum Message { from: PathBuf, to: PathBuf, }, + + /// Sent by either side when a preemptive disk-space check fails after + /// receiving the remote manifest. The receiver should abort the sync + /// and surface the error to the user / operator. + InsufficientDiskSpace { + /// Free bytes on the sender's filesystem at the time of the check. + available_bytes: u64, + /// Bytes that would have been needed to complete the sync. + required_bytes: u64, + }, } pub fn serialise_message(msg: &Message) -> io::Result> { diff --git a/crates/filesync/src/server.rs b/crates/filesync/src/server.rs index 988f88f..d615bab 100644 --- a/crates/filesync/src/server.rs +++ b/crates/filesync/src/server.rs @@ -315,6 +315,22 @@ fn handle_client( let remote = match conn.recv()? { Message::ManifestExchange(m) => m, + Message::InsufficientDiskSpace { + available_bytes, + required_bytes, + } => { + error!( + "filesync: client {client_id} reports insufficient disk space — \ + {available_bytes} B available, {required_bytes} B required; aborting sync" + ); + return Err(io::Error::new( + io::ErrorKind::StorageFull, + format!( + "filesync: client {client_id} out of disk space: \ + {available_bytes} B available, {required_bytes} B required" + ), + )); + } _ => { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -329,6 +345,41 @@ fn handle_client( r_files, r_dirs ); + // ── Preemptive disk-space check (server) ────────────────────────────── + // Simulate the client's send-list computation (is_server = false) to + // predict the bytes the client will upload. Abort before sending + // anything if the server filesystem cannot accommodate them. + let bytes_from_client: u64 = manifest::compute_send_list(&remote, &local, false) + .iter() + .filter_map(|p| remote.files.get(p)) + .filter(|m| !m.is_dir) + .map(|m| m.size) + .sum(); + if bytes_from_client > 0 { + let avail = common::available_disk_space(engine.root()).unwrap_or(0); + if avail < bytes_from_client { + error!( + "filesync: not enough disk space on server for {client_id} — \ + {avail} B available, {bytes_from_client} B required; aborting sync" + ); + let _ = conn.send(&Message::InsufficientDiskSpace { + available_bytes: avail, + required_bytes: bytes_from_client, + }); + return Err(io::Error::new( + io::ErrorKind::StorageFull, + format!( + "filesync: server out of disk space: \ + {avail} B available, {bytes_from_client} B required" + ), + )); + } + debug!( + "filesync: disk-space check OK for {client_id} — \ + {avail} B available, {bytes_from_client} B required" + ); + } + let to_client = manifest::compute_send_list(&local, &remote, true); let files_sent_to_client = to_client .iter() @@ -370,7 +421,7 @@ fn handle_client( "filesync: initial recv Bundle from {client_id}: bundle_id={} files={} size={} B", b.bundle_id, n_files, b_bytes ); - let n = engine.apply_bundle(&b)?; + let n = engine.apply_bundle(&b)?.written; for fd in &b.files { if !fd.metadata.is_dir { files_received += 1; @@ -457,6 +508,38 @@ fn handle_client( }, ); } + Ok(crate::sync_engine::FinishResult::CommittedWithConflict(ci)) => { + files_received += 1; + warn!( + "filesync: initial sync large file committed {path:?} from {client_id} \ + (conflict copy: {:?})", + ci.conflict_copy_path + ); + if let Some(ref bus) = bus { + bus.publish( + "filesync", + "filesync.conflict_copy", + serde_json::json!({ + "original": ci.original_path, + "conflict_copy": ci.conflict_copy_path, + "peer": client_id, + }), + ); + bus.publish( + "filesync", + "filesync.file_changed", + serde_json::json!({ "path": path, "node": client_id }), + ); + } + broadcast_to_others( + &peers, + &client_id, + &Message::LargeFileEnd { + path: path.clone(), + final_hash, + }, + ); + } Ok(crate::sync_engine::FinishResult::MissingChunks(indices)) => { warn!( "filesync: initial sync {path:?} missing {} chunk(s), requesting retransmit", @@ -479,6 +562,22 @@ fn handle_client( ); break; } + Message::InsufficientDiskSpace { + available_bytes, + required_bytes, + } => { + error!( + "filesync: client {client_id} reports insufficient disk space during upload — \ + {available_bytes} B available, {required_bytes} B required; aborting sync" + ); + return Err(io::Error::new( + io::ErrorKind::StorageFull, + format!( + "filesync: client {client_id} out of disk space: \ + {available_bytes} B available, {required_bytes} B required" + ), + )); + } _ => { warn!("filesync: unexpected message during initial sync from {client_id}"); } @@ -710,6 +809,16 @@ fn client_recv_loop( } return; } + Ok(Message::InsufficientDiskSpace { + available_bytes, + required_bytes, + }) => { + error!( + "filesync: {client_id} reports insufficient disk space — \ + {available_bytes} B available, {required_bytes} B required; disconnecting" + ); + return; + } _ => { warn!("filesync: unexpected message from {client_id} in live sync phase"); } diff --git a/crates/filesync/src/sync_engine.rs b/crates/filesync/src/sync_engine.rs index 843f8a4..1c32e13 100644 --- a/crates/filesync/src/sync_engine.rs +++ b/crates/filesync/src/sync_engine.rs @@ -29,10 +29,28 @@ pub enum ChunkResult { #[derive(Debug)] pub enum FinishResult { Committed, - + CommittedWithConflict(ConflictInfo), MissingChunks(Vec), } +/// Information about a conflict that was resolved by creating a conflict copy. +#[derive(Debug, Clone)] +pub struct ConflictInfo { + /// Original file path (incoming file applied here). + pub original_path: PathBuf, + /// Path where the diverged local copy was saved. + pub conflict_copy_path: PathBuf, +} + +/// Result of applying a bundle. +#[derive(Debug, Default)] +pub struct ApplyResult { + /// Number of entries actually written (files + dirs). + pub written: usize, + /// Conflict copies created during this apply. + pub conflicts: Vec, +} + struct LargeFileAssembly { tmp_path: PathBuf, total_chunks: u32, @@ -59,6 +77,44 @@ pub struct SyncEngine { exclusions: Arc, } +/// Returns the path that a conflict copy of `rel_path` should use. +/// +/// Format: `{dir}/{stem} (conflict {unix_secs} {node_id}).{ext}` +/// If the file has no extension the extension suffix is omitted. +pub fn conflict_copy_name(rel_path: &Path, node_id: &str, unix_secs: u64) -> PathBuf { + let stem = rel_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + let ext = rel_path.extension().and_then(|s| s.to_str()); + let conflict_filename = match ext { + Some(e) => format!("{stem} (conflict {unix_secs} {node_id}).{e}"), + None => format!("{stem} (conflict {unix_secs} {node_id})"), + }; + match rel_path.parent().filter(|p| *p != Path::new("")) { + Some(parent) => parent.join(conflict_filename), + None => PathBuf::from(conflict_filename), + } +} + +/// Compute the BLAKE3 hash of a file on disk. Returns `None` if the file +/// cannot be read (e.g. does not exist yet). +fn hash_file(path: &Path) -> Option<[u8; 32]> { + use std::io::Read; + let file = fs::File::open(path).ok()?; + let mut reader = std::io::BufReader::with_capacity(64 * 1024, file); + let mut hasher = blake3::Hasher::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = reader.read(&mut buf).ok()?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Some(hasher.finalize().into()) +} + impl SyncEngine { pub fn new(root: PathBuf, node_id: String, exclusions: Arc) -> Self { log::debug!( @@ -188,9 +244,32 @@ impl SyncEngine { .collect() } - pub fn apply_bundle(&self, bundle: &FileBundle) -> std::io::Result { + /// Returns `Some(manifest_hash)` when both the on-disk file and the incoming + /// file have diverged from the recorded manifest hash (i.e. a genuine + /// two-sided conflict). Returns `None` when there is no conflict. + fn detect_conflict( + &self, + rel_path: &Path, + full_path: &Path, + incoming_hash: &[u8; 32], + ) -> Option<[u8; 32]> { + // Must be a known file (present in our manifest) + let manifest_hash = self.manifest.read().files.get(rel_path).map(|m| m.hash)?; + // The incoming file must differ from the last-synced state + if incoming_hash == &manifest_hash { + return None; + } + // The on-disk file must also differ from the last-synced state + let on_disk_hash = hash_file(full_path)?; + if on_disk_hash == manifest_hash { + return None; // local never changed — no conflict + } + Some(manifest_hash) + } + + pub fn apply_bundle(&self, bundle: &FileBundle) -> std::io::Result { let mut written = Vec::new(); - let mut count = 0usize; + let mut result = ApplyResult::default(); for fd in &bundle.files { if !safe_relative(&fd.metadata.rel_path) { @@ -199,6 +278,42 @@ impl SyncEngine { } let full = self.root.join(&fd.metadata.rel_path); + + // --- Conflict detection (files only) --- + if !fd.metadata.is_dir { + if let Some(_ancestor_hash) = + self.detect_conflict(&fd.metadata.rel_path, &full, &fd.metadata.hash) + { + let unix_secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let conflict_rel = + conflict_copy_name(&fd.metadata.rel_path, &self.node_id, unix_secs); + let conflict_full = self.root.join(&conflict_rel); + if let Some(p) = conflict_full.parent() { + fs::create_dir_all(p)?; + } + match fs::copy(&full, &conflict_full) { + Ok(_) => { + log::info!( + "conflict: {:?} diverged; local copy saved as {:?}", + fd.metadata.rel_path, + conflict_rel + ); + result.conflicts.push(ConflictInfo { + original_path: fd.metadata.rel_path.clone(), + conflict_copy_path: conflict_rel, + }); + } + Err(e) => { + warn!("conflict copy failed for {:?}: {e}", fd.metadata.rel_path); + } + } + } + } + + // --- Apply the incoming file --- self.suppressed.write().insert(fd.metadata.rel_path.clone()); written.push(fd.metadata.rel_path.clone()); @@ -215,11 +330,11 @@ impl SyncEngine { .write() .files .insert(fd.metadata.rel_path.clone(), fd.metadata.clone()); - count += 1; + result.written += 1; } self.schedule_unsuppress(written); - Ok(count) + Ok(result) } pub fn begin_large_file( @@ -376,6 +491,54 @@ impl SyncEngine { } } + // --- Conflict detection --- + let conflict = { + let manifest_hash = self.manifest.read().files.get(path).map(|m| m.hash); + if let Some(manifest_hash) = manifest_hash { + if final_hash != manifest_hash { + if let Some(on_disk_hash) = hash_file(&asm.dst) { + if on_disk_hash != manifest_hash { + let unix_secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let conflict_rel = conflict_copy_name(path, &self.node_id, unix_secs); + let conflict_full = self.root.join(&conflict_rel); + if let Some(p) = conflict_full.parent() { + let _ = fs::create_dir_all(p); + } + match fs::copy(&asm.dst, &conflict_full) { + Ok(_) => { + log::info!( + "conflict: large file {:?} diverged; \ + local copy saved as {:?}", + path, + conflict_rel + ); + Some(ConflictInfo { + original_path: path.clone(), + conflict_copy_path: conflict_rel, + }) + } + Err(e) => { + warn!("conflict copy failed for large file {:?}: {e}", path); + None + } + } + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + } + }; + fs::rename(&asm.tmp_path, &asm.dst)?; let meta = fs::metadata(&asm.dst)?; @@ -401,7 +564,11 @@ impl SyncEngine { .unwrap_or(&asm.tmp_path) .to_path_buf(); self.schedule_unsuppress(vec![path.clone(), tmp_rel]); - Ok(FinishResult::Committed) + if let Some(ci) = conflict { + Ok(FinishResult::CommittedWithConflict(ci)) + } else { + Ok(FinishResult::Committed) + } } pub fn clear_in_progress(&self) { diff --git a/crates/filesync/tests/test_conflict.rs b/crates/filesync/tests/test_conflict.rs new file mode 100644 index 0000000..2b6668e --- /dev/null +++ b/crates/filesync/tests/test_conflict.rs @@ -0,0 +1,1101 @@ +//! Comprehensive tests for **Option A: conflict-copy resolution**. +//! +//! When both the local and the remote side independently modify a file after +//! the last common sync point, the engine must: +//! +//! 1. Detect the two-sided divergence (compare on-disk hash and incoming hash +//! against the last-recorded manifest hash). +//! 2. Save a copy of the current (locally-modified) file at a conflict-copy +//! path before overwriting. +//! 3. Apply the incoming content to the original path. +//! +//! Coverage sections +//! ───────────────── +//! Part 1 – `conflict_copy_name` naming logic +//! Part 2 – `apply_bundle` no-conflict cases +//! Part 3 – `apply_bundle` conflict cases +//! Part 4 – `commit_large_file` no-conflict cases +//! Part 5 – `commit_large_file` conflict cases +//! Part 6 – Regression: behaviour is preserved when no conflict exists + +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; + +use bytehive_filesync::{ + exclusions::{ExclusionConfig, Exclusions}, + protocol::{FileBundle, FileData, FileMetadata}, + sync_engine::{conflict_copy_name, FinishResult, SyncEngine}, +}; + +// ── Shared test helpers ─────────────────────────────────────────────────────── + +fn tmp_dir(suffix: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!( + "filesync_conflict_{}_{suffix}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&d).unwrap(); + d +} + +fn make_engine(root: PathBuf) -> SyncEngine { + let ex = Arc::new(Exclusions::compile(&ExclusionConfig::default())); + SyncEngine::new(root, "test-node".to_string(), ex) +} + +/// Build a single-file bundle carrying `content` at relative path `rel`. +fn file_bundle(rel: &str, content: &[u8]) -> FileBundle { + let hash: [u8; 32] = blake3::hash(content).into(); + FileBundle { + files: vec![FileData { + metadata: FileMetadata { + rel_path: PathBuf::from(rel), + size: content.len() as u64, + hash, + modified_ms: 1_000, + is_dir: false, + }, + content: content.to_vec(), + }], + bundle_id: 1, + } +} + +/// Drive the large-file protocol: +/// +/// 1. Apply `prior_content` via a bundle so the manifest records it. +/// 2. If `local_edit` is `Some`, overwrite the on-disk file to simulate a +/// local edit that happened after the last sync. +/// 3. Run the full large-file protocol (`begin` → `receive` → `finish`) for +/// `incoming_content`, and return the `FinishResult`. +fn large_file_with_prior( + engine: &SyncEngine, + rel: &str, + prior_content: &[u8], + local_edit: Option<&[u8]>, + incoming_content: &[u8], +) -> std::io::Result { + // Step 1: establish the manifest at the "ancestor" state. + engine.apply_bundle(&file_bundle(rel, prior_content))?; + + // Step 2: optional local divergence. + if let Some(edited) = local_edit { + fs::write(engine.root().join(rel), edited)?; + } + + // Step 3: run the large-file protocol for the incoming version. + let incoming_hash: [u8; 32] = blake3::hash(incoming_content).into(); + let rel_path = PathBuf::from(rel); + let meta = FileMetadata { + rel_path: rel_path.clone(), + size: incoming_content.len() as u64, + hash: incoming_hash, + modified_ms: 2_000, + is_dir: false, + }; + engine.begin_large_file(meta, 1)?; + engine.receive_large_file_chunk(&rel_path, 0, incoming_content)?; + engine.finish_large_file(&rel_path, incoming_hash) +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Part 1 – `conflict_copy_name` naming logic +// ═════════════════════════════════════════════════════════════════════════════ + +#[test] +fn conflict_name_file_with_extension_contains_all_components() { + let p = conflict_copy_name(Path::new("report.txt"), "node-1", 1_000); + let name = p.file_name().unwrap().to_string_lossy(); + assert!(name.contains("report"), "stem must appear: {name}"); + assert!( + name.ends_with(".txt"), + "extension must be preserved: {name}" + ); + assert!( + name.contains("conflict"), + "'conflict' keyword must appear: {name}" + ); + assert!(name.contains("node-1"), "node-id must appear: {name}"); + assert!(name.contains("1000"), "timestamp must appear: {name}"); +} + +#[test] +fn conflict_name_file_without_extension_has_no_dot() { + let p = conflict_copy_name(Path::new("Makefile"), "n", 42); + let name = p.file_name().unwrap().to_string_lossy(); + // `Makefile` has no extension; the result should not acquire one. + assert!(!name.ends_with('.'), "no spurious trailing dot: {name}"); + assert!(name.contains("Makefile"), "stem must appear: {name}"); + assert!(name.contains("conflict"), "'conflict' must appear: {name}"); +} + +#[test] +fn conflict_name_preserves_parent_directory() { + let p = conflict_copy_name(Path::new("docs/report.txt"), "node-1", 5_000); + assert_eq!( + p.parent().unwrap(), + Path::new("docs"), + "parent directory must be preserved" + ); +} + +#[test] +fn conflict_name_deeply_nested_preserves_full_parent() { + let p = conflict_copy_name(Path::new("a/b/c/file.rs"), "n", 0); + assert_eq!( + p.parent().unwrap(), + Path::new("a/b/c"), + "full nested parent must be preserved" + ); + let name = p.file_name().unwrap().to_string_lossy(); + assert!(name.ends_with(".rs"), "extension must be preserved: {name}"); + assert!(name.contains("file"), "stem must appear: {name}"); +} + +#[test] +fn conflict_name_root_level_file_has_no_spurious_parent() { + let p = conflict_copy_name(Path::new("plain.bin"), "x", 99); + // Either no parent at all, or an empty parent path (""). + let parent_is_empty = p.parent().map_or(true, |pp| pp == Path::new("")); + assert!( + parent_is_empty, + "root-level file must not gain a spurious parent: {p:?}" + ); +} + +#[test] +fn conflict_name_different_timestamps_produce_different_names() { + let p1 = conflict_copy_name(Path::new("f.txt"), "n", 1_000); + let p2 = conflict_copy_name(Path::new("f.txt"), "n", 2_000); + assert_ne!(p1, p2, "different timestamps must produce different names"); +} + +#[test] +fn conflict_name_different_node_ids_produce_different_names() { + let p1 = conflict_copy_name(Path::new("f.txt"), "node-A", 1_000); + let p2 = conflict_copy_name(Path::new("f.txt"), "node-B", 1_000); + assert_ne!(p1, p2, "different node IDs must produce different names"); +} + +#[test] +fn conflict_name_same_inputs_are_deterministic() { + let p1 = conflict_copy_name(Path::new("data.bin"), "srv", 12345); + let p2 = conflict_copy_name(Path::new("data.bin"), "srv", 12345); + assert_eq!(p1, p2, "same inputs must always produce the same output"); +} + +#[test] +fn conflict_name_compound_extension_uses_last_ext_as_rust_extension() { + // Rust's `Path::extension()` returns only the final component. + // "archive.tar.gz" → stem "archive.tar", ext "gz". + let p = conflict_copy_name(Path::new("archive.tar.gz"), "n", 0); + let name = p.file_name().unwrap().to_string_lossy(); + assert!(name.ends_with(".gz"), "last extension preserved: {name}"); + assert!( + name.contains("archive.tar"), + "compound stem preserved: {name}" + ); +} + +#[test] +fn conflict_name_node_id_embedded_verbatim() { + let node_id = "my-special-node-42"; + let p = conflict_copy_name(Path::new("f.txt"), node_id, 0); + let name = p.file_name().unwrap().to_string_lossy(); + assert!( + name.contains(node_id), + "node ID must be embedded verbatim in the filename: {name}" + ); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Part 2 – `apply_bundle`: no-conflict cases +// ═════════════════════════════════════════════════════════════════════════════ + +#[test] +fn no_conflict_when_file_is_new() { + // The file is not in the manifest at all → no common ancestor → no conflict. + let dir = tmp_dir("nc_new_file"); + let engine = make_engine(dir.clone()); + let result = engine + .apply_bundle(&file_bundle("new.txt", b"hello")) + .unwrap(); + assert!( + result.conflicts.is_empty(), + "brand-new file must not produce a conflict copy" + ); + assert_eq!(result.written, 1); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn no_conflict_when_only_remote_changed() { + // manifest = A, on-disk = A (local never diverged), incoming = B. + // Only the remote side changed → just update, no conflict. + let dir = tmp_dir("nc_remote_only"); + let engine = make_engine(dir.clone()); + // Establish manifest at version A. + engine + .apply_bundle(&file_bundle("file.txt", b"version A")) + .unwrap(); + // Do NOT touch the on-disk file — it still matches the manifest. + let result = engine + .apply_bundle(&file_bundle("file.txt", b"version B")) + .unwrap(); + assert!( + result.conflicts.is_empty(), + "remote-only change must not produce a conflict copy" + ); + assert_eq!( + fs::read(dir.join("file.txt")).unwrap(), + b"version B", + "incoming content must overwrite cleanly" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn no_conflict_when_incoming_hash_equals_manifest_hash() { + // manifest = A, on-disk = B (locally edited), incoming = A. + // The remote is re-sending the same version → incoming hash == manifest hash + // → no conflict (the condition "remote changed" is not met). + let dir = tmp_dir("nc_same_incoming"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("stable.txt", b"stable")) + .unwrap(); + // Simulate a local edit. + fs::write(dir.join("stable.txt"), b"local edit").unwrap(); + // Incoming is the same as the last-synced (manifest) version. + let result = engine + .apply_bundle(&file_bundle("stable.txt", b"stable")) + .unwrap(); + assert!( + result.conflicts.is_empty(), + "incoming == manifest hash must not trigger conflict" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn no_conflict_for_directory_entries_in_bundle() { + // Directories have no content hash; conflict detection must be skipped. + let dir = tmp_dir("nc_dir_entry"); + let engine = make_engine(dir.clone()); + let dir_bundle = FileBundle { + files: vec![FileData { + metadata: FileMetadata { + rel_path: PathBuf::from("mydir"), + size: 0, + hash: [0u8; 32], + modified_ms: 0, + is_dir: true, + }, + content: vec![], + }], + bundle_id: 10, + }; + engine.apply_bundle(&dir_bundle).unwrap(); + let result = engine.apply_bundle(&dir_bundle).unwrap(); + assert!( + result.conflicts.is_empty(), + "directory entries must never produce conflict copies" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn no_conflict_when_file_absent_from_disk() { + // Manifest has an entry for the path but the file has since been removed + // from disk (e.g. external deletion). `hash_file` returns None → + // `detect_conflict` returns None → no conflict copy. + let dir = tmp_dir("nc_absent"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("ghost.txt", b"was here")) + .unwrap(); + // Remove without updating the manifest. + fs::remove_file(dir.join("ghost.txt")).unwrap(); + let result = engine + .apply_bundle(&file_bundle("ghost.txt", b"incoming")) + .unwrap(); + assert!( + result.conflicts.is_empty(), + "absent on-disk file must not trigger a conflict" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Part 3 – `apply_bundle`: conflict cases (two-sided divergence) +// ═════════════════════════════════════════════════════════════════════════════ + +#[test] +fn conflict_detected_when_both_sides_diverge() { + // manifest = A, on-disk = B (local offline edit), incoming = C. + let dir = tmp_dir("c_both_diverge"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("shared.txt", b"version A")) + .unwrap(); + fs::write(dir.join("shared.txt"), b"version B (local)").unwrap(); + let result = engine + .apply_bundle(&file_bundle("shared.txt", b"version C (remote)")) + .unwrap(); + assert_eq!( + result.conflicts.len(), + 1, + "exactly one conflict must be reported" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn conflict_copy_file_exists_on_disk() { + let dir = tmp_dir("c_copy_on_disk"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("doc.txt", b"base")) + .unwrap(); + fs::write(dir.join("doc.txt"), b"local edit").unwrap(); + let result = engine + .apply_bundle(&file_bundle("doc.txt", b"remote edit")) + .unwrap(); + let ci = &result.conflicts[0]; + assert!( + dir.join(&ci.conflict_copy_path).exists(), + "conflict copy must exist on disk at {:?}", + ci.conflict_copy_path + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn conflict_copy_contains_pre_overwrite_local_content() { + // The conflict copy must preserve the local (diverged) version verbatim. + let dir = tmp_dir("c_local_content"); + let local_content = b"local diverged content must be preserved"; + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("notes.txt", b"common ancestor")) + .unwrap(); + fs::write(dir.join("notes.txt"), local_content).unwrap(); + let result = engine + .apply_bundle(&file_bundle("notes.txt", b"remote version")) + .unwrap(); + let ci = &result.conflicts[0]; + assert_eq!( + fs::read(dir.join(&ci.conflict_copy_path)).unwrap(), + local_content, + "conflict copy must hold the exact pre-overwrite content" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn original_path_gets_incoming_content_after_conflict() { + // After resolution the original path must hold the incoming (winning) content. + let dir = tmp_dir("c_incoming_wins"); + let incoming = b"incoming remote content - this should win"; + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("f.txt", b"ancestor")) + .unwrap(); + fs::write(dir.join("f.txt"), b"local change").unwrap(); + engine + .apply_bundle(&file_bundle("f.txt", incoming)) + .unwrap(); + assert_eq!( + fs::read(dir.join("f.txt")).unwrap(), + incoming, + "original path must contain the incoming content" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn conflict_info_original_path_is_correct() { + let dir = tmp_dir("c_info_original"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("work.txt", b"v0")) + .unwrap(); + fs::write(dir.join("work.txt"), b"v1 local").unwrap(); + let result = engine + .apply_bundle(&file_bundle("work.txt", b"v2 remote")) + .unwrap(); + assert_eq!(result.conflicts[0].original_path, PathBuf::from("work.txt")); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn conflict_copy_path_is_in_same_directory_as_original() { + let dir = tmp_dir("c_same_dir"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("subdir/file.txt", b"base")) + .unwrap(); + fs::write(dir.join("subdir/file.txt"), b"local").unwrap(); + let result = engine + .apply_bundle(&file_bundle("subdir/file.txt", b"remote")) + .unwrap(); + let ci = &result.conflicts[0]; + assert_eq!( + ci.conflict_copy_path.parent().unwrap(), + ci.original_path.parent().unwrap(), + "conflict copy must reside in the same directory as the original" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn conflict_copy_filename_contains_conflict_keyword() { + let dir = tmp_dir("c_keyword"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("data.bin", b"v0")) + .unwrap(); + fs::write(dir.join("data.bin"), b"local").unwrap(); + let result = engine + .apply_bundle(&file_bundle("data.bin", b"remote")) + .unwrap(); + let name = result.conflicts[0] + .conflict_copy_path + .file_name() + .unwrap() + .to_string_lossy(); + assert!( + name.to_lowercase().contains("conflict"), + "conflict copy filename must contain 'conflict': {name}" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn conflict_copy_filename_preserves_file_extension() { + let dir = tmp_dir("c_ext"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("readme.md", b"# v0")) + .unwrap(); + fs::write(dir.join("readme.md"), b"# local").unwrap(); + let result = engine + .apply_bundle(&file_bundle("readme.md", b"# remote")) + .unwrap(); + let name = result.conflicts[0] + .conflict_copy_path + .file_name() + .unwrap() + .to_string_lossy(); + assert!( + name.ends_with(".md"), + "original extension must be preserved in conflict copy name: {name}" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn apply_result_written_count_includes_conflicted_file() { + let dir = tmp_dir("c_written_count"); + let engine = make_engine(dir.clone()); + engine.apply_bundle(&file_bundle("a.txt", b"base")).unwrap(); + fs::write(dir.join("a.txt"), b"local").unwrap(); + let result = engine + .apply_bundle(&file_bundle("a.txt", b"remote")) + .unwrap(); + assert_eq!( + result.written, 1, + "`written` must count the file even when a conflict copy was made" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn only_conflicted_files_get_copies_in_multi_file_bundle() { + // Bundle with two files. Only the one with a local divergence should + // produce a conflict copy. + let dir = tmp_dir("c_selective"); + let engine = make_engine(dir.clone()); + + // Establish both files in the manifest. + let initial = FileBundle { + files: vec![ + FileData { + metadata: FileMetadata { + rel_path: PathBuf::from("will_conflict.txt"), + size: 5, + hash: blake3::hash(b"base1").into(), + modified_ms: 100, + is_dir: false, + }, + content: b"base1".to_vec(), + }, + FileData { + metadata: FileMetadata { + rel_path: PathBuf::from("no_conflict.txt"), + size: 5, + hash: blake3::hash(b"base2").into(), + modified_ms: 100, + is_dir: false, + }, + content: b"base2".to_vec(), + }, + ], + bundle_id: 1, + }; + engine.apply_bundle(&initial).unwrap(); + + // Locally edit only the first file. + fs::write(dir.join("will_conflict.txt"), b"local edit").unwrap(); + // Leave no_conflict.txt untouched — it still matches the manifest. + + // Incoming bundle updates both files. + let update = FileBundle { + files: vec![ + FileData { + metadata: FileMetadata { + rel_path: PathBuf::from("will_conflict.txt"), + size: 13, + hash: blake3::hash(b"remote update1").into(), + modified_ms: 200, + is_dir: false, + }, + content: b"remote update1".to_vec(), + }, + FileData { + metadata: FileMetadata { + rel_path: PathBuf::from("no_conflict.txt"), + size: 13, + hash: blake3::hash(b"remote update2").into(), + modified_ms: 200, + is_dir: false, + }, + content: b"remote update2".to_vec(), + }, + ], + bundle_id: 2, + }; + let result = engine.apply_bundle(&update).unwrap(); + + assert_eq!(result.written, 2, "both files must be written"); + assert_eq!( + result.conflicts.len(), + 1, + "only the locally-edited file must produce a conflict copy" + ); + assert_eq!( + result.conflicts[0].original_path, + PathBuf::from("will_conflict.txt"), + "conflict must reference the correct file" + ); + assert!( + result + .conflicts + .iter() + .all(|ci| ci.original_path != PathBuf::from("no_conflict.txt")), + "non-conflicting file must not appear in conflicts list" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn unsafe_path_still_rejected_even_with_conflict_logic_active() { + let dir = tmp_dir("c_unsafe_path"); + let engine = make_engine(dir.clone()); + let bad_bundle = FileBundle { + files: vec![FileData { + metadata: FileMetadata { + rel_path: PathBuf::from("../escape.txt"), + size: 4, + hash: blake3::hash(b"evil").into(), + modified_ms: 0, + is_dir: false, + }, + content: b"evil".to_vec(), + }], + bundle_id: 99, + }; + let result = engine.apply_bundle(&bad_bundle).unwrap(); + assert_eq!(result.written, 0, "unsafe path must be rejected"); + assert!( + result.conflicts.is_empty(), + "no conflict copy must be created for a rejected path" + ); + assert!( + !dir.parent().unwrap().join("escape.txt").exists(), + "no file must be written outside the root" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn conflict_copy_does_not_pollute_manifest() { + // The conflict copy path is a local artefact; it must NOT appear in the + // engine manifest. The original path must still be present. + let dir = tmp_dir("c_no_manifest_pollution"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("keep.txt", b"v0")) + .unwrap(); + fs::write(dir.join("keep.txt"), b"local").unwrap(); + let result = engine + .apply_bundle(&file_bundle("keep.txt", b"remote")) + .unwrap(); + let ci = &result.conflicts[0]; + let m = engine.get_manifest(); + assert!( + !m.files.contains_key(&ci.conflict_copy_path), + "conflict copy path must not appear in the manifest" + ); + assert!( + m.files.contains_key(&ci.original_path), + "original path must remain in the manifest after conflict resolution" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn manifest_records_incoming_hash_after_conflict() { + // After conflict resolution the manifest entry for the original path + // must reflect the incoming (winning) content, not the local version. + let dir = tmp_dir("c_manifest_hash"); + let incoming = b"incoming wins"; + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("f.txt", b"ancestor")) + .unwrap(); + fs::write(dir.join("f.txt"), b"local diverged").unwrap(); + engine + .apply_bundle(&file_bundle("f.txt", incoming)) + .unwrap(); + let meta = engine + .get_manifest() + .files + .get(&PathBuf::from("f.txt")) + .cloned() + .expect("original path must be in manifest"); + let expected_hash: [u8; 32] = blake3::hash(incoming).into(); + assert_eq!( + meta.hash, expected_hash, + "manifest must record the incoming hash, not the local one" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn repeated_conflicts_on_same_file_each_produce_a_copy() { + // Two successive conflict rounds; each must produce its own conflict copy. + let dir = tmp_dir("c_repeated"); + let engine = make_engine(dir.clone()); + engine.apply_bundle(&file_bundle("rep.txt", b"v0")).unwrap(); + + // Round 1: local diverges to "local-1", remote sends "remote-1". + fs::write(dir.join("rep.txt"), b"local-1").unwrap(); + let r1 = engine + .apply_bundle(&file_bundle("rep.txt", b"remote-1")) + .unwrap(); + assert_eq!(r1.conflicts.len(), 1, "round 1 must produce a conflict"); + + // Round 2: local diverges to "local-2", remote sends "remote-2". + fs::write(dir.join("rep.txt"), b"local-2").unwrap(); + let r2 = engine + .apply_bundle(&file_bundle("rep.txt", b"remote-2")) + .unwrap(); + assert_eq!( + r2.conflicts.len(), + 1, + "round 2 must also produce a conflict" + ); + + // Both conflict copies must exist on disk (they may share the same path + // if the test runs within the same second, but the copy content is valid). + assert!( + dir.join(&r1.conflicts[0].conflict_copy_path).exists(), + "round-1 conflict copy must be on disk" + ); + assert!( + dir.join(&r2.conflicts[0].conflict_copy_path).exists(), + "round-2 conflict copy must be on disk" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Part 4 – `commit_large_file`: no-conflict cases +// ═════════════════════════════════════════════════════════════════════════════ + +#[test] +fn large_file_no_conflict_for_brand_new_file() { + // No prior manifest entry → no ancestor to diverge from → Committed. + let dir = tmp_dir("lf_nc_new"); + let engine = make_engine(dir.clone()); + let content = b"brand new large file - no prior version"; + let hash: [u8; 32] = blake3::hash(content).into(); + let rel = PathBuf::from("new_large.bin"); + let meta = FileMetadata { + rel_path: rel.clone(), + size: content.len() as u64, + hash, + modified_ms: 0, + is_dir: false, + }; + engine.begin_large_file(meta, 1).unwrap(); + engine.receive_large_file_chunk(&rel, 0, content).unwrap(); + let result = engine.finish_large_file(&rel, hash).unwrap(); + assert!( + matches!(result, FinishResult::Committed), + "brand-new large file must yield Committed, not CommittedWithConflict" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn large_file_no_conflict_when_local_unchanged() { + // manifest = A, on-disk = A (no local edit), incoming large-file = B. + // Only the remote changed → no conflict. + let dir = tmp_dir("lf_nc_unchanged"); + let engine = make_engine(dir.clone()); + let result = large_file_with_prior( + &engine, + "data.bin", + b"prior version A", + None, // do NOT locally edit the on-disk file + b"incoming version B", + ) + .unwrap(); + assert!( + matches!(result, FinishResult::Committed), + "local-unchanged large file must yield Committed: {result:?}" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn large_file_no_conflict_when_incoming_equals_manifest() { + // manifest = A, on-disk = B (locally edited), incoming = A (re-send). + // incoming hash == manifest hash → condition "remote changed" is false → no conflict. + let dir = tmp_dir("lf_nc_same_incoming"); + let engine = make_engine(dir.clone()); + let content = b"stable content"; + engine + .apply_bundle(&file_bundle("stable.bin", content)) + .unwrap(); + // Simulate a local edit. + fs::write(dir.join("stable.bin"), b"local edit").unwrap(); + // Large-file incoming is the same bytes that were originally synced. + let incoming_hash: [u8; 32] = blake3::hash(content).into(); + let rel = PathBuf::from("stable.bin"); + let meta = FileMetadata { + rel_path: rel.clone(), + size: content.len() as u64, + hash: incoming_hash, + modified_ms: 0, + is_dir: false, + }; + engine.begin_large_file(meta, 1).unwrap(); + engine.receive_large_file_chunk(&rel, 0, content).unwrap(); + let result = engine.finish_large_file(&rel, incoming_hash).unwrap(); + assert!( + matches!(result, FinishResult::Committed), + "re-sending same content must not trigger conflict" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Part 5 – `commit_large_file`: conflict cases +// ═════════════════════════════════════════════════════════════════════════════ + +#[test] +fn large_file_returns_committed_with_conflict_on_two_sided_divergence() { + // manifest = A, on-disk = B (local edit), incoming large-file = C. + let dir = tmp_dir("lf_c_diverge"); + let engine = make_engine(dir.clone()); + let result = large_file_with_prior( + &engine, + "lf.bin", + b"version A", + Some(b"version B - local edit"), + b"version C - remote large file", + ) + .unwrap(); + assert!( + matches!(result, FinishResult::CommittedWithConflict(_)), + "two-sided divergence must yield CommittedWithConflict: {result:?}" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn large_file_conflict_copy_exists_on_disk() { + let dir = tmp_dir("lf_c_copy_exists"); + let engine = make_engine(dir.clone()); + let result = large_file_with_prior( + &engine, + "report.bin", + b"ancestor", + Some(b"local diverged"), + b"incoming large", + ) + .unwrap(); + if let FinishResult::CommittedWithConflict(ci) = result { + assert!( + dir.join(&ci.conflict_copy_path).exists(), + "large-file conflict copy must exist on disk at {:?}", + ci.conflict_copy_path + ); + } else { + panic!("expected CommittedWithConflict"); + } + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn large_file_conflict_copy_contains_local_pre_overwrite_content() { + let dir = tmp_dir("lf_c_local_content"); + let local_content = b"this is the local version that must be saved as a conflict copy"; + let engine = make_engine(dir.clone()); + let result = large_file_with_prior( + &engine, + "preserve.bin", + b"common ancestor", + Some(local_content), + b"completely different remote large-file content", + ) + .unwrap(); + if let FinishResult::CommittedWithConflict(ci) = result { + assert_eq!( + fs::read(dir.join(&ci.conflict_copy_path)).unwrap(), + local_content, + "large-file conflict copy must hold the exact pre-overwrite local bytes" + ); + } else { + panic!("expected CommittedWithConflict"); + } + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn large_file_original_path_gets_incoming_content_after_conflict() { + let dir = tmp_dir("lf_c_incoming"); + let incoming = b"final incoming large-file content that wins"; + let engine = make_engine(dir.clone()); + large_file_with_prior( + &engine, + "target.bin", + b"v0 ancestor", + Some(b"v1 local edit"), + incoming, + ) + .unwrap(); + assert_eq!( + fs::read(dir.join("target.bin")).unwrap(), + incoming, + "original path must hold the incoming content after large-file conflict" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn large_file_conflict_info_original_path_is_correct() { + let dir = tmp_dir("lf_c_paths"); + let engine = make_engine(dir.clone()); + let result = large_file_with_prior( + &engine, + "img.dat", + b"base", + Some(b"local mod"), + b"remote large", + ) + .unwrap(); + if let FinishResult::CommittedWithConflict(ci) = result { + assert_eq!( + ci.original_path, + PathBuf::from("img.dat"), + "ConflictInfo.original_path must match the transferred file path" + ); + } else { + panic!("expected CommittedWithConflict"); + } + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn large_file_conflict_copy_filename_contains_conflict_keyword() { + let dir = tmp_dir("lf_c_keyword"); + let engine = make_engine(dir.clone()); + let result = large_file_with_prior(&engine, "kw.bin", b"a", Some(b"b"), b"c").unwrap(); + if let FinishResult::CommittedWithConflict(ci) = result { + let name = ci.conflict_copy_path.file_name().unwrap().to_string_lossy(); + assert!( + name.to_lowercase().contains("conflict"), + "large-file conflict copy filename must contain 'conflict': {name}" + ); + } else { + panic!("expected CommittedWithConflict"); + } + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn large_file_manifest_updated_with_incoming_hash_after_conflict() { + // Even when a conflict copy is created the manifest must record the + // incoming (winning) hash for the original path. + let dir = tmp_dir("lf_c_manifest"); + let incoming = b"incoming remote content wins"; + let incoming_hash: [u8; 32] = blake3::hash(incoming).into(); + let engine = make_engine(dir.clone()); + large_file_with_prior( + &engine, + "win.bin", + b"ancestor", + Some(b"local diverged"), + incoming, + ) + .unwrap(); + let meta = engine + .get_manifest() + .files + .get(&PathBuf::from("win.bin")) + .cloned() + .expect("manifest must have an entry after large-file commit"); + assert_eq!( + meta.hash, incoming_hash, + "manifest must record the incoming hash after conflict resolution" + ); + fs::remove_dir_all(&dir).unwrap(); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Part 6 – Regression: existing behaviour preserved when no conflict exists +// ═════════════════════════════════════════════════════════════════════════════ + +#[test] +fn regression_apply_bundle_written_count_no_conflict() { + let dir = tmp_dir("reg_written"); + let engine = make_engine(dir.clone()); + let result = engine + .apply_bundle(&file_bundle("a.txt", b"hello")) + .unwrap(); + assert_eq!(result.written, 1); + assert!(result.conflicts.is_empty()); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn regression_apply_bundle_writes_correct_content() { + let dir = tmp_dir("reg_content"); + let engine = make_engine(dir.clone()); + engine + .apply_bundle(&file_bundle("out.txt", b"expected content")) + .unwrap(); + assert_eq!(fs::read(dir.join("out.txt")).unwrap(), b"expected content"); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn regression_apply_bundle_updates_manifest() { + let dir = tmp_dir("reg_manifest"); + let engine = make_engine(dir.clone()); + engine.apply_bundle(&file_bundle("m.txt", b"data")).unwrap(); + assert!(engine + .get_manifest() + .files + .contains_key(&PathBuf::from("m.txt"))); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn regression_large_file_happy_path_yields_committed() { + // Full large-file flow with no prior version must complete with + // FinishResult::Committed (not CommittedWithConflict). + let dir = tmp_dir("reg_lf_happy"); + let engine = make_engine(dir.clone()); + let content = b"large-file regression - option A must not break happy path"; + let hash: [u8; 32] = blake3::hash(content).into(); + let rel = PathBuf::from("lf.bin"); + let meta = FileMetadata { + rel_path: rel.clone(), + size: content.len() as u64, + hash, + modified_ms: 0, + is_dir: false, + }; + engine.begin_large_file(meta, 1).unwrap(); + engine.receive_large_file_chunk(&rel, 0, content).unwrap(); + let result = engine.finish_large_file(&rel, hash).unwrap(); + assert!( + matches!(result, FinishResult::Committed), + "no-conflict large file must yield Committed" + ); + assert_eq!(fs::read(dir.join("lf.bin")).unwrap(), content); + assert!(engine.get_manifest().files.contains_key(&rel)); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn regression_large_file_hash_mismatch_still_errors() { + // A corrupted transfer (final_hash ≠ actual content hash) must still + // return an InvalidData error — conflict-copy logic must not interfere. + let dir = tmp_dir("reg_lf_hash"); + let engine = make_engine(dir.clone()); + let content = b"some content"; + let wrong_hash = [0xFFu8; 32]; + let rel = PathBuf::from("corrupt.bin"); + let meta = FileMetadata { + rel_path: rel.clone(), + size: content.len() as u64, + hash: wrong_hash, + modified_ms: 0, + is_dir: false, + }; + engine.begin_large_file(meta, 1).unwrap(); + engine.receive_large_file_chunk(&rel, 0, content).unwrap(); + let result = engine.finish_large_file(&rel, wrong_hash); + assert!(result.is_err(), "hash mismatch must still return an error"); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData); + fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn regression_apply_bundle_multiple_files_written_correctly() { + let dir = tmp_dir("reg_multi"); + let engine = make_engine(dir.clone()); + let bundle = FileBundle { + files: vec![ + FileData { + metadata: FileMetadata { + rel_path: PathBuf::from("one.txt"), + size: 3, + hash: blake3::hash(b"aaa").into(), + modified_ms: 0, + is_dir: false, + }, + content: b"aaa".to_vec(), + }, + FileData { + metadata: FileMetadata { + rel_path: PathBuf::from("two.txt"), + size: 3, + hash: blake3::hash(b"bbb").into(), + modified_ms: 0, + is_dir: false, + }, + content: b"bbb".to_vec(), + }, + ], + bundle_id: 5, + }; + let result = engine.apply_bundle(&bundle).unwrap(); + assert_eq!(result.written, 2); + assert!(result.conflicts.is_empty()); + assert_eq!(fs::read(dir.join("one.txt")).unwrap(), b"aaa"); + assert_eq!(fs::read(dir.join("two.txt")).unwrap(), b"bbb"); + fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/filesync/tests/test_sync_engine.rs b/crates/filesync/tests/test_sync_engine.rs index 0a940c7..a05d3fd 100644 --- a/crates/filesync/tests/test_sync_engine.rs +++ b/crates/filesync/tests/test_sync_engine.rs @@ -86,7 +86,8 @@ fn apply_bundle_writes_file_to_disk() { let engine = make_engine(dir.clone()); let n = engine .apply_bundle(&file_bundle("hello.txt", b"hello world")) - .unwrap(); + .unwrap() + .written; assert_eq!(n, 1); assert_eq!(fs::read(dir.join("hello.txt")).unwrap(), b"hello world"); fs::remove_dir_all(&dir).unwrap(); @@ -122,7 +123,7 @@ fn apply_bundle_creates_parent_directories() { fn apply_bundle_creates_directory_entry() { let dir = tmp_dir("dir_entry"); let engine = make_engine(dir.clone()); - let n = engine.apply_bundle(&dir_bundle("mydir")).unwrap(); + let n = engine.apply_bundle(&dir_bundle("mydir")).unwrap().written; assert_eq!(n, 1); assert!(dir.join("mydir").is_dir()); fs::remove_dir_all(&dir).unwrap(); @@ -145,7 +146,7 @@ fn apply_bundle_rejects_path_traversal() { }], bundle_id: 99, }; - let n = engine.apply_bundle(&evil_bundle).unwrap(); + let n = engine.apply_bundle(&evil_bundle).unwrap().written; assert_eq!(n, 0, "path traversal must be rejected"); assert!(!dir.parent().unwrap().join("escaped.txt").exists()); fs::remove_dir_all(&dir).unwrap(); @@ -168,7 +169,7 @@ fn apply_bundle_rejects_absolute_path() { }], bundle_id: 100, }; - let n = engine.apply_bundle(&evil_bundle).unwrap(); + let n = engine.apply_bundle(&evil_bundle).unwrap().written; assert_eq!(n, 0); fs::remove_dir_all(&dir).unwrap(); } @@ -214,7 +215,7 @@ fn apply_bundle_handles_multiple_files_in_one_bundle() { ], bundle_id: 5, }; - let n = engine.apply_bundle(&bundle).unwrap(); + let n = engine.apply_bundle(&bundle).unwrap().written; assert_eq!(n, 2); assert_eq!(fs::read(dir.join("a.txt")).unwrap(), b"a"); assert_eq!(fs::read(dir.join("b.txt")).unwrap(), b"b"); @@ -526,6 +527,7 @@ fn large_file_finish_detects_missing_chunks() { assert_eq!(missing, vec![1], "chunk 1 must be reported missing"); } FinishResult::Committed => panic!("must not commit when a chunk is missing"), + FinishResult::CommittedWithConflict(_) => panic!("must not commit when a chunk is missing"), } fs::remove_dir_all(&dir).unwrap(); }