Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
11a0fa3
implemented direct mounting without libfuse or fusermount
Vociferix Feb 4, 2026
e56eaac
fixed mount options in fuse_direct.rs
Vociferix Feb 4, 2026
7b8c00f
Merge branch 'master' into direct-mount
Vociferix Feb 4, 2026
dd452af
Merge branch 'master' into direct-mount
Vociferix Feb 7, 2026
7c45654
implemented 'should_auto_unmount' and fixed 'NoAtime' option handling
Vociferix Feb 7, 2026
12dadcf
refactor direct-mount option handling to reuse functions from pure-ru…
Vociferix Feb 8, 2026
37bf52f
added direct-mount tests to Linux and BSD suites
Vociferix Feb 8, 2026
0e29072
Merge branch 'master' into direct-mount
Vociferix Feb 8, 2026
8d92cf8
fixed typo'd tests
Vociferix Feb 8, 2026
07c9af4
removed unneeded #[cfg()] on mount option utilities
Vociferix Feb 8, 2026
2bc26aa
direct-mount implementation for FreeBSD
Vociferix Feb 8, 2026
2de658c
fixed macos and freebsd lints
Vociferix Feb 8, 2026
15909d2
cleanup and fixed a few mistakes/oversights
Vociferix Feb 8, 2026
c7fbf54
implemented BSD specific version of MountImpl::should_auto_unmount
Vociferix Feb 8, 2026
fd307d2
small correction to direct-mount linux test cases
Vociferix Feb 8, 2026
a176799
updated direct-mount MountImpl::unmount_impl to properly check if the…
Vociferix Feb 9, 2026
608fa07
Merge branch 'master' into direct-mount
Vociferix Feb 16, 2026
0f4f455
direct-mount waits for auto unmount daemon on unmount, and changed to…
Vociferix Feb 16, 2026
4ce1d62
CI bump
Vociferix Feb 16, 2026
35ab821
CI bump
Vociferix Feb 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ libfuse2 = ["libfuse"]
libfuse3 = ["libfuse"]
serializable = ["serde"]
macfuse-4-compat = []
direct-mount = ["nix/resource", "nix/signal"]
# abi-7-xx feature flags are deprecated and don't do anything.
abi-7-20 = []
abi-7-21 = []
Expand Down
8 changes: 6 additions & 2 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
fn main() {
// Register rustc cfg for switching between mount implementations.
println!(
"cargo::rustc-check-cfg=cfg(fuser_mount_impl, values(\"pure-rust\", \"libfuse2\", \"libfuse3\", \"macos-no-mount\"))"
"cargo::rustc-check-cfg=cfg(fuser_mount_impl, values(\"direct-mount\", \"pure-rust\", \"libfuse2\", \"libfuse3\", \"macos-no-mount\"))"
);

let target_os =
Expand All @@ -12,7 +12,11 @@ fn main() {
"linux" | "freebsd" | "dragonfly" | "openbsd" | "netbsd"
) && cfg!(not(feature = "libfuse"))
{
println!("cargo::rustc-cfg=fuser_mount_impl=\"pure-rust\"");
if cfg!(feature = "direct-mount") {
println!("cargo::rustc-cfg=fuser_mount_impl=\"direct-mount\"");
} else {
println!("cargo::rustc-cfg=fuser_mount_impl=\"pure-rust\"");
}
} else if target_os == "macos" {
if cfg!(feature = "macos-no-mount") {
println!("cargo::rustc-cfg=fuser_mount_impl=\"macos-no-mount\"");
Expand Down
310 changes: 310 additions & 0 deletions src/mnt/fuse_direct.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
use std::fs::File;
use std::io;
use std::io::Read;
use std::io::Write;
use std::os::fd::AsFd;
use std::os::fd::AsRawFd;
use std::os::fd::RawFd;
use std::os::unix::fs::MetadataExt;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::path::PathBuf;
use std::process::exit;
use std::sync::Arc;

use log::error;
use log::warn;
use nix::fcntl::OFlag;
use nix::fcntl::open;
use nix::mount::MntFlags;
use nix::mount::MsFlags;
use nix::mount::mount;
use nix::mount::umount2;
use nix::sys::resource::Resource;
use nix::sys::resource::getrlimit;
use nix::sys::signal::SigSet;
use nix::sys::signal::SigmaskHow;
use nix::sys::signal::sigprocmask;
use nix::sys::stat::Mode;
use nix::sys::stat::SFlag;
use nix::unistd::ForkResult;
use nix::unistd::Gid;
use nix::unistd::SysconfVar;
use nix::unistd::Uid;
use nix::unistd::close;
use nix::unistd::dup2_stderr;
use nix::unistd::dup2_stdin;
use nix::unistd::dup2_stdout;
use nix::unistd::fork;
use nix::unistd::setsid;
use nix::unistd::sysconf;

use crate::SessionACL;
use crate::dev_fuse::DevFuse;
use crate::mnt::mount_options::MountOption;

const DEV_FUSE: &str = "/dev/fuse";

#[derive(Debug)]
pub(crate) struct MountImpl {
mountpoint: PathBuf,
auto_unmount_socket: Option<UnixStream>,
}

impl MountImpl {
pub(crate) fn new(
mountpoint: &Path,
options: &[MountOption],
acl: SessionACL,
) -> io::Result<(Arc<DevFuse>, Self)> {
let mountpoint = mountpoint.canonicalize()?;
let dev = Arc::new(DevFuse(
File::options().read(true).write(true).open(DEV_FUSE)?,
));
let dev_fd = dev.as_raw_fd();

let uid = Uid::current();
let gid = Gid::current();

let mut fsname: Option<&str> = None;
let mut subtype: Option<&str> = None;
let mut blkdev = false;
let mut auto_unmount = false;
let mut flags = MsFlags::MS_NOSUID | MsFlags::MS_NODEV;

let mut opts = Vec::new();
for opt in options {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The for loop looks very similar to the code in the "pure" implementation. Let's reuse the option_group() and option_to_flag() functions

match opt {
MountOption::FSName(val) => fsname = Some(val),
MountOption::Subtype(val) => subtype = Some(val),
MountOption::CUSTOM(val) if val == "blkdev" => {
if !uid.is_root() {
return Err(io::ErrorKind::PermissionDenied.into());
}
blkdev = true;
}
MountOption::AutoUnmount => auto_unmount = true,
MountOption::RW => flags &= !MsFlags::MS_RDONLY,
MountOption::RO => flags |= MsFlags::MS_RDONLY,
MountOption::Suid if uid.is_root() => flags &= !MsFlags::MS_NOSUID,
MountOption::Suid => warn!("unsafe mount option 'suid' ignored"),
MountOption::NoSuid => flags |= MsFlags::MS_NOSUID,
MountOption::Dev if uid.is_root() => flags &= !MsFlags::MS_NODEV,
MountOption::Dev => warn!("unsafe mount option 'nodev' ignored"),
MountOption::NoDev => flags |= MsFlags::MS_NODEV,
MountOption::Exec => flags &= !MsFlags::MS_NOEXEC,
MountOption::NoExec => flags |= MsFlags::MS_NOEXEC,
MountOption::Async => flags &= !MsFlags::MS_SYNCHRONOUS,
MountOption::Sync => flags |= MsFlags::MS_SYNCHRONOUS,
MountOption::Atime => flags &= !MsFlags::MS_NOATIME,
MountOption::NoAtime => flags |= !MsFlags::MS_NOATIME,
Comment thread
cberner marked this conversation as resolved.
Outdated
MountOption::CUSTOM(val) if val == "diratime" => flags &= !MsFlags::MS_NODIRATIME,
MountOption::CUSTOM(val) if val == "nodiratime" => flags |= MsFlags::MS_NODIRATIME,
MountOption::CUSTOM(val) if val == "lazytime" => flags |= MsFlags::MS_LAZYTIME,
MountOption::CUSTOM(val) if val == "nolazytime" => flags &= !MsFlags::MS_LAZYTIME,
MountOption::CUSTOM(val) if val == "relatime" => flags |= MsFlags::MS_RELATIME,
MountOption::CUSTOM(val) if val == "norelatime" => flags &= !MsFlags::MS_RELATIME,
MountOption::CUSTOM(val) if val == "strictatime" => {
flags |= MsFlags::MS_STRICTATIME
}
MountOption::CUSTOM(val) if val == "nostrictatime" => {
flags &= !MsFlags::MS_STRICTATIME
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should handle these CUSTOM options here. Instead please submit a separate PR that adds them to the MountOption enum

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. I only covered these options to because the reference fusermount does.

MountOption::DirSync => flags |= MsFlags::MS_DIRSYNC,
MountOption::DefaultPermissions => write!(opts, "default_permissions,")?,
MountOption::CUSTOM(val)
if val.starts_with("max_read=") || val.starts_with("blksize=") =>
{
write!(opts, "{val},")?
}
MountOption::CUSTOM(val) => {
error!("invalid mount option '{val}'");
return Err(nix::Error::EINVAL.into());
}
}
}

if let Some(opt) = acl.to_mount_option() {
write!(opts, "{opt},")?;
}

let root_mode = mountpoint
.metadata()
.map(|meta| meta.mode() & SFlag::S_IFMT.bits())?;

let old_len = opts.len();
write!(
opts,
"fd={},rootmode={:o},user_id={},group_id={}",
dev_fd,
root_mode,
uid.as_raw(),
gid.as_raw(),
)?;
Comment on lines +191 to +203
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rootmode is derived using mode() & S_IFMT, which keeps only the file type bits and drops permission bits. This will produce values like 040000 (directory type) without actual permissions, which can cause incorrect access checks/behavior compared to fusermount. Use the full mode (or at least preserve permission bits, e.g., type bits + 0o7777) to match expected kernel FUSE mount semantics.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mode & S_IFMT is what fusermount does:

res = do_mount(real_mnt, type, stbuf.st_mode & S_IFMT,
         fd, do_mount_opts, dev, &source, &mnt_opts);


let mut ty = match (subtype, blkdev) {
(None, false) => "fuse".into(),
(None, true) => "fuseblk".into(),
(Some(subtype), false) => format!("fuse.{subtype}"),
(Some(subtype), true) => format!("fuseblk.{subtype}"),
};

let mut source = if let Some(fsname) = fsname {
fsname
} else if let Some(subtype) = subtype {
subtype
} else {
DEV_FUSE
};

let pagesize = sysconf(SysconfVar::PAGE_SIZE)?
.map_or(usize::MAX, |ps| ps.try_into().unwrap_or(usize::MAX))
- 1;

if opts.len() > pagesize {
error!(
"mount options too long: '{}'",
String::from_utf8_lossy(&opts)
);
return Err(nix::Error::EINVAL.into());
}

let mut res = mount(
Some(source),
&mountpoint,
Some(ty.as_str()),
flags,
Some(opts.as_slice()),
);
let source_tmp;
if let Err(nix::Error::ENODEV) = &res {
if let Some(subtype) = subtype {
ty = (if blkdev { "fuseblk" } else { "fuse" }).into();
source_tmp = match (fsname, blkdev) {
(Some(fsname), false) => format!("{subtype}#{fsname}"),
(Some(_), true) => source.into(),
_ => ty.clone(),
};
source = source_tmp.as_str();

res = mount(
Some(source),
&mountpoint,
Some(ty.as_str()),
flags,
Some(opts.as_slice()),
);
}
}
if let Err(nix::Error::EINVAL) = &res {
opts.truncate(old_len);

write!(
opts,
"fd={},rootmode={:o},user_id={}",
dev_fd,
root_mode,
uid.as_raw(),
)?;

res = mount(
Some(source),
&mountpoint,
Some(ty.as_str()),
flags,
Some(opts.as_slice()),
);
}
res.inspect_err(|err| error!("mount failed: {err}"))?;

let mut mnt = MountImpl {
mountpoint,
auto_unmount_socket: None,
};

if auto_unmount {
mnt.setup_auto_unmount()?;
}

Ok((dev, mnt))
}

pub(crate) fn umount_impl(&mut self) -> io::Result<()> {
self.do_unmount(true)
}
Comment on lines +323 to +355
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On non-Linux targets, lazy=true maps to MNT_FORCE, and umount_impl() always passes true, meaning unmount becomes forced by default. That’s a much stronger behavior than Linux MNT_DETACH semantics and can be disruptive. Consider making umount_impl() do a non-forced unmount first (empty flags), and only retry with MNT_FORCE when explicitly requested or when a non-forced attempt fails with EBUSY.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed so MNT_FORCE is used when lazy=false.


fn do_unmount(&mut self, lazy: bool) -> io::Result<()> {
let flags = if lazy {
MntFlags::MNT_DETACH
} else {
MntFlags::empty()
};
umount2(&self.mountpoint, flags)?;
Ok(())
}
Comment on lines +375 to +383
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On non-Linux targets, lazy=true maps to MNT_FORCE, and umount_impl() always passes true, meaning unmount becomes forced by default. That’s a much stronger behavior than Linux MNT_DETACH semantics and can be disruptive. Consider making umount_impl() do a non-forced unmount first (empty flags), and only retry with MNT_FORCE when explicitly requested or when a non-forced attempt fails with EBUSY.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the opposite of what it should be. Fixed so lazy=true results in empty flags, and lazy=false results in MNT_FORCE.


fn setup_auto_unmount(&mut self) -> io::Result<()> {
let (tx, rx) = UnixStream::pair()?;

if let ForkResult::Child = unsafe { fork() }? {
exit(match self.do_auto_unmount(rx) {
Ok(()) => 0,
Err(err) => err.raw_os_error().unwrap_or(1),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reap auto-unmount helper after explicit unmount

The AutoUnmount path forks a long-lived helper but discards its PID, so when MountImpl::umount_impl drops the socket and the helper exits, the parent has no way to waitpid it. In long-running processes that mount/unmount repeatedly without exiting immediately, this leaks zombie processes until process termination. Tracking the child PID and reaping it on unmount (or using a double-fork/no-zombie strategy) avoids this resource leak.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

A call to MountImpl::unmount_impl when auto unmount is enabled will now signal the daemon to perform the unmount immediately and wait for it to exit. When an error is detected from the daemon, the parent process reattempts the unmount as if auto unmount was not enabled (but still checks if the fs is still mounted first).

}

self.auto_unmount_socket = Some(tx);

Ok(())
}

fn do_auto_unmount(&mut self, mut pipe: UnixStream) -> io::Result<()> {
close_inherited_fds(pipe.as_raw_fd());
let _ = setsid();
let _ = sigprocmask(SigmaskHow::SIG_BLOCK, Some(&SigSet::empty()), None);
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SIG_BLOCK with an empty set is effectively a no-op (it blocks nothing). If the intent is to clear the signal mask in the daemon after fork(), this should likely be SigmaskHow::SIG_SETMASK with an empty set; otherwise, the call can be removed to reduce confusion.

Suggested change
let _ = sigprocmask(SigmaskHow::SIG_BLOCK, Some(&SigSet::empty()), None);
let _ = sigprocmask(SigmaskHow::SIG_SETMASK, Some(&SigSet::empty()), None);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this was supposed to be SigSet::all(). Fixed


let mut buf = [0u8; 16];
loop {
match pipe.read(&mut buf) {
Ok(0) => break,
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
_ => break,
}
}

if self.should_auto_unmount()? {
self.do_unmount(false)?;
}

Ok(())
}

fn should_auto_unmount(&self) -> io::Result<bool> {
todo!()
Comment thread
cberner marked this conversation as resolved.
Outdated
}
}

fn close_inherited_fds(pipe: RawFd) {
let max_fds = getrlimit(Resource::RLIMIT_NOFILE).map_or(RawFd::MAX, |(soft, hard)| {
Ord::min(soft, hard).try_into().unwrap_or(RawFd::MAX)
});

let _ = redirect_stdio();

for fd in 3..=max_fds {
if fd != pipe {
let _ = close(fd);
}
}
}
Comment on lines +583 to +595
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iterating and closing every fd up to RLIMIT_NOFILE can be very expensive when limits are large (common in container environments). On Linux, consider using close_range (or the nix equivalent) to close fd ranges efficiently, or at least cap the loop to a reasonable maximum when RLIMIT_NOFILE is unexpectedly huge.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surprisingly, close_range does essentially the same thing internally rather than something more efficient. It's also not in nix and is a relatively recent addition to glibc. I think it's best to just use the loop for maximum portability.


fn redirect_stdio() -> io::Result<()> {
let nullfd = open("/dev/null", OFlag::O_RDWR, Mode::empty())?;

let _ = dup2_stdin(nullfd.as_fd());
let _ = dup2_stdout(nullfd.as_fd());
let _ = dup2_stderr(nullfd.as_fd());

Ok(())
}
24 changes: 23 additions & 1 deletion src/mnt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ mod fuse3_sys;

#[cfg(fuser_mount_impl = "pure-rust")]
mod fuse_pure;

#[cfg(fuser_mount_impl = "direct-mount")]
mod fuse_direct;

pub(crate) mod mount_options;

use std::io;
Expand Down Expand Up @@ -65,6 +69,8 @@ use crate::SessionACL;

#[derive(Debug)]
enum MountImpl {
#[cfg(fuser_mount_impl = "direct-mount")]
Direct(fuse_direct::MountImpl),
#[cfg(fuser_mount_impl = "pure-rust")]
Pure(fuse_pure::MountImpl),
#[cfg(fuser_mount_impl = "libfuse2")]
Expand All @@ -76,6 +82,8 @@ enum MountImpl {
impl MountImpl {
fn umount_impl(&mut self) -> io::Result<()> {
match self {
#[cfg(fuser_mount_impl = "direct-mount")]
MountImpl::Direct(mount) => mount.umount_impl(),
#[cfg(fuser_mount_impl = "pure-rust")]
MountImpl::Pure(mount) => mount.umount_impl(),
#[cfg(fuser_mount_impl = "libfuse2")]
Expand All @@ -101,6 +109,17 @@ impl Mount {
options: &[MountOption],
acl: SessionACL,
) -> io::Result<(Arc<DevFuse>, Mount)> {
#[cfg(fuser_mount_impl = "direct-mount")]
{
let (dev_fuse, mount) = fuse_direct::MountImpl::new(mountpoint, options, acl)?;
Ok((
dev_fuse,
Mount {
mount_impl: Some(MountImpl::Direct(mount)),
mount_point: mountpoint.to_path_buf(),
},
))
}
#[cfg(fuser_mount_impl = "pure-rust")]
{
let (dev_fuse, mount) = fuse_pure::MountImpl::new(mountpoint, options, acl)?;
Expand Down Expand Up @@ -165,7 +184,10 @@ impl Drop for Mount {
}
}

#[cfg_attr(fuser_mount_impl = "macos-no-mount", expect(dead_code))]
#[cfg_attr(
any(fuser_mount_impl = "macos-no-mount", fuser_mount_impl = "direct-mount"),
expect(dead_code)
)]
fn libc_umount(mnt: &CStr) -> nix::Result<()> {
#[cfg(any(
target_os = "macos",
Expand Down
Loading