sndr 0.3.0

Easily and securely share files from the command line. A fully featured Send client (formerly ffsend, rebranded by tarnover).
use std::fs;
use std::io::Error as IoError;
use std::path::PathBuf;

use failure::Fail;
use ffsend_api::{
    file::remote_file::{FileParseError, RemoteFile},
    url::Url,
};
use toml::{de::Error as DeError, ser::Error as SerError};
use version_compare::Cmp;

use crate::util::{print_error, print_warning};

/// The minimum supported history file version.
const VERSION_MIN: &str = "0.0.1";

/// The maximum supported history file version.
const VERSION_MAX: &str = crate_version!();

#[derive(Serialize, Deserialize)]
pub struct History {
    /// The application version the history file was built with.
    /// Used for compatibility checking.
    version: Option<String>,

    /// The file history.
    files: Vec<RemoteFile>,

    /// Whether the list of files has changed.
    #[serde(skip)]
    changed: bool,

    /// An optional path to automatically save the history to.
    #[serde(skip)]
    autosave: Option<PathBuf>,
}

impl History {
    /// Construct a new history.
    /// A path may be given to automatically save the history to once changed.
    pub fn new(autosave: Option<PathBuf>) -> Self {
        let mut history = History::default();
        history.autosave = autosave;
        history
    }

    /// Load the history from the given file.
    pub fn load(path: PathBuf) -> Result<Self, LoadError> {
        // Read the file to a string
        let data = fs::read_to_string(&path)?;

        // Parse the data, set the autosave path
        let mut history: Self = toml::from_str(&data)?;
        history.autosave = Some(path);

        // Make sure the file version is supported
        match history.version.as_ref() {
            None => {
                print_warning("History file has no version, ignoring");
                history.version = Some(crate_version!().into());
            }
            Some(version) => {
                if let Ok(true) = version_compare::compare_to(version, VERSION_MIN, Cmp::Lt) {
                    print_warning("history file version is too old, ignoring");
                } else if let Ok(true) = version_compare::compare_to(version, VERSION_MAX, Cmp::Gt) {
                    print_warning("history file has an unknown version, ignoring");
                }
            }
        }

        // Garbage collect
        history.gc();

        Ok(history)
    }

    /// Load the history from the given file.
    /// If the file doesn't exist, create a new empty history instance.
    ///
    /// Autosaving will be enabled, and will save to the given file path.
    pub fn load_or_new(file: PathBuf) -> Result<Self, LoadError> {
        if file.is_file() {
            Self::load(file)
        } else {
            Ok(Self::new(Some(file)))
        }
    }

    /// Save the history to the internal autosave file.
    pub fn save(&mut self) -> Result<(), SaveError> {
        // Garbage collect
        self.gc();

        // Get the path
        let path = self.autosave.as_ref().ok_or(SaveError::NoPath)?;

        // If we have no files, remove the history file if it exists
        if self.files.is_empty() {
            if path.is_file() {
                fs::remove_file(&path).map_err(SaveError::Delete)?;
            }
            return Ok(());
        }

        // Ensure the file parent directories are available
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }

        // Build the data to write to a file
        let data = toml::to_string(self)?;

        // Write the file, enforcing user-only (0o600) permissions on Unix on
        // every save. The history file contains share secrets and owner
        // tokens; relying on permissions set only at first-create time would
        // miss the case where the file was created by something else with
        // looser permissions.
        #[cfg(unix)]
        {
            use std::io::Write;
            use std::os::unix::fs::OpenOptionsExt;

            let mut file = fs::OpenOptions::new()
                .write(true)
                .create(true)
                .truncate(true)
                .mode(0o600)
                .open(path)
                .map_err(SaveError::Write)?;

            // If the file existed already, force-restore restrictive
            // permissions: the mode flag on OpenOptions only applies on create.
            use std::fs::Permissions;
            use std::os::unix::fs::PermissionsExt;
            file.set_permissions(Permissions::from_mode(0o600))
                .map_err(SaveError::SetPermissions)?;

            file.write_all(data.as_bytes()).map_err(SaveError::Write)?;
        }

        #[cfg(not(unix))]
        fs::write(&path, data)?;

        // There are no new changes, set the flag
        self.changed = false;

