garbage 0.4.3

CLI tool for interacting with the freedesktop trashcan
Documentation
use std::io::{self, Write};
use std::os::unix::prelude::PermissionsExt;
use std::path::Path;

use anyhow::{Context, Result};
use chrono::Local;
use garbage_fs::Filesystem;

use crate::{utils, TrashDir, TrashInfo};
use crate::{HOME_MOUNT, MOUNTS};

use super::PutOptions;

/// DeletionStrategy describes a strategy by which a file is deleted
#[derive(Debug)]
pub 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>,
    fs: &impl Filesystem,
  ) -> Result<DeletionStrategy> {
    let target = target.as_ref();
    let target_mount = MOUNTS
      .get_mount_point(target)
      .context("Could not find mount point.")?;

    // first, are we on the home mount?
    if target_mount == *HOME_MOUNT {
      debug!(
        ?target,
        "Picking strategy MoveTo because target was on the home mount.",
      );
      return Ok(DeletionStrategy::MoveTo(TrashDir::get_home_trash()));
    }

    // try to use the $topdir/.Trash directory
    // NOTE: really wish i could break from if statements...
    debug!("Considering $mount/.Trash");
    'topdir: while should_use_topdir_trash(&target_mount, fs)? {
      let topdir_trash_dir = target_mount
        .join(".Trash")
        .join(utils::get_uid().to_string());
      let trash_dir = TrashDir::from(&topdir_trash_dir);
      match trash_dir.mkdir(fs) {
        Ok(_) => (),
        Err(err) => {
          debug!(
            file = ?target,
            mount_trash_dir = ?topdir_trash_dir,
            ?err,
            "Not deleting file in mount's trash-dir because of IO error"
          );
          break 'topdir;
        }
      }
      return Ok(DeletionStrategy::MoveTo(trash_dir));
    }

    // try to use the $topdir/.Trash-$uid directory
    debug!("Considering $mount/.Trash-$uid");
    'cond: while should_use_topdir_trash_uid(&target_mount, fs) {
      let topdir_trash_uid =
        target_mount.join(format!(".Trash-{}", utils::get_uid()));
      let trash_dir = TrashDir::from(topdir_trash_uid);

      debug!(
        trash_dir = ?trash_dir,
        "Trying to create trash directory."
      );

      if let Err(err) = trash_dir.mkdir(fs) {
        debug!(
          err = err.to_string(),
          trash_dir = ?trash_dir,
          "Could not use the $mount/.Trash-$uid directory, falling back instead."
        );
        break 'cond;
      }
      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),
    }
  }

  /// The actual deletion happens here
  pub fn delete(
    &self,
    target: impl AsRef<Path>,
    options: &PutOptions,
    fs: &impl Filesystem,
  ) -> Result<()> {
    let target = target.as_ref();

    // this will be None if target isn't a symlink
    let _link_info = target.read_link().ok();

    let (trash_dir, requires_copy) = self.get_target_trash();

    let _guard = debug_span!("trash_dir", "{trash_dir:?}").entered();

    // prompt if not suppressed
    // TODO: streamline this logic better
    if !options.force && (requires_copy || options.prompt) {
      if requires_copy {
        eprint!("Removing file '{}' requires potentially expensive copying. Continue? [y/n] ", target.to_str().unwrap());
      } else if options.prompt {
        eprint!("Remove file '{}'? [y/n] ", target.to_str().unwrap());
      }

      // TODO: actually handle prompting instead of manually flushing
      // or use a library for handling prompt input
      io::stderr().flush()?;

      let should_continue = 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_continue {
        bail!("Cancelled by user.");
      }
    }

    // preparing metadata
    let now = Local::now();
    let elapsed = now.timestamp_millis();

    let elapsed_str = elapsed.to_string();
    let target_file = match target.file_name() {
      Some(file) => file.to_os_string(),
      None => bail!("Invalid filename found"),
    };
    let file_name = concat_os_str!(elapsed_str, ".", target_file);

    let trash_file_path = trash_dir
      .files_dir(fs)
      .context("Failed to get trash dir files")?
      .join(&file_name);
    let trash_info_path = trash_dir
      .info_dir(fs)?
      .join(concat_os_str!(file_name, ".trashinfo"));

    debug!(path_to_trashed_file = trash_file_path.display().to_string());
    debug!(path_to_trash_info = trash_info_path.display().to_string());

    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 mut trash_info_file = fs.create_file(trash_info_path)?;
      trash_info.write(&mut trash_info_file)?;
    }
    debug!("Trash info: {:?}", trash_info);

    let is_symlink = {
      let meta = fs.symlink_metadata(target)?;
      let file_type = meta.file_type();
      file_type.is_symlink()
    };

    // copy the file over
    if requires_copy {
      utils::recursive_copy(&target, &trash_file_path)?;
      if target.is_dir() {
        fs.remove_dir_all(target)?;
      } else if target.is_file() {
        fs.remove_file(target)?;
      }
    } else {
      debug!(
        from = ?target,
        to = ?trash_file_path,
        is_symlink,
        "Renaming file into trash directory.",
      );

      if is_symlink {
        let real_target = fs.read_link(target)?;
        debug!("Real target: {:?}", real_target);
        std::os::unix::fs::symlink(&real_target, &trash_file_path)?;
        fs.remove_file(target)?;
      } else {
        fs.rename(target, trash_file_path)?;
      }

      info!("Removed {}", target.display());
    }

    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>,
  fs: &impl Filesystem,
) -> Result<bool> {
  let mount = mount.as_ref();
  let trash_dir = mount.join(".Trash");

  if !fs.path_exists(&trash_dir)? {
    return Ok(false);
  }

  let dir = fs.open_file(&trash_dir).with_context(|| {
    format!("Error opening top directory: {}", trash_dir.display())
  })?;

  let meta = fs
    .file_metadata(&dir)
    .context("Could not retrieve file metadata.")?;

  if meta.file_type().is_symlink() {
    debug!("Trashdir {:?} is a symlink.", trash_dir);
    return Ok(false);
  }

  let perms = meta.permissions();
  Ok(perms.mode() & 0o1000 > 0)
}

/// Can we use $topdir/.Trash-uid?
fn should_use_topdir_trash_uid(
  path: impl AsRef<Path>,
  fs: &impl Filesystem,
) -> bool {
  let path = path.as_ref();
  if !path.exists() {
    match fs.create_dir(path) {
      Ok(_) => (),
      Err(_) => return false,
    };
  }
  return true;
}