Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion src/backend/renamecompat.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)));
}
}
31 changes: 31 additions & 0 deletions tests/ops.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down