Skip to content

Commit 2f4e377

Browse files
committed
Fix: dir-Overwrite must NEVER wholesale-replace, even on recursive-delete backends
The previous Overwrite logic relied on Volume::delete failing benignly when called on a non-empty directory — a contract honored by every current backend (LocalPosix, SMB, MTP, InMemory) but enforced only by the trait doc. A future backend with recursive delete semantics, or a refactor that consolidates delete + delete_recursive, would silently flip dir-Overwrite from "merge with overwrite-on-file-conflict" (Cmdr's UX promise) to "wholesale replace, deleting files unique to dest". Data-loss footgun. Fix: stat the dest first, skip the delete entirely for directories. The recursive copy then merges into the existing tree by construction — same-named files inside get overwritten by the streaming writers, files unique to dest are preserved. The merge guarantee is now architectural, not emergent. The test `dir_overwrite_must_merge_not_replace_even_with_recursive_delete` proves this with a `RecursiveDeleteVolume` wrapper around `InMemoryVolume` that intentionally violates the trait's "file or empty directory" contract. Without the fix the test fails (the wrapper deletes the dest tree before the copy runs); with the fix it passes (delete is skipped because is_directory returns true). The companion `file_overwrite_still_deletes_the_existing_file` test pins the file branch — Overwrite still genuinely replaces files. Also tightened `Volume::delete` doc to spell out the strict "single node only" contract and what relies on it. Future backend authors landing here to wire up a new platform should now get a louder signal that recursive behavior is forbidden — not just unusual. CLAUDE.md note in `write_operations/` updated to reflect the new "enforced architecturally, not by trait contract" rationale.
1 parent 8ae6c28 commit 2f4e377

3 files changed

Lines changed: 186 additions & 27 deletions

File tree

apps/desktop/src-tauri/src/file_system/volume/mod.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,21 @@ pub trait Volume: Send + Sync {
368368
Box::pin(async { Err(VolumeError::NotSupported) })
369369
}
370370

371-
/// Deletes a file or empty directory.
371+
/// Deletes a single file or **empty** directory.
372+
///
373+
/// **Strict contract — must NOT recurse.** If `path` is a non-empty directory,
374+
/// the implementation must return an error (typically `VolumeError::IoError`
375+
/// with errno `ENOTEMPTY` or equivalent), not silently delete the contents.
376+
/// The conflict resolver and several callers rely on this: `apply_volume_conflict_resolution`
377+
/// uses `is_directory` + skip-delete to enforce "Overwrite means merge for dirs"
378+
/// architecturally, but other call sites (rollback, partial-file cleanup) assume
379+
/// they only ever delete one node at a time and would over-delete if this contract
380+
/// loosened.
381+
///
382+
/// For recursive deletes, callers should walk the tree themselves and call
383+
/// `delete` per leaf — see `delete_volume_path_recursive` in `volume_copy.rs`.
384+
///
385+
/// Default: `NotSupported`.
372386
fn delete<'a>(&'a self, path: &'a Path) -> Pin<Box<dyn Future<Output = Result<(), VolumeError>> + Send + 'a>> {
373387
let _ = path;
374388
Box::pin(async { Err(VolumeError::NotSupported) })

apps/desktop/src-tauri/src/file_system/write_operations/CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ actual `copy_files_start` can consume the cache via `preview_id` in `WriteOperat
110110
**Move strategy.** Same filesystem detected via device ID comparison (`MetadataExt::dev`). Cross-filesystem move uses a
111111
`.cmdr-staging-<uuid>` dir at the destination root, then atomic `rename` into place, then source deletion.
112112

113-
**Dir-vs-dir conflicts route through `resolve_volume_conflict` like every other shape.** The `volume_move` and `volume_copy` loops used to short-circuit dir-into-dir as a silent merge, bypassing the user's `conflict_resolution`. That made the merge invisible — even when the user picked "Stop" (= ask), nothing prompted. Now every conflict (file-vs-file, dir-vs-dir, file-vs-dir, dir-vs-file) goes through the resolver. For Overwrite specifically, `apply_volume_conflict_resolution` calls `Volume::delete(dest)` which fails benignly on non-empty dirs (`Volume::delete` is contractually for files or empty dirs); the recursive copy then merges into the existing tree (same-named files overwritten, others kept). Net effect for the user: dir-vs-dir Overwrite is "merge with overwrite-on-file-conflict", not "wholesale replace". That's intentional — wholesale replace would risk data loss of files outside the source tree. See the comment in `volume_conflict.rs::apply_volume_conflict_resolution` for the per-shape walkthrough.
113+
**Dir-vs-dir conflicts route through `resolve_volume_conflict` like every other shape.** The `volume_move` and `volume_copy` loops used to short-circuit dir-into-dir as a silent merge, bypassing the user's `conflict_resolution`. That made the merge invisible — even when the user picked "Stop" (= ask), nothing prompted. Now every conflict (file-vs-file, dir-vs-dir, file-vs-dir, dir-vs-file) goes through the resolver.
114+
115+
**Overwrite means merge for dirs, replace for files — enforced architecturally, not by trait contract.** `apply_volume_conflict_resolution` stats the dest first; for files it deletes (so the streaming writer lands a fresh copy), for directories it skips the delete entirely (the recursive copy merges into the existing tree). This is enforced at the call site rather than relying on `Volume::delete`'s "file or empty directory only" contract — a future backend with recursive delete semantics, or a refactor that consolidates `delete` + `delete_recursive`, would otherwise silently flip the UX from merge to wholesale replace and delete files unique to dest. The `dir_overwrite_must_merge_not_replace_even_with_recursive_delete` test in `volume_conflict.rs` pins this with a wrapper Volume that violates the trait contract.
114116

115117
**Cross-volume move source-delete is recursive.** `move_between_volumes` in `volume_move.rs` deletes the source via
116118
`delete_volume_path_recursive` (re-exported from `volume_copy.rs` for this purpose) when the source is a directory.

apps/desktop/src-tauri/src/file_system/write_operations/volume_conflict.rs

Lines changed: 168 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -118,33 +118,36 @@ async fn apply_volume_conflict_resolution(
118118
}
119119
ConflictResolution::Skip => Ok(None),
120120
ConflictResolution::Overwrite => {
121-
// Try to delete the existing item, then return the same path so the caller
122-
// writes there.
121+
// Cmdr's UX promise is "Overwrite means merge for dirs, replace for files":
123122
//
124-
// - For files: `Volume::delete` removes the dest, the recursive copy lands
125-
// a fresh copy. Net effect: file is replaced.
126-
// - For non-empty directories: `Volume::delete` is contractually for files
127-
// or *empty* directories (`std::fs::remove_dir` semantics, see Volume
128-
// trait doc). The call fails with ENOTEMPTY/equivalent — we log debug
129-
// and continue. The recursive copy then merges into the existing tree:
130-
// same-named files inside get overwritten by the streaming writers,
131-
// files in dest that aren't in source remain untouched. Net effect:
132-
// "merge with overwrite-on-file-conflict". This is the desired UX for
133-
// dir-vs-dir Overwrite — wholesale dir replacement would be surprising
134-
// and risk data loss.
135-
// - For empty directories: `Volume::delete` succeeds, the recursive copy
136-
// recreates the dir. Net effect: equivalent to merge.
123+
// - For files: delete the dest first so the streaming writer lands a fresh
124+
// copy. Same-named files in dest get genuinely replaced, byte-for-byte.
125+
// - For directories: SKIP the delete entirely. The recursive copy will
126+
// merge into the existing tree — same-named files inside get overwritten
127+
// by the streaming writers, files in dest that aren't in source are
128+
// preserved.
137129
//
138-
// Demoted to debug because the ENOTEMPTY case is the expected branch for
139-
// dir merges, not a real failure.
140-
if let Err(e) = dest_volume.delete(dest_path).await {
141-
log::debug!(
142-
"apply_volume_conflict_resolution(Overwrite): delete of {} returned {} \
143-
(expected for non-empty directories — recursive copy will merge)",
144-
dest_path.display(),
145-
e
146-
);
147-
}
130+
// The dir branch is enforced HERE rather than relying on `Volume::delete`'s
131+
// "file or empty directory" trait contract. Today every backend honors
132+
// that contract (delete of a non-empty dir fails benignly), but a future
133+
// backend with recursive delete semantics — or a refactor that consolidates
134+
// delete + delete_recursive — would silently flip the UX from merge to
135+
// wholesale replace, deleting files unique to dest. That's a data-loss
136+
// footgun. Stat-and-skip makes the merge guarantee architectural, not
137+
// emergent. See `dir_overwrite_must_merge_not_replace_even_with_recursive_delete`
138+
// in the test module — it pins this with a wrapper Volume that violates
139+
// the contract.
140+
let is_dir = dest_volume.is_directory(dest_path).await.unwrap_or(false);
141+
if !is_dir
142+
&& let Err(e) = dest_volume.delete(dest_path).await {
143+
log::warn!(
144+
"apply_volume_conflict_resolution(Overwrite): delete of file {} failed: {}",
145+
dest_path.display(),
146+
e
147+
);
148+
// Continue — the streaming writer might still succeed if the failure
149+
// was transient.
150+
}
148151
Ok(Some(dest_path.to_path_buf()))
149152
}
150153
ConflictResolution::Rename => {
@@ -187,3 +190,143 @@ async fn find_unique_volume_name(dest_volume: &Arc<dyn Volume>, path: &Path) ->
187190
}
188191
}
189192
}
193+
194+
#[cfg(test)]
195+
mod tests {
196+
use super::*;
197+
use crate::file_system::listing::FileEntry;
198+
use crate::file_system::volume::{InMemoryVolume, VolumeError};
199+
use std::pin::Pin;
200+
use std::sync::Arc;
201+
202+
/// Wraps an `InMemoryVolume` but makes `delete` recursive — simulates a future
203+
/// backend (or refactor) that doesn't honor the trait's "file or empty directory"
204+
/// contract.
205+
///
206+
/// Used to assert that `apply_volume_conflict_resolution(Overwrite)` produces a
207+
/// merge UX even when the underlying delete is recursive. If this volume's
208+
/// `delete` ever runs against a non-empty `dest_path`, the test below catches
209+
/// it because files unique to the dest tree disappear.
210+
struct RecursiveDeleteVolume {
211+
inner: Arc<InMemoryVolume>,
212+
}
213+
214+
impl Volume for RecursiveDeleteVolume {
215+
fn name(&self) -> &str {
216+
self.inner.name()
217+
}
218+
fn root(&self) -> &Path {
219+
self.inner.root()
220+
}
221+
fn list_directory<'a>(
222+
&'a self,
223+
path: &'a Path,
224+
on_progress: Option<&'a (dyn Fn(usize) + Sync)>,
225+
) -> Pin<Box<dyn Future<Output = Result<Vec<FileEntry>, VolumeError>> + Send + 'a>> {
226+
self.inner.list_directory(path, on_progress)
227+
}
228+
fn get_metadata<'a>(
229+
&'a self,
230+
path: &'a Path,
231+
) -> Pin<Box<dyn Future<Output = Result<FileEntry, VolumeError>> + Send + 'a>> {
232+
self.inner.get_metadata(path)
233+
}
234+
fn exists<'a>(&'a self, path: &'a Path) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
235+
self.inner.exists(path)
236+
}
237+
fn is_directory<'a>(
238+
&'a self,
239+
path: &'a Path,
240+
) -> Pin<Box<dyn Future<Output = Result<bool, VolumeError>> + Send + 'a>> {
241+
self.inner.is_directory(path)
242+
}
243+
/// Recursive delete — contractually wrong, but plausible for some backends.
244+
fn delete<'a>(
245+
&'a self,
246+
path: &'a Path,
247+
) -> Pin<Box<dyn Future<Output = Result<(), VolumeError>> + Send + 'a>> {
248+
Box::pin(async move {
249+
if self.inner.is_directory(path).await.unwrap_or(false) {
250+
let entries = self.inner.list_directory(path, None).await?;
251+
for entry in entries {
252+
let child = PathBuf::from(&entry.path);
253+
// Recurse — child might also be a non-empty directory.
254+
Box::pin(self.delete(&child)).await.ok();
255+
}
256+
}
257+
self.inner.delete(path).await
258+
})
259+
}
260+
}
261+
262+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
263+
async fn dir_overwrite_must_merge_not_replace_even_with_recursive_delete() {
264+
// Build a dest dir with two files: one will conflict with the source,
265+
// one is unique to dest (`keep-me.jpg`) and MUST survive merge.
266+
let inner = Arc::new(InMemoryVolume::new("dest"));
267+
inner.create_directory(Path::new("/photos")).await.unwrap();
268+
inner
269+
.create_file(Path::new("/photos/keep-me.jpg"), b"existing")
270+
.await
271+
.unwrap();
272+
inner
273+
.create_file(Path::new("/photos/will-conflict.jpg"), b"old")
274+
.await
275+
.unwrap();
276+
277+
// Wrap so `delete` is recursive — the dangerous future-backend scenario.
278+
let dest_recursive: Arc<dyn Volume> = Arc::new(RecursiveDeleteVolume {
279+
inner: Arc::clone(&inner),
280+
});
281+
282+
// Resolve an Overwrite conflict for `/photos`.
283+
let result =
284+
apply_volume_conflict_resolution(ConflictResolution::Overwrite, &dest_recursive, Path::new("/photos"))
285+
.await
286+
.unwrap();
287+
288+
// The resolver should hand back the same path (caller will write to it).
289+
assert_eq!(result, Some(PathBuf::from("/photos")));
290+
291+
// CRITICAL: files unique to dest must still be there. If this fails, the
292+
// resolver wholesale-deleted the dest tree — Cmdr's "Overwrite means merge
293+
// for dirs" UX has silently flipped to "Overwrite means replace", and any
294+
// file in dest that isn't in source is gone.
295+
assert!(
296+
inner.exists(Path::new("/photos/keep-me.jpg")).await,
297+
"Overwrite resolution must NOT recursively delete the dest directory. \
298+
Cmdr's UX promise is merge-not-replace for dirs; if this fails, users \
299+
will lose files that exist in dest but not in source."
300+
);
301+
302+
// Also check the dir itself is intact (not a `delete` retry surprise).
303+
assert!(
304+
inner.exists(Path::new("/photos")).await,
305+
"Dest directory itself must remain — the recursive copy needs it as a merge target."
306+
);
307+
}
308+
309+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
310+
async fn file_overwrite_still_deletes_the_existing_file() {
311+
// Sanity: for files (not dirs), Overwrite SHOULD delete first so the
312+
// recursive writer lands a fresh copy. The dir-merge guarantee must
313+
// not regress this case.
314+
let dest = Arc::new(InMemoryVolume::new("dest"));
315+
dest.create_file(Path::new("/notes.txt"), b"old content").await.unwrap();
316+
let dest_dyn: Arc<dyn Volume> = dest.clone();
317+
318+
let result =
319+
apply_volume_conflict_resolution(ConflictResolution::Overwrite, &dest_dyn, Path::new("/notes.txt"))
320+
.await
321+
.unwrap();
322+
323+
assert_eq!(result, Some(PathBuf::from("/notes.txt")));
324+
// After resolution, before the recursive copy runs, the file should be gone
325+
// (so the writer creates a fresh one rather than appending or failing).
326+
assert!(
327+
!dest.exists(Path::new("/notes.txt")).await,
328+
"Overwrite resolution must delete an existing FILE so the writer can \
329+
create a fresh copy. Skipping delete only applies to directories."
330+
);
331+
}
332+
}

0 commit comments

Comments
 (0)