Mountfd
Mountfd exposes Linux's file-descriptor-based mount API to Ruby. It builds a
mount while detached, applies its final attributes, and only then attaches it
to the mount namespace. This avoids the visibility and option-string problems
of the legacy mount(2) API.
FsContext (fsopen) -> superblock (fsconfig) -> DetachedMount (fsmount)
|
existing mount ---------------- open_tree(CLONE) ---+
|
move_mount
v
mounted path
The native extension calls fsopen, fsconfig, fsmount, fspick,
open_tree, move_mount, mount_setattr, statmount, listmount, and
umount2 directly. It does not use Fiddle or FFI.
Requirements
- Ruby 3.2 or newer
- Linux 5.2 or newer for the new mount API
- Linux 5.12 or newer for
mount_setattrand idmapped mounts - Linux 6.8 or newer for
statmount/listmount; older kernels use/proc/self/mountinfo CAP_SYS_ADMINin the owning user namespace, normally obtained through an unprivileged user namespace or a privileged container
require "mountfd" succeeds on unsupported platforms so applications can use
feature detection:
Mountfd.supported? # false on macOS or a kernel older than 5.2
Mountfd.features # [:new_mount_api, :mount_setattr, :idmap, ...]
Known unsupported or restricted environments:
| Environment | Result |
|---|---|
| macOS and Windows | The gem builds and loads, but mount operations raise UnsupportedError. |
| WSL2 with a kernel older than 5.2 | The new mount syscalls are unavailable. |
| Docker Desktop | Operations affect the Linux VM/container namespace, never the host filesystem. |
| Unprivileged Docker | The default seccomp/capability policy commonly rejects mount and user-namespace operations. |
| Ubuntu 24.04+ | AppArmor may block unprivileged user namespaces via kernel.apparmor_restrict_unprivileged_userns=1. |
| GitHub-hosted runners | Unit tests work; system tests depend on the runner's user-namespace policy. |
Filesystem support for idmapped mounts is kernel-dependent. 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 rake research:idmap_support in the target environment. Filesystems that
need a block device or mount options are deliberately excluded from this safe
probe; the system suite separately verifies ext4 on a loop device on Linux
5.15 and 6.8.
Installation
bundle add mountfd
Or install it directly with gem install mountfd.
Create and attach a mount
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'
The high-level form performs the same lifecycle:
Mountfd.mount(
"tmpfs", "/mnt/tmp",
options: {size: "64M"},
attrs: {nosuid: true, nodev: true, atime: :noatime}
)
Mountfd.umount("/mnt/tmp")
Bind and idmapped mounts
bind uses open_tree(OPEN_TREE_CLONE). Recursive attributes are applied to
the detached tree before it 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/gid_map in kernel order. When the caller
uses the initial user namespace, mapping an on-disk UID 100000 so it is reported
as UID 0 uses {100_000 => [0, 1]}. A container that itself 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) } # statmount, Linux 6.11+
The mountinfo fallback decodes octal path escapes and handles the variable
optional-field section. A disappearing mount during statmount enumeration is
ignored as a normal race. An open mount namespace descriptor can be passed on
Linux 6.11 or newer; an integer namespace argument remains a process ID.
Generic attributes and propagation are normalized across both backends.
source or filesystem-specific options may be nil or empty when the running
kernel does not return the corresponding statmount field.
Namespace helpers
Namespace changes affect the calling OS thread and are intended for 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. The in-process unshare_user! is available when the Ruby
process has only one OS thread.
See examples/ for an overlay mini-container, idmapped volume, read-only
sandbox with a writable tmpfs at /tmp, and MOVE_MOUNT_BENEATH atomic swap.
Set MOUNTFD_LANDLOCK=1 when running readonly_sandbox.rb with the optional
landlock gem installed to restrict filesystem writes to /tmp (plus /dev/null)
as defense in depth. The exec-based examples use a supervising parent so their
temporary mount trees are removed after the command exits, including on a
nonzero exit status.
Development
bundle install
bundle exec rake test:unit
bundle exec rbs -I sig validate
System tests change mount namespaces and must run on 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 access
For kernel-matrix testing, install virtme-ng and run:
make -C tools/vm KVER=6.12.20
The source-free idmap probe prints a Markdown table for the current kernel:
bundle exec rake research:idmap_support
bundle exec rake benchmark:mounts # defaults to a namespace with 1000 mounts
Scope
Mountfd intentionally does not wrap legacy mount(2), FUSE mount helpers, or
systemd .mount units. Filesystem-specific fsconfig keys are passed to the
kernel without duplicating kernel validation.
License
Mountfd is available under the MIT License.