borg-hive 0.0.2

Automated backups using Borg Backup
// borg-hive - Zero-configuration wrapper for borg
// Copyright (C) 2017  Lorenzo Villani
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 of the License.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.

use std::path;
use std::process;

use utils;

use errors::*;

pub fn borg_create(
    repo: &str,
    password: &str,
    source: &path::Path,
    exclude: &[String],
) -> Result<()> {
    let archive_name = borg_archive_name(repo);

    let source = source.to_str();

    let source = match source {
        Some(x) => x,
        None => bail!("expected valid source path"),
    };

    let excludes: Vec<String> = utils::expanded_vec(&exclude.to_vec())?
        .into_iter()
        .flat_map(|x| vec!["--exclude".to_string(), x.into_owned()])
        .collect();

    let args = vec![
        "create",
        "--compression",
        "lz4",
        "--exclude-caches",
        "--info",
        "--one-file-system",
        "--progress",
        "--stats",
    ].into_iter()
        .chain(excludes.iter().map(|x| x.as_ref()))
        .chain(vec![archive_name.as_ref(), source])
        .collect();

    borg_verbose(repo, password, args)
}

pub fn borg_archive_name(repo: &str) -> String {
    format!("{}::home-{{now:%Y-%m-%dT%H:%M:%S}}", repo)
}

pub fn borg_init(repo: &str, password: &str) -> Result<()> {
    borg_silent(repo, password, vec!["init", "--encryption=repokey"])
}

pub fn borg_prune(repo: &str, password: &str) -> Result<()> {
    borg_silent(
        repo,
        password,
        vec![
            "prune",
            "--info",
            "--keep-hourly",
            "24",
            "--keep-daily",
            "7",
            "--keep-weekly",
            "-1",
        ],
    )
}

pub fn borg_silent(repo: &str, password: &str, args: Vec<&str>) -> Result<()> {
    borg(repo, password, args, true)
}

pub fn borg_verbose(repo: &str, password: &str, args: Vec<&str>) -> Result<()> {
    // Verbose by default, silent during tests ;-)
    borg(repo, password, args, cfg!(test))
}

fn borg(repo: &str, password: &str, args: Vec<&str>, silent: bool) -> Result<()> {
    let (stderr, stdout) = if silent {
        (process::Stdio::null(), process::Stdio::null())
    } else {
        (process::Stdio::inherit(), process::Stdio::inherit())
    };

    debug!("running borg {:?} (with repo: {:?})", args, repo);

    let status = process::Command::new("nice")
        .arg("-n")
        .arg("19")
        .arg("borg")
        .args(args)
        .env("BORG_PASSPHRASE", password)
        .env("BORG_REPO", repo)
        .stderr(stderr)
        .stdin(process::Stdio::null())
        .stdout(stdout)
        .status()
        .chain_err(|| "unable to run borg")?;

    if !status.success() {
        match status.code() {
            Some(code) => bail!("borg exited with status code {}", code),
            None => bail!("borg exited with an unknown status code"),
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path;

    use tempdir;

    use super::*;

    #[test]
    fn test_no_args() {
        with_repo(|repo| {
            assert!(borg_silent(repo, "test", vec![]).is_ok());
        });
    }

    #[test]
    fn test_bogus_arg() {
        with_repo(|repo| {
            assert!(borg_silent(repo, "test", vec!["bogus"]).is_err());
        });
    }

    #[test]
    fn test_repo_not_a_directory() {
        with_repo(|repo| {
            let test_file = path::Path::new(repo).join("test_file");

            fs::File::create(&test_file).unwrap();

            assert!(borg_silent(test_file.to_str().unwrap(), "test", vec!["init"]).is_err());
        });
    }

    #[test]
    fn test_init() {
        with_repo(|repo| {
            assert!(borg_silent(repo, "test", vec!["init"]).is_ok());
            assert!(borg_silent(repo, "test", vec!["list"]).is_ok());
        });
    }

    #[test]
    fn test_init_wrong_password() {
        with_repo(|repo| {
            assert!(borg_silent(repo, "test", vec!["init"]).is_ok());
            assert!(borg_silent(repo, "wrong", vec!["list"]).is_err());
        });
    }

    #[test]
    fn test_no_reinit() {
        with_repo(|repo| {
            let repo_path = path::Path::new(repo);

            // Repo must not exist beforehand
            assert!(!repo_path.join("config").is_file());

            // Create repo
            assert!(borg_init(repo, "test").is_ok());
            assert!(repo_path.join("config").is_file());
            assert!(borg_silent(repo, "test", vec!["list"]).is_ok());

            // Must not re-init a repo: try to re-create the existing repo with a different password
            // and ensure that we don't do anything.
            assert!(borg_init(repo, "test_no_re_init").is_err());
            assert!(borg_silent(repo, "test_no_re_init", vec!["list"]).is_err());
            assert!(borg_silent(repo, "test", vec!["list"]).is_ok());
        });
    }

    #[test]
    fn test_create() {
        with_repo(|repo| {
            let repo_path = path::Path::new(repo);

            with_test_source(|source| {
                // Creating a backup must fail if the repo is not initialized
                assert!(borg_create(repo, "test", source, &vec![]).is_err());

                // Create backup
                let index_0 = repo_path.join("index.0");

                assert!(borg_init(repo, "test").is_ok());
                assert!(index_0.is_file());
                assert!(borg_create(repo, "test", source, &vec![]).is_ok());

                // index.0 must not exist after adding some files to the repo
                assert_eq!(index_0.is_file(), false);
            });
        });
    }

    #[test]
    fn test_archive_name() {
        assert_eq!(
            borg_archive_name("/tmp/borg-hive"),
            "/tmp/borg-hive::home-{now:%Y-%m-%dT%H:%M:%S}"
        );
    }

    // Test helpers

    fn with_repo<F: FnOnce(&str) -> ()>(block: F) {
        let repo = tempdir::TempDir::new("borg-hive").unwrap();

        block(repo.path().to_str().unwrap());
    }

    fn with_test_source<F: FnOnce(&path::Path) -> ()>(block: F) {
        let source = tempdir::TempDir::new("borg-hive-backup").unwrap();

        fs::File::create(source.path().join("test_file_1.txt")).unwrap();
        fs::File::create(source.path().join("test_file_2.txt")).unwrap();

        block(source.path());
    }
}