diff --git a/Cargo.lock b/Cargo.lock index 3a50f19..9f6f90d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,8 +89,15 @@ dependencies = [ "serde", "serde_json", "thiserror", + "uuid", ] +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "cfg-if" version = "1.0.4" @@ -206,6 +213,18 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -254,6 +273,16 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.180" @@ -383,6 +412,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -421,6 +456,12 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scopeguard" version = "1.2.0" @@ -525,6 +566,71 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -562,6 +668,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "zerocopy" version = "0.8.37" diff --git a/Cargo.toml b/Cargo.toml index 95dc9bf..91b198c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,4 @@ parking_lot = "0.12" dashmap = "5" anyhow = "1" thiserror = "1" +uuid = { version = "1", features = ["v4"] } diff --git a/src/fs.rs b/src/fs.rs index 2be7068..6dae6d9 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -24,8 +24,9 @@ use crate::storage; pub(crate) const TTL: Duration = Duration::from_secs(0); pub(crate) const BLOCK_SIZE: u32 = 512; -pub const BRANCHFS_IOC_COMMIT: u32 = 0x4201; -pub const BRANCHFS_IOC_ABORT: u32 = 0x4202; +pub const FS_IOC_BRANCH_CREATE: u32 = 0x6200; // _IO('b', 0) +pub const FS_IOC_BRANCH_COMMIT: u32 = 0x6201; // _IO('b', 1) +pub const FS_IOC_BRANCH_ABORT: u32 = 0x6202; // _IO('b', 2) pub(crate) const CTL_FILE: &str = ".branchfs_ctl"; pub(crate) const CTL_INO: u64 = u64::MAX - 1; @@ -1227,7 +1228,25 @@ impl Filesystem for BranchFs { ) { let branch_name = self.get_branch_name(); match cmd { - BRANCHFS_IOC_COMMIT => { + FS_IOC_BRANCH_CREATE => { + let name = format!("branch-{}", uuid::Uuid::new_v4()); + log::info!("ioctl: CREATE branch '{}' from '{}'", name, branch_name); + match self.manager.create_branch(&name, &branch_name) { + Ok(()) => { + self.switch_to_branch(&name); + log::info!("Switched to new branch '{}'", name); + // _IO encoding has no data direction, so we cannot + // return data through restricted FUSE ioctl. The + // mount is already switched to the new branch. + reply.ioctl(0, &[]) + } + Err(e) => { + log::error!("create branch failed: {}", e); + reply.error(libc::EIO); + } + } + } + FS_IOC_BRANCH_COMMIT => { log::info!("ioctl: COMMIT for branch '{}'", branch_name); match self.manager.commit(&branch_name) { Ok(parent) => { @@ -1241,7 +1260,7 @@ impl Filesystem for BranchFs { } } } - BRANCHFS_IOC_ABORT => { + FS_IOC_BRANCH_ABORT => { log::info!("ioctl: ABORT for branch '{}'", branch_name); match self.manager.abort(&branch_name) { Ok(parent) => { diff --git a/src/lib.rs b/src/lib.rs index 0383212..76e4ad9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,3 +13,4 @@ pub use daemon::{ Response, }; pub use error::{BranchError, Result}; +pub use fs::{FS_IOC_BRANCH_ABORT, FS_IOC_BRANCH_COMMIT, FS_IOC_BRANCH_CREATE}; diff --git a/tests/test_ioctl.rs b/tests/test_ioctl.rs new file mode 100644 index 0000000..9854dd7 --- /dev/null +++ b/tests/test_ioctl.rs @@ -0,0 +1,331 @@ +//! Integration tests for FS_IOC_BRANCH_* ioctls. +//! +//! These tests require FUSE support and are ignored by default. +//! Run with: cargo test --test test_ioctl -- --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}; + +/// Call an ioctl with no data argument. Returns the raw ioctl return value +/// (0 on success, -1 on failure with errno set). +unsafe fn branch_ioctl(fd: i32, cmd: u32) -> i32 { + libc::ioctl(fd, cmd as libc::c_ulong, 0 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_ioctl_{}_{}", 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 with initial files + fs::write(base.join("file1.txt"), "base content\n").unwrap(); + fs::write(base.join("file2.txt"), "another file\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)); + } + + /// Open the control file; caller must keep the returned `File` alive. + fn open_ctl(&self) -> fs::File { + fs::OpenOptions::new() + .read(true) + .write(true) + .open(self.mnt.join(".branchfs_ctl")) + .expect("open .branchfs_ctl") + } +} + +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); + } +} + +// ── CREATE + COMMIT ───────────────────────────────────────────────── + +#[test] +#[ignore] +fn test_ioctl_create_and_commit_new_file() { + let fix = TestFixture::new("create_commit"); + fix.mount(); + let ctl = fix.open_ctl(); + let fd = ctl.as_raw_fd(); + + // CREATE a branch + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_CREATE) }; + assert_eq!(ret, 0, "CREATE should succeed"); + + // Write a new file on the branch + fs::write(fix.mnt.join("new_file.txt"), "branch content\n").unwrap(); + assert!(fix.mnt.join("new_file.txt").exists()); + + // Base should NOT have the file yet + assert!( + !fix.base.join("new_file.txt").exists(), + "new file must not appear in base before commit" + ); + + // COMMIT the branch + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_COMMIT) }; + assert_eq!(ret, 0, "COMMIT should succeed"); + + // File should now be in base + assert!( + fix.base.join("new_file.txt").exists(), + "new file should be in base after commit" + ); + assert_eq!( + fs::read_to_string(fix.base.join("new_file.txt")).unwrap(), + "branch content\n" + ); +} + +// ── CREATE + modify existing file + COMMIT ────────────────────────── + +#[test] +#[ignore] +fn test_ioctl_modify_existing_and_commit() { + let fix = TestFixture::new("modify_commit"); + fix.mount(); + let ctl = fix.open_ctl(); + let fd = ctl.as_raw_fd(); + + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_CREATE) }; + assert_eq!(ret, 0); + + // Overwrite an existing base file + fs::write(fix.mnt.join("file1.txt"), "modified\n").unwrap(); + + // Base still has the original + assert_eq!( + fs::read_to_string(fix.base.join("file1.txt")).unwrap(), + "base content\n" + ); + + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_COMMIT) }; + assert_eq!(ret, 0); + + // Base should reflect the modification + assert_eq!( + fs::read_to_string(fix.base.join("file1.txt")).unwrap(), + "modified\n" + ); +} + +// ── CREATE + ABORT ────────────────────────────────────────────────── + +#[test] +#[ignore] +fn test_ioctl_create_and_abort() { + let fix = TestFixture::new("create_abort"); + fix.mount(); + let ctl = fix.open_ctl(); + let fd = ctl.as_raw_fd(); + + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_CREATE) }; + assert_eq!(ret, 0, "CREATE should succeed"); + + // Write a file and modify another + fs::write(fix.mnt.join("abort_file.txt"), "will be discarded\n").unwrap(); + fs::write(fix.mnt.join("file1.txt"), "modified\n").unwrap(); + + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_ABORT) }; + assert_eq!(ret, 0, "ABORT should succeed"); + + // New file should be gone + assert!( + !fix.mnt.join("abort_file.txt").exists(), + "new file should vanish after abort" + ); + // Modification should be reverted + assert_eq!( + fs::read_to_string(fix.mnt.join("file1.txt")).unwrap(), + "base content\n", + "existing file should revert after abort" + ); + // Base untouched + assert!(!fix.base.join("abort_file.txt").exists()); + assert_eq!( + fs::read_to_string(fix.base.join("file1.txt")).unwrap(), + "base content\n" + ); +} + +// ── Nested CREATE + COMMIT chain ──────────────────────────────────── + +#[test] +#[ignore] +fn test_ioctl_nested_create_and_commit() { + let fix = TestFixture::new("nested"); + fix.mount(); + let ctl = fix.open_ctl(); + let fd = ctl.as_raw_fd(); + + // First branch (child of main) + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_CREATE) }; + assert_eq!(ret, 0, "first CREATE should succeed"); + fs::write(fix.mnt.join("level1.txt"), "level1\n").unwrap(); + + // Second branch (child of first) + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_CREATE) }; + assert_eq!(ret, 0, "second CREATE should succeed"); + fs::write(fix.mnt.join("level2.txt"), "level2\n").unwrap(); + + // All files visible through the mount + assert!(fix.mnt.join("file1.txt").exists()); + assert!(fix.mnt.join("level1.txt").exists()); + assert!(fix.mnt.join("level2.txt").exists()); + + // COMMIT level-2 → merges into level-1 + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_COMMIT) }; + assert_eq!(ret, 0, "commit level-2 should succeed"); + + // Neither file should be in base yet + assert!(!fix.base.join("level1.txt").exists()); + assert!(!fix.base.join("level2.txt").exists()); + + // COMMIT level-1 → merges into main / base + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_COMMIT) }; + assert_eq!(ret, 0, "commit level-1 should succeed"); + + // Both files in base now + assert!(fix.base.join("level1.txt").exists()); + assert!(fix.base.join("level2.txt").exists()); +} + +// ── COMMIT / ABORT on main should fail ────────────────────────────── + +#[test] +#[ignore] +fn test_ioctl_commit_on_main_fails() { + let fix = TestFixture::new("commit_main"); + fix.mount(); + let ctl = fix.open_ctl(); + let fd = ctl.as_raw_fd(); + + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_COMMIT) }; + assert!(ret < 0, "COMMIT on main should fail (got {})", ret); +} + +#[test] +#[ignore] +fn test_ioctl_abort_on_main_fails() { + let fix = TestFixture::new("abort_main"); + fix.mount(); + let ctl = fix.open_ctl(); + let fd = ctl.as_raw_fd(); + + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_ABORT) }; + assert!(ret < 0, "ABORT on main should fail (got {})", ret); +} + +// ── Multiple CREATE + ABORT returns to previous branch ────────────── + +#[test] +#[ignore] +fn test_ioctl_abort_returns_to_parent_branch() { + let fix = TestFixture::new("abort_parent"); + fix.mount(); + let ctl = fix.open_ctl(); + let fd = ctl.as_raw_fd(); + + // CREATE first branch, write a file + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_CREATE) }; + assert_eq!(ret, 0); + fs::write(fix.mnt.join("parent_file.txt"), "parent\n").unwrap(); + + // CREATE second (nested) branch, write another file + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_CREATE) }; + assert_eq!(ret, 0); + fs::write(fix.mnt.join("child_file.txt"), "child\n").unwrap(); + + // ABORT second branch — should land back on first branch + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_ABORT) }; + assert_eq!(ret, 0); + + // Child's file gone, parent's file still visible + assert!(!fix.mnt.join("child_file.txt").exists()); + assert!(fix.mnt.join("parent_file.txt").exists()); + + // COMMIT first branch back to main + let ret = unsafe { branch_ioctl(fd, FS_IOC_BRANCH_COMMIT) }; + assert_eq!(ret, 0); + + assert!(fix.base.join("parent_file.txt").exists()); + assert!(!fix.base.join("child_file.txt").exists()); +}