This commit is contained in:
Michael Zhang 2020-03-04 00:13:46 -06:00
parent a7bd15b0d9
commit a64e391364
Signed by: michael
GPG key ID: BDA47A31A3C8EE6B
13 changed files with 388 additions and 269 deletions

2
Cargo.lock generated
View file

@ -88,7 +88,7 @@ dependencies = [
[[package]] [[package]]
name = "garbage" name = "garbage"
version = "0.1.4" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow 1.0.25 (registry+https://github.com/rust-lang/crates.io-index)", "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)", "chrono 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "garbage" name = "garbage"
version = "0.1.4" version = "0.2.0"
authors = ["Michael Zhang <iptq@protonmail.com>"] authors = ["Michael Zhang <iptq@protonmail.com>"]
description = "cli tool for interacting with the freedesktop trashcan" description = "cli tool for interacting with the freedesktop trashcan"
license = "MIT" license = "MIT"

View file

@ -1,4 +0,0 @@
#!/bin/bash
IMAGE=$(docker build -q -f tests.Dockerfile .)
exec docker run --rm -it $IMAGE

View file

@ -12,15 +12,32 @@ use crate::XDG;
pub struct TrashDir(pub PathBuf); pub struct TrashDir(pub PathBuf);
impl TrashDir { impl TrashDir {
pub fn from(path: impl AsRef<Path>) -> Self {
TrashDir(path.as_ref().to_path_buf())
}
/// Gets your user's "home" trash directory. /// Gets your user's "home" trash directory.
/// ///
/// According to Trash spec v1.0: /// According to Trash spec v1.0:
/// ///
/// > For every user2 a “home trash” directory MUST be available. /// > 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. /// > $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 { 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<impl AsRef<Path>>) -> 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. /// Returns the path to this trash directory.

View file

@ -17,7 +17,6 @@ mod errors;
mod info; mod info;
mod mounts; mod mounts;
pub mod ops; pub mod ops;
mod strategy;
mod utils; mod utils;
use std::path::PathBuf; use std::path::PathBuf;

View file

@ -2,101 +2,46 @@
extern crate anyhow; extern crate anyhow;
use std::fs;
use std::io;
use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use garbage::{ use garbage::ops::{self, EmptyOptions, ListOptions, PutOptions, RestoreOptions};
ops::{self, EmptyOptions},
TrashDir,
};
use structopt::StructOpt; use structopt::StructOpt;
#[derive(StructOpt)] #[derive(StructOpt)]
enum Command { enum Command {
/// Empty a trash directory.
#[structopt(name = "empty")] #[structopt(name = "empty")]
Empty(EmptyOptions), Empty(EmptyOptions),
/// List the contents of a trash directory.
#[structopt(name = "list")] #[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")] #[structopt(name = "put")]
Put { Put(PutOptions),
/// The target path to be trashed
#[structopt(parse(from_os_str))]
paths: Vec<PathBuf>,
/// Trashes directories recursively
#[structopt(long = "recursive", short = "r")]
recursive: bool,
/// Suppress prompts/messages
#[structopt(long = "force", short = "f")]
force: bool,
},
/// Restores files from the trash.
#[structopt(name = "restore")] #[structopt(name = "restore")]
Restore, Restore(RestoreOptions),
} }
fn run() -> Result<()> { fn run() -> Result<()> {
env_logger::init();
let cmd = Command::from_args(); let cmd = Command::from_args();
match cmd { match cmd {
Command::Empty(options) => ops::empty(options), Command::Empty(options) => ops::empty(options),
Command::List => { Command::List(options) => ops::list(options),
ops::list(); Command::Put(options) => ops::put(options),
Ok(()) Command::Restore(options) => ops::restore(options),
}
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::<Vec<_>>();
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::<usize>() {
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(())
}
} }
} }
fn main() { fn main() {
env_logger::init();
match run() { match run() {
Ok(_) => (), Ok(_) => (),
Err(err) => { Err(err) => {

View file

@ -11,22 +11,23 @@ use crate::TrashDir;
pub struct EmptyOptions { pub struct EmptyOptions {
/// Only list the files that are to be deleted, without /// Only list the files that are to be deleted, without
/// actually deleting anything. /// actually deleting anything.
#[structopt(long = "dry")]
pub dry: bool, 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 /// Removes everything if this option is not specified
#[structopt(long = "days")]
days: Option<u32>, days: Option<u32>,
/// The path to the trash directory to empty. /// 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<PathBuf>, trash_dir: Option<PathBuf>,
} }
/// Actually delete files in the trash. /// Actually delete files in the trash.
pub fn empty(options: EmptyOptions) -> Result<()> { pub fn empty(options: EmptyOptions) -> Result<()> {
let trash_dir = options let trash_dir = TrashDir::from_opt(options.trash_dir);
.trash_dir
.map(TrashDir)
.unwrap_or_else(|| TrashDir::get_home_trash());
// cutoff date // cutoff date
let cutoff = if let Some(days) = options.days { let cutoff = if let Some(days) = options.days {

View file

@ -1,8 +1,21 @@
use std::path::PathBuf;
use anyhow::Result;
use crate::TrashDir; use crate::TrashDir;
pub fn list() { #[derive(StructOpt)]
let home_trash = TrashDir::get_home_trash(); pub struct ListOptions {
let mut files = home_trash /// 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<PathBuf>,
}
pub fn list(options: ListOptions) -> Result<()> {
let trash_dir = TrashDir::from_opt(options.trash_dir);
let mut files = trash_dir
.iter() .iter()
.unwrap() .unwrap()
.filter_map(|entry| match entry { .filter_map(|entry| match entry {
@ -17,4 +30,6 @@ pub fn list() {
for info in files { for info in files {
println!("{}\t{}", info.deletion_date, info.path.to_str().unwrap()); println!("{}\t{}", info.deletion_date, info.path.to_str().unwrap());
} }
Ok(())
} }

View file

@ -6,6 +6,6 @@ mod put;
mod restore; mod restore;
pub use self::empty::{empty, EmptyOptions}; pub use self::empty::{empty, EmptyOptions};
pub use self::list::list; pub use self::list::{list, ListOptions};
pub use self::put::put; pub use self::put::{put, PutOptions};
pub use self::restore::restore; pub use self::restore::{restore, RestoreOptions};

View file

@ -1,33 +1,289 @@
use std::env; 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 anyhow::Result;
use chrono::Local;
use crate::strategy::DeletionStrategy; use crate::utils;
use crate::HOME_TRASH; use crate::{TrashDir, TrashInfo};
use crate::{HOME_MOUNT, HOME_TRASH, MOUNTS};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
#[error("File {0} doesn't exist.")]
FileDoesntExist(PathBuf),
#[error("Refusing to remove '.' or '..', skipping...")] #[error("Refusing to remove '.' or '..', skipping...")]
CannotTrashDotDirs, CannotTrashDotDirs,
#[error("Cancelled by user.")]
CancelledByUser,
}
#[derive(StructOpt)]
pub struct PutOptions {
/// The target path to be trashed
#[structopt(parse(from_os_str))]
paths: Vec<PathBuf>,
/// 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<PathBuf>,
} }
/// Throw some files into the trash. /// Throw some files into the trash.
pub fn put(paths: Vec<PathBuf>, _recursive: bool, _force: bool) -> Result<()> { pub fn put(options: PutOptions) -> Result<()> {
let strategy = DeletionStrategy::pick_strategy(&HOME_TRASH); for path in options.paths.iter() {
for path in paths {
// don't allow deleting '.' or '..' // don't allow deleting '.' or '..'
let current_dir = env::current_dir()?; let current_dir = env::current_dir()?;
ensure!( ensure!(
!(path == current_dir !(path.as_path() == current_dir.as_path()
|| (current_dir.parent().is_some() && path == current_dir.parent().unwrap())), || (current_dir.parent().is_some() && path == current_dir.parent().unwrap())),
Error::CannotTrashDotDirs 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); eprintln!("{}", err);
} }
} }
Ok(()) 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<Path>) -> Result<DeletionStrategy> {
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>,
// path: impl AsRef<Path>,
// ) -> 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<Path>, 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<Path>) -> 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<Path>) -> bool {
let path = path.as_ref();
if !path.exists() {
match fs::create_dir(path) {
Ok(_) => (),
Err(_) => return false,
};
}
return true;
}

View file

@ -1,3 +1,60 @@
pub fn restore() { use std::fs;
// let trash = select_trash(); 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<PathBuf>,
}
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::<Vec<_>>();
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::<usize>() {
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(())
} }

View file

@ -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>,
path: impl AsRef<Path>,
) -> 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<Path>) -> 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<Path>) -> 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<Path>) -> 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(())
}
}

View file

@ -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 .