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]]
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)",

View file

@ -1,6 +1,6 @@
[package]
name = "garbage"
version = "0.1.4"
version = "0.2.0"
authors = ["Michael Zhang <iptq@protonmail.com>"]
description = "cli tool for interacting with the freedesktop trashcan"
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);
impl TrashDir {
pub fn from(path: impl AsRef<Path>) -> 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<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.

View file

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

View file

@ -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<PathBuf>,
/// 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::<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(())
}
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) => {

View file

@ -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<u32>,
/// 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>,
}
/// 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 {

View file

@ -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<PathBuf>,
}
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(())
}

View file

@ -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};

View file

@ -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<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.
pub fn put(paths: Vec<PathBuf>, _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<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() {
// 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<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 .