Mountfd
Ruby bindings for Linux's file-descriptor-based mount API
Features · Installation · Quick Start · How It Works · Usage · Compatibility
Mountfd builds Linux mounts while detached, applies their final attributes,
and only then attaches them to a mount namespace. This avoids the visibility
window and option-string limitations of the legacy mount(2) API.
Features
- Create and configure filesystems through
fsopen,fsconfig, andfsmount - Clone bind mounts with
open_treeand attach them atomically withmove_mount - Apply read-only, recursive, propagation, and idmapped mount attributes
- Surface structured kernel diagnostics from filesystem contexts
- Discover mounts through
statmount/listmount, with a mountinfo fallback - Create user namespaces and inspect or change mount namespaces
- Load safely on macOS and Windows for runtime feature detection
- Use a native C extension without Fiddle or FFI
Installation
Add Mountfd to your Gemfile:
gem "mountfd"Then install dependencies:
bundle installAlternatively, run bundle add mountfd or gem install mountfd.
Requirements
- Ruby 3.2 or newer
- Linux 5.2 or newer for the new mount API
-
CAP_SYS_ADMINin the owning user namespace, normally provided by an unprivileged user namespace or privileged container
Newer operations have additional kernel requirements:
| Feature | Minimum kernel |
|---|---|
| New mount API | 5.2 |
mount_setattr and idmapped mounts |
5.12 |
statmount and listmount
|
6.8 |
Mount namespace descriptors with statmount
|
6.11 |
Quick Start
Create, configure, and attach a tmpfs mount:
require "mountfd"
Mountfd.mount(
"tmpfs", "/mnt/tmp",
options: {size: "64M"},
attrs: {nosuid: true, nodev: true, atime: :noatime}
)
Mountfd.umount("/mnt/tmp")Check support before using mount operations on a portable application:
Mountfd.supported? # false on macOS or a kernel older than 5.2
Mountfd.features # [:new_mount_api, :mount_setattr, :idmap, ...]How It Works
FsContext (fsopen) -> superblock (fsconfig) -> DetachedMount (fsmount)
|
existing mount ---------------- open_tree(CLONE) ---+
|
move_mount
v
mounted path
Mountfd calls fsopen, fsconfig, fsmount, fspick, open_tree,
move_mount, mount_setattr, statmount, listmount, and umount2
directly. A detached mount remains invisible until move_mount attaches it;
closing its file descriptor discards it.
Usage
Filesystem contexts
The block form closes the filesystem context while returning ownership of the detached mount:
mount = Mountfd::FsContext.open("tmpfs") do |context|
context.set("size", "64M")
context.set_flag("noswap")
context.create!
context.mount(attrs: {nosuid: true, nodev: true})
end
mount.attach("/mnt/tmp")Configuration failures include diagnostics read from the filesystem context:
context = Mountfd::FsContext.new("tmpfs")
context.set("sizee", "64M")
# Mountfd::ConfigError: fsconfig: Invalid argument
# error: tmpfs: Unknown parameter 'sizee'Bind and idmapped mounts
bind clones a detached mount tree. Recursive attributes are applied before
the tree becomes visible:
Mountfd.bind("/src", "/dst", recursive: true, attrs: {rdonly: true})Pass an existing user namespace or a mapping. Mapping ranges may use a hash or
an array of [inside, outside, length] triples:
Mountfd.bind(
"/data", "/container/data", recursive: true,
idmap: {
uid: {0 => [100_000, 65_536]},
gid: {0 => [100_000, 65_536]},
helper: :auto
}
)The triples are written to uid_map and gid_map in kernel order. Mapping an
on-disk UID 100000 to UID 0 from the initial user namespace uses
{100_000 => [0, 1]}. A container that maps root to host UID 100000 normally
uses {0 => [100_000, 65_536]} for both its user namespace and mount.
:auto uses newuidmap and newgidmap for non-root callers when both are
installed. Requested ranges must also be delegated in /etc/subuid and
/etc/subgid.
Mount discovery
Mountfd.mounts # Array<Mountfd::MountInfo>
Mountfd.mount_at("/home") # MountInfo or nil
Mountfd.mounts_backend # :statmount or :mountinfo
Mountfd.mounts(ns: 1234) # parses /proc/1234/mountinfo
File.open("/proc/1234/ns/mnt") { Mountfd.mounts(ns: _1) } # Linux 6.11+The mountinfo fallback decodes octal path escapes and variable optional
fields. A disappearing mount during statmount enumeration is treated as a
normal race. Generic attributes and propagation are normalized across both
backends. An integer namespace argument remains a process ID. source and
filesystem-specific options may be nil or empty when the kernel does not
return the corresponding statmount field.
Namespace helpers
Namespace changes affect the calling OS thread and belong in a single-threaded setup phase:
Mountfd::Namespace.reexec_user! # robust entry path, including Ruby 3.4+
Mountfd::Namespace.unshare_user!(map_root: true)
Mountfd::Namespace.unshare_mount!(propagation: :private)
Mountfd.pivot_root(new_root, put_old)reexec_user! restarts the current command through unshare -Ur; use it
before creating threads. In-process unshare_user! is available when Ruby has
only one OS thread.
Examples
The examples directory contains:
- An overlay mini-container
- An idmapped volume
- A read-only sandbox with a writable tmpfs at
/tmp - An atomic mount replacement using
MOVE_MOUNT_BENEATH
Set MOUNTFD_LANDLOCK=1 when running readonly_sandbox.rb with the optional
landlock gem to restrict writes to /tmp and /dev/null. Exec-based examples
use a supervising parent to remove temporary mount trees after the command
exits, including after a nonzero status. Landlock provides defense in depth;
the mount namespace remains the primary boundary.
Compatibility
Unsupported platforms still load the gem, but mount operations raise
Mountfd::UnsupportedError.
| Environment | Behavior |
|---|---|
| macOS and Windows | The gem builds and loads; mount operations are unavailable. |
| WSL2 with a kernel older than 5.2 | The new mount syscalls are unavailable. |
| Docker Desktop | Operations affect the Linux VM or container namespace, not the host filesystem. |
| Unprivileged Docker | Default seccomp and capability policies commonly reject mount and user-namespace operations. |
| Ubuntu 24.04+ | AppArmor may block unprivileged user namespaces through kernel.apparmor_restrict_unprivileged_userns=1. |
| GitHub-hosted runners | Unit tests work; system tests depend on the runner's user-namespace policy. |
Idmapped mount support also varies by filesystem and kernel. The safe, source-free probe produced:
| Kernel | tmpfs | ramfs | hugetlbfs |
|---|---|---|---|
| 5.10 | no/unavailable | no/unavailable | no/unavailable |
| 5.15 | no/unavailable | no/unavailable | no/unavailable |
| 6.1 | no/unavailable | no/unavailable | no/unavailable |
| 6.6 | yes | no/unavailable | no/unavailable |
| 6.8 | yes | no/unavailable | no/unavailable |
| 6.12 | yes | no/unavailable | no/unavailable |
Run bundle exec rake research:idmap_support in the target environment.
Filesystems that need a block device or mount options are excluded from the
safe probe; the system suite separately verifies ext4 on a loop device on
Linux 5.15 and 6.8.
Development
bundle install
bundle exec rake test:unit
bundle exec rbs -I sig validateSystem tests change mount namespaces and require Linux with user namespaces enabled:
bundle exec rake test:system
bundle exec rake test:adversarial # 270-mount pagination and long statmount data
bundle exec rake test:ext4 # root plus loop-device accessFor kernel-matrix testing, install virtme-ng and run:
make -C tools/vm KVER=6.12.20Additional diagnostics and benchmarks:
bundle exec rake research:idmap_support
bundle exec rake benchmark:mounts # defaults to a namespace with 1000 mountsScope
Mountfd intentionally does not wrap legacy mount(2), FUSE mount helpers, or
systemd .mount units. Filesystem-specific fsconfig keys are passed directly
to the kernel without duplicating kernel validation.
Contributing
Bug reports and pull requests are welcome at github.com/ydah/mountfd.
License
Released under the MIT License.