        Ok(())
    }

    /// Add the given remote file to the history.
    /// If a file with the same ID as the given file exists,
    /// the files are merged, see `RemoteFile::merge()`.
    ///
    /// If `overwrite` is set to true, the given file will overwrite
    /// properties on the existing file.
    pub fn add(&mut self, file: RemoteFile, overwrite: bool) {
        // Merge any existing file with the same ID
        {
            // Find anything to merge
            let merge_info: Vec<bool> = self
                .files
                .iter_mut()
                .filter(|f| f.id() == file.id())
                .map(|ref mut f| f.merge(&file, overwrite))
                .collect();
            let merged = !merge_info.is_empty();
            let changed = merge_info.iter().any(|i| *i);

            // Return if merged, update the changed state
            if merged {
                if changed {
                    self.changed = true;
                }
                return;
            }
        }

        // Add the file to the list
        self.files.push(file);
        self.changed = true;
    }

    /// Remove a file, matched by it's file ID.
    ///
    /// If any file was removed, true is returned.
    pub fn remove(&mut self, id: &str) -> bool {
        // Get the indices of files that have expired
        let expired_indices: Vec<usize> = self
            .files
            .iter()
            .enumerate()
            .filter(|&(_, f)| f.id() == id)
            .map(|(i, _)| i)
            .collect();

        // Remove these specific files
        for i in expired_indices.iter().rev() {
            self.files.remove(*i);
        }

        // Set the changed flag if something was actually removed, and return
        let removed = !expired_indices.is_empty();
        if removed {
            self.changed = true;
        }
        removed
    }

    /// Remove a file by the given URL.
    ///
    /// If any file was removed, true is returned.
    pub fn remove_url(&mut self, url: Url) -> Result<bool, FileParseError> {
        Ok(self.remove(RemoteFile::parse_url(url, None)?.id()))
    }

    /// Get all files.
    pub fn files(&self) -> &Vec<RemoteFile> {
        &self.files
    }

    /// Get a file from the history, based on the given remote file.
    /// The file ID and host will be compared against all files in this history.
    /// If multiple files exist within the history that are equal, only one is returned.
    /// If no matching file was found, `None` is returned.
    pub fn get_file(&self, file: &RemoteFile) -> Option<&RemoteFile> {
        self.files
            .iter()
            .find(|f| f.id() == file.id() && f.host() == file.host())
    }

    /// Clear all history.
    pub fn clear(&mut self) {
        self.changed = !self.files.is_empty();
        self.files.clear();
    }

    /// Garbage collect (remove) all files that have been expired,
    /// as defined by their `expire_at` property.
    ///
    /// If the expiry property is None (thus unknown), the file will be kept.
    ///
    /// The number of expired files is returned.
    pub fn gc(&mut self) -> usize {
        // Get a list of expired files
        let expired: Vec<RemoteFile> = self
            .files
            .iter()
            .filter(|f| f.has_expired())
            .cloned()
            .collect();

        // Remove the files
        for f in &expired {
            self.remove(f.id());
        }

        // Set the changed flag
        if !expired.is_empty() {
            self.changed = true;
        }

        // Return the number of expired files
        expired.len()
    }
}

impl Drop for History {
    fn drop(&mut self) {
        // Automatically save if enabled and something was changed
        if self.autosave.is_some() && self.changed {
            // Save and report errors
            if let Err(err) = self.save() {
                print_error(err.context("failed to auto save history, ignoring"));
            }
        }
    }
}

impl Default for History {
    fn default() -> Self {
        Self {
            version: Some(crate_version!().into()),
            files: Vec::new(),
            changed: false,
            autosave: None,
        }
    }
}

#[derive(Debug, Fail)]
pub enum Error {
    /// An error occurred while loading the history from a file.
    #[fail(display = "failed to load history from file")]
    Load(#[cause] LoadError),

    /// An error occurred while saving the history to a file.
    #[fail(display = "failed to save history to file")]
    Save(#[cause] SaveError),
}

impl From<LoadError> for Error {
    fn from(err: LoadError) -> Self {
        Error::Load(err)
    }
}

impl From<SaveError> for Error {
    fn from(err: SaveError) -> Self {
        Error::Save(err)
    }
}

#[derive(Debug, Fail)]
pub enum LoadError {
    /// Failed to read the file contents from the given file.
    #[fail(display = "failed to read from the history file")]
    Read(#[cause] IoError),

    /// Failed to parse the loaded file.
    #[fail(display = "failed to parse the file contents")]
    Parse(#[cause] DeError),
}

impl From<IoError> for LoadError {
    fn from(err: IoError) -> Self {
        LoadError::Read(err)
    }
}

impl From<DeError> for LoadError {
    fn from(err: DeError) -> Self {
        LoadError::Parse(err)
    }
}

#[derive(Debug, Fail)]
pub enum SaveError {
    /// No autosave file path was present, failed to save.
    #[fail(display = "no autosave file path specified")]
    NoPath,

    /// Failed to serialize the history for saving.
    #[fail(display = "failed to serialize the history for saving")]
    Serialize(#[cause] SerError),

    /// Failed to write to the history file.
    #[fail(display = "failed to write to the history file")]
    Write(#[cause] IoError),

    /// Failed to set file permissions to the history file.
    #[fail(display = "failed to set permissions to the history file")]
    SetPermissions(#[cause] IoError),

    /// Failed to delete the history file, which was tried because there
    /// are no history items to save.
    #[fail(display = "failed to delete history file, because history is empty")]
    Delete(#[cause] IoError),
}

impl From<SerError> for SaveError {
    fn from(err: SerError) -> Self {
        SaveError::Serialize(err)
    }
}

impl From<IoError> for SaveError {
    fn from(err: IoError) -> Self {
        SaveError::Write(err)
    }
}