From a64e3913640d8dafc2eeae3e7b64af5e9309290e Mon Sep 17 00:00:00 2001 From: Michael Zhang Date: Wed, 4 Mar 2020 00:13:46 -0600 Subject: [PATCH] ok --- Cargo.lock | 2 +- Cargo.toml | 2 +- run-tests.sh | 4 - src/dir.rs | 21 +++- src/lib.rs | 1 - src/main.rs | 89 +++------------ src/ops/empty.rs | 11 +- src/ops/list.rs | 21 +++- src/ops/mod.rs | 6 +- src/ops/put.rs | 272 +++++++++++++++++++++++++++++++++++++++++++-- src/ops/restore.rs | 61 +++++++++- src/strategy.rs | 158 -------------------------- tests.Dockerfile | 9 -- 13 files changed, 388 insertions(+), 269 deletions(-) delete mode 100755 run-tests.sh delete mode 100644 src/strategy.rs delete mode 100644 tests.Dockerfile diff --git a/Cargo.lock b/Cargo.lock index e84efe5..2ea99e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,7 +88,7 @@ dependencies = [ [[package]] name = "garbage" -version = "0.1.4" +version = "0.2.0" dependencies = [ "anyhow 1.0.25 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index fe37378..18e7e97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "garbage" -version = "0.1.4" +version = "0.2.0" authors = ["Michael Zhang "] description = "cli tool for interacting with the freedesktop trashcan" license = "MIT" diff --git a/run-tests.sh b/run-tests.sh deleted file mode 100755 index baa7b95..0000000 --- a/run-tests.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -IMAGE=$(docker build -q -f tests.Dockerfile .) -exec docker run --rm -it $IMAGE diff --git a/src/dir.rs b/src/dir.rs index 10779ed..1bad7bd 100644 --- a/src/dir.rs +++ b/src/dir.rs @@ -12,15 +12,32 @@ use crate::XDG; pub struct TrashDir(pub PathBuf); impl TrashDir { + pub fn from(path: impl AsRef) -> Self { + TrashDir(path.as_ref().to_path_buf()) + } + /// Gets your user's "home" trash directory. /// /// According to Trash spec v1.0: /// /// > For every user2 a “home trash” directory MUST be available. - /// > Its name and location are $XDG_DATA_HOME/Trash; + /// > Its name and location are $XDG_DATA_HOME/Trash /// > $XDG_DATA_HOME is the base directory for user-specific data, as defined in the Desktop Base Directory Specification. pub fn get_home_trash() -> Self { - TrashDir(XDG.get_data_home().join("Trash")) + TrashDir::from(XDG.get_data_home().join("Trash")) + } + + pub fn from_opt(opt: Option>) -> Self { + opt.map(|path| TrashDir::from(path.as_ref().to_path_buf())) + .unwrap_or_else(|| TrashDir::get_home_trash()) + } + + pub fn create(&self) -> Result<(), Error> { + let path = &self.0; + if !path.exists() { + fs::create_dir_all(&path)?; + } + Ok(()) } /// Returns the path to this trash directory. diff --git a/src/lib.rs b/src/lib.rs index 0ce063a..ac6341d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,7 +17,6 @@ mod errors; mod info; mod mounts; pub mod ops; -mod strategy; mod utils; use std::path::PathBuf; diff --git a/src/main.rs b/src/main.rs index 3e7e322..ae31c49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,101 +2,46 @@ extern crate anyhow; -use std::fs; -use std::io; -use std::path::PathBuf; - use anyhow::Result; -use garbage::{ - ops::{self, EmptyOptions}, - TrashDir, -}; +use garbage::ops::{self, EmptyOptions, ListOptions, PutOptions, RestoreOptions}; use structopt::StructOpt; #[derive(StructOpt)] enum Command { + /// Empty a trash directory. #[structopt(name = "empty")] Empty(EmptyOptions), + /// List the contents of a trash directory. #[structopt(name = "list")] - List, + List(ListOptions), + /// Puts files into the trash. + /// + /// If a trash directory isn't specified, the best strategy is picked + /// for each file that's deleted (after shell glob expansion). The algorithm + /// for deciding a strategy is specified in the FreeDesktop Trash spec. #[structopt(name = "put")] - Put { - /// The target path to be trashed - #[structopt(parse(from_os_str))] - paths: Vec, - - /// Trashes directories recursively - #[structopt(long = "recursive", short = "r")] - recursive: bool, - - /// Suppress prompts/messages - #[structopt(long = "force", short = "f")] - force: bool, - }, + Put(PutOptions), + /// Restores files from the trash. #[structopt(name = "restore")] - Restore, + Restore(RestoreOptions), } fn run() -> Result<()> { - env_logger::init(); - let cmd = Command::from_args(); match cmd { Command::Empty(options) => ops::empty(options), - Command::List => { - ops::list(); - Ok(()) - } - Command::Put { - paths, - recursive, - force, - } => ops::put(paths, recursive, force), - Command::Restore => { - let home_trash = TrashDir::get_home_trash(); - let mut files = home_trash - .iter() - .unwrap() - .filter_map(|entry| match entry { - Ok(info) => Some(info), - Err(err) => { - eprintln!("failed to get file info: {:?}", err); - None - } - }) - .collect::>(); - files.sort_unstable_by_key(|info| info.deletion_date); - for (i, info) in files.iter().enumerate() { - println!( - "[{}]\t{}\t{}", - i, - info.deletion_date, - info.path.to_str().unwrap() - ); - } - - let stdin = io::stdin(); - let mut s = String::new(); - println!("which file to restore? [0..{}]", files.len() - 1); - stdin.read_line(&mut s).unwrap(); - - match s.trim_end().parse::() { - Ok(i) if i < files.len() => { - let info = files.get(i).unwrap(); - println!("moving {:?} to {:?}", &info.deleted_path, &info.path); - fs::rename(&info.deleted_path, &info.path)?; - } - _ => println!("Invalid number."), - } - Ok(()) - } + Command::List(options) => ops::list(options), + Command::Put(options) => ops::put(options), + Command::Restore(options) => ops::restore(options), } } fn main() { + env_logger::init(); + match run() { Ok(_) => (), Err(err) => { diff --git a/src/ops/empty.rs b/src/ops/empty.rs index 092b0bd..9b9be03 100644 --- a/src/ops/empty.rs +++ b/src/ops/empty.rs @@ -11,22 +11,23 @@ use crate::TrashDir; pub struct EmptyOptions { /// Only list the files that are to be deleted, without /// actually deleting anything. + #[structopt(long = "dry")] pub dry: bool, - /// Delete all files older than (this number) of days. + /// Delete all files older than (this number) of integer days. /// Removes everything if this option is not specified + #[structopt(long = "days")] days: Option, /// The path to the trash directory to empty. + /// By default, this is your home directory's trash ($XDG_DATA_HOME/Trash) + #[structopt(long = "trash-dir", parse(from_os_str))] trash_dir: Option, } /// Actually delete files in the trash. pub fn empty(options: EmptyOptions) -> Result<()> { - let trash_dir = options - .trash_dir - .map(TrashDir) - .unwrap_or_else(|| TrashDir::get_home_trash()); + let trash_dir = TrashDir::from_opt(options.trash_dir); // cutoff date let cutoff = if let Some(days) = options.days { diff --git a/src/ops/list.rs b/src/ops/list.rs index 812faef..b4415d9 100644 --- a/src/ops/list.rs +++ b/src/ops/list.rs @@ -1,8 +1,21 @@ +use std::path::PathBuf; + +use anyhow::Result; + use crate::TrashDir; -pub fn list() { - let home_trash = TrashDir::get_home_trash(); - let mut files = home_trash +#[derive(StructOpt)] +pub struct ListOptions { + /// The path to the trash directory to list. + /// By default, this is your home directory's trash ($XDG_DATA_HOME/Trash) + #[structopt(long = "trash-dir", parse(from_os_str))] + trash_dir: Option, +} + +pub fn list(options: ListOptions) -> Result<()> { + let trash_dir = TrashDir::from_opt(options.trash_dir); + + let mut files = trash_dir .iter() .unwrap() .filter_map(|entry| match entry { @@ -17,4 +30,6 @@ pub fn list() { for info in files { println!("{}\t{}", info.deletion_date, info.path.to_str().unwrap()); } + + Ok(()) } diff --git a/src/ops/mod.rs b/src/ops/mod.rs index 2247ee2..df2191f 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -6,6 +6,6 @@ mod put; mod restore; pub use self::empty::{empty, EmptyOptions}; -pub use self::list::list; -pub use self::put::put; -pub use self::restore::restore; +pub use self::list::{list, ListOptions}; +pub use self::put::{put, PutOptions}; +pub use self::restore::{restore, RestoreOptions}; diff --git a/src/ops/put.rs b/src/ops/put.rs index 80c5d11..c2f84d7 100644 --- a/src/ops/put.rs +++ b/src/ops/put.rs @@ -1,33 +1,289 @@ use std::env; -use std::path::PathBuf; +use std::fs::{self, File}; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; use anyhow::Result; +use chrono::Local; -use crate::strategy::DeletionStrategy; -use crate::HOME_TRASH; +use crate::utils; +use crate::{TrashDir, TrashInfo}; +use crate::{HOME_MOUNT, HOME_TRASH, MOUNTS}; #[derive(Debug, Error)] pub enum Error { + #[error("File {0} doesn't exist.")] + FileDoesntExist(PathBuf), + #[error("Refusing to remove '.' or '..', skipping...")] CannotTrashDotDirs, + + #[error("Cancelled by user.")] + CancelledByUser, +} + +#[derive(StructOpt)] +pub struct PutOptions { + /// The target path to be trashed + #[structopt(parse(from_os_str))] + paths: Vec, + + /// Trashes directories recursively + #[structopt(long = "recursive", short = "r")] + recursive: bool, + + /// Suppress prompts/messages + #[structopt(long = "force", short = "f")] + force: bool, + + /// Put all the trashed files into this trash directory + /// regardless of what filesystem is on. + /// + /// If a copy is required to copy the file, a prompt will be raised, + /// which can be bypassed by passing --force. + /// + /// If this option is not passed, the best strategy will be chosen + /// automatically for each file. + #[structopt(long = "trash-dir", parse(from_os_str))] + trash_dir: Option, } /// Throw some files into the trash. -pub fn put(paths: Vec, _recursive: bool, _force: bool) -> Result<()> { - let strategy = DeletionStrategy::pick_strategy(&HOME_TRASH); - for path in paths { +pub fn put(options: PutOptions) -> Result<()> { + for path in options.paths.iter() { // don't allow deleting '.' or '..' let current_dir = env::current_dir()?; ensure!( - !(path == current_dir + !(path.as_path() == current_dir.as_path() || (current_dir.parent().is_some() && path == current_dir.parent().unwrap())), Error::CannotTrashDotDirs ); - if let Err(err) = strategy.delete(path) { + // pick the best strategy for deleting this particular file + let strategy = if let Some(ref trash_dir) = options.trash_dir { + DeletionStrategy::Fixed(TrashDir::from(trash_dir)) + } else { + DeletionStrategy::pick_strategy(path)? + }; + println!("Strategy: {:?}", strategy); + + if let Err(err) = strategy.delete(path, options.force) { eprintln!("{}", err); } } Ok(()) } + +/// DeletionStrategy describes a strategy by which a file is deleted +#[derive(Debug)] +enum DeletionStrategy { + /// move or copy the file to this particular trash + Fixed(TrashDir), + + /// move the candidate files/directories to the trash directory + /// (this requires that both the candidate and the trash directories be on the same filesystem) + MoveTo(TrashDir), + + /// recursively copy the candidate files/directories to the trash directory + CopyTo(TrashDir), +} + +impl DeletionStrategy { + /// This method picks the ideal strategy + pub fn pick_strategy(target: impl AsRef) -> Result { + let target = target.as_ref(); + let target_mount = MOUNTS + .get_mount_point(target) + .ok_or_else(|| anyhow!("couldn't get mount point"))?; + + // first, are we on the home mount? + if target_mount == *HOME_MOUNT { + return Ok(DeletionStrategy::MoveTo(TrashDir::get_home_trash())); + } + + // try to use the $topdir/.Trash directory + if should_use_topdir_trash(&target_mount) { + let topdir_trash_dir = target_mount + .join(".Trash") + .join(utils::get_uid().to_string()); + let trash_dir = TrashDir::from(topdir_trash_dir); + trash_dir.create()?; + return Ok(DeletionStrategy::MoveTo(trash_dir)); + } + + // try to use the $topdir/.Trash-$uid directory + if should_use_topdir_trash_uid(&target_mount) { + let topdir_trash_uid = target_mount.join(format!(".Trash-{}", utils::get_uid())); + let trash_dir = TrashDir::from(topdir_trash_uid); + trash_dir.create()?; + return Ok(DeletionStrategy::MoveTo(trash_dir)); + } + + // it's not on the home mount, but we'll copy into it anyway + Ok(DeletionStrategy::CopyTo(TrashDir::get_home_trash())) + } + + fn get_target_trash(&self) -> (&TrashDir, bool) { + match self { + DeletionStrategy::Fixed(trash) => { + // TODO: finish + (trash, true) + } + DeletionStrategy::MoveTo(trash) => (trash, false), + DeletionStrategy::CopyTo(trash) => (trash, true), + } + } + + // fn get_target_trash( + // &self, + // mount: impl AsRef, + // path: impl AsRef, + // ) -> Option<(TrashDir, bool)> { + // let mount = mount.as_ref(); + // let _path = path.as_ref(); + + // // first, are we on the home mount? + // if mount == *HOME_MOUNT { + // return Some((HOME_TRASH.clone(), false)); + // } + + // // are we just copying? + // if let DeletionStrategy::Copy = self { + // return Some((HOME_TRASH.clone(), true)); + // } + + // // try to use the $topdir/.Trash directory + // let topdir_trash = mount.join(".Trash"); + // if self.should_use_topdir_trash(&topdir_trash) { + // return Some(( + // TrashDir(topdir_trash.join(utils::get_uid().to_string())), + // false, + // )); + // } + + // // try to use the $topdir/.Trash-$uid directory + // let topdir_trash_uid = mount.join(format!(".Trash-{}", utils::get_uid())); + // if self.should_use_topdir_trash_uid(&topdir_trash_uid) { + // return Some((TrashDir(topdir_trash_uid), false)); + // } + + // // do we have the copy option + // if let DeletionStrategy::TopdirOrCopy = self { + // return Some((HOME_TRASH.clone(), true)); + // } + + // None + // } + + /// The actual deletion happens here + pub fn delete(&self, target: impl AsRef, force: bool) -> Result<()> { + let target = target.as_ref(); + if !target.exists() { + bail!(Error::FileDoesntExist(target.to_path_buf())); + } + + let (trash_dir, requires_copy) = self.get_target_trash(); + + // prompt if not suppressed + if !force { + eprint!( + "Copy file '{}' to the trash? [Y/n] ", + target.to_str().unwrap() + ); + let should_copy = loop { + let stdin = io::stdin(); + let mut s = String::new(); + stdin.read_line(&mut s).unwrap(); + match s.trim().to_lowercase().as_str() { + "yes" | "y" => break true, + "no" | "n" => break false, + _ => { + eprint!("Invalid response. Please type yes or no: "); + } + } + }; + if !should_copy { + bail!(Error::CancelledByUser); + } + } + + // preparing metadata + let now = Local::now(); + let elapsed = now.timestamp_millis(); + let file_name = format!( + "{}.{}", + elapsed, + target.file_name().unwrap().to_str().unwrap() + ); + + let trash_file_path = trash_dir.files_dir()?.join(&file_name); + let trash_info_path = trash_dir.info_dir()?.join(file_name + ".trashinfo"); + + let trash_info = TrashInfo { + path: utils::into_absolute(target)?, + deletion_date: now, + deleted_path: trash_file_path.clone(), + info_path: trash_info_path.clone(), + }; + { + let trash_info_file = File::create(trash_info_path)?; + trash_info.write(&trash_info_file)?; + } + + // copy the file over + if requires_copy { + utils::recursive_copy(&target, &trash_file_path)?; + fs::remove_dir_all(&target)?; + } else { + fs::rename(&target, &trash_file_path)?; + } + + Ok(()) + } +} + +/// Can we use $topdir/.Trash? +/// +/// 1. If it doesn't exist, don't create it. +/// 2. All users should be able to write to it +/// 3. It must have sticky-bit permissions if the filesystem supports it. +/// 4. The directory must not be a symbolic link. +fn should_use_topdir_trash(mount: impl AsRef) -> bool { + let mount = mount.as_ref(); + let trash_dir = mount.join(".Trash"); + + if !trash_dir.exists() { + return false; + } + + let dir = match File::open(trash_dir) { + Ok(file) => file, + Err(_) => return false, + }; + let meta = match dir.metadata() { + Ok(meta) => meta, + Err(_) => return false, + }; + if meta.file_type().is_symlink() { + return false; + } + let perms = meta.permissions(); + + perms.mode() & 0o1000 > 0 +} + +/// Can we use $topdir/.Trash-uid? + +fn should_use_topdir_trash_uid(path: impl AsRef) -> bool { + let path = path.as_ref(); + if !path.exists() { + match fs::create_dir(path) { + Ok(_) => (), + Err(_) => return false, + }; + } + + return true; +} diff --git a/src/ops/restore.rs b/src/ops/restore.rs index 7cc8ca6..3d2f897 100644 --- a/src/ops/restore.rs +++ b/src/ops/restore.rs @@ -1,3 +1,60 @@ -pub fn restore() { - // let trash = select_trash(); +use std::fs; +use std::io; +use std::path::PathBuf; + +use anyhow::Result; + +use crate::TrashDir; + +#[derive(StructOpt)] +pub struct RestoreOptions { + /// The path to the trash directory to restore from. + /// By default, this is your home directory's trash ($XDG_DATA_HOME/Trash) + #[structopt(long = "trash-dir", parse(from_os_str))] + trash_dir: Option, +} + +pub fn restore(options: RestoreOptions) -> Result<()> { + let trash_dir = TrashDir::from_opt(options.trash_dir); + + // get list of files sorted by deletion date + let files = { + let mut files = trash_dir + .iter() + .unwrap() + .filter_map(|entry| match entry { + Ok(info) => Some(info), + Err(err) => { + eprintln!("failed to get file info: {:?}", err); + None + } + }) + .collect::>(); + files.sort_unstable_by_key(|info| info.deletion_date); + files + }; + + for (i, info) in files.iter().enumerate() { + println!( + "[{}]\t{}\t{}", + i, + info.deletion_date, + info.path.to_str().unwrap() + ); + } + + let stdin = io::stdin(); + let mut s = String::new(); + eprintln!("which file to restore? [0..{}]", files.len() - 1); + stdin.read_line(&mut s).unwrap(); + + match s.trim_end().parse::() { + Ok(i) if i < files.len() => { + let info = files.get(i).unwrap(); + eprintln!("moving {:?} to {:?}", &info.deleted_path, &info.path); + fs::rename(&info.deleted_path, &info.path)?; + } + _ => eprintln!("Invalid number."), + } + Ok(()) } diff --git a/src/strategy.rs b/src/strategy.rs deleted file mode 100644 index 4391d14..0000000 --- a/src/strategy.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Contains [DeletionStrategy][1], which determines how the target -//! file will actually be deleted. -//! -//! [1]: crate::strategy::DeletionStrategy - -use std::fs::{self, File}; -use std::os::unix::fs::PermissionsExt; -use std::path::Path; - -use anyhow::Result; -use chrono::Local; - -use crate::dir::TrashDir; -use crate::info::TrashInfo; -use crate::utils; -use crate::{HOME_MOUNT, HOME_TRASH, MOUNTS}; - -// TODO: implement the other ones - -/// DeletionStrategy describes whether the deleting a directory will: -/// -/// * Move: move the candidate files/directories to the trash directory (this requires that both the candidate and the trash directories be on the same filesystem) -/// * Copy: recursively copy the candidate files/directories to the trash directory -/// * Topdir: -#[allow(dead_code)] -pub enum DeletionStrategy { - Move, - Copy, - Topdir, - TopdirOrCopy, -} - -impl DeletionStrategy { - /// This method picks the ideal strategy - pub fn pick_strategy(trash_dir: &TrashDir) -> DeletionStrategy { - DeletionStrategy::Move - } - - fn get_target_trash( - &self, - mount: impl AsRef, - path: impl AsRef, - ) -> Option<(TrashDir, bool)> { - let mount = mount.as_ref(); - let _path = path.as_ref(); - - // first, are we on the home mount? - if mount == *HOME_MOUNT { - return Some((HOME_TRASH.clone(), false)); - } - - // are we just copying? - if let DeletionStrategy::Copy = self { - return Some((HOME_TRASH.clone(), true)); - } - - // try to use the $topdir/.Trash directory - let topdir_trash = mount.join(".Trash"); - if self.should_use_topdir_trash(&topdir_trash) { - return Some(( - TrashDir(topdir_trash.join(utils::get_uid().to_string())), - false, - )); - } - - // try to use the $topdir/.Trash-$uid directory - let topdir_trash_uid = mount.join(format!(".Trash-{}", utils::get_uid())); - if self.should_use_topdir_trash_uid(&topdir_trash_uid) { - return Some((TrashDir(topdir_trash_uid), false)); - } - - // do we have the copy option - if let DeletionStrategy::TopdirOrCopy = self { - return Some((HOME_TRASH.clone(), true)); - } - - None - } - - fn should_use_topdir_trash(&self, path: impl AsRef) -> bool { - let path = path.as_ref(); - if !path.exists() { - return false; - } - - let dir = match File::open(path) { - Ok(file) => file, - Err(_) => return false, - }; - let meta = match dir.metadata() { - Ok(meta) => meta, - Err(_) => return false, - }; - if meta.file_type().is_symlink() { - return false; - } - let perms = meta.permissions(); - - perms.mode() & 0o1000 > 0 - } - - fn should_use_topdir_trash_uid(&self, path: impl AsRef) -> bool { - let path = path.as_ref(); - if !path.exists() { - match fs::create_dir(path) { - Ok(_) => (), - Err(_) => return false, - }; - } - - return true; - } - - pub fn delete(&self, target: impl AsRef) -> Result<()> { - let target = target.as_ref(); - - let target_mount = MOUNTS - .get_mount_point(target) - .ok_or_else(|| anyhow!("couldn't get mount point"))?; - let (trash_dir, copy) = match self.get_target_trash(target_mount, target) { - Some(x) => x, - None => bail!("no trash dir could be selected, u suck"), - }; - - // preparing metadata - let now = Local::now(); - let elapsed = now.timestamp_millis(); - let file_name = format!( - "{}.{}", - elapsed, - target.file_name().unwrap().to_str().unwrap() - ); - - let trash_file_path = trash_dir.files_dir()?.join(&file_name); - let trash_info_path = trash_dir.info_dir()?.join(file_name + ".trashinfo"); - - let trash_info = TrashInfo { - path: utils::into_absolute(target)?, - deletion_date: now, - deleted_path: trash_file_path.clone(), - info_path: trash_info_path.clone(), - }; - { - let trash_info_file = File::create(trash_info_path)?; - trash_info.write(&trash_info_file)?; - } - - // copy the file over - if copy { - utils::recursive_copy(&target, &trash_file_path)?; - fs::remove_dir_all(&target)?; - } else { - fs::rename(&target, &trash_file_path)?; - } - - Ok(()) - } -} diff --git a/tests.Dockerfile b/tests.Dockerfile deleted file mode 100644 index 5dd77fb..0000000 --- a/tests.Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM rust:1.39 -WORKDIR /usr/src/garbage -COPY . . -RUN pwd -RUN ls -l -RUN cargo build --release - -FROM alpine:latest -COPY --from=0 /usr/src/garbage/target/release/garbage .