Skip to content

Commit 0a07661

Browse files
authored
Port branchfs to macOS (#26)
* Port branchfs to macOS with verified integration tests and stable control interface * Address review comments. * Address review comments. * Address review comments. * Remove unused import and fix Cargo.toml to use cfg(target_os = "macos")
1 parent e038389 commit 0a07661

12 files changed

Lines changed: 360 additions & 101 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ keywords = ["fuse", "filesystem", "branching", "copy-on-write"]
99
categories = ["filesystem"]
1010

1111
[dependencies]
12-
fuser = { version = "0.16", features = ["abi-7-40"] }
1312
clap = { version = "4", features = ["derive"] }
1413
serde = { version = "1", features = ["derive"] }
1514
serde_json = "1"
@@ -22,3 +21,9 @@ dashmap = "5"
2221
anyhow = "1"
2322
thiserror = "1"
2423
uuid = { version = "1", features = ["v4"] }
24+
25+
[target.'cfg(target_os = "linux")'.dependencies]
26+
fuser = { version = "0.16", features = ["abi-7-40", "libfuse"] }
27+
28+
[target.'cfg(target_os = "macos")'.dependencies]
29+
fuser = { version = "0.16", features = ["abi-7-31", "libfuse"] }

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ FUSE adds userspace-kernel context switches per operation, which is slower than
3636

3737
## Prerequisites
3838

39-
- Linux with FUSE support
40-
- libfuse3 development libraries
39+
- Linux with FUSE support or macOS with macFUSE
40+
- libfuse3 development libraries (Linux) or macFUSE (macOS)
4141
- Rust toolchain (1.70 or later)
4242

4343
### Installing Dependencies
@@ -57,6 +57,24 @@ sudo dnf install fuse3-devel pkg-config
5757
sudo pacman -S fuse3 pkg-config
5858
```
5959

60+
**macOS:**
61+
```bash
62+
brew install macfuse pkg-config
63+
```
64+
65+
### macOS Support
66+
67+
BranchFS supports macOS via **macFUSE**.
68+
69+
1. **Install macFUSE**: `brew install macfuse pkg-config`.
70+
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.
71+
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:
72+
- `echo "create:name" > .branchfs_ctl`
73+
- `echo "commit" > .branchfs_ctl`
74+
- `echo "abort" > .branchfs_ctl`
75+
4. **FUSE ABI**: On macOS, BranchFS targets FUSE ABI 7.31 for maximum compatibility and to resolve path resolution issues.
76+
5. **Advanced Features**: Linux-specific features like FUSE passthrough and `RENAME_EXCHANGE` are currently disabled on macOS.
77+
6078
## Building
6179

6280
```bash

src/daemon.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,10 @@ impl Daemon {
147147
self.manager.set_mount_branch(mountpoint, branch_name);
148148

149149
let fs = BranchFs::new(self.manager.clone(), mountpoint.to_path_buf(), passthrough);
150-
let options = vec![
150+
let mut options = vec![
151151
MountOption::FSName("branchfs".to_string()),
152-
MountOption::DefaultPermissions,
153152
];
153+
options.extend(crate::platform::get_mount_options());
154154

155155
log::info!(
156156
"Spawning mount for branch '{}' at {:?}",

src/fs.rs

Lines changed: 30 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::sync::Arc;
88
use std::time::{Duration, SystemTime, UNIX_EPOCH};
99

1010
use fuser::{
11-
BackingId, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry,
11+
FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry,
1212
ReplyIoctl, ReplyOpen, ReplyStatfs, ReplyWrite, Request, TimeOrNow,
1313
};
1414
use parking_lot::RwLock;
@@ -18,17 +18,14 @@ use crate::error::BranchError;
1818
use crate::fs_path::{classify_path, PathContext};
1919
use crate::inode::{InodeManager, ROOT_INO};
2020
use crate::storage;
21+
use crate::platform::{FS_IOC_BRANCH_ABORT, FS_IOC_BRANCH_COMMIT, FS_IOC_BRANCH_CREATE};
2122

2223
// Zero TTL forces the kernel to always revalidate with FUSE, ensuring consistent
2324
// behavior after branch switches. This is important for speculative execution
2425
// where branches can change at any time.
2526
pub(crate) const TTL: Duration = Duration::from_secs(0);
2627
pub(crate) const BLOCK_SIZE: u32 = 512;
2728

28-
pub const FS_IOC_BRANCH_CREATE: u32 = 0x8080_6200; // _IOR('b', 0, [u8; 128])
29-
pub const FS_IOC_BRANCH_COMMIT: u32 = 0x0000_6201; // _IO ('b', 1)
30-
pub const FS_IOC_BRANCH_ABORT: u32 = 0x0000_6202; // _IO ('b', 2)
31-
3229
pub(crate) const CTL_FILE: &str = ".branchfs_ctl";
3330
pub(crate) const CTL_INO: u64 = u64::MAX - 1;
3431

@@ -132,10 +129,8 @@ pub struct BranchFs {
132129
write_cache: WriteFileCache,
133130
/// Whether FUSE passthrough mode is enabled (--passthrough flag).
134131
passthrough_enabled: bool,
135-
/// Monotonically increasing file handle counter for passthrough opens.
136-
next_fh: AtomicU64,
137-
/// BackingId objects kept alive until release() — one per passthrough open().
138-
backing_ids: HashMap<u64, BackingId>,
132+
/// FUSE passthrough state (fh counter, backing_ids)
133+
passthrough_state: crate::platform::PassthroughState,
139134
}
140135

141136
impl BranchFs {
@@ -155,8 +150,7 @@ impl BranchFs {
155150
open_cache: OpenFileCache::new(),
156151
write_cache: WriteFileCache::new(),
157152
passthrough_enabled: passthrough,
158-
next_fh: AtomicU64::new(1),
159-
backing_ids: HashMap::new(),
153+
passthrough_state: crate::platform::PassthroughState::new(),
160154
}
161155
}
162156

@@ -228,7 +222,6 @@ impl BranchFs {
228222
/// Attempt to open a file with FUSE passthrough. Falls back to non-passthrough on failure.
229223
fn try_open_passthrough(
230224
&mut self,
231-
_ino: u64,
232225
flags: i32,
233226
branch: &str,
234227
rel_path: &str,
@@ -237,13 +230,10 @@ impl BranchFs {
237230
) {
238231
let is_writable = (flags & libc::O_ACCMODE) != libc::O_RDONLY;
239232

240-
// For writable opens, do eager COW — the kernel will write directly to
241-
// the backing file, bypassing our write() callback.
242233
let backing_path = if is_writable {
243234
match self.ensure_cow_for_branch(branch, rel_path) {
244235
Ok(p) => p,
245236
Err(_) => {
246-
// Fallback to non-passthrough
247237
reply.opened(0, 0);
248238
return;
249239
}
@@ -252,7 +242,6 @@ impl BranchFs {
252242
resolved.to_path_buf()
253243
};
254244

255-
// Open the backing file
256245
let open_result = if is_writable {
257246
std::fs::OpenOptions::new()
258247
.read(true)
@@ -261,26 +250,11 @@ impl BranchFs {
261250
} else {
262251
File::open(&backing_path)
263252
};
264-
let file = match open_result {
265-
Ok(f) => f,
266-
Err(_) => {
267-
reply.opened(0, 0);
268-
return;
269-
}
270-
};
271-
272-
// Register the fd with the kernel
273-
let backing_id = match reply.open_backing(&file) {
274-
Ok(id) => id,
275-
Err(_) => {
276-
reply.opened(0, 0);
277-
return;
278-
}
279-
};
280-
281-
let fh = self.next_fh.fetch_add(1, Ordering::Relaxed);
282-
reply.opened_passthrough(fh, 0, &backing_id);
283-
self.backing_ids.insert(fh, backing_id);
253+
254+
match open_result {
255+
Ok(f) => crate::platform::try_open_passthrough(&mut self.passthrough_state, f, reply),
256+
Err(_) => reply.opened(0, 0),
257+
}
284258
}
285259

286260
/// Classify an inode number. Returns None for root and CTL_INO (handled separately).
@@ -311,21 +285,7 @@ impl Filesystem for BranchFs {
311285
}
312286

313287
if self.passthrough_enabled {
314-
if let Err(e) = config.add_capabilities(fuser::consts::FUSE_PASSTHROUGH) {
315-
log::warn!(
316-
"Kernel does not support FUSE_PASSTHROUGH (unsupported bits: {:#x}), disabling",
317-
e
318-
);
319-
self.passthrough_enabled = false;
320-
} else if let Err(e) = config.set_max_stack_depth(2) {
321-
log::warn!(
322-
"Failed to set max_stack_depth (max: {}), disabling passthrough",
323-
e
324-
);
325-
self.passthrough_enabled = false;
326-
} else {
327-
log::info!("FUSE passthrough enabled");
328-
}
288+
crate::platform::setup_capabilities(config, &mut self.passthrough_enabled);
329289
}
330290

331291
Ok(())
@@ -1164,11 +1124,11 @@ impl Filesystem for BranchFs {
11641124
name: &OsStr,
11651125
newparent: u64,
11661126
newname: &OsStr,
1167-
flags: u32,
1127+
_flags: u32,
11681128
reply: ReplyEmpty,
11691129
) {
1170-
if flags & libc::RENAME_EXCHANGE != 0 {
1171-
reply.error(libc::EINVAL);
1130+
if let Err(e) = crate::platform::check_rename_flags(_flags) {
1131+
reply.error(e);
11721132
return;
11731133
}
11741134

@@ -1254,7 +1214,7 @@ impl Filesystem for BranchFs {
12541214
}
12551215

12561216
// RENAME_NOREPLACE
1257-
if flags & libc::RENAME_NOREPLACE != 0
1217+
if crate::platform::check_rename_noreplace(_flags)
12581218
&& self.resolve_for_branch(&branch, &dst_rel).is_some()
12591219
{
12601220
reply.error(libc::EEXIST);
@@ -1330,7 +1290,7 @@ impl Filesystem for BranchFs {
13301290
reply.ok();
13311291
}
13321292

1333-
fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen) {
1293+
fn open(&mut self, _req: &Request, ino: u64, _flags: i32, reply: ReplyOpen) {
13341294
// Control file is always openable (no epoch check)
13351295
if ino == CTL_INO {
13361296
reply.opened(0, 0);
@@ -1363,7 +1323,7 @@ impl Filesystem for BranchFs {
13631323
reply.error(libc::ENOENT);
13641324
return;
13651325
}
1366-
let resolved = match self.resolve_for_branch(&branch, &rel_path) {
1326+
let _resolved = match self.resolve_for_branch(&branch, &rel_path) {
13671327
Some(p) => p,
13681328
None => {
13691329
reply.error(libc::ENOENT);
@@ -1373,7 +1333,7 @@ impl Filesystem for BranchFs {
13731333
self.manager.register_opened_inode(&branch, ino);
13741334

13751335
if self.passthrough_enabled {
1376-
self.try_open_passthrough(ino, flags, &branch, &rel_path, &resolved, reply);
1336+
self.try_open_passthrough(_flags, &branch, &rel_path, &_resolved, reply);
13771337
} else {
13781338
reply.opened(0, 0);
13791339
}
@@ -1384,7 +1344,7 @@ impl Filesystem for BranchFs {
13841344
reply.error(libc::ESTALE);
13851345
return;
13861346
}
1387-
let resolved = match self.resolve(&path) {
1347+
let _resolved = match self.resolve(&path) {
13881348
Some(p) => p,
13891349
None => {
13901350
reply.error(libc::ENOENT);
@@ -1395,7 +1355,7 @@ impl Filesystem for BranchFs {
13951355
self.manager.register_opened_inode(&branch_name, ino);
13961356

13971357
if self.passthrough_enabled {
1398-
self.try_open_passthrough(ino, flags, &branch_name, &path, &resolved, reply);
1358+
self.try_open_passthrough(_flags, &branch_name, &path, &_resolved, reply);
13991359
} else {
14001360
reply.opened(0, 0);
14011361
}
@@ -1414,7 +1374,7 @@ impl Filesystem for BranchFs {
14141374
reply: ReplyEmpty,
14151375
) {
14161376
if fh != 0 {
1417-
self.backing_ids.remove(&fh);
1377+
crate::platform::release_passthrough(&mut self.passthrough_state, fh);
14181378
}
14191379
reply.ok();
14201380
}
@@ -1794,6 +1754,10 @@ impl Filesystem for BranchFs {
17941754
}
17951755
}
17961756

1757+
fn access(&mut self, _req: &Request, _ino: u64, _mask: i32, reply: ReplyEmpty) {
1758+
crate::platform::handle_access(reply);
1759+
}
1760+
17971761
fn symlink(
17981762
&mut self,
17991763
_req: &Request,
@@ -1909,11 +1873,11 @@ impl Filesystem for BranchFs {
19091873
match nix::sys::statvfs::statvfs(storage_path) {
19101874
Ok(stat) => {
19111875
reply.statfs(
1912-
stat.blocks(),
1913-
stat.blocks_free(),
1914-
stat.blocks_available(),
1915-
stat.files(),
1916-
stat.files_free(),
1876+
stat.blocks().into(),
1877+
stat.blocks_free().into(),
1878+
stat.blocks_available().into(),
1879+
stat.files().into(),
1880+
stat.files_free().into(),
19171881
stat.block_size() as u32,
19181882
stat.name_max() as u32,
19191883
stat.fragment_size() as u32,

0 commit comments

Comments
 (0)