metadata-backup 0.1.0

Program to back up file system metadata.
Documentation
// Copyright 2019 metadata-backup Authors (see AUTHORS.md)

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at

//     http://www.apache.org/licenses/LICENSE-2.0

// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

extern crate tempdir;

use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};

#[cfg(target_os = "linux")]
use std::os::unix::fs::PermissionsExt;

use tempdir::TempDir;

use metadata_backup::backup;

/// Currently this is just a "smoke test" to ensure that no errors are raised
/// when building a zip file.
#[test]
fn test_backup_basic() {
    let cwd = TempDir::new("backup_test").unwrap();
    let cwd_path = cwd.path();
    let base_path = cwd_path.join("root");
    let dir1 = base_path.join("dir1");
    let dir2 = base_path.join("dir2");
    let dir2_sub = dir2.clone().join("subdir");

    let mut tree = vec![
        FSO::directory(base_path.clone()),
        FSO::directory(base_path.join("empty")), // Empty directory
        FSO::directory(dir1.clone()),            // Directory with files in it
        FSO::file(dir1.join("file1"), b"Hello"),
        FSO::file(dir1.join("file2"), b"Goodbye"),
        FSO::directory(dir2.clone()), // Directory with only directories in it
        FSO::directory(dir2_sub.clone()), // Subdirectory with files in it
        FSO::file(dir2_sub.join("subfile1"), b"Nested at depth 2"),
        FSO::directory(dir2.join("empty")), // Empty subdirectory
    ];

    build_tree(&mut tree);

    let output_loc = cwd_path.join("backup.zip");
    backup::write_backup(&base_path, &output_loc).ok();

    assert!(output_loc.exists());
    // TODO: Assert something meaningful about the contents of the zip file.
}

#[cfg(target_os = "linux")]
#[test]
fn test_backup_permissions() {
    let cwd = TempDir::new("backup_test").unwrap();
    let cwd_path = cwd.path();
    let base_path = cwd_path.join("root");
    let good_dir = base_path.join("good_dir");
    let bad_dir = base_path.join("bad_dir");
    let bad_file = good_dir.join("bad_file");
    let bad_subdir = good_dir.join("bad_subdir");

    let mut tree = vec![
        FSO::directory(base_path.clone()),
        FSO::directory(good_dir.clone()),
        FSO::directory(bad_dir.clone()),
        FSO::directory(bad_subdir.clone()),
        FSO::file(good_dir.join("file1"), b"Hello"),
        FSO::file(bad_dir.join("file2"), b"Goodbye"),
        FSO::file(bad_file.clone(), b"Bad permissions"),
        FSO::file(bad_subdir.join("subfile"), b""),
    ];

    build_tree(&mut tree);

    // Now remove the x permission on the file
    let output_loc = cwd_path.join("backup.zip");

    // Set some of this tree to have bad permissions, then reset the permissions
    // afterwards so this can easily be cleaned up.
    set_mode(&bad_dir, 0o000).unwrap();
    set_mode(&bad_file, 0o000).unwrap();
    set_mode(&bad_subdir, 0o000).unwrap();
    let res = backup::write_backup(&base_path, &output_loc);
    set_mode(&bad_dir, 0o777).unwrap();
    set_mode(&bad_file, 0o777).unwrap();
    set_mode(&bad_subdir, 0o777).unwrap();

    assert!(res.ok().is_some());

    // Doesn't include anything where there should have been a permissions error
    let mut expected_contents = vec![
        ("FILESYSTEM_ROOT/", true),
        ("FILESYSTEM_ROOT/contents.csv", false),
        ("FILESYSTEM_ROOT/good_dir/", true),
        ("FILESYSTEM_ROOT/good_dir/contents.csv", false),
        (backup::MANIFEST_FILE_NAME, false),
    ];
    let mut output_contents = read_backup(&output_loc);

    expected_contents.sort();
    output_contents.sort();
    let expected_contents = expected_contents;
    let output_contents = output_contents;

    assert_eq!(
        expected_contents.len(),
        output_contents.len(),
        "\nExpected: {:?}\nOutput: {:?}",
        expected_contents,
        output_contents
    );
    for ((exp_name, exp_is_dir), (ref act_name, ref act_size)) in
        expected_contents.iter().zip(output_contents)
    {
        let act_is_dir = !act_size.is_some();
        assert_eq!(exp_name, act_name);
        assert_eq!(*exp_is_dir, act_is_dir);
    }
}

#[test]
fn test_manifest() {
    let cwd = TempDir::new("backup_test").unwrap();
    let cwd_path = cwd.path();
    let base_path = cwd_path.join("root");
    let dir1 = base_path.join("dir1");
    let dir2 = base_path.join("dir2");
    let subdir_fs = dir1.join("subdir_file_subdir");
    let subdir_l2 = subdir_fs.join("subdir_l2");

    let mut tree = vec![
        FSO::directory(base_path.clone()),
        FSO::directory(dir1.clone()),
        FSO::directory(dir2.clone()),
        FSO::directory(subdir_fs.clone()),
        FSO::directory(dir1.join("empty_subdir")),
        FSO::directory(subdir_l2.clone()),
        FSO::file(dir1.join("file1.txt"), b"Foo"),
        FSO::file(dir1.join("file2.txt"), b"Bar"),
        FSO::file(dir2.join("empty_file.txt"), b""),
        FSO::file(subdir_fs.join("subfile.txt"), b"Subdir file"),
        FSO::file(
            subdir_l2.join("deepest_nested.txt"),
            b"Deepest nested file.",
        ),
    ];

    let mut expected = vec![
        "dir1",
        "dir1/file1.txt",
        "dir1/file2.txt",
        "dir1/empty_subdir",
        "dir1/subdir_file_subdir",
        "dir1/subdir_file_subdir/subfile.txt",
        "dir1/subdir_file_subdir/subdir_l2",
        "dir1/subdir_file_subdir/subdir_l2/deepest_nested.txt",
        "dir2",
        "dir2/empty_file.txt",
    ];

    expected.sort();
    let expected = expected;

    build_tree(&mut tree);

    let output_loc = cwd_path.join("output.zip");
    let res = backup::write_backup(&base_path, &output_loc);
    assert!(res.ok().is_some());

    let manifest_output = read_file_manifest(&output_loc);
    assert_eq!(
        expected.len(),
        manifest_output.len(),
        "Length differs from expecataion\nExpected: {:?}\nActual: {:?}",
        expected,
        manifest_output,
    );

    for (expected_path, actual_path) in expected.iter().zip(&manifest_output) {
        assert_eq!(
            *expected_path, *actual_path,
            "Element mismatch {:?} != {:?}\nExpected: {:?}\nActual: {:?})",
            expected_path, actual_path, expected, manifest_output
        );
    }
}

fn build_tree(tree: &mut Vec<FileSystemObject>) {
    tree.sort();

    for fso in tree {
        match fso {
            FSO::Directory(path) => fs::create_dir(path).unwrap(),
            FSO::File((path, contents)) => {
                let mut fobj = fs::File::create(path).unwrap();
                fobj.write_all(contents.as_slice()).unwrap();
            }
        }
    }
}

fn read_backup<P: AsRef<Path>>(p: P) -> Vec<(String, Option<u64>)> {
    let f = fs::File::open(p.as_ref()).unwrap();
    let mut archive = zip::ZipArchive::new(f).unwrap();

    let mut out: Vec<(String, Option<u64>)> = Vec::with_capacity(archive.len());
    for i in 0..archive.len() {
        let zf = archive.by_index(i).unwrap();
        let name = zf.name().to_owned();
        let size = if zf.is_dir() { None } else { Some(zf.size()) };

        out.push((name, size));
    }

    out
}

fn read_file_manifest<P: AsRef<Path>>(p: P) -> Vec<String> {
    let f = fs::File::open(p.as_ref()).unwrap();
    let mut archive = zip::ZipArchive::new(f).unwrap();

    let mut manifest_file = archive.by_name(backup::MANIFEST_FILE_NAME).unwrap();
    let mut buffer = String::new();
    manifest_file.read_to_string(&mut buffer).unwrap();

    // Remove trailing whitespace then read one file path per line
    buffer.trim_end().split("\n").map(String::from).collect()
}

#[cfg(target_os = "linux")]
fn set_mode<P: AsRef<Path>>(p: P, mode: u32) -> Result<(), std::io::Error> {
    let mut perms = p.as_ref().metadata()?.permissions();
    perms.set_mode(mode);

    fs::set_permissions(p, perms)?;
    Ok(())
}

#[derive(Debug)]
enum FileSystemObject {
    File((PathBuf, Vec<u8>)),
    Directory(PathBuf),
}

type FSO = FileSystemObject;

impl FileSystemObject {
    fn file(p: PathBuf, contents: &[u8]) -> Self {
        Self::File {
            0: (p, contents.to_vec()),
        }
    }

    fn directory(p: PathBuf) -> Self {
        Self::Directory { 0: p }
    }

    fn get_path(&self) -> &Path {
        match self {
            FileSystemObject::File((path, _contents)) => &path,
            FileSystemObject::Directory(path) => &path,
        }
    }
}

impl Ord for FileSystemObject {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.get_path().cmp(other.get_path())
    }
}

impl Eq for FileSystemObject {}

impl PartialEq for FileSystemObject {
    fn eq(&self, other: &Self) -> bool {
        self.get_path() == other.get_path()
    }
}

impl PartialOrd for FileSystemObject {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}