├── .github └── workflows │ └── check_and_test.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── directorysizes.rs ├── error.rs ├── ffi.rs ├── ffi ├── getpwuid.rs ├── lstat.rs ├── mount_point.rs └── time.rs ├── fs.rs ├── home_dir.rs ├── info_file.rs ├── light_fs.rs ├── main.rs ├── tests.rs └── trash.rs /.github/workflows/check_and_test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: check-and-test 4 | 5 | jobs: 6 | # armv7-glibc: 7 | # name: Ubuntu 18.04 (for ARMv7 - glibc) 8 | # runs-on: ubuntu-18.04 9 | # steps: 10 | # - uses: actions/checkout@v2 11 | # - uses: actions-rs/toolchain@v1 12 | # with: 13 | # toolchain: stable 14 | # target: armv7-unknown-linux-gnueabihf 15 | # override: true 16 | 17 | # - name: Install binutils-arm-none-eabi 18 | # run: | 19 | # sudo apt-get update 20 | # sudo apt-get install binutils-arm-none-eabi 21 | 22 | # - uses: actions-rs/cargo@v1 23 | # with: 24 | # use-cross: true 25 | # command: check 26 | # args: --target=armv7-unknown-linux-gnueabihf 27 | 28 | # - name: Strip binary 29 | # run: arm-none-eabi-strip target/armv7-unknown-linux-gnueabihf/release/tt 30 | 31 | # - name: Upload binary 32 | # uses: actions/upload-artifact@v2 33 | # with: 34 | # name: 'tt-linux-armv7-glibc' 35 | # path: target/armv7-unknown-linux-gnueabihf/release/tt 36 | 37 | # armv7-musl: 38 | # name: Ubuntu 20.01 (for ARMv7 - musl) 39 | # runs-on: ubuntu-latest 40 | # steps: 41 | # - uses: actions/checkout@v2 42 | # - uses: actions-rs/toolchain@v1 43 | # with: 44 | # toolchain: stable 45 | # target: armv7-unknown-linux-musleabihf 46 | # override: true 47 | 48 | # - name: Install binutils-arm-none-eabi 49 | # run: | 50 | # sudo apt-get update 51 | # sudo apt-get install binutils-arm-none-eabi 52 | 53 | # - name: Run cargo check 54 | # uses: actions-rs/cargo@v1 55 | # with: 56 | # use-cross: true 57 | # command: check 58 | # args: --target=armv7-unknown-linux-musleabihf 59 | 60 | # - name: Strip binary 61 | # run: arm-none-eabi-strip target/armv7-unknown-linux-musleabihf/release/tt 62 | 63 | # - name: Upload binary 64 | # uses: actions/upload-artifact@v2 65 | # with: 66 | # name: 'tt-linux-armv7-musl' 67 | # path: target/armv7-unknown-linux-musleabihf/release/tt 68 | 69 | ubuntu: 70 | name: Ubuntu 20.04 71 | runs-on: ubuntu-latest 72 | strategy: 73 | matrix: 74 | rust: 75 | - stable 76 | steps: 77 | - name: Checkout sources 78 | uses: actions/checkout@v2 79 | 80 | - name: Install toolchain 81 | uses: actions-rs/toolchain@v1 82 | with: 83 | toolchain: stable 84 | target: x86_64-unknown-linux-musl 85 | override: true 86 | 87 | - name: Install dependencies for musl libc 88 | run: | 89 | sudo apt-get update 90 | sudo apt-get install musl-tools 91 | - name: Run cargo check 92 | uses: actions-rs/cargo@v1 93 | with: 94 | command: check 95 | args: --target x86_64-unknown-linux-musl 96 | 97 | - name: Run cargo test 98 | uses: actions-rs/cargo@v1 99 | with: 100 | command: test 101 | args: --target x86_64-unknown-linux-musl 102 | 103 | # - name: Strip binary 104 | # run: strip target/x86_64-unknown-linux-musl/release/tt 105 | 106 | # - name: Upload binary 107 | # uses: actions/upload-artifact@v2 108 | # with: 109 | # name: 'tt-x86-64-musl' 110 | # path: target/x86_64-unknown-linux-musl/release/tt 111 | 112 | ubuntu-glibc: 113 | name: Ubuntu 18.04 - glibc 114 | runs-on: ubuntu-18.04 115 | strategy: 116 | matrix: 117 | rust: 118 | - stable 119 | steps: 120 | - name: Checkout sources 121 | uses: actions/checkout@v2 122 | 123 | - name: Install toolchain 124 | uses: actions-rs/toolchain@v1 125 | with: 126 | toolchain: stable 127 | 128 | - name: Run cargo check 129 | uses: actions-rs/cargo@v1 130 | with: 131 | command: check 132 | args: 133 | 134 | - name: Run cargo test 135 | uses: actions-rs/cargo@v1 136 | with: 137 | command: test 138 | args: 139 | 140 | # - name: Strip binary 141 | # run: strip target/release/tt 142 | 143 | # - name: Upload binary 144 | # uses: actions/upload-artifact@v2 145 | # with: 146 | # name: 'tt-x86-64-glibc' 147 | # path: target/release/tt -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | 13 | # Added by cargo 14 | 15 | /target 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tt" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["Vinícius R. Miguel ",] 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | cstr = "0.2.9" 11 | uuid = { version = "0.8.2", features = ["v4"] } 12 | libc = "0.2.112" 13 | fs-err = "2.6.0" 14 | walkdir = "2.3.2" 15 | tempfile = "3.3.0" 16 | thiserror = "1.0.30" 17 | unixstring = "0.2.7" 18 | lazy_static = "1.4.0" 19 | percent-encoding = "2.1.0" 20 | 21 | [dev-dependencies] 22 | chrono = "0.4.19" 23 | rand = { version = "0.8.4", default-features = false, features = ["small_rng", "std"] } 24 | 25 | [profile.release] 26 | lto = true 27 | codegen-units = 1 28 | opt-level = 3 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vinícius Miguel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `to-trash` 🚮 2 | 3 | `to-trash` (`tt` for short) is a fast, small, and hopefully FreeDesktop-compliant file trasher for Linux (or other Unix-like systems which comply to this standard). 4 | 5 | ## Building 6 | 7 | Requisites: 8 | * [Rust 1.58+](https://rustup.rs/) 9 | 10 | ``` 11 | git clone https://github.com/vrmiguel/to-trash 12 | cargo install --path to-trash 13 | ``` 14 | 15 | ## Usage 16 | 17 | ``` 18 | Usage: tt [files to be trashed] 19 | ``` 20 | 21 | ## Compliance 22 | 23 | `tt` aims to have compliance with the [FreeDesktop.org Trash specification](https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html). 24 | 25 | Checked items below are what `tt` considers to be implemented, unchecked is anything that has no or partial implementation. 26 | 27 | Some of those are my interpretation of the spec. and not necessarily verbatim to the specification text. 28 | 29 | * [x] Considers that the "home trash" is located at `$XDG_DATA_HOME/Trash`. 30 | * If `XDG_DATA_HOME` is not defined, falls back to `~/.local/share/Trash`. 31 | * [x] Files that the user trashes from the same mount point as home are stored in the home trash. 32 | * [x] Trashed files are sent to `$trash/files`. 33 | * [x] An *info file* is created for every file being trashed. 34 | * [x] Contains a `Path` key with the absolute pathname of the original location of the file/directory 35 | * [x] Contains a `DeletionDate` key with the date and time when the file/directory was trashed in the `YYYY-MM-DDThh:mm:ss` format and in the user's local timezone. 36 | * [x] Create or update the `$trash/directorysizes` file, which is a cache of the sizes of the directories that were trashed into this trash directory. 37 | * [x] Each entry contains the name and size of the trashed directory, as well as the modification time of the corresponding trashinfo file 38 | * [x] The size is calculated as the disk space used by the directory and its contents. 39 | * [x] The directory name in the directorysizes must be percent-encoded. 40 | * [x] To update this file, a temporary file followed by an atomic rename() operation must be used in order to avoid corruption due to two implementations writing to the file at the same time. 41 | * [ ] Note: the implementation currently calculates the total size of the directory in bytes. I'm not sure if this is what the standard meant. 42 | * [x] If a `$topdir/.Trash` does not exist or has not passed the checks: 43 | * [x] If a `$topdir/.Trash-$uid` directory does not exist, the implementation must immediately create it, without any warnings or delays for the user. 44 | 45 | Feel free to open an issue if you feel like `tt` is lacking any important features. 46 | 47 | -------------------------------------------------------------------------------- /src/directorysizes.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsStr; 2 | use std::io::Write; 3 | use std::os::unix::prelude::OsStrExt; 4 | use std::time::Duration; 5 | 6 | use fs_err as fs; 7 | use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; 8 | 9 | use crate::fs::copy_directorysizes; 10 | use crate::trash::Trash; 11 | 12 | /// Updates the $trash/directorysizes file with the information 13 | /// of a directory being trashed. 14 | // TODO: receive the that this directory will have in the trash? 15 | // TODO: add test 16 | pub fn update_directory_sizes( 17 | // The trash that this directory was sent to 18 | trash: &Trash, 19 | // The total size of the directory and its contents, in bytes 20 | directory_size: u64, 21 | // The name of this directory in `$trash/files` 22 | file_name_in_trash: &OsStr, 23 | // When this file was trashed 24 | deletion_time: Duration, 25 | ) -> crate::Result<()> { 26 | // The name of this directory (after trashed), in bytes 27 | let file_name = file_name_in_trash.as_bytes(); 28 | 29 | // The percent encoded name of this directory 30 | let percent_encoded = percent_encode(file_name, NON_ALPHANUMERIC); 31 | 32 | // Unix timestamp of when this directory was deleted 33 | let deletion_time = deletion_time.as_secs(); 34 | 35 | // Copy $trash/directorysizes to temp file 36 | let _temp = copy_directorysizes(trash)?; 37 | 38 | // Even though we already have a handle to this file (right above), 39 | // we'll reopen it in order to be able to append to it, instead of overwriting its contents 40 | let mut temp = fs::OpenOptions::new().append(true).open(_temp.path())?; 41 | 42 | // Append to temp file 43 | writeln!(temp, "{directory_size} {deletion_time} {percent_encoded}")?; 44 | 45 | // Atomic rename to actual directorysizes file 46 | fs::rename(temp.path(), trash.directory_sizes.as_path())?; 47 | 48 | Ok(()) 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use std::{ 54 | fs::{self, File}, 55 | io::Write, 56 | os::unix::prelude::OsStrExt, 57 | }; 58 | 59 | use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; 60 | use tempfile::TempDir; 61 | 62 | use crate::{fs::directory_size, tests::dummy_bytes, trash::Trash}; 63 | 64 | fn dummy_dir() -> crate::Result<(TempDir, Vec)> { 65 | let dir = tempfile::tempdir()?; 66 | let mut files = Vec::with_capacity(5); 67 | 68 | for _ in 0..5 { 69 | let mut file = tempfile::tempfile_in(dir.path())?; 70 | file.write_all(&dummy_bytes())?; 71 | files.push(file); 72 | } 73 | 74 | Ok((dir, files)) 75 | } 76 | 77 | #[test] 78 | fn updates_directorysizes_correctly_when_trashing() -> crate::Result<()> { 79 | let (dir_to_trash, _files) = dummy_dir()?; 80 | 81 | let directory_size = directory_size(dir_to_trash.path().to_owned().try_into()?)?; 82 | 83 | let temp_trash = tempfile::tempdir()?; 84 | let trash = Trash::from_root(temp_trash.path())?; 85 | 86 | fs::create_dir(&trash.files)?; 87 | fs::create_dir(&trash.info)?; 88 | 89 | const FIRST_LINE: &str = "16384 15803468 Documents"; 90 | 91 | { 92 | let mut directorysizes = std::fs::File::create(&trash.directory_sizes)?; 93 | writeln!(directorysizes, "{FIRST_LINE}")?; 94 | } 95 | 96 | let trashed_file_name = trash.send_to_trash(dir_to_trash.path())?; 97 | let percent_encoded = 98 | percent_encode(trashed_file_name.as_os_str().as_bytes(), NON_ALPHANUMERIC); 99 | 100 | let directorysizes = fs::read_to_string(&trash.directory_sizes)?; 101 | let mut lines = directorysizes.lines(); 102 | 103 | // Must not have overwritten the first line 104 | assert!(lines.next().unwrap().trim() == FIRST_LINE); 105 | let second_line = lines.next().unwrap(); 106 | assert!(lines.next().is_none()); 107 | 108 | let mut second_line_items = second_line.split_ascii_whitespace(); 109 | 110 | assert_eq!( 111 | second_line_items.next().unwrap().parse::().unwrap(), 112 | directory_size 113 | ); 114 | 115 | // TODO: We don't know what the timestamp is exactly. Maybe make `send_to_trash` return it. 116 | assert!(second_line_items.next().unwrap().parse::().is_ok()); 117 | 118 | assert_eq!( 119 | second_line_items.next().unwrap().trim(), 120 | percent_encoded.to_string() 121 | ); 122 | 123 | Ok(()) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[derive(thiserror::Error, Debug)] 4 | pub enum Error { 5 | #[error("Interior nul byte found in CString")] 6 | InteriorNulByte(#[from] unixstring::Error), 7 | #[error("Path {0} does not contain a working trash directory")] 8 | TrashDirDoesNotExist(PathBuf), 9 | #[error("IO: {0}")] 10 | Io(#[from] std::io::Error), 11 | #[error("Failed to parse mount points")] 12 | FailedToObtainMountPoints, 13 | #[error("Clock went backwards: {0}")] 14 | SystemTime(#[from] std::time::SystemTimeError), 15 | #[error("Failed to obtain filename of path {0}")] 16 | FailedToObtainFileName(PathBuf), 17 | #[error("Failed to obtain string from a sequence of bytes")] 18 | StringFromBytes, 19 | #[error("Invalid UTF-8: {0}")] 20 | Utf8(#[from] std::str::Utf8Error), 21 | } 22 | 23 | pub type Result = std::result::Result; 24 | -------------------------------------------------------------------------------- /src/ffi.rs: -------------------------------------------------------------------------------- 1 | mod getpwuid; 2 | mod lstat; 3 | mod mount_point; 4 | mod time; 5 | 6 | pub fn effective_user_id() -> u32 { 7 | // Safety: the POSIX Programmer's Manual states that 8 | // geteuid will always be successful. 9 | unsafe { libc::geteuid() } 10 | } 11 | 12 | pub fn real_user_id() -> u32 { 13 | // Safety: the POSIX Programmer's Manual states that 14 | // getuid will always be successful. 15 | unsafe { libc::getuid() } 16 | } 17 | 18 | pub use getpwuid::get_home_dir; 19 | pub use lstat::Lstat; 20 | pub use mount_point::{probe_mount_points, probe_mount_points_in, MountPoint}; 21 | pub use time::format_timestamp; 22 | -------------------------------------------------------------------------------- /src/ffi/getpwuid.rs: -------------------------------------------------------------------------------- 1 | use std::{mem, ptr}; 2 | 3 | use libc::{getpwuid_r, passwd}; 4 | use unixstring::UnixString; 5 | 6 | use super::effective_user_id; 7 | 8 | /// Looks up the password entry to find the user's username 9 | pub fn get_home_dir() -> Option { 10 | let mut buf = [0; 2048]; 11 | let mut result = ptr::null_mut(); 12 | let mut passwd: passwd = unsafe { mem::zeroed() }; 13 | 14 | let uid = effective_user_id(); 15 | 16 | let getpwuid_r_code = 17 | unsafe { getpwuid_r(uid, &mut passwd, buf.as_mut_ptr(), buf.len(), &mut result) }; 18 | 19 | if getpwuid_r_code == 0 && !result.is_null() { 20 | let home_dir = unsafe { UnixString::from_ptr(passwd.pw_dir) }; 21 | 22 | return Some(home_dir); 23 | } 24 | 25 | None 26 | } 27 | -------------------------------------------------------------------------------- /src/ffi/lstat.rs: -------------------------------------------------------------------------------- 1 | use std::mem; 2 | use std::os::unix::fs::PermissionsExt; 3 | use std::{ffi::CStr, fs::Permissions}; 4 | 5 | use libc::lstat; 6 | 7 | use crate::error::{Error, Result}; 8 | 9 | pub struct Lstat { 10 | inner: libc::stat, 11 | } 12 | 13 | #[allow(dead_code)] 14 | impl Lstat { 15 | pub fn lstat(path: impl AsRef) -> Result { 16 | Ok(Self { 17 | inner: _lstat(path)?, 18 | }) 19 | } 20 | 21 | pub const fn mode(&self) -> u32 { 22 | self.inner.st_mode 23 | } 24 | 25 | /// Total size, in bytes 26 | pub const fn size(&self) -> u64 { 27 | self.inner.st_size as u64 28 | } 29 | 30 | pub const fn block_size(&self) -> i64 { 31 | self.inner.st_blksize 32 | } 33 | 34 | pub fn permissions(&self) -> Permissions { 35 | Permissions::from_mode(self.mode()) 36 | } 37 | 38 | pub const fn blocks(&self) -> i64 { 39 | self.inner.st_blocks 40 | } 41 | 42 | pub const fn accessed(&self) -> u64 { 43 | self.inner.st_atime as u64 44 | } 45 | 46 | pub const fn modified(&self) -> u64 { 47 | self.inner.st_mtime as u64 48 | } 49 | 50 | pub const fn owner_user_id(&self) -> u32 { 51 | self.inner.st_uid 52 | } 53 | 54 | pub const fn owner_group_id(&self) -> u32 { 55 | self.inner.st_gid 56 | } 57 | } 58 | 59 | fn _lstat(path: impl AsRef) -> Result { 60 | // Safety: The all-zero byte-pattern is a valid `struct stat` 61 | let mut stat_buf = unsafe { mem::zeroed() }; 62 | 63 | if -1 == unsafe { lstat(path.as_ref().as_ptr(), &mut stat_buf) } { 64 | let io_err = std::io::Error::last_os_error(); 65 | Err(Error::Io(io_err)) 66 | } else { 67 | Ok(stat_buf) 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | mod tests { 73 | use std::{convert::TryFrom, time::UNIX_EPOCH}; 74 | 75 | use tempfile::NamedTempFile; 76 | use unixstring::UnixString; 77 | 78 | use super::Lstat; 79 | 80 | #[test] 81 | fn permissions() { 82 | let file = NamedTempFile::new().unwrap(); 83 | let path = file.path().to_owned(); 84 | let permissions = path.metadata().unwrap().permissions(); 85 | let path = UnixString::try_from(path).unwrap(); 86 | 87 | assert_eq!(permissions, Lstat::lstat(&path).unwrap().permissions()); 88 | } 89 | 90 | #[test] 91 | fn time_of_last_modification() { 92 | let file = NamedTempFile::new().unwrap(); 93 | let path = file.path(); 94 | let mod_timestamp = path 95 | .metadata() 96 | .unwrap() 97 | .modified() 98 | .unwrap() 99 | .duration_since(UNIX_EPOCH) 100 | .unwrap() 101 | .as_secs(); 102 | 103 | let unx = UnixString::try_from(path.to_owned()).unwrap(); 104 | let stat = Lstat::lstat(&unx).unwrap(); 105 | 106 | assert_eq!(mod_timestamp, stat.modified()); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/ffi/mount_point.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::Reverse, 3 | collections::BinaryHeap, 4 | ffi::CStr, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use crate::error::{Error, Result}; 9 | use cstr::cstr; 10 | use libc::{getmntent, setmntent}; 11 | use unixstring::UnixString; 12 | 13 | #[derive(Debug, PartialEq, Eq)] 14 | pub struct MountPoint { 15 | pub fs_name: String, 16 | pub fs_path_prefix: PathBuf, 17 | } 18 | 19 | impl MountPoint { 20 | pub fn is_root(&self) -> bool { 21 | self.fs_path_prefix == Path::new("/") 22 | } 23 | 24 | pub fn is_home(&self) -> bool { 25 | self.fs_path_prefix == Path::new("/home") 26 | } 27 | 28 | pub fn contains(&self, path: &Path) -> bool { 29 | path.starts_with(&self.fs_path_prefix) 30 | } 31 | } 32 | 33 | #[cfg(test)] 34 | mod mount_point_fns { 35 | 36 | use crate::ffi::MountPoint; 37 | 38 | fn root() -> MountPoint { 39 | MountPoint { 40 | fs_name: "/dev/sda2".into(), 41 | fs_path_prefix: "/".into(), 42 | } 43 | } 44 | 45 | fn home() -> MountPoint { 46 | MountPoint { 47 | fs_name: "/dev/sda2".into(), 48 | fs_path_prefix: "/home".into(), 49 | } 50 | } 51 | 52 | #[test] 53 | fn is_root() { 54 | assert!(root().is_root()); 55 | assert!(!home().is_root()); 56 | } 57 | 58 | #[test] 59 | fn is_home() { 60 | assert!(!root().is_home()); 61 | assert!(home().is_home()); 62 | } 63 | } 64 | 65 | impl PartialOrd for MountPoint { 66 | fn partial_cmp(&self, other: &Self) -> Option { 67 | Some(self.cmp(other)) 68 | } 69 | } 70 | 71 | impl Ord for MountPoint { 72 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 73 | self.fs_path_prefix 74 | .as_os_str() 75 | .len() 76 | .cmp(&other.fs_path_prefix.as_os_str().len()) 77 | } 78 | } 79 | 80 | /// Parses `/etc/mtab` (symlink to `/proc/self/mounts`) to list currently mounted file systems` 81 | pub fn probe_mount_points() -> Result> { 82 | let path = cstr!("/etc/mtab"); 83 | 84 | probe_mount_points_in(path) 85 | } 86 | 87 | /// Parses the mounted file systems table given by `path` 88 | pub fn probe_mount_points_in(path: &CStr) -> Result> { 89 | let mut mount_points = BinaryHeap::new(); 90 | 91 | let read_arg = cstr!("r"); 92 | let file = unsafe { setmntent(path.as_ptr(), read_arg.as_ptr()) }; 93 | 94 | if file.is_null() { 95 | return Err(Error::FailedToObtainMountPoints); 96 | } 97 | 98 | loop { 99 | let entry = unsafe { getmntent(file) }; 100 | if entry.is_null() { 101 | break; 102 | } 103 | // We just made sure `entry` is not null, 104 | // so this deref must be safe (I guess?) 105 | let fs_name = unsafe { (*entry).mnt_fsname }; 106 | let fs_dir = unsafe { (*entry).mnt_dir }; 107 | 108 | let fs_name = unsafe { UnixString::from_ptr(fs_name) }; 109 | 110 | let fs_dir = unsafe { UnixString::from_ptr(fs_dir) }; 111 | 112 | let mount_point = MountPoint { 113 | fs_name: fs_name.into_string_lossy(), 114 | fs_path_prefix: fs_dir.into(), 115 | }; 116 | mount_points.push(Reverse(mount_point)); 117 | } 118 | 119 | Ok(mount_points 120 | .into_sorted_vec() 121 | .into_iter() 122 | .map(|rev_mount_point| rev_mount_point.0) 123 | .collect()) 124 | } 125 | 126 | #[cfg(test)] 127 | mod mount_point_probing_tests { 128 | use tempfile::NamedTempFile; 129 | 130 | use std::{ 131 | collections::BTreeSet, ffi::CString, io::Write, os::unix::prelude::OsStrExt, time::Duration, 132 | }; 133 | 134 | use crate::ffi::{probe_mount_points_in, MountPoint}; 135 | 136 | const TEST_MTAB: &str = r#" 137 | proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0 138 | sys /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0 139 | dev /dev devtmpfs rw,nosuid,relatime,size=10574240k,nr_inodes=5743635,mode=755,inode64 0 0 140 | run /run tmpfs rw,nosuid,nodev,relatime,mode=755,inode64 0 0 141 | efivarfs /sys/firmware/efi/efivars efivarfs rw,nosuid,nodev,noexec,relatime 0 0 142 | /dev/sda2 / ext4 rw,noatime 0 0 143 | securityfs /sys/kernel/security securityfs rw,nosuid,nodev,noexec,relatime 0 0 144 | tmpfs /dev/shm tmpfs rw,nosuid,nodev,inode64 0 0 145 | devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000 0 0 146 | "#; 147 | 148 | #[test] 149 | // TODO: this test sometimes fails for weird reasons 150 | fn test_mount_point_probing() { 151 | // getmntent is not reentrant so this is currently needed to sort out multi-threaded weirdness 152 | std::thread::sleep(Duration::from_secs(1)); 153 | 154 | let mut temp = NamedTempFile::new().unwrap(); 155 | 156 | let temp_path = temp.path(); 157 | let temp_path_cstr = CString::new(temp_path.as_os_str().as_bytes()).unwrap(); 158 | 159 | write!(temp, "{}", TEST_MTAB).unwrap(); 160 | 161 | let mount_points = probe_mount_points_in(&temp_path_cstr).unwrap(); 162 | 163 | let mount_points: BTreeSet<_> = mount_points.into_iter().collect(); 164 | 165 | let expected = vec![ 166 | MountPoint { 167 | fs_name: "efivarfs".into(), 168 | fs_path_prefix: "/sys/firmware/efi/efivars".into(), 169 | }, 170 | MountPoint { 171 | fs_name: "securityfs".into(), 172 | fs_path_prefix: "/sys/kernel/security".into(), 173 | }, 174 | MountPoint { 175 | fs_name: "devpts".into(), 176 | fs_path_prefix: "/dev/pts".into(), 177 | }, 178 | MountPoint { 179 | fs_name: "tmpfs".into(), 180 | fs_path_prefix: "/dev/shm".into(), 181 | }, 182 | MountPoint { 183 | fs_name: "proc".into(), 184 | fs_path_prefix: "/proc".into(), 185 | }, 186 | MountPoint { 187 | fs_name: "run".into(), 188 | fs_path_prefix: "/run".into(), 189 | }, 190 | MountPoint { 191 | fs_name: "dev".into(), 192 | fs_path_prefix: "/dev".into(), 193 | }, 194 | MountPoint { 195 | fs_name: "sys".into(), 196 | fs_path_prefix: "/sys".into(), 197 | }, 198 | MountPoint { 199 | fs_name: "/dev/sda2".into(), 200 | fs_path_prefix: "/".into(), 201 | }, 202 | ]; 203 | 204 | let expected: BTreeSet<_> = expected.into_iter().collect(); 205 | 206 | assert_eq!(mount_points, expected); 207 | } 208 | } 209 | 210 | #[cfg(test)] 211 | mod mount_point_ordering_tests { 212 | use std::cmp::Reverse; 213 | 214 | use super::MountPoint; 215 | 216 | #[test] 217 | fn mount_point_cmp() { 218 | let first = MountPoint { 219 | fs_name: "portal".into(), 220 | fs_path_prefix: "/run/user/1000".into(), 221 | }; 222 | 223 | let second = MountPoint { 224 | fs_name: "portal".into(), 225 | fs_path_prefix: "/run/user/1001/doc".into(), 226 | }; 227 | 228 | assert!(first < second); 229 | 230 | assert!(Reverse(first) > Reverse(second)) 231 | } 232 | 233 | #[test] 234 | fn mount_point_neq() { 235 | // 1st case: same `fs_name` but differing prefix 236 | let first = MountPoint { 237 | fs_name: "portal".into(), 238 | fs_path_prefix: "/run/user/1000/doc".into(), 239 | }; 240 | 241 | let second = MountPoint { 242 | fs_name: "portal".into(), 243 | fs_path_prefix: "/run/user/1001/doc".into(), 244 | }; 245 | 246 | assert!(first != second); 247 | 248 | // 2nd case: differing `fs_name` but same prefix 249 | let first = MountPoint { 250 | fs_name: "portal2".into(), 251 | fs_path_prefix: "/run/user/1000/doc".into(), 252 | }; 253 | 254 | let second = MountPoint { 255 | fs_name: "portal".into(), 256 | fs_path_prefix: "/run/user/1000/doc".into(), 257 | }; 258 | 259 | assert!(first != second); 260 | 261 | // 3rd case: both properties differ 262 | let first = MountPoint { 263 | fs_name: "portal2".into(), 264 | fs_path_prefix: "/run/user/1000/doc".into(), 265 | }; 266 | 267 | let second = MountPoint { 268 | fs_name: "portal".into(), 269 | fs_path_prefix: "/run/user/1001/doc".into(), 270 | }; 271 | 272 | assert!(first != second); 273 | } 274 | 275 | #[test] 276 | fn probing_returns_ordered_mount_points() { 277 | let mount_points = super::probe_mount_points().unwrap(); 278 | 279 | if mount_points.len() < 2 { 280 | // We didn't get enough data in order to test this :C 281 | // 282 | // TODO: check if it's possible to mock `probe_mount_points`. 283 | panic!(); 284 | } 285 | 286 | assert!(mount_points.windows(2).all(|w| w[0] >= w[1])); 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/ffi/time.rs: -------------------------------------------------------------------------------- 1 | use std::{mem, time::Duration}; 2 | 3 | use cstr::cstr; 4 | use libc::{c_char, localtime_r, size_t, time, tm}; 5 | use unixstring::UnixString; 6 | 7 | use crate::error::Result; 8 | 9 | // crate libc doesn't have bindings to those yet 10 | extern "C" { 11 | pub fn strftime( 12 | s: *mut c_char, 13 | maxsize: size_t, 14 | format: *const c_char, 15 | timeptr: *const tm, 16 | ) -> size_t; 17 | 18 | pub fn tzset(); 19 | } 20 | 21 | const BUF_SIZ: usize = 64; 22 | 23 | /// Formats a timestamp (represented as a [`Duration`] since UNIX_EPOCH) into a YYYY-MM-DDThh:mm:ss format 24 | pub fn format_timestamp(now: Duration) -> Result { 25 | let mut timestamp = now.as_secs(); 26 | 27 | // Safety: the all-zero byte-pattern is valid struct tm 28 | let mut new_time: tm = unsafe { mem::zeroed() }; 29 | 30 | // Safety: time is memory-safe 31 | // TODO: it'd be better to call `time(NULL)` here 32 | let ltime = unsafe { time(&mut timestamp as *mut _ as *mut _) }; 33 | 34 | unsafe { tzset() }; 35 | 36 | // Safety: localtime_r is memory safe, threadsafe. 37 | unsafe { localtime_r(<ime as *const i64, &mut new_time as *mut tm) }; 38 | 39 | let mut char_buf: [c_char; BUF_SIZ] = [0; BUF_SIZ]; 40 | 41 | // RFC3339 timestamp 42 | let format = cstr!("%Y-%m-%dT%T"); 43 | 44 | unsafe { 45 | strftime( 46 | char_buf.as_mut_ptr(), 47 | BUF_SIZ, 48 | format.as_ptr(), 49 | &new_time as *const tm, 50 | ) 51 | }; 52 | 53 | let unx = unsafe { UnixString::from_ptr(char_buf.as_ptr()) }; 54 | 55 | Ok(unx.to_string_lossy().into()) 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use std::time::{SystemTime, UNIX_EPOCH}; 61 | 62 | use chrono::Local; 63 | 64 | use crate::ffi::time::format_timestamp; 65 | 66 | #[test] 67 | fn formats_timestamp_into_valid_rfc3339() { 68 | let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); 69 | 70 | // We'll use the chrono crate to make sure that 71 | // our own formatting (done through libc's strftime) works 72 | let date_time = Local::now(); 73 | 74 | // YYYY-MM-DDThh:mm:ss 75 | let rfc3339 = date_time.format("%Y-%m-%dT%T").to_string(); 76 | 77 | assert_eq!(&rfc3339, &format_timestamp(now).unwrap()); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/fs.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ffi::OsString, 3 | fs::{self}, 4 | path::Path, 5 | }; 6 | 7 | use tempfile::NamedTempFile; 8 | use unixstring::UnixString; 9 | use uuid::Uuid; 10 | 11 | use crate::{ 12 | error::Result, 13 | ffi::Lstat, 14 | light_fs::{path_is_directory, path_is_regular_file}, 15 | trash::Trash, 16 | }; 17 | 18 | /// Assuming that a file with path `path` exists in the directory `dir`, 19 | /// this function appends to `path` an UUID in order to make its path unique. 20 | /// 21 | /// This is needed whenever we want to send a file to $trash/files but it already contains a file with the same path. 22 | pub fn build_unique_file_name(path: impl AsRef, _dir: impl AsRef) -> OsString { 23 | // debug_assert!(dir.join(path).exists()); 24 | 25 | let uuid = Uuid::new_v4().to_string(); 26 | let mut new_file_name = path.as_ref().as_os_str().to_owned(); 27 | new_file_name.push(uuid); 28 | new_file_name 29 | } 30 | 31 | /// Tries to rename a file from `from` to `to`. 32 | /// 33 | /// If renaming fails, copies the contents of the file to the new path and removes the original source. 34 | pub fn move_file(from: impl AsRef, to: impl AsRef) -> Result<()> { 35 | // TODO: add rename to light-fs and switch these arguments to impl AsRef 36 | if fs::rename(&from, &to).is_err() { 37 | // rename(2) failed, likely because the files are in different mount points 38 | // or are on separate filesystems. 39 | copy_and_remove(from, to)?; 40 | } 41 | 42 | Ok(()) 43 | } 44 | 45 | /// Will copy the contents of `from` into `to`. 46 | /// 47 | /// The file in `from` is then deleted. 48 | fn copy_and_remove(from: impl AsRef, to: impl AsRef) -> Result<()> { 49 | let (from, to) = (from.as_ref(), to.as_ref()); 50 | fs::copy(from, to)?; 51 | if from.is_dir() { 52 | fs::remove_dir_all(from)?; 53 | } else { 54 | fs::remove_file(from)?; 55 | } 56 | 57 | Ok(()) 58 | } 59 | 60 | /// Makes a temporary copy of `$trash/directorysizes`. 61 | pub fn copy_directorysizes(path: &Trash) -> Result { 62 | let temp = NamedTempFile::new_in(path.files.as_path())?; 63 | 64 | // Copy the directorysizes to our new path 65 | fs::copy(path.directory_sizes.as_path(), temp.path())?; 66 | 67 | Ok(temp) 68 | } 69 | 70 | /// Scans a directory recursively adding up the total of bytes it contains. 71 | /// 72 | /// Symlinks found are not followed. 73 | pub fn directory_size(path: UnixString) -> Result { 74 | let mut size = 0; 75 | 76 | let lstat_size = |path: &UnixString| -> crate::Result { Ok(Lstat::lstat(path)?.size()) }; 77 | 78 | if path_is_directory(&path) { 79 | for entry in fs::read_dir(&path)? { 80 | let entry: UnixString = entry?.path().try_into()?; 81 | if path_is_regular_file(&entry) { 82 | size += lstat_size(&entry)?; 83 | } else if path_is_directory(&entry) { 84 | size += directory_size(entry)?; 85 | } 86 | } 87 | } else { 88 | size = lstat_size(&path)?; 89 | } 90 | 91 | Ok(size) 92 | } 93 | 94 | #[cfg(test)] 95 | mod tests { 96 | use std::convert::TryInto; 97 | use std::fs::File; 98 | use std::io::Write; 99 | 100 | use unixstring::UnixString; 101 | 102 | use crate::ffi::Lstat; 103 | use crate::fs::{copy_and_remove, move_file}; 104 | use crate::tests::dummy_bytes; 105 | 106 | #[test] 107 | fn test_clone_and_delete() { 108 | let dir = tempfile::tempdir().unwrap(); 109 | let dir_path = dir.path(); 110 | 111 | let contents = dummy_bytes(); 112 | 113 | let file_path: UnixString = dir_path.join("dummy").try_into().unwrap(); 114 | File::create(&file_path) 115 | .unwrap() 116 | .write_all(&contents) 117 | .unwrap(); 118 | assert!(file_path.as_path().exists()); 119 | 120 | let prev_stat = Lstat::lstat(&file_path).unwrap(); 121 | 122 | let new_path: UnixString = dir_path.join("moved_dummy").try_into().unwrap(); 123 | // There shouldn't be anything here yet 124 | assert!(!new_path.as_path().exists()); 125 | copy_and_remove(file_path.as_path(), new_path.as_path()).unwrap(); 126 | 127 | // This file shouldn't exist anymore! 128 | assert!(!file_path.as_path().exists()); 129 | // And this one should now exist 130 | assert!(new_path.as_path().exists()); 131 | 132 | let new_stat = Lstat::lstat(&new_path).unwrap(); 133 | 134 | assert_eq!(contents, std::fs::read(new_path).unwrap()); 135 | 136 | // Make sure that permission bits, accessed & modified times were maintained 137 | assert_eq!(prev_stat.permissions(), new_stat.permissions()); 138 | 139 | assert_eq!(prev_stat.modified(), new_stat.modified()); 140 | 141 | assert_eq!(prev_stat.accessed(), new_stat.accessed()); 142 | } 143 | 144 | #[test] 145 | fn test_move_file() { 146 | let dir = tempfile::tempdir().unwrap(); 147 | let dir_path = dir.path(); 148 | 149 | let contents = dummy_bytes(); 150 | 151 | let file_path: UnixString = dir_path.join("dummy").try_into().unwrap(); 152 | { 153 | let mut file = File::create(&file_path).unwrap(); 154 | file.write_all(&contents).unwrap(); 155 | } 156 | assert!(file_path.as_path().exists()); 157 | 158 | let prev_stat = Lstat::lstat(&file_path).unwrap(); 159 | 160 | let new_path: UnixString = dir_path.join("moved_dummy").try_into().unwrap(); 161 | // There shouldn't be anything here yet 162 | assert!(!new_path.as_path().exists()); 163 | move_file(&file_path, &new_path).unwrap(); 164 | 165 | // This file shouldn't exist anymore! 166 | assert!(!file_path.as_path().exists()); 167 | // And this one should now exist 168 | assert!(new_path.as_path().exists()); 169 | 170 | let new_stat = Lstat::lstat(&new_path).unwrap(); 171 | 172 | assert_eq!(contents, std::fs::read(new_path).unwrap()); 173 | 174 | // Make sure that permission bits, accessed & modified times were maintained 175 | assert_eq!(prev_stat.permissions(), new_stat.permissions()); 176 | 177 | assert_eq!(prev_stat.modified(), new_stat.modified()); 178 | 179 | assert_eq!(prev_stat.accessed(), new_stat.accessed()); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/home_dir.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use unixstring::UnixString; 4 | 5 | use crate::error::Result; 6 | use crate::ffi; 7 | 8 | // Attemps to find the calling user's home directory. 9 | /// Will check for the HOME env. variable first, falling back to 10 | /// checking passwd if HOME isn't set. 11 | pub fn home_dir() -> Option { 12 | match std::env::var_os("HOME").map(UnixString::from_os_string) { 13 | Some(Ok(unx)) => Some(unx), 14 | None => ffi::get_home_dir(), 15 | Some(Err(_)) => panic!("HOME has an interior nul byte"), 16 | } 17 | } 18 | 19 | /// XDG claims that the trash directory is located at $XDG_DATA_HOME/Trash. 20 | /// Since XDG_DATA_HOME is often undefined by distros, we fallback to $HOME/.local/share/Trash 21 | pub fn home_trash_path(home_dir: impl AsRef) -> Result { 22 | Ok(std::env::var_os("XDG_DATA_HOME") 23 | .map(PathBuf::from) 24 | .map(|home| home.join("Trash")) 25 | .unwrap_or_else(|| home_dir.as_ref().join(".local/share/Trash")) 26 | .try_into()?) 27 | } 28 | -------------------------------------------------------------------------------- /src/info_file.rs: -------------------------------------------------------------------------------- 1 | //! The $trash/info directory contains an “information file” for every file and directory in $trash/files. This file MUST have exactly the same name as the file or directory in $trash/files, plus the extension “.trashinfo”7. 2 | //! 3 | //! The format of this file is similar to the format of a desktop entry file, as described in the Desktop Entry Specification . Its first line must be [Trash Info]. 4 | //! 5 | //! It also must have two lines that are key/value pairs as described in the Desktop Entry Specification: 6 | //! 7 | //! * The key “Path” contains the original location of the file/directory, as either an absolute pathname (starting with the slash character “/”) or a relative pathname (starting with any other character). A relative pathname is to be from the directory in which the trash directory resides (for example, from $XDG_DATA_HOME for the “home trash” directory); it MUST not include a “..” directory, and for files not “under” that directory, absolute pathnames must be used. The system SHOULD support absolute pathnames only in the “home trash” directory, not in the directories under $topdir. 8 | //! - The value type for this key is “string”; it SHOULD store the file name as the sequence of bytes produced by the file system, with characters escaped as in URLs (as defined by RFC 2396, section 2). 9 | //! * The key “DeletionDate” contains the date and time when the file/directory was trashed. The date and time are to be in the YYYY-MM-DDThh:mm:ss format (see RFC 3339). The time zone should be the user's (or filesystem's) local time. The value type for this key is “string”. 10 | 11 | use std::ffi::OsStr; 12 | use std::io::Write; 13 | use std::path::{Path, PathBuf}; 14 | 15 | use crate::error::Result; 16 | use crate::ffi; 17 | use crate::trash::Trash; 18 | use fs_err::File; 19 | use std::time::Duration; 20 | 21 | /// Builds the name of the info file for a file being trashed. 22 | pub fn build_info_file_path(file_name: &OsStr, trash_info_path: &Path) -> PathBuf { 23 | let mut file_name = file_name.to_owned(); 24 | file_name.push(".trashinfo"); 25 | 26 | trash_info_path.join(file_name) 27 | } 28 | 29 | /// The $trash/info directory contains an “information file” for every file and directory in $trash/files. This file MUST have exactly the same name as the file or directory in $trash/files, plus the extension “.trashinfo”7. 30 | /// 31 | /// The format of this file is similar to the format of a desktop entry file, as described in the Desktop Entry Specification . Its first line must be [Trash Info]. 32 | /// 33 | /// It also must have two lines that are key/value pairs as described in the Desktop Entry Specification: 34 | /// 35 | /// * The key “Path” contains the original location of the file/directory, as either an absolute pathname (starting with the slash character “/”) or a relative pathname (starting with any other character). A relative pathname is to be from the directory in which the trash directory resides (for example, from $XDG_DATA_HOME for the “home trash” directory); it MUST not include a “..” directory, and for files not “under” that directory, absolute pathnames must be used. The system SHOULD support absolute pathnames only in the “home trash” directory, not in the directories under $topdir. 36 | /// - The value type for this key is “string”; it SHOULD store the file name as the sequence of bytes produced by the file system, with characters escaped as in URLs (as defined by RFC 2396, section 2). 37 | /// * The key “DeletionDate” contains the date and time when the file/directory was trashed. The date and time are to be in the YYYY-MM-DDThh:mm:ss format (see RFC 3339). The time zone should be the user's (or filesystem's) local time. The value type for this key is “string”. 38 | /// 39 | /// This function writes the info file for the file given by `file_name`, which was originally in `original_path` (before getting trashed). 40 | /// 41 | /// The trash used is given by `trash`. 42 | /// 43 | /// The deletion timestamp is given by `deletion_date`, a [`Duration`] starting in UNIX_EPOCH. 44 | /// 45 | /// Returns the path of the created info file, if successful. 46 | pub fn write_info_file( 47 | original_path: &Path, 48 | file_name: &OsStr, 49 | trash: &Trash, 50 | deletion_date: Duration, 51 | ) -> Result { 52 | // The date and time are to be in the YYYY-MM-DDThh:mm:ss format. 53 | // The time zone should be the user's (or filesystem's) local time. 54 | let rfc3339 = ffi::format_timestamp(deletion_date)?; 55 | 56 | // The info file is to be built in $trash/info 57 | let info_path = trash.info_path(); 58 | 59 | // This file MUST have exactly the same name as the file or directory in $trash/files, plus the extension “.trashinfo”. 60 | let info_file_path = build_info_file_path(file_name, info_path); 61 | 62 | let mut info_file = File::create(&info_file_path)?; 63 | 64 | writeln!(info_file, "[Trash Info]")?; 65 | // TODO: is this correct when `original_path` isn't valid UTF-8? 66 | writeln!(info_file, "Path={}", original_path.display())?; 67 | writeln!(info_file, "DeletionDate={}", &rfc3339)?; 68 | 69 | info_file.sync_all()?; 70 | 71 | Ok(info_file_path) 72 | } 73 | 74 | #[cfg(test)] 75 | mod tests { 76 | use std::{ 77 | ffi::{OsStr, OsString}, 78 | fs::{self, File}, 79 | io::Write, 80 | path::Path, 81 | time::{SystemTime, UNIX_EPOCH}, 82 | }; 83 | 84 | use crate::{ 85 | ffi, 86 | home_dir::home_dir, 87 | info_file::{build_info_file_path, write_info_file}, 88 | tests::dummy_bytes, 89 | trash::Trash, 90 | }; 91 | 92 | #[test] 93 | fn builds_info_file_path_correctly() { 94 | let trash_info = Path::new("/home/dummy/.local/share/Trash/info"); 95 | let file_name = OsStr::new("deleted-file"); 96 | 97 | assert_eq!( 98 | build_info_file_path(file_name, trash_info), 99 | Path::new("/home/dummy/.local/share/Trash/info/deleted-file.trashinfo") 100 | ); 101 | } 102 | 103 | #[test] 104 | fn builds_and_writes_info_file_correctly() { 105 | let home_dir = home_dir().unwrap(); 106 | let dir = tempfile::tempdir_in(&home_dir).unwrap(); 107 | let dir_path = dir.path(); 108 | let trash = Trash::from_root(dir_path).unwrap(); 109 | 110 | fs::create_dir(trash.info_path()).unwrap(); 111 | 112 | // The file to be trashed 113 | let file_name = OsString::from("dummy"); 114 | let dummy_file_path = dir_path.join("dummy"); 115 | let mut dummy = File::create(&dummy_file_path).unwrap(); 116 | dummy.write_all(&dummy_bytes()).unwrap(); 117 | 118 | let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); 119 | 120 | write_info_file(&dummy_file_path, &file_name, &trash, now).unwrap(); 121 | 122 | let info_file_path = trash.info_path().join("dummy.trashinfo"); 123 | let info_file = fs::read_to_string(&info_file_path).unwrap(); 124 | 125 | let rfc3339 = ffi::format_timestamp(now).unwrap(); 126 | 127 | let info_file_should_be = format!( 128 | "[Trash Info]\nPath={}\nDeletionDate={}\n", 129 | dummy_file_path.display(), 130 | rfc3339 131 | ); 132 | 133 | assert_eq!(info_file, info_file_should_be) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/light_fs.rs: -------------------------------------------------------------------------------- 1 | //! Small filesystem-related utilities. These are used instead of std::fs since these 2 | //! avoid the CString allocation caused whenever std::fs uses a syscall. 3 | 4 | use std::ffi::CStr; 5 | 6 | use crate::ffi::Lstat; 7 | 8 | /// Checks if the given path exists 9 | pub fn path_exists(path: impl AsRef) -> bool { 10 | 0 == unsafe { libc::access(path.as_ref().as_ptr(), libc::F_OK) } 11 | } 12 | 13 | /// Returns true if the given path exists and is a directory 14 | pub fn path_is_directory(path: impl AsRef) -> bool { 15 | let is_directory = |lstat: Lstat| lstat.mode() & libc::S_IFMT == libc::S_IFDIR; 16 | Lstat::lstat(path).map(is_directory).unwrap_or_default() 17 | } 18 | 19 | pub fn path_is_regular_file(path: impl AsRef) -> bool { 20 | let is_directory = |lstat: Lstat| lstat.mode() & libc::S_IFMT == libc::S_IFREG; 21 | Lstat::lstat(path).map(is_directory).unwrap_or_default() 22 | } 23 | 24 | #[cfg(test)] 25 | mod tests { 26 | use std::fs; 27 | 28 | use unixstring::UnixString; 29 | 30 | use crate::light_fs::{path_exists, path_is_directory}; 31 | 32 | #[test] 33 | fn path_exists_works() { 34 | let tempfile = tempfile::NamedTempFile::new().unwrap(); 35 | let path: UnixString = tempfile.path().to_owned().try_into().unwrap(); 36 | assert_eq!(path_exists(&path), true); 37 | 38 | fs::remove_file(&path).unwrap(); 39 | assert_eq!(path_exists(&path), false); 40 | } 41 | 42 | #[test] 43 | fn path_is_directory_works() { 44 | let tempfile = tempfile::NamedTempFile::new().unwrap(); 45 | let tempdir = tempfile::tempdir().unwrap(); 46 | 47 | let file_path: UnixString = tempfile.path().to_owned().try_into().unwrap(); 48 | let dir_path: UnixString = tempdir.path().to_owned().try_into().unwrap(); 49 | 50 | assert_eq!(path_is_directory(&file_path), false); 51 | assert_eq!(path_is_directory(&dir_path), true); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod directorysizes; 2 | mod error; 3 | mod ffi; 4 | mod fs; 5 | mod home_dir; 6 | mod info_file; 7 | mod light_fs; 8 | mod trash; 9 | 10 | #[cfg(test)] 11 | mod tests; 12 | 13 | use std::{ 14 | env, 15 | path::{Path, PathBuf}, 16 | }; 17 | 18 | use lazy_static::lazy_static; 19 | 20 | pub use error::{Error, Result}; 21 | use trash::Trash; 22 | use unixstring::UnixString; 23 | 24 | use crate::ffi::real_user_id; 25 | use crate::ffi::MountPoint; 26 | 27 | lazy_static! { 28 | // TODO: add a set of trashes of other mount points 29 | pub static ref HOME_DIR: UnixString = home_dir::home_dir().unwrap(); 30 | pub static ref HOME_TRASH_PATH: UnixString = 31 | home_dir::home_trash_path(&*HOME_DIR).expect("failed to obtain user's home directory!"); 32 | pub static ref MOUNT_POINTS: Vec = 33 | ffi::probe_mount_points().expect("failed to probe mount points!"); 34 | pub static ref HOME_TRASH: Trash = 35 | Trash::from_root(&*HOME_TRASH_PATH).expect("failed to probe mount points!"); 36 | } 37 | 38 | fn find_mount_point_of_file(path: &Path) -> Result<&MountPoint> { 39 | MOUNT_POINTS 40 | .iter() 41 | .find(|mount_point| mount_point.contains(path)) 42 | .ok_or(Error::FailedToObtainMountPoints) 43 | } 44 | 45 | fn main() { 46 | if let Err(err) = run() { 47 | eprintln!("tt: error: {}", err); 48 | std::process::exit(127); 49 | } 50 | } 51 | 52 | fn run() -> Result<()> { 53 | for file in env::args_os().skip(1) { 54 | let file = PathBuf::from(file).canonicalize()?; 55 | if file.starts_with("/home") { 56 | // The file is located at home so we'll send it to the home trash 57 | HOME_TRASH.send_to_trash(&file)?; 58 | } else { 59 | trash_file_in_other_mount_point(file)?; 60 | } 61 | } 62 | 63 | Ok(()) 64 | } 65 | 66 | /// Tries to trash a file (given by `path` which is located in a non-home mount point) 67 | fn trash_file_in_other_mount_point(path: PathBuf) -> Result<()> { 68 | // Try to find the mount point of this file 69 | let mount_point = find_mount_point_of_file(&path)?; 70 | let topdir = &mount_point.fs_path_prefix; 71 | 72 | // Check if a valid trash already exists in this mount point 73 | if let Ok(trash) = Trash::from_root_checked(topdir) { 74 | trash.send_to_trash(&path)?; 75 | return Ok(()); 76 | }; 77 | 78 | // If a $topdir/.Trash does not exist or has not passed the checks, check if `$topdir/.Trash-$uid` exists. 79 | // If a $topdir/.Trash-$uid directory does not exist, the implementation must immediately create it, without any warnings or delays for the user. 80 | // TODO: should we use the effective user ID here? 81 | let uid = real_user_id(); 82 | 83 | let trash_uid_path = topdir.join(format!(".Trash-{}", uid)); 84 | 85 | let trash = if let Ok(trash) = Trash::from_root_checked(&trash_uid_path) { 86 | trash 87 | } else { 88 | let trash = Trash::from_root(&trash_uid_path)?; 89 | fs_err::create_dir(&trash.info)?; 90 | fs_err::create_dir(&trash.files)?; 91 | fs_err::File::create(&trash.directory_sizes)?; 92 | 93 | trash 94 | }; 95 | 96 | trash.send_to_trash(&path)?; 97 | 98 | Ok(()) 99 | } 100 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::File, io::Write}; 2 | 3 | use rand::{prelude::SmallRng, RngCore, SeedableRng}; 4 | 5 | use crate::{home_dir::home_dir, trash::Trash}; 6 | 7 | pub fn dummy_bytes() -> Vec { 8 | let mut rng = SmallRng::from_entropy(); 9 | let quantity = 1024 + rng.next_u32() % 1024; 10 | let mut vec = vec![0; quantity as usize]; 11 | rng.fill_bytes(&mut vec); 12 | vec 13 | } 14 | 15 | #[test] 16 | /// TODO: check for info file 17 | /// TODO: add test for directorysizes 18 | fn sends_file_to_trash() -> crate::Result<()> { 19 | let home_dir = home_dir().unwrap(); 20 | let dir = tempfile::tempdir_in(&home_dir).unwrap(); 21 | let dir_path = dir.path(); 22 | let trash = Trash::from_root(dir_path)?; 23 | 24 | std::fs::File::create(&trash.directory_sizes)?; 25 | std::fs::create_dir(&trash.files)?; 26 | std::fs::create_dir(&trash.info)?; 27 | 28 | let dummy_path = dir_path.join("dummy"); 29 | let mut dummy = File::create(&*dummy_path).unwrap(); 30 | dummy.write_all(&dummy_bytes()).unwrap(); 31 | 32 | trash.send_to_trash(&dummy_path)?; 33 | 34 | // This path should no longer exist! 35 | assert!(!dummy_path.exists()); 36 | 37 | // The file should now be in the trash 38 | let new_path = trash.files.as_path().join("dummy"); 39 | 40 | // The new file (now in the trash) should now exist 41 | assert!(new_path.exists()); 42 | 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /src/trash.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | time::{SystemTime, UNIX_EPOCH}, 4 | }; 5 | 6 | use fs_err as fs; 7 | use unixstring::UnixString; 8 | 9 | use crate::{ 10 | directorysizes::update_directory_sizes, 11 | error::{Error, Result}, 12 | fs::{build_unique_file_name, directory_size}, 13 | info_file::write_info_file, 14 | light_fs::path_exists, 15 | }; 16 | 17 | #[derive(Debug)] 18 | /// A trash directory contains three subdirectories, named `info`, `directorysizes` and `files`. 19 | pub struct Trash { 20 | /// The $trash/files directory contains the files and directories that were trashed. When a file or directory is trashed, it must be moved into this directory. 21 | pub files: UnixString, 22 | /// The $trash/directorysizes directory is a cache of the sizes of the directories that were trashed 23 | /// in this trash directory. Individual trashed files are not present in this cache, since their size can be determined with a call to stat(). 24 | pub directory_sizes: UnixString, 25 | /// The $trash/info directory contains an “information file” for every file and directory in $trash/files. 26 | /// This file must have exactly the same name as the file or directory in $trash/files, plus the extension “.trashinfo” 27 | pub info: UnixString, 28 | } 29 | 30 | impl Trash { 31 | /// Builds a trash directory rooted at `root`. 32 | /// 33 | /// Does not check if the directories of this trash directory exist. 34 | pub fn from_root(root: impl AsRef) -> Result { 35 | let root = root.as_ref(); 36 | 37 | let files = root.join("files").try_into()?; 38 | let directory_sizes = root.join("directorysizes").try_into()?; 39 | let info = root.join("info").try_into()?; 40 | 41 | Ok(Self { 42 | files, 43 | directory_sizes, 44 | info, 45 | }) 46 | } 47 | 48 | /// Builds a trash directory rooted at `root` checking if the directories of this trash directory exist. 49 | pub fn from_root_checked(root: impl AsRef) -> Result { 50 | let trash = Self::from_root(root)?; 51 | trash.assert_exists()?; 52 | Ok(trash) 53 | } 54 | 55 | /// The path of the `info` folder for this trash directory 56 | pub fn info_path(&self) -> &Path { 57 | self.info.as_path() 58 | } 59 | 60 | /// Checks that the directories of this trash exist. 61 | /// 62 | /// Doesn't check for `$trash/directorysizes` since it was added in a later version of the spec 63 | /// so it might have been created. 64 | fn assert_exists(&self) -> Result<()> { 65 | if !path_exists(&self.info) || !path_exists(&self.files) { 66 | let root = self 67 | .files 68 | .as_path() 69 | .parent() 70 | .expect("catastrophe: trash root ends with a root or prefix"); 71 | return Err(Error::TrashDirDoesNotExist(root.to_owned())); 72 | } 73 | 74 | Ok(()) 75 | } 76 | 77 | /// Sends the file given by `path` to the given trash structure 78 | /// 79 | /// 80 | /// In case of success, returns the name of the trashed file 81 | /// exactly as sent to `TRASH/files`. 82 | /// 83 | /// # Note: 84 | /// 85 | /// From the FreeDesktop Trash spec 1.0: 86 | /// 87 | ///``` 88 | /// When trashing a file or directory, the implementation 89 | /// MUST create the corresponding file in $trash/info first 90 | ///``` 91 | /// Our implementation respects this by calling `build_info_file` before `move_file` 92 | pub fn send_to_trash(&self, to_be_removed: &Path) -> Result { 93 | // How much time has passed since Jan 1st 1970? 94 | let now = SystemTime::now().duration_since(UNIX_EPOCH)?; 95 | 96 | // If we're trashing a directory, we must calculate its size 97 | let directory_size = if to_be_removed.is_dir() { 98 | let unx = to_be_removed.to_owned().try_into()?; 99 | Some(directory_size(unx)?) 100 | } else { 101 | None 102 | }; 103 | 104 | // The name of the file to be removed 105 | let file_name = to_be_removed 106 | .file_name() 107 | .ok_or_else(|| Error::FailedToObtainFileName(to_be_removed.into()))?; 108 | 109 | // Where the file will be sent to once trashed 110 | let file_in_trash = self.files.as_path().join(&file_name); 111 | 112 | // According to the trash-spec 1.0 states that, a file in the trash 113 | // must not be overwritten by a newer file with the same filename. 114 | // 115 | // For this reason, we'll make a new unique filename for the file we're deleting if this 116 | // occurs 117 | let file_name = if file_in_trash.exists() { 118 | build_unique_file_name(&file_name, &self.files.as_path()) 119 | } else { 120 | file_name.to_owned() 121 | }; 122 | 123 | // The path of the trashed file in `$trash/files` 124 | let trash_file_path = self.files.as_path().join(&file_name); 125 | 126 | // Writes the info file for the file being trashed in `$trash/info`. 127 | // This must be done before deleting the original file, as per the spec. 128 | let info_file_path = write_info_file(&to_be_removed, &file_name, self, now)?; 129 | 130 | // Send the file being trashed... to the trash 131 | if let Err(err) = crate::fs::move_file(to_be_removed, &*trash_file_path) { 132 | // Remove the info file if moving the file fails 133 | fs::remove_file(info_file_path)?; 134 | eprintln!( 135 | "failed to move {} to {}", 136 | to_be_removed.display(), 137 | trash_file_path.display() 138 | ); 139 | return Err(err); 140 | } 141 | 142 | // If we just trashed a directory, update `$trash/directorysizes`. 143 | if let Some(directory_size) = directory_size { 144 | update_directory_sizes( 145 | // The trash the directory was sent to 146 | self, 147 | // The size of this directory, in bytes 148 | directory_size, 149 | // The name of this directory in $trash/files 150 | &file_name, 151 | // When this directory was trashed 152 | now, 153 | )?; 154 | } 155 | 156 | println!( 157 | "tt: successfully sent {} to {}.", 158 | to_be_removed.display(), 159 | self.files.as_path().display() 160 | ); 161 | 162 | Ok(file_name.into()) 163 | } 164 | } 165 | 166 | #[cfg(test)] 167 | mod tests { 168 | use super::Trash; 169 | use crate::error::Result; 170 | 171 | #[test] 172 | fn trash_from_root_has_correct_paths() -> Result<()> { 173 | let trash = Trash::from_root("/home/vrmiguel/.Trash")?; 174 | 175 | assert_eq!(trash.files, "/home/vrmiguel/.Trash/files"); 176 | 177 | assert_eq!( 178 | trash.directory_sizes, 179 | "/home/vrmiguel/.Trash/directorysizes" 180 | ); 181 | 182 | assert_eq!(trash.info, "/home/vrmiguel/.Trash/info"); 183 | 184 | Ok(()) 185 | } 186 | } 187 | --------------------------------------------------------------------------------