From 2224502780ba19e3a576b7d60027d2559c8609ff Mon Sep 17 00:00:00 2001 From: Nitin Kesarwani Date: Sun, 15 Mar 2026 01:24:59 -0700 Subject: [PATCH 1/2] Port branchfs to macOS with verified integration tests and stable control interface --- Cargo.lock | 7 +++ Cargo.toml | 6 ++- README.md | 22 ++++++++- src/daemon.rs | 14 ++++-- src/fs.rs | 94 ++++++++++++++++++++++++++++----------- src/fs_ctl.rs | 86 ++++++++++++++++++++++++++++------- src/fs_helpers.rs | 6 +-- tests/test_integration.rs | 74 +++++++++++++++++++++++++----- 8 files changed, 247 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f6f90d..16fabd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,6 +209,7 @@ dependencies = [ "memchr", "nix 0.29.0", "page_size", + "pkg-config", "smallvec", "zerocopy", ] @@ -379,6 +380,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "portable-atomic" version = "1.13.1" diff --git a/Cargo.toml b/Cargo.toml index 6e428a3..97a2cd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,11 @@ keywords = ["fuse", "filesystem", "branching", "copy-on-write"] categories = ["filesystem"] [dependencies] -fuser = { version = "0.16", features = ["abi-7-40"] } +[target.'cfg(target_os = "linux")'.dependencies] +fuser = { version = "0.16", features = ["abi-7-40", "libfuse"] } + +[target.'cfg(not(target_os = "linux"))'.dependencies] +fuser = { version = "0.16", features = ["abi-7-31", "libfuse"] } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index aded682..954d276 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ FUSE adds userspace-kernel context switches per operation, which is slower than ## Prerequisites -- Linux with FUSE support -- libfuse3 development libraries +- Linux with FUSE support or macOS with macFUSE +- libfuse3 development libraries (Linux) or macFUSE (macOS) - Rust toolchain (1.70 or later) ### Installing Dependencies @@ -57,6 +57,24 @@ sudo dnf install fuse3-devel pkg-config sudo pacman -S fuse3 pkg-config ``` +**macOS:** +```bash +brew install macfuse pkg-config +``` + +### macOS Support + +BranchFS supports macOS via **macFUSE**. + +1. **Install macFUSE**: `brew install macfuse pkg-config`. +2. **System Extension**: You must approve the `macFUSE` system extension in System Settings. On Apple Silicon Macs, you may need to enable third-party kernel extensions in Recovery Mode. +3. **Control Interface**: Since `ioctl` support can be inconsistent on macOS, BranchFS provides a reliable write-based interface. You can send commands to `.branchfs_ctl` via direct writes: + - `echo "create:name" > .branchfs_ctl` + - `echo "commit" > .branchfs_ctl` + - `echo "abort" > .branchfs_ctl` +4. **FUSE ABI**: On macOS, BranchFS targets FUSE ABI 7.31 for maximum compatibility and to resolve path resolution issues. +5. **Advanced Features**: Linux-specific features like FUSE passthrough and `RENAME_EXCHANGE` are currently disabled on macOS. + ## Building ```bash diff --git a/src/daemon.rs b/src/daemon.rs index 2177a5b..1c043d9 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -147,10 +147,16 @@ impl Daemon { self.manager.set_mount_branch(mountpoint, branch_name); let fs = BranchFs::new(self.manager.clone(), mountpoint.to_path_buf(), passthrough); - let options = vec![ + let mut options = vec![ MountOption::FSName("branchfs".to_string()), - MountOption::DefaultPermissions, ]; + #[cfg(target_os = "macos")] + { + options.push(MountOption::CUSTOM("noappledouble".to_string())); + options.push(MountOption::CUSTOM("volname=branchfs".to_string())); + options.push(MountOption::CUSTOM("defer_permissions".to_string())); + options.push(MountOption::CUSTOM("local".to_string())); + } log::info!( "Spawning mount for branch '{}' at {:?}", @@ -407,8 +413,8 @@ pub fn start_daemon_background( cmd.args(["--max-storage", &max.to_string()]); } cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) .spawn()?; // Wait for daemon to be ready diff --git a/src/fs.rs b/src/fs.rs index 8e0658e..870b088 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -8,9 +8,11 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use fuser::{ - BackingId, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, + FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyIoctl, ReplyOpen, ReplyStatfs, ReplyWrite, Request, TimeOrNow, }; +#[cfg(target_os = "linux")] +use fuser::BackingId; use parking_lot::RwLock; use crate::branch::BranchManager; @@ -25,10 +27,20 @@ use crate::storage; pub(crate) const TTL: Duration = Duration::from_secs(0); pub(crate) const BLOCK_SIZE: u32 = 512; +#[cfg(target_os = "linux")] pub const FS_IOC_BRANCH_CREATE: u32 = 0x8080_6200; // _IOR('b', 0, [u8; 128]) +#[cfg(target_os = "linux")] pub const FS_IOC_BRANCH_COMMIT: u32 = 0x0000_6201; // _IO ('b', 1) +#[cfg(target_os = "linux")] pub const FS_IOC_BRANCH_ABORT: u32 = 0x0000_6202; // _IO ('b', 2) +#[cfg(not(target_os = "linux"))] +pub const FS_IOC_BRANCH_CREATE: u32 = 0x4080_6200; // _IOR('b', 0, [u8; 128]) +#[cfg(not(target_os = "linux"))] +pub const FS_IOC_BRANCH_COMMIT: u32 = 0x2000_6201; // _IO ('b', 1) +#[cfg(not(target_os = "linux"))] +pub const FS_IOC_BRANCH_ABORT: u32 = 0x2000_6202; // _IO ('b', 2) + pub(crate) const CTL_FILE: &str = ".branchfs_ctl"; pub(crate) const CTL_INO: u64 = u64::MAX - 1; @@ -133,8 +145,10 @@ pub struct BranchFs { /// Whether FUSE passthrough mode is enabled (--passthrough flag). passthrough_enabled: bool, /// Monotonically increasing file handle counter for passthrough opens. + #[cfg(target_os = "linux")] next_fh: AtomicU64, /// BackingId objects kept alive until release() — one per passthrough open(). + #[cfg(target_os = "linux")] backing_ids: HashMap, } @@ -155,7 +169,9 @@ impl BranchFs { open_cache: OpenFileCache::new(), write_cache: WriteFileCache::new(), passthrough_enabled: passthrough, + #[cfg(target_os = "linux")] next_fh: AtomicU64::new(1), + #[cfg(target_os = "linux")] backing_ids: HashMap::new(), } } @@ -226,6 +242,7 @@ impl BranchFs { } /// Attempt to open a file with FUSE passthrough. Falls back to non-passthrough on failure. + #[cfg(target_os = "linux")] fn try_open_passthrough( &mut self, _ino: u64, @@ -301,7 +318,7 @@ impl BranchFs { } impl Filesystem for BranchFs { - fn init(&mut self, req: &Request, config: &mut fuser::KernelConfig) -> Result<(), libc::c_int> { + fn init(&mut self, req: &Request, _config: &mut fuser::KernelConfig) -> Result<(), libc::c_int> { // The init request may come from the kernel (uid=0) rather than the // mounting user, so only override the process-derived defaults when // the request carries a real (non-root) uid. @@ -311,20 +328,28 @@ impl Filesystem for BranchFs { } if self.passthrough_enabled { - if let Err(e) = config.add_capabilities(fuser::consts::FUSE_PASSTHROUGH) { - log::warn!( - "Kernel does not support FUSE_PASSTHROUGH (unsupported bits: {:#x}), disabling", - e - ); - self.passthrough_enabled = false; - } else if let Err(e) = config.set_max_stack_depth(2) { - log::warn!( - "Failed to set max_stack_depth (max: {}), disabling passthrough", - e - ); + #[cfg(target_os = "linux")] + { + if let Err(e) = config.add_capabilities(fuser::consts::FUSE_PASSTHROUGH) { + log::warn!( + "Kernel does not support FUSE_PASSTHROUGH (unsupported bits: {:#x}), disabling", + e + ); + self.passthrough_enabled = false; + } else if let Err(e) = config.set_max_stack_depth(2) { + log::warn!( + "Failed to set max_stack_depth (max: {}), disabling passthrough", + e + ); + self.passthrough_enabled = false; + } else { + log::info!("FUSE passthrough enabled"); + } + } + #[cfg(not(target_os = "linux"))] + { + log::warn!("FUSE passthrough is only supported on Linux, disabling"); self.passthrough_enabled = false; - } else { - log::info!("FUSE passthrough enabled"); } } @@ -1164,9 +1189,10 @@ impl Filesystem for BranchFs { name: &OsStr, newparent: u64, newname: &OsStr, - flags: u32, + _flags: u32, reply: ReplyEmpty, ) { + #[cfg(target_os = "linux")] if flags & libc::RENAME_EXCHANGE != 0 { reply.error(libc::EINVAL); return; @@ -1254,6 +1280,7 @@ impl Filesystem for BranchFs { } // RENAME_NOREPLACE + #[cfg(target_os = "linux")] if flags & libc::RENAME_NOREPLACE != 0 && self.resolve_for_branch(&branch, &dst_rel).is_some() { @@ -1330,7 +1357,7 @@ impl Filesystem for BranchFs { reply.ok(); } - fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen) { + fn open(&mut self, _req: &Request, ino: u64, _flags: i32, reply: ReplyOpen) { // Control file is always openable (no epoch check) if ino == CTL_INO { reply.opened(0, 0); @@ -1363,7 +1390,7 @@ impl Filesystem for BranchFs { reply.error(libc::ENOENT); return; } - let resolved = match self.resolve_for_branch(&branch, &rel_path) { + let _resolved = match self.resolve_for_branch(&branch, &rel_path) { Some(p) => p, None => { reply.error(libc::ENOENT); @@ -1372,11 +1399,14 @@ impl Filesystem for BranchFs { }; self.manager.register_opened_inode(&branch, ino); + #[cfg(target_os = "linux")] if self.passthrough_enabled { - self.try_open_passthrough(ino, flags, &branch, &rel_path, &resolved, reply); + self.try_open_passthrough(ino, _flags, &branch, &rel_path, &_resolved, reply); } else { reply.opened(0, 0); } + #[cfg(not(target_os = "linux"))] + reply.opened(0, 0); } _ => { // Root path @@ -1384,7 +1414,7 @@ impl Filesystem for BranchFs { reply.error(libc::ESTALE); return; } - let resolved = match self.resolve(&path) { + let _resolved = match self.resolve(&path) { Some(p) => p, None => { reply.error(libc::ENOENT); @@ -1394,11 +1424,14 @@ impl Filesystem for BranchFs { let branch_name = self.get_branch_name(); self.manager.register_opened_inode(&branch_name, ino); + #[cfg(target_os = "linux")] if self.passthrough_enabled { - self.try_open_passthrough(ino, flags, &branch_name, &path, &resolved, reply); + self.try_open_passthrough(ino, _flags, &branch_name, &path, &_resolved, reply); } else { reply.opened(0, 0); } + #[cfg(not(target_os = "linux"))] + reply.opened(0, 0); } } } @@ -1414,6 +1447,7 @@ impl Filesystem for BranchFs { reply: ReplyEmpty, ) { if fh != 0 { + #[cfg(target_os = "linux")] self.backing_ids.remove(&fh); } reply.ok(); @@ -1589,6 +1623,7 @@ impl Filesystem for BranchFs { _out_size: u32, reply: ReplyIoctl, ) { + log::info!("ioctl: ino={}, cmd={:#x}", ino, cmd); // Resolve ino to the branch name this ctl fd refers to. let branch_name = if ino == CTL_INO { self.get_branch_name() @@ -1794,6 +1829,13 @@ impl Filesystem for BranchFs { } } + fn access(&mut self, _req: &Request, _ino: u64, _mask: i32, reply: ReplyEmpty) { + // macOS macFUSE sometimes requires access() to be implemented when DefaultPermissions + // is not used, otherwise it may return EPERM. We trust the open() and other calls + // to handle specific permissions. + reply.ok(); + } + fn symlink( &mut self, _req: &Request, @@ -1909,11 +1951,11 @@ impl Filesystem for BranchFs { match nix::sys::statvfs::statvfs(storage_path) { Ok(stat) => { reply.statfs( - stat.blocks(), - stat.blocks_free(), - stat.blocks_available(), - stat.files(), - stat.files_free(), + stat.blocks().into(), + stat.blocks_free().into(), + stat.blocks_available().into(), + stat.files().into(), + stat.files_free().into(), stat.block_size() as u32, stat.name_max() as u32, stat.fragment_size() as u32, diff --git a/src/fs_ctl.rs b/src/fs_ctl.rs index b814aee..6c289c7 100644 --- a/src/fs_ctl.rs +++ b/src/fs_ctl.rs @@ -38,12 +38,14 @@ impl BranchFs { /// Only supports `switch:` — commit/abort go through /// per-branch ctl files (`/@/.branchfs_ctl`). pub(crate) fn handle_root_ctl_write(&mut self, data: &[u8], reply: ReplyWrite) { - let cmd = String::from_utf8_lossy(data).trim().to_string(); + let cmd = String::from_utf8_lossy(data) + .trim_matches(|c: char| c.is_whitespace() || c == '\0') + .to_string(); let cmd_lower = cmd.to_lowercase(); log::info!("Root ctl command: '{}'", cmd); - if let Some(new_branch) = cmd_lower.strip_prefix("switch:") { - let new_branch = new_branch.trim(); + if cmd_lower.starts_with("switch:") { + let new_branch = cmd[7..].trim(); if new_branch.is_empty() { log::warn!("Empty branch name in switch command"); reply.error(libc::EINVAL); @@ -57,9 +59,38 @@ impl BranchFs { self.switch_to_branch(new_branch); log::info!("Switched to branch '{}'", new_branch); reply.written(data.len() as u32); + } else if cmd_lower == "create" || cmd_lower.starts_with("create:") { + let name = if cmd_lower.starts_with("create:") { + cmd[7..].trim().to_string() + } else { + format!("branch-{}", uuid::Uuid::new_v4()) + }; + if name.is_empty() { + reply.error(libc::EINVAL); + return; + } + log::info!("Root ctl: CREATE branch '{}' from current", name); + let parent = self.get_branch_name(); + match self.manager.create_branch(&name, &parent) { + Ok(()) => { + self.current_epoch + .store(self.manager.get_epoch(), Ordering::SeqCst); + reply.written(data.len() as u32); + } + Err(e) => { + log::error!("create branch failed: {}", e); + reply.error(libc::EIO); + } + } + } else if cmd_lower == "commit" { + let branch = self.get_branch_name(); + self.finalize_branch_op(&branch, self.manager.commit(&branch), "commit", data.len() as u32, reply); + } else if cmd_lower == "abort" { + let branch = self.get_branch_name(); + self.finalize_branch_op(&branch, self.manager.abort(&branch), "abort", data.len() as u32, reply); } else { log::warn!( - "Unknown root ctl command: '{}' (use /@branch/.branchfs_ctl for commit/abort)", + "Unknown root ctl command: '{}' (supported: create, commit, abort, switch:name)", cmd ); reply.error(libc::EINVAL); @@ -68,20 +99,45 @@ impl BranchFs { /// Handle a write to a per-branch ctl file. pub(crate) fn handle_branch_ctl_write(&mut self, branch: &str, data: &[u8], reply: ReplyWrite) { - let cmd = String::from_utf8_lossy(data).trim().to_string(); + let cmd = String::from_utf8_lossy(data) + .trim_matches(|c: char| c.is_whitespace() || c == '\0') + .to_string(); let cmd_lower = cmd.to_lowercase(); log::info!("Branch ctl command: '{}' for branch '{}'", cmd, branch); - let result = match cmd_lower.as_str() { - "commit" => self.manager.commit(branch), - "abort" => self.manager.abort(branch), - _ => { - log::warn!("Unknown branch ctl command: {}", cmd); + if cmd_lower == "commit" { + self.finalize_branch_op(branch, self.manager.commit(branch), "commit", data.len() as u32, reply); + } else if cmd_lower == "abort" { + self.finalize_branch_op(branch, self.manager.abort(branch), "abort", data.len() as u32, reply); + } else if cmd_lower == "create" || cmd_lower.starts_with("create:") { + let name = if cmd_lower.starts_with("create:") { + cmd[7..].trim().to_string() + } else { + format!("branch-{}", uuid::Uuid::new_v4()) + }; + if name.is_empty() { reply.error(libc::EINVAL); return; } - }; + log::info!("Branch ctl: CREATE branch '{}' from '{}'", name, branch); + match self.manager.create_branch(&name, branch) { + Ok(()) => { + self.current_epoch + .store(self.manager.get_epoch(), Ordering::SeqCst); + reply.written(data.len() as u32); + } + Err(e) => { + log::error!("create branch failed: {}", e); + reply.error(libc::EIO); + } + } + } else { + log::warn!("Unknown branch ctl command: {}", cmd); + reply.error(libc::EINVAL); + } + } + fn finalize_branch_op(&mut self, branch: &str, result: Result, op: &str, written_len: u32, reply: ReplyWrite) { match result { Ok(parent) => { // Clear inodes for the affected branch prefix and update epoch @@ -94,22 +150,22 @@ impl BranchFs { self.manager.switch_mount_branch(&self.mountpoint, &parent); log::info!( "Branch ctl {} succeeded for '{}', switched to '{}'", - cmd_lower, + op, branch, parent ); } else { log::info!( "Branch ctl {} succeeded for '{}' (mount stays on '{}')", - cmd_lower, + op, branch, current ); } - reply.written(data.len() as u32) + reply.written(written_len) } Err(BranchError::Conflict(_)) => { - log::warn!("Branch ctl {} conflict for '{}'", cmd_lower, branch); + log::warn!("Branch ctl {} conflict for '{}'", op, branch); reply.error(libc::ESTALE); } Err(e) => { diff --git a/src/fs_helpers.rs b/src/fs_helpers.rs index f26aab1..7afcfa2 100644 --- a/src/fs_helpers.rs +++ b/src/fs_helpers.rs @@ -4,7 +4,7 @@ use std::time::UNIX_EPOCH; use fuser::{FileAttr, FileType}; -use crate::fs::{BranchFs, BLOCK_SIZE, CTL_INO}; +use crate::fs::{BranchFs, BLOCK_SIZE}; use crate::storage; impl BranchFs { @@ -126,7 +126,7 @@ impl BranchFs { pub(crate) fn ctl_file_attr(&self, ino: u64) -> FileAttr { // Report a non-zero size so the kernel issues read() calls. // The actual content length is determined by the read handler. - let size = if ino == CTL_INO { 256 } else { 0 }; + let size = 0; FileAttr { ino, size, @@ -136,7 +136,7 @@ impl BranchFs { ctime: UNIX_EPOCH, crtime: UNIX_EPOCH, kind: FileType::RegularFile, - perm: 0o600, + perm: 0o777, nlink: 1, uid: self.uid.load(std::sync::atomic::Ordering::Relaxed), gid: self.gid.load(std::sync::atomic::Ordering::Relaxed), diff --git a/tests/test_integration.rs b/tests/test_integration.rs index 5c21801..91783e7 100644 --- a/tests/test_integration.rs +++ b/tests/test_integration.rs @@ -11,27 +11,74 @@ use std::process::{Command, Stdio}; use std::thread; use std::time::Duration; +#[cfg(not(target_os = "macos"))] 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()); + #[cfg(target_os = "macos")] + { + use std::io::{Seek, SeekFrom, Write}; + use std::os::unix::io::FromRawFd; + let name = format!("test-branch-{}", uuid::Uuid::new_v4()); + let mut file = std::fs::File::from_raw_fd(libc::dup(fd)); + let _ = file.seek(SeekFrom::Start(0)); + if let Err(_) = file.write_all(format!("create:{}", name).as_bytes()) { + return Err(libc::EIO); + } + Ok(name) + } + #[cfg(not(target_os = "macos"))] + { + 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 { + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "watchos", target_os = "tvos", target_os = "freebsd", target_os = "dragonfly", target_os = "openbsd", target_os = "netbsd"))] + return Err(unsafe { *libc::__error() }); + #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "watchos", target_os = "tvos", target_os = "freebsd", target_os = "dragonfly", target_os = "openbsd", target_os = "netbsd")))] + return Err(unsafe { *libc::__errno_location() }); + } + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + Ok(String::from_utf8_lossy(&buf[..end]).to_string()) } - 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) + #[cfg(target_os = "macos")] + { + use std::io::{Seek, SeekFrom, Write}; + use std::os::unix::io::FromRawFd; + let mut file = std::fs::File::from_raw_fd(libc::dup(fd)); + let _ = file.seek(SeekFrom::Start(0)); + if let Err(_) = file.write_all(b"commit") { + return -1; + } + 0 + } + #[cfg(not(target_os = "macos"))] + { + 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) + #[cfg(target_os = "macos")] + { + use std::io::{Seek, SeekFrom, Write}; + use std::os::unix::io::FromRawFd; + let mut file = std::fs::File::from_raw_fd(libc::dup(fd)); + let _ = file.seek(SeekFrom::Start(0)); + if let Err(_) = file.write_all(b"abort") { + return -1; + } + 0 + } + #[cfg(not(target_os = "macos"))] + { + libc::ioctl(fd, FS_IOC_BRANCH_ABORT as libc::c_ulong) + } } struct TestFixture { @@ -50,7 +97,12 @@ impl TestFixture { let mnt = PathBuf::from(format!("{}_mnt", prefix)); // Clean up leftovers from a previous failed run - let _ = Command::new("fusermount3") + #[cfg(target_os = "linux")] + let unmount_cmd = "fusermount3"; + #[cfg(not(target_os = "linux"))] + let unmount_cmd = "umount"; + + let _ = Command::new(unmount_cmd) .args(["-u", mnt.to_str().unwrap()]) .stderr(Stdio::null()) .status(); @@ -88,8 +140,8 @@ impl TestFixture { self.storage.to_str().unwrap(), self.mnt.to_str().unwrap(), ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) .status() .expect("failed to run branchfs mount"); From 2333cfde343c0883b73cc4a44ca37e148f40ad4f Mon Sep 17 00:00:00 2001 From: Nitin Kesarwani Date: Tue, 17 Mar 2026 17:09:42 -0700 Subject: [PATCH 2/2] Address review comments. --- src/daemon.rs | 8 +-- src/fs.rs | 113 +++++++----------------------------------- src/lib.rs | 3 +- src/platform/linux.rs | 84 +++++++++++++++++++++++++++++++ src/platform/macos.rs | 57 +++++++++++++++++++++ src/platform/mod.rs | 9 ++++ 6 files changed, 171 insertions(+), 103 deletions(-) create mode 100644 src/platform/linux.rs create mode 100644 src/platform/macos.rs create mode 100644 src/platform/mod.rs diff --git a/src/daemon.rs b/src/daemon.rs index 1c043d9..a65c83e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -150,13 +150,7 @@ impl Daemon { let mut options = vec![ MountOption::FSName("branchfs".to_string()), ]; - #[cfg(target_os = "macos")] - { - options.push(MountOption::CUSTOM("noappledouble".to_string())); - options.push(MountOption::CUSTOM("volname=branchfs".to_string())); - options.push(MountOption::CUSTOM("defer_permissions".to_string())); - options.push(MountOption::CUSTOM("local".to_string())); - } + options.extend(crate::platform::get_mount_options()); log::info!( "Spawning mount for branch '{}' at {:?}", diff --git a/src/fs.rs b/src/fs.rs index 870b088..fb348ef 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -11,8 +11,6 @@ use fuser::{ FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyIoctl, ReplyOpen, ReplyStatfs, ReplyWrite, Request, TimeOrNow, }; -#[cfg(target_os = "linux")] -use fuser::BackingId; use parking_lot::RwLock; use crate::branch::BranchManager; @@ -20,6 +18,7 @@ use crate::error::BranchError; use crate::fs_path::{classify_path, PathContext}; use crate::inode::{InodeManager, ROOT_INO}; use crate::storage; +use crate::platform::{FS_IOC_BRANCH_ABORT, FS_IOC_BRANCH_COMMIT, FS_IOC_BRANCH_CREATE}; // Zero TTL forces the kernel to always revalidate with FUSE, ensuring consistent // behavior after branch switches. This is important for speculative execution @@ -27,20 +26,6 @@ use crate::storage; pub(crate) const TTL: Duration = Duration::from_secs(0); pub(crate) const BLOCK_SIZE: u32 = 512; -#[cfg(target_os = "linux")] -pub const FS_IOC_BRANCH_CREATE: u32 = 0x8080_6200; // _IOR('b', 0, [u8; 128]) -#[cfg(target_os = "linux")] -pub const FS_IOC_BRANCH_COMMIT: u32 = 0x0000_6201; // _IO ('b', 1) -#[cfg(target_os = "linux")] -pub const FS_IOC_BRANCH_ABORT: u32 = 0x0000_6202; // _IO ('b', 2) - -#[cfg(not(target_os = "linux"))] -pub const FS_IOC_BRANCH_CREATE: u32 = 0x4080_6200; // _IOR('b', 0, [u8; 128]) -#[cfg(not(target_os = "linux"))] -pub const FS_IOC_BRANCH_COMMIT: u32 = 0x2000_6201; // _IO ('b', 1) -#[cfg(not(target_os = "linux"))] -pub const FS_IOC_BRANCH_ABORT: u32 = 0x2000_6202; // _IO ('b', 2) - pub(crate) const CTL_FILE: &str = ".branchfs_ctl"; pub(crate) const CTL_INO: u64 = u64::MAX - 1; @@ -144,12 +129,8 @@ pub struct BranchFs { write_cache: WriteFileCache, /// Whether FUSE passthrough mode is enabled (--passthrough flag). passthrough_enabled: bool, - /// Monotonically increasing file handle counter for passthrough opens. - #[cfg(target_os = "linux")] - next_fh: AtomicU64, - /// BackingId objects kept alive until release() — one per passthrough open(). - #[cfg(target_os = "linux")] - backing_ids: HashMap, + /// FUSE passthrough state (fh counter, backing_ids) + passthrough_state: crate::platform::PassthroughState, } impl BranchFs { @@ -169,10 +150,7 @@ impl BranchFs { open_cache: OpenFileCache::new(), write_cache: WriteFileCache::new(), passthrough_enabled: passthrough, - #[cfg(target_os = "linux")] - next_fh: AtomicU64::new(1), - #[cfg(target_os = "linux")] - backing_ids: HashMap::new(), + passthrough_state: crate::platform::PassthroughState::new(), } } @@ -242,10 +220,8 @@ impl BranchFs { } /// Attempt to open a file with FUSE passthrough. Falls back to non-passthrough on failure. - #[cfg(target_os = "linux")] fn try_open_passthrough( &mut self, - _ino: u64, flags: i32, branch: &str, rel_path: &str, @@ -254,13 +230,10 @@ impl BranchFs { ) { let is_writable = (flags & libc::O_ACCMODE) != libc::O_RDONLY; - // For writable opens, do eager COW — the kernel will write directly to - // the backing file, bypassing our write() callback. let backing_path = if is_writable { match self.ensure_cow_for_branch(branch, rel_path) { Ok(p) => p, Err(_) => { - // Fallback to non-passthrough reply.opened(0, 0); return; } @@ -269,7 +242,6 @@ impl BranchFs { resolved.to_path_buf() }; - // Open the backing file let open_result = if is_writable { std::fs::OpenOptions::new() .read(true) @@ -278,26 +250,11 @@ impl BranchFs { } else { File::open(&backing_path) }; - let file = match open_result { - Ok(f) => f, - Err(_) => { - reply.opened(0, 0); - return; - } - }; - - // Register the fd with the kernel - let backing_id = match reply.open_backing(&file) { - Ok(id) => id, - Err(_) => { - reply.opened(0, 0); - return; - } - }; - - let fh = self.next_fh.fetch_add(1, Ordering::Relaxed); - reply.opened_passthrough(fh, 0, &backing_id); - self.backing_ids.insert(fh, backing_id); + + match open_result { + Ok(f) => crate::platform::try_open_passthrough(&mut self.passthrough_state, f, reply), + Err(_) => reply.opened(0, 0), + } } /// Classify an inode number. Returns None for root and CTL_INO (handled separately). @@ -318,7 +275,7 @@ impl BranchFs { } impl Filesystem for BranchFs { - fn init(&mut self, req: &Request, _config: &mut fuser::KernelConfig) -> Result<(), libc::c_int> { + fn init(&mut self, req: &Request, config: &mut fuser::KernelConfig) -> Result<(), libc::c_int> { // The init request may come from the kernel (uid=0) rather than the // mounting user, so only override the process-derived defaults when // the request carries a real (non-root) uid. @@ -328,29 +285,7 @@ impl Filesystem for BranchFs { } if self.passthrough_enabled { - #[cfg(target_os = "linux")] - { - if let Err(e) = config.add_capabilities(fuser::consts::FUSE_PASSTHROUGH) { - log::warn!( - "Kernel does not support FUSE_PASSTHROUGH (unsupported bits: {:#x}), disabling", - e - ); - self.passthrough_enabled = false; - } else if let Err(e) = config.set_max_stack_depth(2) { - log::warn!( - "Failed to set max_stack_depth (max: {}), disabling passthrough", - e - ); - self.passthrough_enabled = false; - } else { - log::info!("FUSE passthrough enabled"); - } - } - #[cfg(not(target_os = "linux"))] - { - log::warn!("FUSE passthrough is only supported on Linux, disabling"); - self.passthrough_enabled = false; - } + crate::platform::setup_capabilities(config, &mut self.passthrough_enabled); } Ok(()) @@ -1192,9 +1127,8 @@ impl Filesystem for BranchFs { _flags: u32, reply: ReplyEmpty, ) { - #[cfg(target_os = "linux")] - if flags & libc::RENAME_EXCHANGE != 0 { - reply.error(libc::EINVAL); + if let Err(e) = crate::platform::check_rename_flags(_flags) { + reply.error(e); return; } @@ -1280,8 +1214,7 @@ impl Filesystem for BranchFs { } // RENAME_NOREPLACE - #[cfg(target_os = "linux")] - if flags & libc::RENAME_NOREPLACE != 0 + if crate::platform::check_rename_noreplace(_flags) && self.resolve_for_branch(&branch, &dst_rel).is_some() { reply.error(libc::EEXIST); @@ -1399,14 +1332,11 @@ impl Filesystem for BranchFs { }; self.manager.register_opened_inode(&branch, ino); - #[cfg(target_os = "linux")] if self.passthrough_enabled { - self.try_open_passthrough(ino, _flags, &branch, &rel_path, &_resolved, reply); + self.try_open_passthrough(_flags, &branch, &rel_path, &_resolved, reply); } else { reply.opened(0, 0); } - #[cfg(not(target_os = "linux"))] - reply.opened(0, 0); } _ => { // Root path @@ -1424,14 +1354,11 @@ impl Filesystem for BranchFs { let branch_name = self.get_branch_name(); self.manager.register_opened_inode(&branch_name, ino); - #[cfg(target_os = "linux")] if self.passthrough_enabled { - self.try_open_passthrough(ino, _flags, &branch_name, &path, &_resolved, reply); + self.try_open_passthrough(_flags, &branch_name, &path, &_resolved, reply); } else { reply.opened(0, 0); } - #[cfg(not(target_os = "linux"))] - reply.opened(0, 0); } } } @@ -1447,8 +1374,7 @@ impl Filesystem for BranchFs { reply: ReplyEmpty, ) { if fh != 0 { - #[cfg(target_os = "linux")] - self.backing_ids.remove(&fh); + crate::platform::release_passthrough(&mut self.passthrough_state, fh); } reply.ok(); } @@ -1830,10 +1756,7 @@ impl Filesystem for BranchFs { } fn access(&mut self, _req: &Request, _ino: u64, _mask: i32, reply: ReplyEmpty) { - // macOS macFUSE sometimes requires access() to be implemented when DefaultPermissions - // is not used, otherwise it may return EPERM. We trust the open() and other calls - // to handle specific permissions. - reply.ok(); + crate::platform::handle_access(reply); } fn symlink( diff --git a/src/lib.rs b/src/lib.rs index 76e4ad9..5638e62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ mod fs_ctl; mod fs_helpers; pub(crate) mod fs_path; pub mod inode; +pub mod platform; pub mod storage; pub use daemon::{ @@ -13,4 +14,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}; +pub use platform::{FS_IOC_BRANCH_ABORT, FS_IOC_BRANCH_COMMIT, FS_IOC_BRANCH_CREATE}; diff --git a/src/platform/linux.rs b/src/platform/linux.rs new file mode 100644 index 0000000..c4c08cc --- /dev/null +++ b/src/platform/linux.rs @@ -0,0 +1,84 @@ +use fuser::{KernelConfig, MountOption, ReplyEmpty, ReplyOpen}; +use std::collections::HashMap; +use std::fs::File; +use std::sync::atomic::{AtomicU64, Ordering}; +use fuser::BackingId; + +pub const FS_IOC_BRANCH_CREATE: u32 = 0x8080_6200; // _IOR('b', 0, [u8; 128]) +pub const FS_IOC_BRANCH_COMMIT: u32 = 0x0000_6201; // _IO ('b', 1) +pub const FS_IOC_BRANCH_ABORT: u32 = 0x0000_6202; // _IO ('b', 2) + +pub fn get_mount_options() -> Vec { + vec![] +} + +pub fn setup_capabilities(config: &mut KernelConfig, passthrough_enabled: &mut bool) { + if *passthrough_enabled { + if let Err(e) = config.add_capabilities(fuser::consts::FUSE_PASSTHROUGH) { + log::warn!( + "Kernel does not support FUSE_PASSTHROUGH (unsupported bits: {:#x}), disabling", + e + ); + *passthrough_enabled = false; + } else if let Err(e) = config.set_max_stack_depth(2) { + log::warn!( + "Failed to set max_stack_depth (max: {}), disabling passthrough", + e + ); + *passthrough_enabled = false; + } else { + log::info!("FUSE passthrough enabled"); + } + } +} + +pub fn handle_access(reply: ReplyEmpty) { + reply.error(libc::ENOSYS); +} + +pub fn check_rename_flags(flags: u32) -> Result<(), i32> { + if flags & libc::RENAME_EXCHANGE != 0 { + return Err(libc::EINVAL); + } + Ok(()) +} + +pub fn check_rename_noreplace(flags: u32) -> bool { + flags & libc::RENAME_NOREPLACE != 0 +} + +pub struct PassthroughState { + pub next_fh: AtomicU64, + pub backing_ids: HashMap, +} + +impl PassthroughState { + pub fn new() -> Self { + Self { + next_fh: AtomicU64::new(1), + backing_ids: HashMap::new(), + } + } +} + +pub fn try_open_passthrough( + state: &mut PassthroughState, + file: File, + reply: ReplyOpen, +) { + let backing_id = match reply.open_backing(&file) { + Ok(id) => id, + Err(_) => { + reply.opened(0, 0); + return; + } + }; + + let fh = state.next_fh.fetch_add(1, Ordering::Relaxed); + reply.opened_passthrough(fh, 0, &backing_id); + state.backing_ids.insert(fh, backing_id); +} + +pub fn release_passthrough(state: &mut PassthroughState, fh: u64) { + state.backing_ids.remove(&fh); +} diff --git a/src/platform/macos.rs b/src/platform/macos.rs new file mode 100644 index 0000000..a221c28 --- /dev/null +++ b/src/platform/macos.rs @@ -0,0 +1,57 @@ +use fuser::{KernelConfig, MountOption, ReplyEmpty, ReplyOpen}; +use std::fs::File; + +pub const FS_IOC_BRANCH_CREATE: u32 = 0x4080_6200; // _IOR('b', 0, [u8; 128]) +pub const FS_IOC_BRANCH_COMMIT: u32 = 0x2000_6201; // _IO ('b', 1) +pub const FS_IOC_BRANCH_ABORT: u32 = 0x2000_6202; // _IO ('b', 2) + +pub fn get_mount_options() -> Vec { + vec![ + MountOption::CUSTOM("noappledouble".to_string()), + MountOption::CUSTOM("volname=branchfs".to_string()), + MountOption::CUSTOM("defer_permissions".to_string()), + MountOption::CUSTOM("local".to_string()), + ] +} + +pub fn setup_capabilities(_config: &mut KernelConfig, passthrough_enabled: &mut bool) { + if *passthrough_enabled { + log::warn!("FUSE passthrough is only supported on Linux, disabling"); + *passthrough_enabled = false; + } +} + +pub fn handle_access(reply: ReplyEmpty) { + // macOS macFUSE sometimes requires access() to be implemented when DefaultPermissions + // is not used, otherwise it may return EPERM. We trust the open() and other calls + // to handle specific permissions. + reply.ok(); +} + +pub fn check_rename_flags(_flags: u32) -> Result<(), i32> { + // macOS does not use Linux rename flags like RENAME_EXCHANGE + Ok(()) +} + +pub fn check_rename_noreplace(_flags: u32) -> bool { + false // macOS does not have RENAME_NOREPLACE +} + +pub struct PassthroughState {} + +impl PassthroughState { + pub fn new() -> Self { + Self {} + } +} + +pub fn try_open_passthrough( + _state: &mut PassthroughState, + _file: File, + reply: ReplyOpen, +) { + // Fallback if we accidentally call this on non-Linux + reply.opened(0, 0); +} + +pub fn release_passthrough(_state: &mut PassthroughState, _fh: u64) {} diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 0000000..eda80b4 --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,9 @@ +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + +#[cfg(not(target_os = "linux"))] +mod macos; +#[cfg(not(target_os = "linux"))] +pub use macos::*;