diff --git a/src/fs.rs b/src/fs.rs index a884d82..ee9fa15 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -1121,6 +1121,179 @@ impl Filesystem for BranchFs { self.unlink(_req, parent, name, reply); } + fn rename( + &mut self, + _req: &Request, + parent: u64, + name: &OsStr, + newparent: u64, + newname: &OsStr, + flags: u32, + reply: ReplyEmpty, + ) { + if flags & libc::RENAME_EXCHANGE != 0 { + reply.error(libc::EINVAL); + return; + } + + let parent_path = match self.inodes.get_path(parent) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + }; + let newparent_path = match self.inodes.get_path(newparent) { + Some(p) => p, + None => { + reply.error(libc::ENOENT); + return; + } + }; + + let name_str = name.to_string_lossy(); + let newname_str = newname.to_string_lossy(); + + // Normalize both parents to (branch, parent_rel, inode_prefix, is_root_path). + let resolve_parent = |path: &str| -> Option<(String, String, String, bool)> { + match classify_path(path) { + PathContext::BranchDir(b) => { + Some((b.clone(), "/".into(), format!("/@{}", b), false)) + } + PathContext::BranchPath(b, rel) => { + Some((b.clone(), rel, format!("/@{}", b), false)) + } + PathContext::RootPath(rp) => Some((String::new(), rp, String::new(), true)), + _ => None, + } + }; + + let (src_branch, src_parent_rel, src_prefix, src_is_root) = + match resolve_parent(&parent_path) { + Some(t) => t, + None => { + reply.error(libc::EPERM); + return; + } + }; + let (dst_branch, dst_parent_rel, dst_prefix, dst_is_root) = + match resolve_parent(&newparent_path) { + Some(t) => t, + None => { + reply.error(libc::EPERM); + return; + } + }; + + // Both must be the same kind (both root or both same branch) + if src_is_root != dst_is_root || (!src_is_root && src_branch != dst_branch) { + reply.error(libc::EXDEV); + return; + } + + let branch = if src_is_root { + self.get_branch_name() + } else { + if !self.manager.is_branch_valid(&src_branch) { + reply.error(libc::ENOENT); + return; + } + src_branch + }; + + let join_rel = |parent_rel: &str, child: &str| -> String { + if parent_rel == "/" { + format!("/{}", child) + } else { + format!("{}/{}", parent_rel, child) + } + }; + let src_rel = join_rel(&src_parent_rel, &name_str); + let dst_rel = join_rel(&dst_parent_rel, &newname_str); + + // Check source exists + if self.resolve_for_branch(&branch, &src_rel).is_none() { + reply.error(libc::ENOENT); + return; + } + + // RENAME_NOREPLACE + if flags & libc::RENAME_NOREPLACE != 0 + && self.resolve_for_branch(&branch, &dst_rel).is_some() + { + reply.error(libc::EEXIST); + return; + } + + // COW source into delta + let src_delta = match self.ensure_cow_for_branch(&branch, &src_rel) { + Ok(p) => p, + Err(_) => { + reply.error(libc::EIO); + return; + } + }; + + let dst_delta = self.get_delta_path_for_branch(&branch, &dst_rel); + if storage::ensure_parent_dirs(&dst_delta).is_err() { + reply.error(libc::EIO); + return; + } + + // If destination already exists, remove its delta so rename can overwrite + let dst_existed = self.resolve_for_branch(&branch, &dst_rel).is_some(); + if dst_existed { + let _ = self.manager.with_branch(&branch, |b| { + let d = b.delta_path(&dst_rel); + if d.exists() { + if d.is_dir() { + let _ = std::fs::remove_dir_all(&d); + } else { + let _ = std::fs::remove_file(&d); + } + } + Ok(()) + }); + } + + // Move within the delta layer (same filesystem, always succeeds) + if std::fs::rename(&src_delta, &dst_delta).is_err() { + reply.error(libc::EIO); + return; + } + + // Update tombstones: mark src deleted, revive dst, tombstone old dst + let result = self.manager.with_branch(&branch, |b| { + b.add_tombstone(&src_rel)?; + if dst_existed { + b.add_tombstone(&dst_rel)?; + } + b.remove_tombstone(&dst_rel); + Ok(()) + }); + if result.is_err() { + reply.error(libc::EIO); + return; + } + + if src_is_root && self.is_stale() { + reply.error(libc::ESTALE); + return; + } + + // Update inode cache + let src_inode_path = format!("{}{}", src_prefix, src_rel); + let dst_inode_path = format!("{}{}", dst_prefix, dst_rel); + let is_dir = dst_delta.is_dir(); + self.inodes.remove(&src_inode_path); + self.inodes.remove(&dst_inode_path); + let new_ino = self.inodes.get_or_create(&dst_inode_path, is_dir); + self.open_cache.invalidate_ino(new_ino); + self.write_cache.invalidate_ino(new_ino); + + reply.ok(); + } + fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen) { // Control file is always openable (no epoch check) if ino == CTL_INO { diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 19ad503..d64ea94 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -96,6 +96,21 @@ else fi echo "" +# Run filesystem integration tests +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${BLUE} test_integration (Rust integration)${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +TOTAL_TESTS=$((TOTAL_TESTS + 1)) +if (cd "$PROJECT_ROOT" && cargo test --test test_integration -- --ignored 2>&1); then + PASSED_SUITES=$((PASSED_SUITES + 1)) + echo -e "${GREEN}Suite test_integration: PASSED${NC}" +else + FAILED_SUITES=$((FAILED_SUITES + 1)) + FAILED_SUITE_NAMES+=("test_integration") + echo -e "${RED}Suite test_integration: FAILED${NC}" +fi +echo "" + # Final summary echo -e "${BLUE}========================================${NC}" echo -e "${BLUE} Final Summary${NC}" diff --git a/tests/test_integration.rs b/tests/test_integration.rs new file mode 100644 index 0000000..fad8db8 --- /dev/null +++ b/tests/test_integration.rs @@ -0,0 +1,415 @@ +//! Integration tests for core filesystem operations including rename. +//! +//! These tests require FUSE support and are ignored by default. +//! Run with: cargo test --test test_integration -- --ignored + +use std::fs; +use std::os::unix::io::AsRawFd; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; + +use branchfs::{FS_IOC_BRANCH_ABORT, FS_IOC_BRANCH_COMMIT, FS_IOC_BRANCH_CREATE}; + +/// Helper: CREATE a branch. Returns the new branch name. +unsafe fn ioctl_create(fd: i32) -> Result { + let mut buf = [0u8; 128]; + let ret = libc::ioctl(fd, FS_IOC_BRANCH_CREATE as libc::c_ulong, buf.as_mut_ptr()); + if ret < 0 { + return Err(*libc::__errno_location()); + } + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + Ok(String::from_utf8_lossy(&buf[..end]).to_string()) +} + +/// Helper: COMMIT the branch identified by the ctl fd's inode. +unsafe fn ioctl_commit(fd: i32) -> i32 { + libc::ioctl(fd, FS_IOC_BRANCH_COMMIT as libc::c_ulong) +} + +/// Helper: ABORT the branch identified by the ctl fd's inode. +unsafe fn ioctl_abort(fd: i32) -> i32 { + libc::ioctl(fd, FS_IOC_BRANCH_ABORT as libc::c_ulong) +} + +struct TestFixture { + base: PathBuf, + storage: PathBuf, + mnt: PathBuf, + branchfs_bin: PathBuf, +} + +impl TestFixture { + fn new(name: &str) -> Self { + let id = std::process::id(); + let prefix = format!("/tmp/branchfs_integ_{}_{}", name, id); + let base = PathBuf::from(format!("{}_base", prefix)); + let storage = PathBuf::from(format!("{}_storage", prefix)); + let mnt = PathBuf::from(format!("{}_mnt", prefix)); + + // Clean up leftovers from a previous failed run + let _ = Command::new("fusermount3") + .args(["-u", mnt.to_str().unwrap()]) + .stderr(Stdio::null()) + .status(); + let _ = fs::remove_dir_all(&base); + let _ = fs::remove_dir_all(&storage); + let _ = fs::remove_dir_all(&mnt); + + fs::create_dir_all(&base).expect("create base dir"); + fs::create_dir_all(&storage).expect("create storage dir"); + fs::create_dir_all(&mnt).expect("create mount dir"); + + // Seed base directory + fs::write(base.join("file1.txt"), "base content\n").unwrap(); + fs::write(base.join("file2.txt"), "another file\n").unwrap(); + fs::create_dir_all(base.join("subdir")).unwrap(); + fs::write(base.join("subdir").join("nested.txt"), "nested content\n").unwrap(); + + let branchfs_bin = PathBuf::from(env!("CARGO_BIN_EXE_branchfs")); + + Self { + base, + storage, + mnt, + branchfs_bin, + } + } + + fn mount(&self) { + let status = Command::new(&self.branchfs_bin) + .args([ + "mount", + "--base", + self.base.to_str().unwrap(), + "--storage", + self.storage.to_str().unwrap(), + self.mnt.to_str().unwrap(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("failed to run branchfs mount"); + + assert!(status.success(), "mount command failed"); + thread::sleep(Duration::from_millis(500)); + assert!( + self.mnt.join(".branchfs_ctl").exists(), + ".branchfs_ctl should exist after mount" + ); + } + + fn unmount(&self) { + let _ = Command::new(&self.branchfs_bin) + .args([ + "unmount", + self.mnt.to_str().unwrap(), + "--storage", + self.storage.to_str().unwrap(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + thread::sleep(Duration::from_millis(300)); + } + + fn open_ctl(&self) -> fs::File { + fs::OpenOptions::new() + .read(true) + .write(true) + .open(self.mnt.join(".branchfs_ctl")) + .expect("open .branchfs_ctl") + } + + fn open_branch_ctl(&self, branch: &str) -> fs::File { + let path = self.mnt.join(format!("@{}", branch)).join(".branchfs_ctl"); + fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap_or_else(|e| panic!("open branch ctl for '{}' at {:?}: {}", branch, path, e)) + } + + fn branch_dir(&self, branch: &str) -> PathBuf { + self.mnt.join(format!("@{}", branch)) + } +} + +impl Drop for TestFixture { + fn drop(&mut self) { + self.unmount(); + let _ = fs::remove_dir_all(&self.base); + let _ = fs::remove_dir_all(&self.storage); + let _ = fs::remove_dir_all(&self.mnt); + } +} + +// ── Rename tests ──────────────────────────────────────────────────── + +#[test] +#[ignore] +fn test_rename_file_same_dir() { + let fix = TestFixture::new("rename_same"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + // Rename a base file within the same directory + fs::rename(bdir.join("file1.txt"), bdir.join("file1_renamed.txt")).expect("rename should work"); + + assert!(!bdir.join("file1.txt").exists(), "old name should be gone"); + assert!( + bdir.join("file1_renamed.txt").exists(), + "new name should exist" + ); + assert_eq!( + fs::read_to_string(bdir.join("file1_renamed.txt")).unwrap(), + "base content\n" + ); + + // Base should be untouched + assert!(fix.base.join("file1.txt").exists()); +} + +#[test] +#[ignore] +fn test_rename_file_cross_dir() { + let fix = TestFixture::new("rename_xdir"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + // Move file into a subdirectory + fs::rename( + bdir.join("file1.txt"), + bdir.join("subdir").join("moved.txt"), + ) + .expect("cross-dir rename"); + + assert!(!bdir.join("file1.txt").exists()); + assert!(bdir.join("subdir").join("moved.txt").exists()); + assert_eq!( + fs::read_to_string(bdir.join("subdir").join("moved.txt")).unwrap(), + "base content\n" + ); +} + +#[test] +#[ignore] +fn test_rename_overwrite_existing() { + let fix = TestFixture::new("rename_over"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + // Create a new file, then rename it to overwrite file2.txt + fs::write(bdir.join("new.txt"), "new content\n").unwrap(); + fs::rename(bdir.join("new.txt"), bdir.join("file2.txt")).expect("rename overwrite"); + + assert!(!bdir.join("new.txt").exists()); + assert_eq!( + fs::read_to_string(bdir.join("file2.txt")).unwrap(), + "new content\n" + ); +} + +#[test] +#[ignore] +fn test_rename_in_branch_then_commit() { + let fix = TestFixture::new("rename_commit"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + fs::rename(bdir.join("file1.txt"), bdir.join("renamed.txt")).expect("rename"); + + // Commit + let bctl = fix.open_branch_ctl(&branch); + let ret = unsafe { ioctl_commit(bctl.as_raw_fd()) }; + assert_eq!(ret, 0, "commit should succeed"); + + // Base should reflect the rename + assert!( + fix.base.join("renamed.txt").exists(), + "renamed file should be in base" + ); + assert_eq!( + fs::read_to_string(fix.base.join("renamed.txt")).unwrap(), + "base content\n" + ); + // The original should be gone from base (tombstone applied) + assert!( + !fix.base.join("file1.txt").exists(), + "original should be deleted from base after commit" + ); +} + +#[test] +#[ignore] +fn test_rename_in_branch_then_abort() { + let fix = TestFixture::new("rename_abort"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + fs::rename(bdir.join("file1.txt"), bdir.join("renamed.txt")).expect("rename"); + + // Abort + let bctl = fix.open_branch_ctl(&branch); + let ret = unsafe { ioctl_abort(bctl.as_raw_fd()) }; + assert_eq!(ret, 0, "abort should succeed"); + + // Base should be unchanged + assert!(fix.base.join("file1.txt").exists()); + assert!(!fix.base.join("renamed.txt").exists()); +} + +#[test] +#[ignore] +fn test_rename_nonexistent_fails() { + let fix = TestFixture::new("rename_noent"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + let result = fs::rename(bdir.join("no_such_file.txt"), bdir.join("dest.txt")); + assert!(result.is_err(), "rename of nonexistent file should fail"); +} + +// ── General filesystem integration tests ──────────────────────────── + +#[test] +#[ignore] +fn test_create_read_write_delete() { + let fix = TestFixture::new("crud"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + // Create + fs::write(bdir.join("crud.txt"), "hello\n").unwrap(); + assert!(bdir.join("crud.txt").exists()); + + // Read + assert_eq!( + fs::read_to_string(bdir.join("crud.txt")).unwrap(), + "hello\n" + ); + + // Update (overwrite) + fs::write(bdir.join("crud.txt"), "updated\n").unwrap(); + assert_eq!( + fs::read_to_string(bdir.join("crud.txt")).unwrap(), + "updated\n" + ); + + // Delete + fs::remove_file(bdir.join("crud.txt")).unwrap(); + assert!(!bdir.join("crud.txt").exists()); +} + +#[test] +#[ignore] +fn test_branch_isolation() { + let fix = TestFixture::new("isolation"); + fix.mount(); + let ctl = fix.open_ctl(); + + // Create two sibling branches from main + let branch_a = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE A"); + let branch_b = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE B"); + + let dir_a = fix.branch_dir(&branch_a); + let dir_b = fix.branch_dir(&branch_b); + + // Write different files in each branch + fs::write(dir_a.join("only_a.txt"), "A\n").unwrap(); + fs::write(dir_b.join("only_b.txt"), "B\n").unwrap(); + + // Each branch should only see its own file, not the sibling's + assert!(dir_a.join("only_a.txt").exists()); + assert!(!dir_a.join("only_b.txt").exists()); + + assert!(dir_b.join("only_b.txt").exists()); + assert!(!dir_b.join("only_a.txt").exists()); +} + +#[test] +#[ignore] +fn test_nested_branch_inheritance() { + let fix = TestFixture::new("inherit"); + fix.mount(); + let ctl = fix.open_ctl(); + + // Create parent branch, write a file + let parent = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE parent"); + let pdir = fix.branch_dir(&parent); + fs::write(pdir.join("parent_file.txt"), "from parent\n").unwrap(); + + // Create child branch from parent + let pctl = fix.open_branch_ctl(&parent); + let child = unsafe { ioctl_create(pctl.as_raw_fd()) }.expect("CREATE child"); + let cdir = fix.branch_dir(&child); + + // Child should see parent's file and base files + assert!( + cdir.join("parent_file.txt").exists(), + "child should inherit parent's file" + ); + assert_eq!( + fs::read_to_string(cdir.join("parent_file.txt")).unwrap(), + "from parent\n" + ); + assert!( + cdir.join("file1.txt").exists(), + "child should see base files" + ); +} + +#[test] +#[ignore] +fn test_mkdir_and_nested_files() { + let fix = TestFixture::new("mkdir_nested"); + fix.mount(); + let ctl = fix.open_ctl(); + + let branch = unsafe { ioctl_create(ctl.as_raw_fd()) }.expect("CREATE"); + let bdir = fix.branch_dir(&branch); + + // Create nested directory structure + fs::create_dir_all(bdir.join("a").join("b").join("c")).unwrap(); + fs::write( + bdir.join("a").join("b").join("c").join("deep.txt"), + "deep\n", + ) + .unwrap(); + fs::write(bdir.join("a").join("top.txt"), "top\n").unwrap(); + + assert!(bdir.join("a").join("b").join("c").join("deep.txt").exists()); + assert_eq!( + fs::read_to_string(bdir.join("a").join("b").join("c").join("deep.txt")).unwrap(), + "deep\n" + ); + assert_eq!( + fs::read_to_string(bdir.join("a").join("top.txt")).unwrap(), + "top\n" + ); + + // None of this should be in base + assert!(!fix.base.join("a").exists()); +} diff --git a/tests/test_rename.sh b/tests/test_rename.sh new file mode 100644 index 0000000..eb9631c --- /dev/null +++ b/tests/test_rename.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Test rename/mv operations + +source "$(dirname "$0")/test_helper.sh" + +test_rename_same_dir() { + setup + do_mount + do_create "rename_same" "main" + + mv "$TEST_MNT/file1.txt" "$TEST_MNT/file1_renamed.txt" + assert_file_not_exists "$TEST_MNT/file1.txt" "Old name gone after rename" + assert_file_exists "$TEST_MNT/file1_renamed.txt" "New name exists after rename" + assert_file_contains "$TEST_MNT/file1_renamed.txt" "base content" "Renamed file has correct content" + + # Base unchanged + assert_file_exists "$TEST_BASE/file1.txt" "Base file untouched" + + do_unmount +} + +test_rename_cross_dir() { + setup + do_mount + do_create "rename_xdir" "main" + + mv "$TEST_MNT/file1.txt" "$TEST_MNT/subdir/moved.txt" + assert_file_not_exists "$TEST_MNT/file1.txt" "Old location empty" + assert_file_exists "$TEST_MNT/subdir/moved.txt" "File moved to subdir" + assert_file_contains "$TEST_MNT/subdir/moved.txt" "base content" "Moved file has correct content" + + do_unmount +} + +test_rename_overwrite() { + setup + do_mount + do_create "rename_over" "main" + + echo "new content" > "$TEST_MNT/new.txt" + mv "$TEST_MNT/new.txt" "$TEST_MNT/file2.txt" + assert_file_not_exists "$TEST_MNT/new.txt" "Source gone after overwrite rename" + assert_file_contains "$TEST_MNT/file2.txt" "new content" "Overwritten file has new content" + + do_unmount +} + +test_rename_new_file() { + setup + do_mount + do_create "rename_new" "main" + + echo "created" > "$TEST_MNT/original.txt" + mv "$TEST_MNT/original.txt" "$TEST_MNT/moved.txt" + assert_file_not_exists "$TEST_MNT/original.txt" "Original gone" + assert_file_exists "$TEST_MNT/moved.txt" "Moved file exists" + assert_file_contains "$TEST_MNT/moved.txt" "created" "Moved file content correct" + + do_unmount +} + +test_rename_commit() { + setup + do_mount + do_create "rename_commit" "main" + + mv "$TEST_MNT/file1.txt" "$TEST_MNT/renamed.txt" + do_commit + + assert_file_exists "$TEST_BASE/renamed.txt" "Renamed file in base after commit" + assert_file_contains "$TEST_BASE/renamed.txt" "base content" "Committed renamed file content correct" + assert_file_not_exists "$TEST_BASE/file1.txt" "Original deleted from base after commit" + + do_unmount +} + +test_rename_abort() { + setup + do_mount + do_create "rename_abort" "main" + + mv "$TEST_MNT/file1.txt" "$TEST_MNT/renamed.txt" + do_abort + + assert_file_exists "$TEST_BASE/file1.txt" "Base file still exists after abort" + assert_file_not_exists "$TEST_BASE/renamed.txt" "Renamed file not in base after abort" + + do_unmount +} + +test_rename_nonexistent() { + setup + do_mount + do_create "rename_noent" "main" + + if mv "$TEST_MNT/no_such_file.txt" "$TEST_MNT/dest.txt" 2>/dev/null; then + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo -e " ${RED}✗${NC} Rename nonexistent should fail" + else + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}✓${NC} Rename nonexistent correctly fails" + fi + + do_unmount +} + +# Run tests (abort before commit to avoid leftover base state between tests) +run_test "Rename Same Dir" test_rename_same_dir +run_test "Rename Cross Dir" test_rename_cross_dir +run_test "Rename Overwrite Existing" test_rename_overwrite +run_test "Rename New File" test_rename_new_file +run_test "Rename Nonexistent Fails" test_rename_nonexistent +run_test "Rename + Abort" test_rename_abort +run_test "Rename + Commit" test_rename_commit + +print_summary