Skip to main content

qframe/widgets/file_manager/
ops.rs

1//! The file operations of a [`FileManager`](super::FileManager): reading a folder, and making,
2//! renaming, moving and deleting entries under its root.
3//!
4//! Every operation takes the root folder and keys, never a path, and turns a key into a path
5//! itself, so nothing handed over can reach past the root: a key part that is empty, `.`, `..` or
6//! holds a NUL is refused. A confined manager also refuses a key that goes through a symbolic
7//! link, because a link can point anywhere; without that option a link on the way is followed
8//! like any other folder. A link is an entry like any other, so renaming, moving or deleting one
9//! acts on the link and leaves what it points at alone.
10//!
11//! These touch the disk, so they run on a background thread.
12
13use std::fs::{self, OpenOptions};
14use std::io::ErrorKind;
15use std::ops::Range;
16use std::path::{Path, PathBuf};
17
18use super::state::{ROOT, child_key};
19
20/// Why a name cannot be used, as the person types it.
21///
22/// More reasons may be added, so match with a `_` arm and lean on
23/// [`message`](Self::message) for the words.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum NameProblem {
27    /// Nothing, or only spaces, was typed.
28    Empty,
29    /// It holds a `/`, which would make it a path rather than a name.
30    Slash,
31    /// It holds a character no file name can hold.
32    Nul,
33    /// It is `.` or `..`, which already mean this folder and the one above it.
34    Dots,
35    /// An entry of the folder already has it.
36    Taken,
37}
38
39impl NameProblem {
40    /// What is wrong, in the person's language.
41    #[must_use]
42    pub fn message(&self) -> String {
43        let key = match self {
44            Self::Empty => "quvyta.file-manager.name-empty",
45            Self::Slash => "quvyta.file-manager.name-slash",
46            Self::Nul => "quvyta.file-manager.name-nul",
47            Self::Dots => "quvyta.file-manager.name-dots",
48            Self::Taken => "quvyta.file-manager.name-taken",
49        };
50        crate::t!(key)
51    }
52}
53
54/// Checks `name` as the name of a new entry among `siblings`.
55///
56/// `current` is the entry's own name when it is being renamed, which does not count as taken.
57///
58/// # Errors
59///
60/// The first thing wrong with the name.
61pub fn check_name<'a>(
62    name: &str,
63    mut siblings: impl Iterator<Item = &'a str>,
64    current: Option<&str>,
65) -> Result<(), NameProblem> {
66    if name.trim().is_empty() {
67        return Err(NameProblem::Empty);
68    }
69    if name.contains('/') {
70        return Err(NameProblem::Slash);
71    }
72    if name.contains('\0') {
73        return Err(NameProblem::Nul);
74    }
75    if name == "." || name == ".." {
76        return Err(NameProblem::Dots);
77    }
78    if current != Some(name) && siblings.any(|sibling| sibling == name) {
79        return Err(NameProblem::Taken);
80    }
81    Ok(())
82}
83
84/// Why a file operation was not done.
85///
86/// More reasons may be added as more operations arrive, so match with a `_` arm and lean on
87/// [`message`](Self::message) for the words.
88#[derive(Debug, Clone, PartialEq, Eq)]
89#[non_exhaustive]
90pub enum FileError {
91    /// The name cannot be used.
92    Name(NameProblem),
93    /// The entry would be reached, or land, outside the root folder.
94    Outside,
95    /// A folder was to go into itself or into a folder below it.
96    IntoItself,
97    /// The target folder already has an entry of this name.
98    Taken(String),
99    /// Moving would cross to another file system, which a rename cannot do; files are not copied.
100    CrossDevice,
101    /// The user may not read or change this.
102    Denied,
103    /// The user may not see what is in this folder.
104    NotReadable,
105    /// It is not there any more: another program took it away while the rows still showed it.
106    Missing,
107    /// There is no trash this entry can go to: none on this file system, or none at all.
108    NoTrash,
109    /// The disk is full.
110    NoRoom,
111    /// The person said to stop, and what had been written was taken away again.
112    Stopped,
113    /// What the operating system said.
114    System(String),
115}
116
117impl FileError {
118    /// What went wrong, in the person's language.
119    #[must_use]
120    pub fn message(&self) -> String {
121        match self {
122            Self::Name(problem) => problem.message(),
123            Self::Outside => crate::t!("quvyta.file-manager.outside"),
124            Self::IntoItself => crate::t!("quvyta.file-manager.into-itself"),
125            Self::Taken(name) => crate::t!("quvyta.file-manager.taken", name = name.as_str()),
126            Self::CrossDevice => crate::t!("quvyta.file-manager.cross-device"),
127            Self::Denied => crate::t!("quvyta.file-manager.denied"),
128            Self::NotReadable => crate::t!("quvyta.file-manager.not-readable"),
129            Self::Missing => crate::t!("quvyta.file-manager.missing"),
130            Self::NoTrash => crate::t!("quvyta.file-manager.no-trash"),
131            Self::NoRoom => crate::t!("quvyta.file-manager.no-room"),
132            Self::Stopped => crate::t!("quvyta.file-manager.stopped"),
133            Self::System(said) => said.clone(),
134        }
135    }
136}
137
138impl From<std::io::Error> for FileError {
139    fn from(error: std::io::Error) -> Self {
140        match error.kind() {
141            // A move is a rename; copying a whole tree is another operation, not a fallback.
142            ErrorKind::CrossesDevices => Self::CrossDevice,
143            ErrorKind::PermissionDenied => Self::Denied,
144            ErrorKind::NotFound => Self::Missing,
145            ErrorKind::StorageFull => Self::NoRoom,
146            _ => Self::System(error.to_string()),
147        }
148    }
149}
150
151/// Why a folder could not be read, in the person's language rather than the system's.
152///
153/// A folder is read for its rows, so a refusal is about seeing rather than about changing: the
154/// person is told they may not look inside, not that they may not change something.
155pub(super) fn read_error(error: std::io::Error) -> FileError {
156    if error.kind() == ErrorKind::PermissionDenied { FileError::NotReadable } else { error.into() }
157}
158
159/// What an operation changed, by key.
160///
161/// More kinds of change may be added as more operations arrive, so match with a `_` arm.
162#[derive(Debug, Clone, PartialEq, Eq)]
163#[non_exhaustive]
164pub enum FileChange {
165    /// An entry was made.
166    Created(String),
167    /// An entry moved from the first key to the second, by a rename or a move.
168    Moved(String, String),
169    /// An entry was deleted, with everything in it.
170    Deleted(String),
171    /// An entry was copied; the key is the copy, and the entry it was made from stays where it is.
172    Copied(String),
173    /// An entry went to the trash, from where the person can still get it back.
174    Trashed(String),
175}
176
177/// The key of the folder an entry is in, the root for an entry directly in it.
178#[must_use]
179pub fn parent_key(key: &str) -> &str {
180    key.rsplit_once('/').map_or(ROOT, |(parent, _)| parent)
181}
182
183/// The name of the entry `key`, without the folders before it.
184#[must_use]
185pub fn name_of(key: &str) -> &str {
186    key.rsplit_once('/').map_or(key, |(_, name)| name)
187}
188
189/// The characters of `name` a rename starts with selected: the name before its extension, so
190/// typing replaces `report.final` and keeps `.md`.
191///
192/// A folder has no extension, and neither has a name whose only dot starts it (`.gitignore`):
193/// both are selected whole. The range counts characters, the way the field counts them.
194#[must_use]
195pub fn stem(name: &str, folder: bool) -> Range<usize> {
196    let length = name.chars().count();
197    if folder {
198        return 0..length;
199    }
200    match name.chars().rev().position(|c| c == '.').map(|from_end| length - 1 - from_end) {
201        Some(dot) if dot > 0 => 0..dot,
202        _ => 0..length,
203    }
204}
205
206/// Whether `key` is `folder` or somewhere below it.
207#[must_use]
208pub fn is_within(key: &str, folder: &str) -> bool {
209    key == folder || key.strip_prefix(folder).is_some_and(|rest| rest.starts_with('/'))
210}
211
212/// Whether `key` names an entry under a root: a path of plain names, none of them empty, `.` or
213/// `..`, and not starting at `/`.
214///
215/// Keys come from the manager itself, which only ever joins the names a folder listed, but an
216/// application also keeps them in files anyone can edit. A key that climbs out of the root would
217/// reach a file the manager never showed, so it is refused wherever a key becomes a path.
218#[must_use]
219pub fn is_inside(key: &str) -> bool {
220    !key.is_empty() && !key.contains('\0') && key.split('/').all(|part| !part.is_empty() && part != "." && part != "..")
221}
222
223/// The path of the entry `key`, which is not followed if it is a link.
224///
225/// While `confined`, every folder on the way must be a real folder under the root, not a link, so
226/// the path cannot lead out of it.
227fn entry_path(root: &Path, key: &str, confined: bool) -> Result<PathBuf, FileError> {
228    let parts: Vec<&str> = key.split('/').collect();
229    let mut path = root.to_path_buf();
230    for (index, part) in parts.iter().enumerate() {
231        if part.is_empty() || *part == "." || *part == ".." || part.contains('\0') {
232            return Err(FileError::Outside);
233        }
234        path.push(part);
235        if confined && index + 1 < parts.len() {
236            real_folder(&path)?;
237        }
238    }
239    Ok(path)
240}
241
242/// The path of the folder `key`, which must be a folder under the root.
243fn folder_path(root: &Path, key: &str, confined: bool) -> Result<PathBuf, FileError> {
244    if key == ROOT {
245        return Ok(root.to_path_buf());
246    }
247    let path = entry_path(root, key, confined)?;
248    if confined {
249        real_folder(&path)?;
250    }
251    Ok(path)
252}
253
254/// Refuses a path that is a link or not a folder.
255fn real_folder(path: &Path) -> Result<(), FileError> {
256    let meta = fs::symlink_metadata(path)?;
257    if meta.file_type().is_symlink() || !meta.is_dir() {
258        return Err(FileError::Outside);
259    }
260    Ok(())
261}
262
263/// Whether anything, a dangling link included, is at `path`.
264fn occupied(path: &Path) -> bool {
265    fs::symlink_metadata(path).is_ok()
266}
267
268/// Checks a name the operations are handed; the dialog checked it already, so this only guards
269/// against a key or a name that did not come through it.
270fn usable(name: &str) -> Result<(), FileError> {
271    check_name(name, std::iter::empty(), None).map_err(FileError::Name)
272}
273
274/// Makes an empty file `name` in the folder `folder`.
275///
276/// # Errors
277///
278/// A name that cannot be used, a folder outside the root, a name already there, or what the
279/// system said.
280pub fn create_file(root: &Path, folder: &str, name: &str, confined: bool) -> Result<FileChange, FileError> {
281    usable(name)?;
282    let path = folder_path(root, folder, confined)?.join(name);
283    // `create_new` refuses an existing entry in the same step that makes the file, so nothing
284    // already there is ever truncated.
285    OpenOptions::new().write(true).create_new(true).open(&path).map_err(|error| taken_or(error, name))?;
286    Ok(FileChange::Created(child_key(folder, name)))
287}
288
289/// Makes an empty folder `name` in the folder `folder`.
290///
291/// # Errors
292///
293/// As [`create_file`].
294pub fn create_folder(root: &Path, folder: &str, name: &str, confined: bool) -> Result<FileChange, FileError> {
295    usable(name)?;
296    let path = folder_path(root, folder, confined)?.join(name);
297    fs::create_dir(&path).map_err(|error| taken_or(error, name))?;
298    Ok(FileChange::Created(child_key(folder, name)))
299}
300
301/// Gives the entry `key` the name `name`, in the same folder.
302///
303/// # Errors
304///
305/// A name that cannot be used, an entry outside the root, a name already there, or what the
306/// system said.
307pub fn rename(root: &Path, key: &str, name: &str, confined: bool) -> Result<FileChange, FileError> {
308    usable(name)?;
309    let from = entry_path(root, key, confined)?;
310    let folder = parent_key(key);
311    if name_of(key) == name {
312        return Ok(FileChange::Moved(key.to_owned(), key.to_owned()));
313    }
314    let to = folder_path(root, folder, confined)?.join(name);
315    if occupied(&to) {
316        return Err(FileError::Taken(name.to_owned()));
317    }
318    fs::rename(&from, &to)?;
319    Ok(FileChange::Moved(key.to_owned(), child_key(folder, name)))
320}
321
322/// Moves the entry `key` into the folder `into`, keeping its name.
323///
324/// A move is a rename on the same file system; files are never copied, so a move to another file
325/// system is refused and said so.
326///
327/// # Errors
328///
329/// An entry or folder outside the root, a folder into itself, a name already there, another file
330/// system, or what the system said.
331pub fn move_into(root: &Path, key: &str, into: &str, confined: bool) -> Result<FileChange, FileError> {
332    if is_within(into, key) {
333        return Err(FileError::IntoItself);
334    }
335    let from = entry_path(root, key, confined)?;
336    let name = name_of(key);
337    if parent_key(key) == into {
338        return Ok(FileChange::Moved(key.to_owned(), key.to_owned()));
339    }
340    let to = folder_path(root, into, confined)?.join(name);
341    if occupied(&to) {
342        return Err(FileError::Taken(name.to_owned()));
343    }
344    fs::rename(&from, &to)?;
345    Ok(FileChange::Moved(key.to_owned(), child_key(into, name)))
346}
347
348/// Deletes the entry `key`, a folder with everything in it. A link is removed as a link; what it
349/// points at stays.
350///
351/// # Errors
352///
353/// An entry outside the root, or what the system said.
354pub fn delete(root: &Path, key: &str, confined: bool) -> Result<FileChange, FileError> {
355    let path = entry_path(root, key, confined)?;
356    let meta = fs::symlink_metadata(&path)?;
357    // The standard library's `remove_dir_all` does not follow links inside the folder either, so
358    // a link in there is removed without touching where it leads.
359    if meta.is_dir() {
360        fs::remove_dir_all(&path)?;
361    } else {
362        fs::remove_file(&path)?;
363    }
364    Ok(FileChange::Deleted(key.to_owned()))
365}
366
367/// Puts the entry `key` into the trash folder `trash`.
368///
369/// # Errors
370///
371/// An entry outside the root, [`FileError::NoTrash`] when there is no trash the entry can be
372/// renamed into, and what the system said otherwise.
373pub(super) fn to_trash(trash: &Path, root: &Path, key: &str, confined: bool) -> Result<FileChange, FileError> {
374    let path = entry_path(root, key, confined)?;
375    // An entry that is already gone is said so plainly rather than as a failed rename.
376    fs::symlink_metadata(&path)?;
377    super::trash::move_to_trash(trash, &path)?;
378    Ok(FileChange::Trashed(key.to_owned()))
379}
380
381/// An error of making `name`, where an entry already there is said by name.
382fn taken_or(error: std::io::Error, name: &str) -> FileError {
383    if error.kind() == ErrorKind::AlreadyExists { FileError::Taken(name.to_owned()) } else { error.into() }
384}
385
386/// What a long operation tells the outside while it runs, and what it asks it.
387///
388/// A copy of one small file needs none of this; a copy of a folder of photographs needs both, so
389/// the same code does the work either way and a plain copy hands it a watch that says nothing.
390pub(super) struct Watch<'a> {
391    /// Told how many bytes have been written of how many there are to write.
392    pub(super) progress: &'a dyn Fn(u64, u64),
393    /// Asked, between files and between blocks of a large file, whether to stop.
394    pub(super) stopped: &'a dyn Fn() -> bool,
395}
396
397impl Watch<'_> {
398    /// A watch that reports nothing and never stops the work.
399    pub(super) fn none() -> Self {
400        Self { progress: &|_, _| {}, stopped: &|| false }
401    }
402}
403
404/// How much of a large file is written between two looks at whether to stop: big enough that the
405/// check costs nothing, small enough that stopping feels immediate on a slow disk.
406const BLOCK: usize = 256 * 1024;
407
408/// How many bytes the entry at `path` holds, itself and everything below it. A link counts as its
409/// own size and is never followed.
410fn weight(path: &Path) -> u64 {
411    let Ok(meta) = fs::symlink_metadata(path) else { return 0 };
412    if !meta.is_dir() {
413        return meta.len();
414    }
415    let Ok(entries) = fs::read_dir(path) else { return 0 };
416    entries.flatten().map(|entry| weight(&entry.path())).sum()
417}
418
419/// Copies the entry `key` into the folder `into`, keeping its name; a folder is copied with
420/// everything in it.
421///
422/// Nothing already there is overwritten: a name the target folder has is refused. A folder cannot
423/// be copied into itself or into a folder below it, which would never end. A symbolic link is
424/// copied as a link, so what it points at is not duplicated.
425///
426/// # Errors
427///
428/// An entry or folder outside the root, a folder into itself, a name already there, or what the
429/// system said.
430pub fn copy_into(root: &Path, key: &str, into: &str, confined: bool) -> Result<FileChange, FileError> {
431    copy_watched(root, key, into, confined, &Watch::none())
432}
433
434/// [`copy_into`], telling `watch` how far it has come and asking it whether to stop.
435pub(super) fn copy_watched(
436    root: &Path,
437    key: &str,
438    into: &str,
439    confined: bool,
440    watch: &Watch<'_>,
441) -> Result<FileChange, FileError> {
442    if is_within(into, key) {
443        return Err(FileError::IntoItself);
444    }
445    let from = entry_path(root, key, confined)?;
446    let name = name_of(key);
447    let to = folder_path(root, into, confined)?.join(name);
448    if occupied(&to) {
449        return Err(FileError::Taken(name.to_owned()));
450    }
451    let total = weight(&from);
452    let mut written = 0;
453    (watch.progress)(0, total);
454    match copy_tree(&from, &to, total, &mut written, watch) {
455        Ok(()) => Ok(FileChange::Copied(child_key(into, name))),
456        Err(problem) => {
457            // A copy that stopped leaves nothing behind: half a file is worse than no file, and
458            // the person who cancelled did not ask for one.
459            let _ = fs::symlink_metadata(&to)
460                .map(|meta| if meta.is_dir() { fs::remove_dir_all(&to) } else { fs::remove_file(&to) });
461            Err(problem)
462        }
463    }
464}
465
466/// Copies `from` to `to`, a folder with everything in it, counting the bytes written into
467/// `written` out of `total`.
468fn copy_tree(from: &Path, to: &Path, total: u64, written: &mut u64, watch: &Watch<'_>) -> Result<(), FileError> {
469    if (watch.stopped)() {
470        return Err(FileError::Stopped);
471    }
472    let meta = fs::symlink_metadata(from)?;
473    if meta.file_type().is_symlink() {
474        // A link is copied as a link: following it would duplicate whatever it points at, which
475        // is not what the folder holds.
476        let target = fs::read_link(from)?;
477        std::os::unix::fs::symlink(target, to)?;
478        return Ok(());
479    }
480    if !meta.is_dir() {
481        copy_file(from, to, total, written, watch)?;
482        return Ok(());
483    }
484    fs::create_dir(to)?;
485    let mut entries: Vec<PathBuf> = fs::read_dir(from)?.flatten().map(|entry| entry.path()).collect();
486    entries.sort();
487    for entry in entries {
488        let Some(name) = entry.file_name() else { continue };
489        copy_tree(&entry, &to.join(name), total, written, watch)?;
490    }
491    // The folder's own permissions come last, so a folder the user may not write into is still
492    // filled first and then made what it was.
493    fs::set_permissions(to, meta.permissions())?;
494    Ok(())
495}
496
497/// Copies one file block by block, so a large one can be stopped in the middle and reports its
498/// progress on the way.
499fn copy_file(from: &Path, to: &Path, total: u64, written: &mut u64, watch: &Watch<'_>) -> Result<(), FileError> {
500    use std::io::{Read, Write};
501
502    let mut source = fs::File::open(from)?;
503    let mut target = OpenOptions::new().write(true).create_new(true).open(to)?;
504    let mut block = vec![0u8; BLOCK];
505    loop {
506        if (watch.stopped)() {
507            return Err(FileError::Stopped);
508        }
509        let read = source.read(&mut block)?;
510        if read == 0 {
511            break;
512        }
513        target.write_all(&block[..read])?;
514        *written += read as u64;
515        (watch.progress)(*written, total);
516    }
517    target.flush()?;
518    // The mode is what the file is, so a program stays a program and a key stays unreadable.
519    fs::set_permissions(to, fs::metadata(from)?.permissions())?;
520    Ok(())
521}