Skip to main content

tree_space/fs/
ops.rs

1//! File operations: create, rename, copy, move, duplicate and trash.
2//!
3//! Production uses [`FileOps`] over `std::fs` for everything except trashing,
4//! which goes through the [`TrashService`] seam so tests can record calls
5//! instead of putting real files in the user's trash. The trash implementation
6//! that ships ([`GioTrash`]) delegates to the platform's trash via GIO, so the
7//! behaviour matches what GNOME/Nautilus calls "move to trash".
8
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use relm4::gtk::gio::prelude::FileExt;
14
15/// Error type for file operations. Wraps [`io::Error`] and adds a few
16/// higher-level outcomes produced here (name conflicts, cross-device moves).
17#[derive(Debug)]
18pub enum OpsError {
19    Io(io::Error),
20    /// The destination name already exists and an overwrite would be required.
21    Conflict(PathBuf),
22    /// A source path did not exist.
23    NotFound(String),
24    /// Something was expected to be a directory but wasn't.
25    NotADirectory(String),
26    /// Trashing failed for another reason.
27    Trash(String),
28}
29
30impl std::fmt::Display for OpsError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            OpsError::Io(err) => write!(f, "{err}"),
34            OpsError::Conflict(path) => write!(f, "'{}' already exists", path.display()),
35            OpsError::NotFound(name) => write!(f, "no such file or directory: {name}"),
36            OpsError::NotADirectory(name) => write!(f, "not a directory: {name}"),
37            OpsError::Trash(reason) => write!(f, "could not move to trash: {reason}"),
38        }
39    }
40}
41
42impl std::error::Error for OpsError {
43    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
44        match self {
45            OpsError::Io(err) => Some(err),
46            _ => None,
47        }
48    }
49}
50
51impl From<io::Error> for OpsError {
52    fn from(err: io::Error) -> Self {
53        OpsError::Io(err)
54    }
55}
56
57/// Linux `EXDEV` errno, used to detect cross-filesystem moves that `rename(2)`
58/// rejects. Constant avoids an otherwise unnecessary `libc` dependency.
59const EXDEV: i32 = 18;
60
61/// Linux `EINVAL` errno, returned when `RENAME_NOREPLACE` is unsupported.
62const EINVAL: i32 = 22;
63
64/// `rename(2)` with `RENAME_NOREPLACE`: atomically rename `from` to `dest`,
65/// refusing to overwrite an existing `dest`. Returns `AlreadyExists` when
66/// `dest` is taken, and `EINVAL` when the underlying filesystem does not
67/// support the flag (callers fall back to a non-atomic check).
68#[cfg(target_os = "linux")]
69fn rename_noreplace(from: &Path, dest: &Path) -> io::Result<()> {
70    use std::ffi::CString;
71    use std::os::unix::ffi::OsStrExt;
72
73    // RENAME_NOREPLACE is a glibc-level syscall wrapper; call it directly so we
74    // do not need libc as a dependency.
75    const AT_FDCWD: i32 = -100;
76    const RENAME_NOREPLACE: u32 = 1;
77
78    let from_c = CString::new(from.as_os_str().as_bytes())
79        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
80    let dest_c = CString::new(dest.as_os_str().as_bytes())
81        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
82    // Safety: the two paths are valid NUL-terminated C strings that outlive the
83    // call, and the remaining arguments are plain integers.
84    let rc = unsafe {
85        libc_syscall::renameat2(
86            AT_FDCWD,
87            from_c.as_ptr(),
88            AT_FDCWD,
89            dest_c.as_ptr(),
90            RENAME_NOREPLACE,
91        )
92    };
93    if rc == 0 {
94        Ok(())
95    } else {
96        Err(io::Error::last_os_error())
97    }
98}
99
100/// Non-Linux fallback: there is no `RENAME_NOREPLACE`, so report `EINVAL` and
101/// let the caller use its conservative fallback path.
102#[cfg(not(target_os = "linux"))]
103fn rename_noreplace(_from: &Path, _dest: &Path) -> io::Result<()> {
104    Err(io::Error::from_raw_os_error(EINVAL))
105}
106
107/// The minimal `renameat2` declaration, kept in one place so the unsafe FFI is
108/// confined (and this module stays free of a `libc` dependency).
109mod libc_syscall {
110    use std::os::raw::{c_char, c_int};
111
112    pub type Syscall = i64;
113
114    unsafe extern "C" {
115        /// `syscall(number, ...)` from `<unistd.h>`. Declared by hand because we
116        /// deliberately avoid the `libc` crate.
117        fn syscall(number: Syscall, ...) -> c_int;
118    }
119
120    /// `renameat2(AT_FDCWD, old, AT_FDCWD, new, RENAME_NOREPLACE)`.
121    ///
122    /// # Safety
123    /// `old` and `new` must be valid NUL-terminated C strings.
124    pub unsafe fn renameat2(
125        olddirfd: c_int,
126        oldpath: *const c_char,
127        newdirfd: c_int,
128        newpath: *const c_char,
129        flags: u32,
130    ) -> c_int {
131        // __NR_renameat2 is 316 on all Linux architectures we target
132        // (x86_64/aarch64). A wrong number would surface as ENOSYS, which the
133        // caller treats as "unsupported, use the fallback".
134        const SYS_RENAMEAT2: Syscall = 316;
135        unsafe { syscall(SYS_RENAMEAT2, olddirfd, oldpath, newdirfd, newpath, flags) }
136    }
137}
138
139fn is_exdev(err: &io::Error) -> bool {
140    err.raw_os_error() == Some(EXDEV)
141}
142
143/// Where the destination of an operation lands in the trash.
144pub trait TrashService: Send + Sync + std::fmt::Debug {
145    fn trash(&self, path: &Path) -> Result<(), OpsError>;
146}
147
148/// Moves paths to the system trash via GIO. This is a small, thin wrapper so
149/// tests can substitute a recording fake.
150#[derive(Debug, Default, Clone, Copy)]
151pub struct GioTrash;
152
153impl TrashService for GioTrash {
154    fn trash(&self, path: &Path) -> Result<(), OpsError> {
155        let file = relm4::gtk::gio::File::for_path(path);
156        file.trash(None::<&relm4::gtk::gio::Cancellable>)
157            .map_err(|err| OpsError::Trash(format!("{}: {err}", path.display())))
158    }
159}
160
161/// All file operations of the panel.
162#[derive(Debug)]
163pub struct FileOps {
164    trash: Box<dyn TrashService>,
165}
166
167impl FileOps {
168    /// FileOps backed by the platform trash (GIO).
169    pub fn new() -> Self {
170        Self {
171            trash: Box::new(GioTrash),
172        }
173    }
174
175    /// FileOps with an explicit trash backend (used in tests).
176    pub fn with_trash(trash: Box<dyn TrashService>) -> Self {
177        Self { trash }
178    }
179
180    /// Create a new, empty file named `name` inside `dir`.
181    ///
182    /// Fails with [`OpsError::Conflict`] when the name already exists, so this
183    /// is safe against accidental overwrites.
184    pub fn create_file(&self, dir: &Path, name: &str) -> Result<PathBuf, OpsError> {
185        if name.trim().is_empty() {
186            return Err(OpsError::NotFound("empty name".into()));
187        }
188        let dest = dir.join(name);
189        fs::OpenOptions::new()
190            .write(true)
191            .create_new(true)
192            .open(&dest)
193            .map_err(|err| match err.kind() {
194                io::ErrorKind::AlreadyExists => OpsError::Conflict(dest.clone()),
195                _ => OpsError::Io(err),
196            })?;
197        Ok(dest)
198    }
199
200    /// Create a new, empty directory named `name` inside `dir`.
201    pub fn create_dir(&self, dir: &Path, name: &str) -> Result<PathBuf, OpsError> {
202        if name.trim().is_empty() {
203            return Err(OpsError::NotFound("empty name".into()));
204        }
205        let dest = dir.join(name);
206        fs::create_dir(&dest).map_err(|err| match err.kind() {
207            io::ErrorKind::AlreadyExists => OpsError::Conflict(dest.clone()),
208            _ => OpsError::Io(err),
209        })?;
210        Ok(dest)
211    }
212
213    /// Rename `from` to a new name inside its current directory.
214    ///
215    /// Uses `renameat2(..., RENAME_NOREPLACE)` where available so the
216    /// "destination must not exist" check is atomic: the previous
217    /// `dest.exists()` pre-check had a TOCTOU window in which a concurrently
218    /// created file would be silently overwritten by `rename(2)`. On
219    /// filesystems or kernels that reject `RENAME_NOREPLACE` (e.g. some network
220    /// filesystems), it falls back to the check-then-rename sequence, which is
221    /// still correct in the common single-user case.
222    pub fn rename(&self, from: &Path, new_name: &str) -> Result<PathBuf, OpsError> {
223        if new_name.trim().is_empty() {
224            return Err(OpsError::NotFound("empty name".into()));
225        }
226        let dest = from.with_file_name(new_name);
227        match rename_noreplace(from, &dest) {
228            Ok(()) => Ok(dest),
229            // The kernel rejected the flags (not supported here): fall back to
230            // a conservative existence check plus a plain rename.
231            Err(err) if err.raw_os_error() == Some(EINVAL) => {
232                if dest.exists() {
233                    return Err(OpsError::Conflict(dest.clone()));
234                }
235                self.move_path(from, &dest)?;
236                Ok(dest)
237            }
238            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
239                Err(OpsError::Conflict(dest.clone()))
240            }
241            Err(err) if err.kind() == io::ErrorKind::NotFound => {
242                Err(OpsError::NotFound(from.display().to_string()))
243            }
244            Err(err) => Err(OpsError::from(err)),
245        }
246    }
247
248    /// Copy `from` into `dest_dir` using `from`'s file name, resolving name
249    /// conflicts by appending ` (n)` before the extension.
250    pub fn copy(&self, from: &Path, dest_dir: &Path) -> Result<PathBuf, OpsError> {
251        if !from.exists() {
252            return Err(OpsError::NotFound(from.display().to_string()));
253        }
254        let dest = self.unique_dest(dest_dir, from.file_name().unwrap_or_default());
255        self.copy_path(from, &dest)?;
256        Ok(dest)
257    }
258
259    /// Move `from` into `dest_dir` under its current file name (resolving
260    /// conflicts the same way as [`Self::copy`]). Falls back to copy+delete
261    /// when the move crosses a filesystem boundary.
262    pub fn move_(&self, from: &Path, dest_dir: &Path) -> Result<PathBuf, OpsError> {
263        if !from.exists() {
264            return Err(OpsError::NotFound(from.display().to_string()));
265        }
266        let dest = self.unique_dest(dest_dir, from.file_name().unwrap_or_default());
267        self.move_or_copy(from, &dest)?;
268        Ok(dest)
269    }
270
271    /// Duplicate `from` in place (used by the Duplicate menu action).
272    pub fn duplicate(&self, from: &Path) -> Result<PathBuf, OpsError> {
273        let dir = from.parent().unwrap_or_else(|| Path::new("."));
274        self.copy(from, dir)
275    }
276
277    /// Create a symlink to `from` next to it, named "Link to <name>" (with a
278    /// unique suffix on collisions). The link is created with an absolute
279    /// target so it keeps working from any directory.
280    pub fn create_link(&self, from: &Path) -> Result<PathBuf, OpsError> {
281        if !from.exists() {
282            return Err(OpsError::NotFound(from.display().to_string()));
283        }
284        let dir = from.parent().unwrap_or_else(|| Path::new("."));
285        let name = from.file_name().unwrap_or_default();
286        let link_name = format!("Link to {}", name.to_string_lossy());
287        let dest = self.unique_dest(dir, std::ffi::OsStr::new(&link_name));
288        std::os::unix::fs::symlink(from, &dest).map_err(OpsError::from)?;
289        Ok(dest)
290    }
291
292    /// Move each path to the trash. Stops at the first failure.
293    pub fn trash(&self, paths: &[PathBuf]) -> Result<(), OpsError> {
294        for path in paths {
295            self.trash.trash(path)?;
296        }
297        Ok(())
298    }
299
300    /// Permanently delete each path — unlinks files, recursively removes
301    /// directories. Bypasses the trash entirely; there is no undo. Stops at
302    /// the first failure (paths already removed stay removed).
303    pub fn delete_permanently(&self, paths: &[PathBuf]) -> Result<(), OpsError> {
304        for path in paths {
305            self.remove_path(path)?;
306        }
307        Ok(())
308    }
309
310    // -- internals -----------------------------------------------------------
311
312    /// Move `from` onto `dest`, falling back to copy+remove across devices.
313    fn move_or_copy(&self, from: &Path, dest: &Path) -> Result<(), OpsError> {
314        self.rename_with_fallback(from, dest, |a, b| std::fs::rename(a, b))
315    }
316
317    /// `rename` with the EXDEV fallback separated out so tests can inject a
318    /// rename that always fails, without touching a real cross-device setup.
319    fn rename_with_fallback(
320        &self,
321        from: &Path,
322        dest: &Path,
323        rename: impl for<'a, 'b> Fn(&'a Path, &'b Path) -> io::Result<()>,
324    ) -> Result<(), OpsError> {
325        match rename(from, dest) {
326            Err(err) if is_exdev(&err) => {
327                self.copy_path(from, dest)?;
328                self.remove_path(from)?;
329                Ok(())
330            }
331            Err(err) => Err(OpsError::from(err)),
332            Ok(()) => Ok(()),
333        }
334    }
335
336    /// `fs::rename`, no cross-device fallback.
337    fn move_path(&self, from: &Path, dest: &Path) -> Result<(), OpsError> {
338        std::fs::rename(from, dest).map_err(OpsError::from)
339    }
340
341    /// Copy a file or directory tree to `dest`. Symlinks are re-created as
342    /// symlinks rather than followed.
343    fn copy_path(&self, from: &Path, dest: &Path) -> Result<(), OpsError> {
344        let meta = fs::symlink_metadata(from)?;
345        if meta.is_dir() {
346            fs::create_dir_all(dest)?;
347            for entry in fs::read_dir(from)? {
348                let entry = entry?;
349                self.copy_path(&entry.path(), &dest.join(entry.file_name()))?;
350            }
351        } else if meta.file_type().is_symlink() {
352            let target = fs::read_link(from)?;
353            std::os::unix::fs::symlink(target, dest).map_err(OpsError::from)?;
354        } else {
355            fs::copy(from, dest)?;
356        }
357        Ok(())
358    }
359
360    /// Remove a single path (unlink file / remove empty dir). Directories with
361    /// children are removed recursively.
362    fn remove_path(&self, path: &Path) -> Result<(), OpsError> {
363        let meta = fs::symlink_metadata(path)?;
364        if meta.is_dir() {
365            fs::remove_dir_all(path)?;
366        } else {
367            fs::remove_file(path)?;
368        }
369        Ok(())
370    }
371
372    /// Pick a non-existing file name: `name`, `name (1)`, `name (2)`, ...
373    /// The counter is inserted before the final extension when one exists.
374    fn unique_dest(&self, dir: &Path, name: &std::ffi::OsStr) -> PathBuf {
375        let candidate = dir.join(name);
376        if !candidate.exists() {
377            return candidate;
378        }
379        let text = name.to_string_lossy();
380        let (stem, ext) = split_extension(&text);
381        for n in 1.. {
382            let candidate_text = format!("{stem} ({n}){ext}");
383            let candidate = dir.join(&candidate_text);
384            if !candidate.exists() {
385                return candidate;
386            }
387        }
388        unreachable!("the loop above never terminates without a free name")
389    }
390}
391
392impl Default for FileOps {
393    fn default() -> Self {
394        Self::new()
395    }
396}
397
398/// Split `name` into stem and extension (with leading dot). Dotfiles like
399/// `.bashrc` have neither extension nor stem split.
400fn split_extension(name: &str) -> (String, String) {
401    match name.rsplit_once('.') {
402        Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => {
403            (stem.to_owned(), format!(".{ext}"))
404        }
405        _ => (name.to_owned(), String::new()),
406    }
407}
408
409// Linux-only tool; symlinks are re-created as symlinks when copying.
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use std::sync::{Arc, Mutex};
415
416    /// Records trashed paths instead of touching the real trash.
417    #[derive(Debug, Clone, Default)]
418    struct FakeTrash(Arc<Mutex<Vec<PathBuf>>>);
419
420    impl TrashService for FakeTrash {
421        fn trash(&self, path: &Path) -> Result<(), OpsError> {
422            self.0.lock().unwrap().push(path.to_path_buf());
423            Ok(())
424        }
425    }
426
427    fn fops() -> (FileOps, FakeTrash) {
428        let trash = FakeTrash::default();
429        let ops = FileOps::with_trash(Box::new(trash.clone()));
430        (ops, trash)
431    }
432
433    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
434        let p = dir.join(name);
435        fs::write(&p, body).unwrap();
436        p
437    }
438
439    #[test]
440    fn create_file_creates_and_conflicts() {
441        let dir = tempfile::tempdir().unwrap();
442        let ops = FileOps::new();
443        let p = ops.create_file(dir.path(), "hi.txt").unwrap();
444        assert_eq!(fs::read_to_string(&p).unwrap(), "");
445        assert!(matches!(ops.create_file(dir.path(), "hi.txt"), Err(OpsError::Conflict(_))));
446    }
447
448    #[test]
449    fn create_dir_and_conflict() {
450        let dir = tempfile::tempdir().unwrap();
451        let ops = FileOps::new();
452        let p = ops.create_dir(dir.path(), "sub").unwrap();
453        assert!(p.is_dir());
454        assert!(matches!(ops.create_dir(dir.path(), "sub"), Err(OpsError::Conflict(_))));
455    }
456
457    #[test]
458    fn create_with_bad_names() {
459        let dir = tempfile::tempdir().unwrap();
460        let ops = FileOps::new();
461        assert!(ops.create_file(dir.path(), "  ").is_err());
462        assert!(ops.create_dir(dir.path(), "\t").is_err());
463    }
464
465    #[test]
466    fn rename_changes_name_and_rejects_conflict() {
467        let dir = tempfile::tempdir().unwrap();
468        let ops = FileOps::new();
469        let from = write(dir.path(), "a.txt", "data");
470        let to = ops.rename(&from, "b.txt").unwrap();
471        assert_eq!(to.file_name().unwrap(), "b.txt");
472        assert!(!from.exists());
473        // Conflict with an existing name.
474        write(dir.path(), "c.txt", "x");
475        assert!(matches!(
476            ops.rename(&from, "c.txt"),
477            Err(OpsError::Conflict(_)) | Err(OpsError::NotFound(_))
478        ));
479    }
480
481    #[test]
482    fn rename_dir_works() {
483        let dir = tempfile::tempdir().unwrap();
484        let ops = FileOps::new();
485        let from = ops.create_dir(dir.path(), "folder").unwrap();
486        let dest = ops.rename(&from, "folder2").unwrap();
487        assert!(dest.is_dir());
488        assert!(!from.exists());
489    }
490
491    #[test]
492    fn copy_file_into_dir_with_unique_names() {
493        let src = tempfile::tempdir().unwrap();
494        let dst = tempfile::tempdir().unwrap();
495        let ops = FileOps::new();
496        let from = write(src.path(), "photo.png", "bytes");
497
498        let first = ops.copy(&from, dst.path()).unwrap();
499        assert_eq!(first.file_name().unwrap(), "photo.png");
500        assert_eq!(fs::read(&first).unwrap(), b"bytes");
501
502        let second = ops.copy(&from, dst.path()).unwrap();
503        assert_eq!(second.file_name().unwrap(), "photo (1).png");
504        let third = ops.copy(&from, dst.path()).unwrap();
505        assert_eq!(third.file_name().unwrap(), "photo (2).png");
506    }
507
508    #[test]
509    fn copy_directory_tree_preserves_structure_and_symlinks() {
510        let src = tempfile::tempdir().unwrap();
511        let dst = tempfile::tempdir().unwrap();
512        let ops = FileOps::new();
513        fs::create_dir(src.path().join("sub")).unwrap();
514        write(src.path(), "root.txt", "r");
515        write(&src.path().join("sub"), "child.txt", "c");
516        #[cfg(unix)]
517        {
518            std::os::unix::fs::symlink("root.txt", src.path().join("link")).unwrap();
519        }
520
521        let out = ops.copy(src.path(), dst.path()).unwrap();
522        assert!(out.join("sub/child.txt").is_file());
523        assert_eq!(fs::read_to_string(out.join("sub/child.txt")).unwrap(), "c");
524        #[cfg(unix)]
525        assert_eq!(fs::read_link(out.join("link")).unwrap(), Path::new("root.txt"));
526    }
527
528    #[test]
529    fn move_resolves_conflict_and_falls_back_on_cross_device() {
530        let src = tempfile::tempdir().unwrap();
531        let dst = tempfile::tempdir().unwrap();
532        let ops = FileOps::new();
533
534        // A name that already exists inside `dst` → unique suffix.
535        let _occupied = write(dst.path(), "data.bin", "other");
536        let from = write(src.path(), "data.bin", "payload");
537        let moved = ops.move_(&from, dst.path()).unwrap();
538        assert_eq!(moved.file_name().unwrap(), "data (1).bin");
539        assert!(!from.exists());
540
541        // A rename that answers EXDEV must fall back to copy + remove.
542        let fallback_src = write(src.path(), "x.txt", "pay");
543        let fallback_dst = dst.path().join("x.txt");
544        ops.rename_with_fallback(
545            &fallback_src,
546            &fallback_dst,
547            |_from, _to| Err(io::Error::from_raw_os_error(EXDEV)),
548        )
549        .unwrap();
550        assert!(!fallback_src.exists());
551        assert_eq!(fs::read_to_string(&fallback_dst).unwrap(), "pay");
552    }
553
554    #[test]
555    fn duplicate_file() {
556        let dir = tempfile::tempdir().unwrap();
557        let ops = FileOps::new();
558        let from = write(dir.path(), "note.md", "# hello");
559        let dup = ops.duplicate(&from).unwrap();
560        assert_eq!(dup.file_name().unwrap(), "note (1).md");
561    }
562
563    #[test]
564    fn create_link_creates_symlink_next_to_source() {
565        let dir = tempfile::tempdir().unwrap();
566        let ops = FileOps::new();
567        let from = write(dir.path(), "data.txt", "payload");
568
569        let link = ops.create_link(&from).unwrap();
570        assert_eq!(link.file_name().unwrap(), "Link to data.txt");
571        let meta = fs::symlink_metadata(&link).unwrap();
572        assert!(meta.file_type().is_symlink(), "created path is a symlink");
573        assert_eq!(fs::read_link(&link).unwrap(), from);
574
575        // Collision → unique name (suffix before the extension, like other ops).
576        let second = ops.create_link(&from).unwrap();
577        assert_eq!(second.file_name().unwrap(), "Link to data (1).txt");
578    }
579
580    #[test]
581    fn trash_delegates_to_service() {
582        let dir = tempfile::tempdir().unwrap();
583        let (ops, trash) = fops();
584        let a = write(dir.path(), "a.txt", "1");
585        let b = write(dir.path(), "b.txt", "2");
586        ops.trash(&[a.clone(), b.clone()]).unwrap();
587        let trashed = trash.0.lock().unwrap();
588        assert_eq!(*trashed, vec![a, b]);
589    }
590
591    #[test]
592    fn missing_sources_report_not_found() {
593        let dir = tempfile::tempdir().unwrap();
594        let ops = FileOps::new();
595        let missing = dir.path().join("nope");
596        assert!(matches!(ops.copy(&missing, dir.path()), Err(OpsError::NotFound(_))));
597        assert!(matches!(ops.move_(&missing, dir.path()), Err(OpsError::NotFound(_))));
598    }
599
600    #[test]
601    fn split_extension_handles_dotfiles() {
602        assert_eq!(split_extension("photo.png"), ("photo".to_owned(), ".png".to_owned()));
603        assert_eq!(split_extension(".bashrc"), (".bashrc".to_owned(), String::new()));
604        assert_eq!(split_extension("noext"), ("noext".to_owned(), String::new()));
605        assert_eq!(split_extension("a.tar.gz"), ("a.tar".to_owned(), ".gz".to_owned()));
606    }
607}