diff --git a/docs/protocol.md b/docs/protocol.md index fcae30fe..df6c4e11 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -487,7 +487,8 @@ through the same call a `rename` does, so its failure answers `rename` or `renam **The journal is an in-memory ring of the last 50 completed operations and is not persisted**, so it does not survive a restart. Each kind reverses as follows: a rename or a move renames back (still -refusing to clobber, because something may occupy the old name by now), a copy or a duplicate removes +refusing to clobber, because something may occupy the old name by now; a move that crossed filesystems +copies back and then removes, the way it went out), a copy or a duplicate removes what that operation created, and a trash restores through `gio trash --restore` using the URI captured when it was trashed. A `mkdir` removes the folder it made only while it is still empty: a folder the user has filled since is theirs, so that reversal answers an `error` line, leaves it and its contents in diff --git a/src/backend/renamecompat.rs b/src/backend/renamecompat.rs index 1f3d0dd8..ce66af14 100644 --- a/src/backend/renamecompat.rs +++ b/src/backend/renamecompat.rs @@ -1,4 +1,4 @@ -// Linux's atomic no-clobber rename, plus the two measured mounts that need a safe caller-owned copy fallback. +// Linux's atomic no-clobber rename, plus the copy fallback for a rename that crosses filesystems and for the two measured mounts that refuse one. use crate::backend::copyfile::{copy_any, remove_any, Progress}; use crate::backend::mountinfo::mount_type_in; use crate::error::{from_io, FleaError}; @@ -11,6 +11,8 @@ use std::sync::atomic::AtomicBool; const AT_FDCWD: i32 = -100; const RENAME_NOREPLACE: u32 = 1; const EINVAL: i32 = 22; +// What a rename across filesystems answers: a move undone off a USB stick or tmpfs reaches here with it. +const EXDEV: i32 = 18; // GVFS answers a WebDAV rename with EIO instead of refusing it outright. const EIO: i32 = 5; // The kind a half-succeeded rename answers; ui/js/Errors.js words it and ui/PaneWire.qml refreshes on it. @@ -56,6 +58,11 @@ pub(crate) fn rename_path(from: &Path, to: &Path) -> Result<(), FleaError> { // WebDAV is decided from the path and errno alone, so an rclone check never reads mountinfo for it. fn needs_copy_fallback(from: &Path, error: &io::Error) -> bool { + // copyfile.rs's move_any already copies and removes on EXDEV going out; undo comes back through here, + // so without this arm a move onto another filesystem answered "Invalid cross-device link" and stayed. + if error.raw_os_error() == Some(EXDEV) { + return true; + } if needs_gvfs_webdav_fallback(from, error) { return true; } @@ -347,4 +354,14 @@ mod tests { assert!(!needs_copy_fallback(d.path(), &io::Error::from_raw_os_error(EIO))); assert!(!needs_copy_fallback(d.path(), &io::Error::from_raw_os_error(EEXIST))); } + // The two filesystems a cross-device undo needs are not something a unit test can make, so the errno + // is what is pinned here and tests/ops.sh drives the whole reversal between tmpfs and the fixture root. + #[test] + fn a_rename_across_filesystems_takes_the_copy_fallback_on_every_mount() { + let d = TestDir::new("exdevfallback"); + let exdev = io::Error::from_raw_os_error(EXDEV); + assert!(needs_copy_fallback(d.path(), &exdev), "a plain directory on an ordinary mount"); + assert!(needs_copy_fallback(&d.file("file.txt", "body"), &exdev), "a file too"); + assert!(!needs_copy_fallback(d.path(), &io::Error::from_raw_os_error(EACCES))); + } } diff --git a/tests/ops.sh b/tests/ops.sh index 5fcaa929..954c48fb 100755 --- a/tests/ops.sh +++ b/tests/ops.sh @@ -15,6 +15,9 @@ cleanup() { exec 3>&- 2>/dev/null [ -n "${BACKEND_PID:-}" ] && kill "$BACKEND_PID" 2>/dev/null sandbox_remove "$D" + # The cross-device scenario's tmpfs root, the same shape tests/drag.sh R7 uses: its own mktemp, its own + # marker, and the pattern checked again before the delete, because it lives outside the fixture root. + case "${XDEV:-}" in /dev/shm/flea-ops-xdev-*) [ -f "$XDEV/$SANDBOX_MARKER" ] && rm -rf -- "$XDEV" ;; esac } trap cleanup EXIT @@ -145,6 +148,34 @@ check "undo put it back where it came from" "moving" "$(cat "$D/m.txt" 2>/dev/nu check "the destination copy is gone" "no" "$([ -e "$D/dest/m.txt" ] && echo yes || echo no)" stop_backend +echo "--- a move across filesystems, and undo brings it back across them ---" +# A move onto another filesystem is a copy and a remove, and undo has to come back the same way: the +# journal reverses a move through the rename call, and before its EXDEV arm the reversal answered +# "Invalid cross-device link" and spent the entry, so a move onto a USB stick or tmpfs could never be +# undone. The fixture root is a real disk and /dev/shm is tmpfs, which is the pair drag.sh R7 relies on +# too; the device check is what says this scenario measured a crossing rather than a same-disk rename. +start_backend +XDEV=$(mktemp -d /dev/shm/flea-ops-xdev-XXXXXX) +: > "$XDEV/$SANDBOX_MARKER" +mkdir -p "$XDEV/dest" +check "the tmpfs root is another filesystem than the fixture" \ + "$([ "$(stat -c %d "$XDEV")" != "$(stat -c %d "$D")" ] && echo other || echo same)" "other" +printf 'crossing' > "$D/xm.txt" +mkdir -p "$D/xdir/nested"; printf 'inside' > "$D/xdir/nested/in.txt" +send "{\"c\":\"transfer\",\"op\":\"move\",\"paths\":[\"$D/xm.txt\",\"$D/xdir\"],\"dest\":\"$XDEV/dest\"}" +await '"t":"transferdone"' || fail=1 +check "both items moved" "1" "$(seen '"ok":2,"failed":0,"skipped":0')" +check "the file source is gone" "no" "$([ -e "$D/xm.txt" ] && echo yes || echo no)" +check "the tree arrived whole" "inside" "$(cat "$XDEV/dest/xdir/nested/in.txt" 2>/dev/null)" +send '{"c":"undo"}' +await '"t":"undone"' || fail=1 +check "undo names the move it reversed" "1" "$(seen '"t":"undone","op":"move","ok":true')" +check "no reversal answered a rename error" "0" "$(seen '"where":"rename"')" +check "the file is back with its bytes" "crossing" "$(cat "$D/xm.txt" 2>/dev/null)" +check "the tree is back with its bytes" "inside" "$(cat "$D/xdir/nested/in.txt" 2>/dev/null)" +check "the destination copies are gone" "no" "$([ -e "$XDEV/dest/xm.txt" ] || [ -e "$XDEV/dest/xdir" ] && echo yes || echo no)" +stop_backend + echo "--- one failing item is data, and the batch carries on ---" start_backend printf 'good' > "$D/good.txt"; mkdir -p "$D/dest"