Skip to content

Direct mount without fusermount#645

Open
Vociferix wants to merge 16 commits intocberner:masterfrom
Vociferix:direct-mount
Open

Direct mount without fusermount#645
Vociferix wants to merge 16 commits intocberner:masterfrom
Vociferix:direct-mount

Conversation

@Vociferix
Copy link

I have a use case where I'm mounting in a Linux mount+user namespace (essentially a light weight container) and I don't want to depend on fusermount being available.

This PR adds a new feature direct-mount, which provides a new fuser_mount_impl that directly makes the necessary mount syscalls that fusermount would normally do. If AutoUnmount is requested, similar to fusermount, direct-mount will spawn a daemon process that waits for the mounting process to terminate before making the necessary umount2 syscall.

There are two main differences from fusermount:

  1. direct-mount does not attempt to modify the mtab file, which is an old behavior unnecessary on more recent systems.
  2. An unprivileged user can only mount when mapped to UID 0 in a user+mount namespace on Linux (i.e. root within a container/namespace jail).

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd452afdab

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Copy link
Owner

@cberner cberner left a comment

Choose a reason for hiding this comment

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

Cool, I've wanted to remove the fusermount dependency for a while.

I had some comments, and also please add a test for this feature to the Linux & FreeBSD suites

Comment on lines 104 to 115
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
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
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.

let mut flags = MsFlags::MS_NOSUID | MsFlags::MS_NODEV;

let mut opts = Vec::new();
for opt in options {
Copy link
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

@Vociferix
Copy link
Author

It's going to take some time for me to get FreeBSD working. It works differently enough that I'm going to have to review the mount_fusefs source to make sure I'm doing it properly.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new direct-mount mount backend that performs FUSE mounts via direct kernel mount syscalls (without requiring fusermount), including an auto-unmount daemon mode.

Changes:

  • Introduces direct-mount mount implementation and build-time selection via fuser_mount_impl.
  • Refactors mount option grouping/flag conversion helpers into mount_options.rs for reuse.
  • Extends test harness to run mount tests with the new direct-mount feature.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/mnt/mount_options.rs Adds shared option grouping + option→flag conversion for direct mount backend
src/mnt/mod.rs Wires in the new direct-mount backend behind cfg
src/mnt/fuse_pure.rs Switches to shared option helpers after refactor
src/mnt/fuse_direct.rs New direct syscall-based mount/unmount + auto-unmount daemon
fuser-tests/src/features.rs Adds DirectMount test feature flag
fuser-tests/src/commands/mount.rs Runs existing mount tests with DirectMount enabled
fuser-tests/src/commands/bsd_mount.rs Runs BSD mount tests with and without DirectMount
build.rs Adds cfg selection logic for direct-mount
Cargo.toml Adds direct-mount feature and required nix subfeatures

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 76 to 85
#[cfg_attr(
all(fuser_mount_impl = "direct-mount", fuser_mount_impl = "macos-no-mount"),
expect(dead_code)
)]
#[derive(PartialEq)]
pub(crate) enum MountOptionGroup {
KernelOption,
KernelFlag,
Fusermount,
}
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.

The cfg_attr condition uses all(fuser_mount_impl = "direct-mount", fuser_mount_impl = "macos-no-mount"), which can never be true (a single cfg key can’t equal two different values at once). If the intent is to suppress dead_code when either impl is selected, use any(...), or move the expect(dead_code) to the specific platforms/configs where the enum is genuinely unused.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

There is no longer a cfg_attr on this type

Comment on lines 115 to 127
let mut opts = Vec::new();
for opt in options {
match option_group(opt) {
MountOptionGroup::KernelFlag => flags |= option_to_flag(opt)?,
MountOptionGroup::KernelOption => write!(opts, "{},", option_to_string(opt))?,
MountOptionGroup::Fusermount => match opt {
MountOption::FSName(val) => fsname = Some(val),
MountOption::Subtype(val) => subtype = Some(val),
MountOption::AutoUnmount => auto_unmount = true,
_ => {}
},
}
}
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.

opts is built here but never used afterwards (the kernel options are rebuilt inside do_mount). This adds extra work and duplicates formatting/error paths. Consider removing opts from new() and only extracting flags/fsname/subtype/auto_unmount here, leaving kernel-option serialization to do_mount.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

opts was left over from the original linux-only implementation. The logic has been moved to the linux implementation of MountImpl::do_mount.

