diff --git a/src/bin/conserve.rs b/src/bin/conserve.rs index d9547044..044aba73 100644 --- a/src/bin/conserve.rs +++ b/src/bin/conserve.rs @@ -15,7 +15,7 @@ use std::cell::RefCell; use std::fs::{File, OpenOptions}; -use std::io::{BufWriter, Write}; +use std::io::{self, BufWriter, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use std::time::Instant; @@ -188,7 +188,7 @@ enum Command { /// Target folder where the archive should be mounted to destination: PathBuf, - /// Create the target folder and remove all temporarly created + /// Create the target folder and remove all temporarily created /// files on exit #[arg(long, default_value_t = true)] cleanup: bool, @@ -490,7 +490,33 @@ impl Command { } => { let archive = Archive::open(open_transport(archive)?)?; let options = MountOptions { clean: *cleanup }; - mount(archive, destination, options)?; + let projection = match mount(archive, destination, options) { + Ok(handle) => handle, + Err(Error::MountDestinationExists) => { + error!("The destination already exists."); + error!("Please ensure, that the destination does not exists."); + return Ok(ExitCode::Failure); + } + Err(Error::MountDestinationDoesNotExists) => { + error!("The destination does not exists."); + error!("Please ensure, that the destination does exist prior mounting."); + return Ok(ExitCode::Failure); + } + Err(error) => return Err(error), + }; + + info!( + "Projection started at {}.", + projection.mount_root().display() + ); + { + info!("Press any key to stop the projection..."); + let mut stdin = io::stdin(); + let _ = stdin.read(&mut [0u8]).unwrap(); + } + + info!("Stopping projection."); + drop(projection); } Command::Restore { archive, diff --git a/src/errors.rs b/src/errors.rs index 75ce2c91..2dcac82e 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -170,6 +170,12 @@ pub enum Error { #[error("This feature is not implemented")] NotImplemented, + #[error("The destination already exists")] + MountDestinationExists, + + #[error("The destination does not exists")] + MountDestinationDoesNotExists, + /// Generic IO error. #[error(transparent)] IOError { diff --git a/src/mount/mod.rs b/src/mount/mod.rs index dfe99352..a6216c33 100644 --- a/src/mount/mod.rs +++ b/src/mount/mod.rs @@ -13,7 +13,18 @@ pub struct MountOptions { pub clean: bool, } -pub fn mount(archive: Archive, destination: &Path, options: MountOptions) -> Result<()> { +/// Handle for the mount controller. +/// Once dropped, the projection will be stopped and if specified so by MountOptions cleaned. +pub trait MountHandle { + /// Returns the root path where the archive has been mounted. + fn mount_root(&self) -> &Path; +} + +pub fn mount( + archive: Archive, + destination: &Path, + options: MountOptions, +) -> Result> { #[cfg(windows)] return projfs::mount(archive, destination, options); diff --git a/src/mount/projfs.rs b/src/mount/projfs.rs index b858ec49..637a451e 100644 --- a/src/mount/projfs.rs +++ b/src/mount/projfs.rs @@ -14,7 +14,7 @@ use std::{ use bytes::Bytes; use itertools::Itertools; use lru::LruCache; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; use windows_projfs::{ DirectoryEntry, DirectoryInfo, FileInfo, Notification, ProjectedFileSystem, ProjectedFileSystemSource, @@ -23,10 +23,10 @@ use windows_projfs::{ use crate::{ hunk_index::IndexHunkIndex, monitor::{void::VoidMonitor, Monitor}, - Apath, Archive, BandId, BandSelectionPolicy, IndexEntry, Kind, Result, StoredTree, + Apath, Archive, BandId, BandSelectionPolicy, Error, IndexEntry, Kind, Result, StoredTree, }; -use super::MountOptions; +use super::{MountHandle, MountOptions}; struct StoredFileReader { iter: Peekable>>>, @@ -161,7 +161,7 @@ struct ArchiveProjectionSource { hunk_index_cache: Mutex>>, /* - * Cache the last accessed hunks to improve directory travesal speed. + * Cache the last accessed hunks to improve directory traversal speed. */ #[allow(clippy::type_complexity)] hunk_content_cache: Mutex>>>, @@ -216,7 +216,7 @@ impl ArchiveProjectionSource { .lock() .unwrap() .try_get_or_insert(band_id, || { - /* Inform the user that this band has been cached as this is most likely a heavy operaton (cpu and memory wise) */ + /* Inform the user that this band has been cached as this is most likely a heavy operation (cpu and memory wise) */ info!("Caching files for band {}", stored_tree.band().id()); let helper = IndexHunkIndex::from_index(&stored_tree.band().index())?; @@ -450,7 +450,7 @@ impl ProjectedFileSystemSource for ArchiveProjectionSource { if notification.is_cancelable() && !matches!(notification, Notification::FilePreConvertToFull(_)) { - /* try to cancel everything, except retriving data */ + /* try to cancel everything, except retrieving data */ ControlFlow::Break(()) } else { ControlFlow::Continue(()) @@ -459,19 +459,51 @@ impl ProjectedFileSystemSource for ArchiveProjectionSource { } const ERROR_CODE_VIRTUALIZATION_TEMPORARILY_UNAVAILABLE: i32 = 369; -pub fn mount(archive: Archive, destination: &Path, options: MountOptions) -> Result<()> { +struct WindowsMountHandle { + _projection: ProjectedFileSystem, + path: PathBuf, + cleanup: bool, +} + +impl Drop for WindowsMountHandle { + fn drop(&mut self) { + if self.cleanup { + debug!("Removing destination {}", self.path.display()); + let mut attempt_count = 0; + while let Err(err) = fs::remove_dir_all(&self.path) { + attempt_count += 1; + if err.raw_os_error().unwrap_or_default() + != ERROR_CODE_VIRTUALIZATION_TEMPORARILY_UNAVAILABLE + || attempt_count > 5 + { + warn!("Failed to clean up projection destination: {}", err); + break; + } + std::thread::sleep(Duration::from_secs(1)); + } + } + } +} + +impl MountHandle for WindowsMountHandle { + fn mount_root(&self) -> &Path { + &self.path + } +} + +pub fn mount( + archive: Archive, + destination: &Path, + options: MountOptions, +) -> Result> { if options.clean { if destination.exists() { - error!("The destination already exists."); - error!("Please ensure, that the destination does not exists."); - return Ok(()); + return Err(Error::MountDestinationExists); } fs::create_dir_all(destination)?; } else if !destination.exists() { - error!("The destination does not exists."); - error!("Please ensure, that the destination does exist prior mounting."); - return Ok(()); + return Err(Error::MountDestinationDoesNotExists); } let source = ArchiveProjectionSource { @@ -486,31 +518,12 @@ pub fn mount(archive: Archive, destination: &Path, options: MountOptions) -> Res }; let projection = ProjectedFileSystem::new(destination, source)?; - info!("Projection started at {}.", destination.display()); - { - info!("Press any key to stop the projection..."); - let mut stdin = io::stdin(); - let _ = stdin.read(&mut [0u8]).unwrap(); - } - - info!("Stopping projection."); - drop(projection); + let handle: Box = Box::new(WindowsMountHandle { + _projection: projection, - if options.clean { - debug!("Removing destination {}", destination.display()); - let mut attempt_count = 0; - while let Err(err) = fs::remove_dir_all(destination) { - attempt_count += 1; - if err.raw_os_error().unwrap_or_default() - != ERROR_CODE_VIRTUALIZATION_TEMPORARILY_UNAVAILABLE - || attempt_count > 5 - { - warn!("Failed to clean up projection destination: {}", err); - break; - } - std::thread::sleep(Duration::from_secs(1)); - } - } + path: destination.to_owned(), + cleanup: options.clean, + }); - Ok(()) + Ok(handle) } diff --git a/tests/mount.rs b/tests/mount.rs new file mode 100644 index 00000000..67f29432 --- /dev/null +++ b/tests/mount.rs @@ -0,0 +1,258 @@ +use std::{ + fs::{self}, + path::Path, +}; + +use conserve::{ + backup, + monitor::test::TestMonitor, + test_fixtures::{ScratchArchive, TreeFixture}, + BackupOptions, MountOptions, +}; +use tempfile::TempDir; + +fn read_dir(path: &Path) -> Vec<(bool, String)> { + fs::read_dir(path) + .unwrap() + .filter_map(|entry| entry.ok()) + .map(|entry| { + ( + entry.file_type().unwrap().is_dir(), + entry.file_name().to_string_lossy().to_string(), + ) + }) + .collect::>() +} + +#[test] +#[cfg(unix)] +fn mount_unix_not_implemented() { + use assert_matches::assert_matches; + use conserve::Error; + + let archive = ScratchArchive::new(); + let mountdir = TempDir::new().unwrap(); + + let result = conserve::mount( + archive.clone(), + mountdir.path(), + MountOptions { clean: false }, + ); + assert_matches!(result.err(), Some(Error::NotImplemented)); +} + +#[test] +fn mount_empty() { + let archive = ScratchArchive::new(); + let mountdir = TempDir::new().unwrap(); + let _projection = conserve::mount( + archive.clone(), + mountdir.path(), + MountOptions { clean: false }, + ) + .unwrap(); + + assert!(mountdir.path().is_dir()); + + /* An empty projection should not contain the "latest" folder as there is no latest */ + assert_eq!(read_dir(mountdir.path()), [(true, "all".into())]); +} + +#[test] +fn mount_sub_dirs() { + let archive = ScratchArchive::new(); + { + let srcdir = TreeFixture::new(); + + srcdir.create_dir("sub1"); + srcdir.create_dir("sub1/sub1"); + srcdir.create_file("sub1/sub1/file.txt"); + + srcdir.create_dir("sub2"); + backup( + &archive, + srcdir.path(), + &BackupOptions::default(), + TestMonitor::arc(), + ) + .unwrap(); + } + + let mountdir = TempDir::new().unwrap(); + let _projection = conserve::mount( + archive.clone(), + mountdir.path(), + MountOptions { clean: false }, + ) + .unwrap(); + + assert!(mountdir.path().is_dir()); + assert_eq!( + read_dir(&mountdir.path().join("all")), + [(true, "b0000".into())] + ); + assert_eq!( + read_dir(&mountdir.path().join("all").join("b0000")), + [(true, "sub1".into()), (true, "sub2".into())] + ); + assert_eq!( + read_dir(&mountdir.path().join("all").join("b0000").join("sub1")), + [(true, "sub1".into())] + ); + assert_eq!( + read_dir( + &mountdir + .path() + .join("all") + .join("b0000") + .join("sub1") + .join("sub1") + ), + [(false, "file.txt".into())] + ); + assert_eq!( + read_dir(&mountdir.path().join("all").join("b0000").join("sub2")), + [] + ); +} + +#[test] +fn mount_file_versions() { + let archive = ScratchArchive::new(); + { + let srcdir = TreeFixture::new(); + + srcdir.create_file_with_contents("file_v1.txt", b"Hello World"); + backup( + &archive, + srcdir.path(), + &BackupOptions::default(), + TestMonitor::arc(), + ) + .unwrap(); + + srcdir.create_file_with_contents("file_v1.txt", b"Good bye World"); + srcdir.create_file_with_contents("file_v2.txt", b"Only in V2"); + backup( + &archive, + srcdir.path(), + &BackupOptions::default(), + TestMonitor::arc(), + ) + .unwrap(); + } + + let mountdir = TempDir::new().unwrap(); + let _projection = conserve::mount( + archive.clone(), + mountdir.path(), + MountOptions { clean: false }, + ) + .unwrap(); + + assert!(mountdir.path().is_dir()); + assert_eq!( + read_dir(mountdir.path()), + [(true, "all".into()), (true, "latest".into())] + ); + + /* check that "latest" is actually the latest version (version 2) */ + assert_eq!( + read_dir(&mountdir.path().join("latest")), + [(false, "file_v1.txt".into()), (false, "file_v2.txt".into())] + ); + assert_eq!( + fs::read(mountdir.path().join("latest").join("file_v1.txt")).unwrap(), + b"Good bye World" + ); + + /* check if the versions can be properly listed and accessed by "all" */ + assert_eq!( + read_dir(&mountdir.path().join("all")), + [(true, "b0000".into()), (true, "b0001".into())] + ); + + assert_eq!( + read_dir(&mountdir.path().join("all").join("b0000")), + [(false, "file_v1.txt".into())] + ); + assert_eq!( + fs::read( + mountdir + .path() + .join("all") + .join("b0000") + .join("file_v1.txt") + ) + .unwrap(), + b"Hello World" + ); + + assert_eq!( + read_dir(&mountdir.path().join("all").join("b0001")), + [(false, "file_v1.txt".into()), (false, "file_v2.txt".into())] + ); + assert_eq!( + fs::read( + mountdir + .path() + .join("all") + .join("b0001") + .join("file_v1.txt") + ) + .unwrap(), + b"Good bye World" + ); +} + +#[test] +fn mount_cleanup() { + let archive = ScratchArchive::new(); + { + let srcdir = TreeFixture::new(); + srcdir.create_file("file.txt"); + + srcdir.create_dir("sub1"); + srcdir.create_file("sub1/file.txt"); + + srcdir.create_dir("sub2"); + backup( + &archive, + srcdir.path(), + &BackupOptions::default(), + TestMonitor::arc(), + ) + .unwrap(); + } + + let mountdir = TempDir::new().unwrap(); + fs::remove_dir(mountdir.path()).unwrap(); + + let projection = conserve::mount( + archive.clone(), + mountdir.path(), + MountOptions { clean: true }, + ) + .unwrap(); + + assert!(mountdir.path().is_dir()); + + /* actually read some data which may create files in the mount dir */ + fs::read(mountdir.path().join("all").join("b0000").join("file.txt")).unwrap(); + fs::read( + mountdir + .path() + .join("all") + .join("b0000") + .join("sub1") + .join("file.txt"), + ) + .unwrap(); + assert!(read_dir(mountdir.path()).len() > 0); + + /* Mount dir should be cleaned now */ + drop(projection); + + /* the target dir should have been deleted */ + assert!(!mountdir.path().is_dir()); +}