#![cfg(test)] use std::{ fs, os::unix::fs::symlink, path::{Path, PathBuf}, process::{Command, Output}, }; use tempfile::{TempDir, tempdir}; struct Fixture { dir: TempDir, config: PathBuf, } impl Fixture { fn new(script: &str) -> Self { let dir = tempdir().unwrap(); // Only the child process sees this PATH. The real zfs cannot be found. symlink( Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"), dir.path().join("zfs"), ) .unwrap(); // The executable is a checked-in fixture; scenario files are read by // its shell, so parallel tests never execute a file being written. fs::write(dir.path().join("scenario"), script).unwrap(); let config = dir.path().join("config.toml"); fs::write( &config, "[default]\n15_min = 1\nhourly = 0\ndaily = 0\nmonthly = 0\n", ) .unwrap(); Self { dir, config } } fn calls(&self) -> Vec { fs::read_to_string(self.dir.path().join("calls")) .unwrap_or_default() .lines() .map(String::from) .collect() } fn normalized_calls(&self) -> Vec { self.calls() .into_iter() .map(|call| { if call.starts_with("snapshot ") { let (dataset, name) = call.split_once('@').unwrap(); assert!(name.starts_with("auto_"), "{call}"); format!("{dataset}@") } else { call } }) .collect() } fn run(&self) -> Output { Command::new(env!("CARGO_BIN_EXE_snapshot-manager")) .current_dir(self.dir.path()) .env("PATH", self.dir.path()) .env("SNAPSHOT_TEST_SCRIPT", self.dir.path().join("scenario")) .env_remove("SIGNAL_ALERTS") .env_remove("SIGNAL_ALERT_FROM_PHONE") .env_remove("SIGNAL_ALERT_TO_PHONE") .env_remove("RUST_LOG") .arg(&self.config) .output() .unwrap() } } fn stderr(output: &Output) -> &str { std::str::from_utf8(&output.stderr).unwrap() } #[test] fn creates_then_prunes_only_this_datasets_managed_snapshots() { let fixture = Fixture::new( r#" case "$*" in 'list -Hp -t filesystem -o name') printf '%s\n' pool pool/home printf '%s\n' 'warning on stderr' >&2 ;; 'snapshot pool/home@auto_'*) ;; 'list -Hp -t snapshot -o name -d 1 pool/home') printf '%s\n' pool/home@auto_200002020145 pool/home@auto_200002020115 pool/home@manual pool/home@manual_auto_200001010015 pool/home/child@auto_200001010015 pool/other@auto_200001010015 ;; 'destroy pool/home@auto_200002020115') ;; *) exit 99 ;; esac "#, ); let output = fixture.run(); assert!(output.status.success(), "{}", stderr(&output)); assert_eq!( fixture.normalized_calls(), [ "list -Hp -t filesystem -o name", "snapshot pool/home@", "list -Hp -t snapshot -o name -d 1 pool/home", "destroy pool/home@auto_200002020115", ] ); } #[test] fn failed_or_duplicate_creation_skips_pruning_but_continues() { for error in ["snapshot already exists", "permission denied"] { let fixture = Fixture::new(&format!( r#" case "$*" in 'list -Hp -t filesystem -o name') printf '%s\n' pool/home pool/other ;; 'snapshot pool/home@auto_'*) printf '%s\n' '{error}' >&2; exit 1 ;; 'snapshot pool/other@auto_'*) ;; 'list -Hp -t snapshot -o name -d 1 pool/other') ;; *) exit 99 ;; esac "# )); let output = fixture.run(); assert!(output.status.success(), "{}", stderr(&output)); assert!(stderr(&output).contains("pool/home failed to create snapshot auto_")); assert!(stderr(&output).contains(error)); assert_eq!( fixture.normalized_calls(), [ "list -Hp -t filesystem -o name", "snapshot pool/home@", "snapshot pool/other@", "list -Hp -t snapshot -o name -d 1 pool/other", ] ); let calls = fixture.calls(); assert_eq!( calls[1].split_once('@').unwrap().1, calls[2].split_once('@').unwrap().1 ); } } #[test] fn dependent_clone_failure_is_reported_and_remaining_deletions_continue() { let fixture = Fixture::new( r#" case "$*" in 'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;; 'snapshot pool/home@auto_'*) ;; 'list -Hp -t snapshot -o name -d 1 pool/home') printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145 ;; 'destroy pool/home@auto_200002020115') printf '%s\n' "cannot destroy 'pool/home@auto_200002020115': snapshot has dependent clones" >&2; exit 1 ;; 'destroy pool/home@auto_200002020130') ;; *) exit 99 ;; esac "#, ); let output = fixture.run(); assert!(output.status.success(), "{}", stderr(&output)); assert!( stderr(&output).contains( "pool/home@auto_200002020115 failed to delete: snapshot has dependent clones" ) ); assert_eq!( fixture.calls().last().unwrap(), "destroy pool/home@auto_200002020130" ); } #[test] fn unexpected_destroy_failure_aborts_and_reports_the_error() { let fixture = Fixture::new( r#" case "$1" in list) if [ "$4" = filesystem ]; then printf '%s\n' pool/home else printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145 fi ;; snapshot) ;; destroy) printf '%s\n' 'permission denied' >&2; exit 1 ;; esac "#, ); let output = fixture.run(); assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); assert!(stderr(&output).contains("permission denied")); assert!(stderr(&output).contains("snapshot_manager failed")); assert_eq!( fixture.calls().last().unwrap(), "destroy pool/home@auto_200002020115" ); } #[test] fn failed_list_never_uses_partial_output() { for stream in ["", " >&2"] { let fixture = Fixture::new(&format!( "printf '%s\\n' pool/home\nprintf '%s\\n' 'cannot list datasets'{stream}\nexit 1" )); let output = fixture.run(); assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); assert!(stderr(&output).contains("cannot list datasets")); assert!(stderr(&output).contains("snapshot_manager failed")); assert_eq!(fixture.calls(), ["list -Hp -t filesystem -o name"]); } } #[test] fn failed_snapshot_listing_aborts_without_destroying_anything() { let fixture = Fixture::new( r#" case "$*" in 'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;; 'snapshot pool/home@auto_'*) ;; *) printf '%s\n' pool/home@auto_200002020115; exit 1 ;; esac "#, ); let output = fixture.run(); assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); assert!(stderr(&output).contains("snapshot_manager failed")); assert_eq!(fixture.calls().len(), 3); } #[test] fn invalid_config_stops_before_any_zfs_commands() { let fixture = Fixture::new("exit 99"); fs::write( &fixture.config, "[default]\n15_min = -1\nhourly = 1\ndaily = 0\nmonthly = 0", ) .unwrap(); let output = fixture.run(); assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); assert!(stderr(&output).contains("Invalid configuration")); assert!(fixture.calls().is_empty()); } #[test] fn missing_zfs_executable_fails_and_reports_the_error() { let fixture = Fixture::new("exit 99"); fs::remove_file(fixture.dir.path().join("zfs")).unwrap(); let output = fixture.run(); assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); assert!(stderr(&output).contains("Failed to execute zfs")); assert!(stderr(&output).contains("snapshot_manager failed")); assert!(fixture.calls().is_empty()); } #[test] fn dataset_names_are_passed_as_one_argument_without_a_shell() { let fixture = Fixture::new( r#" case "$1" in list) [ "$*" = 'list -Hp -t filesystem -o name' ] || exit 98 printf '%s\n' 'pool/two words;touch injected' ;; snapshot) [ "$#" -eq 2 ] || exit 97 case "$2" in 'pool/two words;touch injected@auto_'*) printf '%s\n' 'argument preserved' >&2; exit 1 ;; *) exit 96 ;; esac ;; *) exit 99 ;; esac "#, ); let output = fixture.run(); assert!(output.status.success(), "{}", stderr(&output)); assert!(stderr(&output).contains("argument preserved")); assert_eq!( fixture.normalized_calls(), [ "list -Hp -t filesystem -o name", "snapshot pool/two words;touch injected@" ] ); assert!(fs::read_dir(fixture.dir.path()).unwrap().all(|entry| { !entry .unwrap() .file_name() .to_string_lossy() .starts_with("injected") })); }