use super::*;
use crate::fs_kind::FilesystemKind;
use crate::fs_primitive_error::PrimitiveError;
use crate::subvolume_arguments::{MigrateArguments, MigrateMode};
use crate::subvolume_error::Interrupt;
use crate::subvolume_platform::Platform;
use crate::subvolume_preflight::layout_of;
use crate::subvolume_test_support::{PlainPlatform, checkout_with_worktree, index_inode, migrate_arguments, run_command};
use std::os::unix::fs::MetadataExt;
use crate::test_support::{git_text, remove};
use std::cell::RefCell;
use std::fs::File;
const REFUSED: i32 = 1;
const DONE: i32 = 0;
const FAILED: i32 = 2;
struct Recording {
inner: PlainPlatform,
destroyed: RefCell<Vec<PathBuf>>,
destroy_fails: bool,
}
impl Platform for Recording {
fn filesystem_facts(&self, path: &Path) -> Result<(FilesystemKind, u64), PrimitiveError> {
return self.inner.filesystem_facts(path);
}
fn is_subvolume_root(&self, path: &Path) -> Result<bool, PrimitiveError> {
return self.inner.is_subvolume_root(path);
}
fn create_subvolume(&self, path: &Path) -> Result<(), PrimitiveError> {
return self.inner.create_subvolume(path);
}
fn destroy_subvolume(&self, path: &Path) -> Result<(), PrimitiveError> {
self.destroyed.borrow_mut().push(path.to_path_buf());
if self.destroy_fails {
return Err(PrimitiveError { operation: "BTRFS_IOC_SNAP_DESTROY", path: path.to_path_buf(), errno: libc::EPERM });
}
return self.inner.destroy_subvolume(path);
}
fn removal_allowed(&self, path: &Path) -> bool {
return self.inner.removal_allowed(path);
}
fn is_mount_point(&self, path: &Path) -> bool {
return self.inner.is_mount_point(path);
}
fn clone_file(&self, source: &File, destination: &File, path: &Path) -> Result<(), PrimitiveError> {
return self.inner.clone_file(source, destination, path);
}
fn exchange(&self, first: &Path, second: &Path) -> Result<(), PrimitiveError> {
return self.inner.exchange(first, second);
}
}
struct KillAfterExchange {
inner: PlainPlatform,
kill_on: u32,
calls: std::cell::Cell<u32>,
}
impl Platform for KillAfterExchange {
fn filesystem_facts(&self, path: &Path) -> Result<(FilesystemKind, u64), PrimitiveError> {
return self.inner.filesystem_facts(path);
}
fn is_subvolume_root(&self, path: &Path) -> Result<bool, PrimitiveError> {
return self.inner.is_subvolume_root(path);
}
fn create_subvolume(&self, path: &Path) -> Result<(), PrimitiveError> {
return self.inner.create_subvolume(path);
}
fn destroy_subvolume(&self, path: &Path) -> Result<(), PrimitiveError> {
return self.inner.destroy_subvolume(path);
}
fn removal_allowed(&self, path: &Path) -> bool {
return self.inner.removal_allowed(path);
}
fn is_mount_point(&self, path: &Path) -> bool {
return self.inner.is_mount_point(path);
}
fn clone_file(&self, source: &File, destination: &File, path: &Path) -> Result<(), PrimitiveError> {
return self.inner.clone_file(source, destination, path);
}
fn exchange(&self, first: &Path, second: &Path) -> Result<(), PrimitiveError> {
self.calls.set(self.calls.get() + 1);
self.inner.exchange(first, second)?;
if self.calls.get() == self.kill_on {
return Err(PrimitiveError { operation: "renameat2(RENAME_EXCHANGE)", path: first.to_path_buf(), errno: libc::EINTR });
}
return Ok(());
}
}
fn recording(destroy_fails: bool) -> Recording {
return Recording { inner: PlainPlatform::new(), destroyed: RefCell::new(Vec::new()), destroy_fails };
}
fn rollback_arguments() -> MigrateArguments {
return MigrateArguments { mode: MigrateMode::Rollback, ..migrate_arguments() };
}
fn remove_old_arguments(discard_unreadable: bool) -> MigrateArguments {
return MigrateArguments { mode: MigrateMode::RemoveOld, discard_unreadable, ..migrate_arguments() };
}
fn running_as_root() -> bool {
return unsafe { libc::geteuid() } == 0;
}
fn migrated(name: &str, platform: &dyn Platform) -> (PathBuf, PathBuf) {
let (parent, root) = checkout_with_worktree(name);
let (code, lines) = run_command(platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
return (parent, root);
}
fn assert_original_again(platform: &dyn Platform, root: &Path) {
let layout = layout_of(root).expect("layout");
assert_eq!(platform.is_subvolume_root(root), Ok(false));
assert!(root.join("README.md").exists() && root.join("wt/README.md").exists());
for path in ["README.md", "src/main.rs"] {
assert_eq!(index_inode(root, path), std::fs::metadata(root.join(path)).expect("file").ino(), "{path}: the restored index describes the restored files");
}
assert_eq!(git_text(root, &["status", "--porcelain"]), "");
assert!(!layout.new_path.exists() && !layout.old_path.exists() && !layout.journal_path.exists() && !layout.unreadable_path.exists());
assert!(!root.join(".git/index.lock").exists());
}
#[test]
fn a_finished_migration_is_rolled_back_with_newer_files_and_nested_subvolumes_deepest_first() {
let platform: Recording = recording(false);
let (parent, root) = migrated("rollback-finished", &platform);
std::fs::write(root.join("ignored/written-after.txt"), b"after the migration\n").expect("new file");
let (code, lines) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
assert_original_again(&platform, root.as_path());
assert_eq!(std::fs::read(root.join("ignored/written-after.txt")).expect("carried back"), b"after the migration\n");
let layout = layout_of(root.as_path()).expect("layout");
assert_eq!(*platform.destroyed.borrow(), vec![layout.new_path.join("wt"), layout.new_path.clone()]);
let text: String = lines.join("\n");
assert!(text.contains("carry back") && text.contains("rolled back") && text.contains("nested subvolumes first"), "{text}");
remove(parent.as_path());
}
#[test]
fn the_removal_falls_back_to_emptying_when_the_destroy_call_is_not_available() {
let mut refused: PlainPlatform = PlainPlatform::new();
refused.removal = false;
let (parent, root) = migrated("rollback-no-option", &refused);
let (code, lines) = run_command(&refused, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
assert_original_again(&refused, root.as_path());
remove(parent.as_path());
let failing: Recording = recording(true);
let (parent2, root2) = migrated("rollback-destroy-fails", &failing);
let (code2, lines2) = run_command(&failing, &rollback_arguments(), root2.as_path(), &Interrupt::never());
assert_eq!(code2, DONE, "{lines2:#?}");
assert_original_again(&failing, root2.as_path());
assert_eq!(failing.destroyed.borrow().len(), 2, "the call was tried for both subvolumes before the fallback");
remove(parent2.as_path());
}
#[test]
fn a_rollback_stopped_at_each_step_is_finished_by_the_next_run() {
for point in ["rollback-before-exchange", "rollback-after-exchange"] {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated(format!("rollback-resume-{point}").as_str(), &platform);
let (code, lines) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::at(point, 0));
assert_eq!(code, FAILED, "{point}: {lines:#?}");
let (code2, lines2) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code2, DONE, "{point}: {lines2:#?}");
assert_original_again(&platform, root.as_path());
remove(parent.as_path());
}
}
#[test]
fn a_rollback_killed_right_after_its_exchange_does_not_exchange_again() {
let platform: KillAfterExchange = KillAfterExchange { inner: PlainPlatform::new(), kill_on: 2, calls: std::cell::Cell::new(0) };
let (parent, root) = migrated("rollback-kill-after-swap", &platform);
let (code, lines) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, FAILED, "{lines:#?}");
let (code2, lines2) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code2, DONE, "{lines2:#?}");
assert_eq!(platform.calls.get(), 2, "the second run did not exchange again");
assert_original_again(&platform, root.as_path());
remove(parent.as_path());
}
#[test]
fn a_rollback_killed_after_the_rename_before_the_record_is_finished() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated("rollback-kill-after-rename", &platform);
let (code, lines) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::at("rollback-after-rename", 0));
assert_eq!(code, FAILED, "{lines:#?}");
let (code2, lines2) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code2, DONE, "{lines2:#?}");
assert_original_again(&platform, root.as_path());
remove(parent.as_path());
}
#[test]
fn a_migration_is_refused_while_a_rollback_is_half_done() {
let platform: KillAfterExchange = KillAfterExchange { inner: PlainPlatform::new(), kill_on: 2, calls: std::cell::Cell::new(0) };
let (parent, root) = migrated("rollback-half-then-migrate", &platform);
let (code, lines) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, FAILED, "{lines:#?}");
let (code2, lines2) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code2, REFUSED, "{lines2:#?}");
assert!(lines2.join("\n").contains("--rollback"), "{lines2:#?}");
let (code3, lines3) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code3, DONE, "{lines3:#?}");
assert_original_again(&platform, root.as_path());
remove(parent.as_path());
}
#[test]
fn a_migration_stopped_before_the_exchange_is_rolled_back_from_every_earlier_stop() {
for point in ["after-create", "after-nested", "copy-entry", "after-copy", "after-delta1", "after-lock", "before-exchange"] {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = checkout_with_worktree(format!("rollback-early-{point}").as_str());
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::at(point, 0));
assert_eq!(code, FAILED, "{point}: {lines:#?}");
let (code2, lines2) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code2, DONE, "{point}: {lines2:#?}");
assert_original_again(&platform, root.as_path());
remove(parent.as_path());
}
}
#[test]
fn a_migration_stopped_between_the_exchange_and_the_rename_is_rolled_back() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = checkout_with_worktree("rollback-mid-exchange");
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::at("after-exchange", 0));
assert_eq!(code, FAILED, "{lines:#?}");
let (code2, lines2) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code2, DONE, "{lines2:#?}");
assert_original_again(&platform, root.as_path());
remove(parent.as_path());
}
#[test]
fn a_rollback_without_a_journal_is_refused() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = checkout_with_worktree("rollback-no-journal");
let (code, lines) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, REFUSED, "{lines:#?}");
let text: String = lines.join("\n");
assert!(text.contains("no migration journal") && text.contains("Nothing was changed."), "{text}");
assert!(root.join("README.md").exists());
remove(parent.as_path());
}
#[test]
fn a_rollback_whose_old_tree_is_missing_is_refused() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated("rollback-old-missing", &platform);
let layout = layout_of(root.as_path()).expect("layout");
std::fs::remove_dir_all(layout.old_path.as_path()).expect("delete the old tree by hand");
let (code, lines) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, REFUSED, "{lines:#?}");
let text: String = lines.join("\n");
assert!(text.contains("old tree") && text.contains("is missing") && text.contains("move it back"), "{text}");
assert_eq!(platform.is_subvolume_root(root.as_path()), Ok(true));
assert!(layout.journal_path.exists());
remove(parent.as_path());
}
#[test]
fn the_dry_run_of_the_other_modes_changes_nothing() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated("rollback-dry", &platform);
let layout = layout_of(root.as_path()).expect("layout");
for (arguments, words) in [
(MigrateArguments { dry_run: true, ..rollback_arguments() }, "restore the original directory"),
(MigrateArguments { dry_run: true, ..remove_old_arguments(false) }, "delete the original tree"),
] {
let (code, lines) = run_command(&platform, &arguments, root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
assert!(lines.join("\n").contains(words), "{lines:#?}");
assert!(layout.old_path.exists() && layout.journal_path.exists());
}
remove(parent.as_path());
}
#[test]
fn the_old_tree_is_deleted_after_a_finished_migration() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated("remove-old-finished", &platform);
let layout = layout_of(root.as_path()).expect("layout");
let (code, lines) = run_command(&platform, &remove_old_arguments(false), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
assert!(!layout.old_path.exists() && !layout.journal_path.exists());
assert_eq!(platform.is_subvolume_root(root.as_path()), Ok(true));
assert_eq!(git_text(root.as_path(), &["status", "--porcelain"]), "");
assert!(lines.join("\n").contains("the old tree is deleted"));
let (code2, lines2) = run_command(&platform, &remove_old_arguments(false), root.as_path(), &Interrupt::never());
assert_eq!(code2, REFUSED, "a second deletion has nothing to delete: {lines2:#?}");
remove(parent.as_path());
}
#[test]
fn the_old_tree_of_an_unfinished_migration_is_not_deleted() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = checkout_with_worktree("remove-old-unfinished");
let (stopped, _stopped_lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::at("after-indexes", 0));
assert_eq!(stopped, FAILED);
let layout = layout_of(root.as_path()).expect("layout");
let (code, lines) = run_command(&platform, &remove_old_arguments(false), root.as_path(), &Interrupt::never());
assert_eq!(code, REFUSED, "{lines:#?}");
assert!(lines.join("\n").contains("not recorded as finished") && layout.old_path.exists());
remove(parent.as_path());
}
#[test]
fn unreadable_files_hold_back_the_deletion_unless_discarded() {
if running_as_root() {
return;
}
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = checkout_with_worktree("remove-old-unreadable");
std::fs::write(root.join("ignored/secret.log"), b"secret").expect("log");
std::fs::set_permissions(root.join("ignored/secret.log"), std::os::unix::fs::PermissionsExt::from_mode(0o000)).expect("unreadable");
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
let layout = layout_of(root.as_path()).expect("layout");
let (code2, lines2) = run_command(&platform, &remove_old_arguments(false), root.as_path(), &Interrupt::never());
assert_eq!(code2, REFUSED, "{lines2:#?}");
let text: String = lines2.join("\n");
assert!(text.contains("1 file(s) that this user may not read") && text.contains("sudo cp --archive") && text.contains("--discard-unreadable"), "{text}");
assert!(layout.old_path.join("ignored/secret.log").exists() && layout.unreadable_path.exists());
let (code3, lines3) = run_command(&platform, &remove_old_arguments(true), root.as_path(), &Interrupt::never());
assert_eq!(code3, DONE, "{lines3:#?}");
assert!(!layout.old_path.exists() && !layout.unreadable_path.exists() && !layout.journal_path.exists());
remove(parent.as_path());
}
#[test]
fn a_rollback_does_not_destroy_an_unreadable_file_that_only_the_new_subvolume_holds() {
if running_as_root() {
return;
}
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated("rollback-unreadable-new", &platform);
let secret: PathBuf = root.join("ignored/new-secret.log");
std::fs::write(secret.as_path(), b"only here").expect("log");
std::fs::set_permissions(secret.as_path(), std::os::unix::fs::PermissionsExt::from_mode(0o000)).expect("unreadable");
let layout = layout_of(root.as_path()).expect("layout");
let (code, lines) = run_command(&platform, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, REFUSED, "{lines:#?}");
let text: String = lines.join("\n");
assert!(text.contains("exist only in the new subvolume") && text.contains("new-secret.log") && text.contains("--discard-unreadable"), "{text}");
assert!(secret.exists() && platform.is_subvolume_root(root.as_path()) == Ok(true), "the checkout is still the new subvolume");
assert!(!layout.unreadable_path.exists(), "the new root's unreadable files do not enter the old tree's list");
let arguments: MigrateArguments = MigrateArguments { discard_unreadable: true, ..rollback_arguments() };
let (code2, lines2) = run_command(&platform, &arguments, root.as_path(), &Interrupt::never());
assert_eq!(code2, DONE, "{lines2:#?}");
assert_original_again(&platform, root.as_path());
remove(parent.as_path());
}
#[test]
fn an_unreadable_manifest_stops_the_deletion_instead_of_counting_as_empty() {
if running_as_root() {
return;
}
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = checkout_with_worktree("remove-old-manifest-unreadable");
std::fs::write(root.join("ignored/secret.log"), b"secret").expect("log");
std::fs::set_permissions(root.join("ignored/secret.log"), std::os::unix::fs::PermissionsExt::from_mode(0o000)).expect("unreadable");
let (code, lines) = run_command(&platform, &migrate_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
let layout = layout_of(root.as_path()).expect("layout");
std::fs::set_permissions(layout.unreadable_path.as_path(), std::os::unix::fs::PermissionsExt::from_mode(0o000)).expect("hide the manifest");
let (code2, lines2) = run_command(&platform, &remove_old_arguments(false), root.as_path(), &Interrupt::never());
std::fs::set_permissions(layout.unreadable_path.as_path(), std::os::unix::fs::PermissionsExt::from_mode(0o644)).expect("restore");
assert_eq!(code2, FAILED, "{lines2:#?}");
let text: String = lines2.join("\n");
assert!(text.contains("unreadable.txt"), "the message names the manifest: {text}");
assert!(layout.old_path.join("ignored/secret.log").exists() && layout.journal_path.exists());
remove(parent.as_path());
}
fn set_seconds(path: &Path, seconds: i64) {
let times: [libc::timespec; 2] = [libc::timespec { tv_sec: seconds, tv_nsec: 0 }, libc::timespec { tv_sec: seconds, tv_nsec: 0 }];
let name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).expect("path text");
let result: libc::c_int = unsafe { libc::utimensat(libc::AT_FDCWD, name.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW) };
assert_eq!(result, 0, "set the time of {}", path.display());
}
#[test]
fn writes_to_the_old_tree_after_the_catch_up_are_carried_before_it_is_deleted() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated("remove-old-late-writes", &platform);
let layout = layout_of(root.as_path()).expect("layout");
std::fs::write(layout.old_path.join("README.md"), b"saved by a late writer\n").expect("late change");
std::fs::write(layout.old_path.join("ignored/late.txt"), b"untracked work").expect("late file");
std::fs::remove_file(root.join("src/main.rs")).expect("deleted on purpose");
set_seconds(layout.old_path.join("src/main.rs").as_path(), 1_000_000_000);
let (code, lines) = run_command(&platform, &remove_old_arguments(false), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
assert_eq!(std::fs::read(root.join("README.md")).expect("carried"), b"saved by a late writer\n");
assert_eq!(std::fs::read(root.join("ignored/late.txt")).expect("carried"), b"untracked work");
assert!(!root.join("src/main.rs").exists(), "a file deleted on purpose is not brought back");
let text: String = lines.join("\n");
assert!(text.contains("carried README.md") && text.contains("carried ignored/late.txt"), "{text}");
assert!(!layout.old_path.exists());
remove(parent.as_path());
}
#[test]
fn an_old_tree_path_that_is_a_symbolic_link_is_refused() {
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated("remove-old-symlink", &platform);
let layout = layout_of(root.as_path()).expect("layout");
let elsewhere: PathBuf = parent.join("elsewhere");
std::fs::rename(layout.old_path.as_path(), elsewhere.as_path()).expect("move the old tree away");
std::os::unix::fs::symlink(elsewhere.as_path(), layout.old_path.as_path()).expect("link in its place");
std::fs::write(elsewhere.join("ignored/newer.txt"), b"fresh").expect("a file the carry would copy if it followed the link");
let (code, lines) = run_command(&platform, &remove_old_arguments(false), root.as_path(), &Interrupt::never());
assert_eq!(code, REFUSED, "{lines:#?}");
assert!(lines.join("\n").contains("is a symbolic link") && elsewhere.join("README.md").exists());
assert!(!root.join("ignored/newer.txt").exists(), "nothing is carried from where the link points");
remove(parent.as_path());
}
#[test]
fn a_deletion_that_fails_is_reported_and_keeps_the_journal() {
if running_as_root() {
return;
}
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated("remove-old-fails", &platform);
let layout = layout_of(root.as_path()).expect("layout");
let mut lines: Vec<String> = Vec::new();
let code: i32 = crate::subvolume_command::execute(&platform, &remove_old_arguments(false), root.as_path(), &Interrupt::never(), &mut |text: &str| {
lines.push(text.to_string());
if text.starts_with("deleting ") {
std::fs::set_permissions(parent.as_path(), std::os::unix::fs::PermissionsExt::from_mode(0o500)).expect("parent not writable");
}
});
std::fs::set_permissions(parent.as_path(), std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("restore");
assert_eq!(code, FAILED, "{lines:#?}");
let text: String = lines.join("\n");
assert!(text.contains("cannot delete") && text.contains("Run the command again"), "{text}");
assert!(layout.journal_path.exists());
remove(parent.as_path());
}
#[test]
fn the_old_tree_with_read_only_directories_is_deleted() {
if running_as_root() {
return;
}
let platform: PlainPlatform = PlainPlatform::new();
let (parent, root) = migrated("remove-old-read-only", &platform);
let layout = layout_of(root.as_path()).expect("layout");
for path in ["ignored/closed", "ignored/closed/inner", "ignored/node_modules"] {
let directory: PathBuf = layout.old_path.join(path);
std::fs::create_dir_all(directory.as_path()).expect("directory");
std::fs::write(directory.join("file"), b"x").ok();
}
for (path, mode) in [("ignored/closed/inner", 0o500), ("ignored/closed", 0o555), ("ignored/node_modules", 0o500)] {
std::fs::set_permissions(layout.old_path.join(path), std::os::unix::fs::PermissionsExt::from_mode(mode)).expect("mode");
}
let (code, lines) = run_command(&platform, &remove_old_arguments(false), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
assert!(!layout.old_path.exists() && !layout.journal_path.exists());
for path in ["ignored/closed/inner", "ignored/closed", "ignored/node_modules"] {
std::fs::set_permissions(root.join(path), std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("restore");
}
remove(parent.as_path());
}
#[test]
fn a_rollback_empties_a_subvolume_that_holds_read_only_directories() {
if running_as_root() {
return;
}
let mut refused: PlainPlatform = PlainPlatform::new();
refused.removal = false;
let (parent, root) = migrated("rollback-read-only", &refused);
std::fs::create_dir_all(root.join("ignored/closed/inner")).expect("directories");
std::fs::write(root.join("ignored/closed/inner/file"), b"x").expect("file");
for (path, mode) in [("ignored/closed/inner", 0o500), ("ignored/closed", 0o555)] {
std::fs::set_permissions(root.join(path), std::os::unix::fs::PermissionsExt::from_mode(mode)).expect("mode");
}
let (code, lines) = run_command(&refused, &rollback_arguments(), root.as_path(), &Interrupt::never());
assert_eq!(code, DONE, "{lines:#?}");
assert_eq!(refused.is_subvolume_root(root.as_path()), Ok(false));
for path in ["ignored/closed", "ignored/closed/inner"] {
std::fs::set_permissions(root.join(path), std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("restore");
}
remove(parent.as_path());
}
#[test]
fn subvolumes_nested_several_levels_deep_are_destroyed_deepest_first() {
let platform: Recording = recording(false);
let parent: PathBuf = crate::test_support::fixture("rollback-deep-nesting");
let top: PathBuf = parent.join("top");
platform.create_subvolume(top.as_path()).expect("top");
platform.create_subvolume(top.join("a").as_path()).expect("a");
platform.create_subvolume(top.join("a/b").as_path()).expect("b");
std::fs::create_dir_all(top.join("a/b/plain")).expect("an ordinary directory");
platform.create_subvolume(top.join("a/b/plain/c").as_path()).expect("c");
platform.create_subvolume(top.join("z").as_path()).expect("z");
let layout = layout_of(parent.join("checkout").as_path()).expect("layout");
let mut say = |_text: &str| {};
let never: Interrupt = Interrupt::never();
let mut run = crate::subvolume_run::Run::open(&platform, &layout, &never, &mut say).expect("run");
remove_subvolume_tree(&mut run, top.as_path()).expect("removal");
let destroyed: Vec<PathBuf> = platform.destroyed.borrow().clone();
assert_eq!(destroyed.len(), 5, "{destroyed:?}");
assert_eq!(destroyed.last(), Some(&top));
let position = |path: PathBuf| -> usize { return destroyed.iter().position(|candidate: &PathBuf| { return *candidate == path; }).expect("destroyed"); };
assert!(position(top.join("a/b/plain/c")) < position(top.join("a/b")), "{destroyed:?}");
assert!(position(top.join("a/b")) < position(top.join("a")), "{destroyed:?}");
assert!(!top.exists());
remove(parent.as_path());
}