Comment on lines +180 to +192
let root_mode = mountpoint
.metadata()
.map(|meta| meta.mode() & nix::sys::stat::SFlag::S_IFMT.bits())?;

let old_len = opts.len();
write!(
opts,
"fd={},rootmode={:o},user_id={},group_id={}",
dev_fd,
root_mode,
Uid::current().as_raw(),
nix::unistd::Gid::current().as_raw(),
)?;
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
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);

Comment on lines 312 to 314
pub(crate) fn umount_impl(&mut self) -> io::Result<()> {
self.do_unmount(true)
}
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
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.

Comment on lines 334 to 342
fn do_unmount(&mut self, lazy: bool) -> io::Result<()> {
let flags = if lazy {
nix::mount::MntFlags::MNT_FORCE
} else {
nix::mount::MntFlags::empty()
};
nix::mount::unmount(&self.mountpoint, flags)?;
Ok(())
}
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
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 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
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

Comment on lines 381 to 391
fn should_auto_unmount(&self) -> io::Result<bool> {
let etc_mtab = Path::new("/etc/mtab");
let proc_mounts = Path::new("/proc/mounts");

let mtab_path = if etc_mtab.try_exists()? {
etc_mtab
} else if proc_mounts.try_exists()? {
proc_mounts
} else {
return Err(io::ErrorKind::NotFound.into());
};
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.

direct-mount is enabled for BSD targets in build.rs, but should_auto_unmount() relies on Linux-style /proc/mounts / /etc/mtab. On BSD (and likely others), this will return NotFound and prevent auto-unmount from happening. Consider making the auto-unmount check OS-specific (e.g., parse the platform’s mount table) or fall back to attempting unmount/umount2 directly and treating EINVAL/ENOENT as “already unmounted”.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Added a BSD specific version of should_auto_unmount() using getfsstat(). Unfortunately, getfsstat() doesn't seem to have safe binding in nix, so I had to use libc::getfsstat FFI binding.

Comment on lines +56 to +63
run_test(
&[Feature::DirectMount],
Unmount::Auto,
libfuse.fusermount(),
1,
false,
)
.await?;
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.

This test path is meant to validate “no fusermount dependency” for direct-mount, but it passes libfuse.fusermount() (which may allow fusermount to be present/used depending on the harness). To specifically cover the new requirement, run the auto-unmount direct-mount test with Fusermount::False so the test proves it works without fusermount available.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Fixed

Comment on lines +477 to +489
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);
}
}
}
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
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.

@cberner cberner self-requested a review February 9, 2026 05:00
@cberner
Copy link
Owner

cberner commented Feb 9, 2026

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd307d2ebc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +178 to +180
let root_mode = mountpoint
.metadata()
.map(|meta| meta.mode() & nix::sys::stat::SFlag::S_IFMT.bits())?;

Choose a reason for hiding this comment

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

P1 Badge Preserve mountpoint permission bits in rootmode

In the direct-mount Linux path, root_mode is masked with S_IFMT, which strips all permission bits and leaves only the file type (e.g., 040000). The kernel uses rootmode to set the permissions of the FUSE root inode, so this makes the root directory appear to have no access bits even when the mountpoint is 0755/0777, leading to unexpected EACCES on lookup/traversal for non-root users. The pure-rust path uses the full mode(); this new masking likely breaks access for direct-mount users.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

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

This is intentional. fusermount does exactly this. Mounting with pure-rust and direct-mount do result in the same permissions on the mount from my experimentations.

Comment on lines 310 to 321
pub(crate) fn umount_impl(&mut self) -> io::Result<()> {
self.do_unmount(true)
}

#[cfg(target_os = "linux")]
fn do_unmount(&mut self, lazy: bool) -> io::Result<()> {
let flags = if lazy {
nix::mount::MntFlags::MNT_DETACH
} else {
nix::mount::MntFlags::empty()
};
nix::mount::umount2(&self.mountpoint, flags)?;

Choose a reason for hiding this comment

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

P2 Badge Avoid unmounting unrelated filesystems on drop

The direct-mount umount_impl always detaches the mountpoint without verifying the mount still corresponds to this DevFuse instance. If a caller manually unmounts and then another filesystem is mounted on the same path before the Mount is dropped, this will unmount the new filesystem instead. The pure-rust path guards this with is_mounted; direct-mount lacks any check, so this race can detach unrelated mounts under legitimate use.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

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

Fixed. unmount_impl now does essentially the same thing as the pure-rust (fusermount) version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants