mountfd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 75db1001630a8a9cc58592f3daa3c263fe50f3c356de527f4f693d1c34ae7e1f
4
+ data.tar.gz: be9d690de9dae6234faeb83b7f5c712b73690fb703769ad12adba3298dfccedd
5
+ SHA512:
6
+ metadata.gz: a305df091fee137702cc27adc98e1ab4537ce3d1b9dd7580a3a98a81e2943fd65753a79a2713009f50d8271f0c99cfb52d333b6d502a59f4efccf691445ea5cd
7
+ data.tar.gz: e60033d763131eed72f6bf3dd5c2d323dbe2c450614865e53933384ee7d9a155ecbc19391fb222ad3eac3c61ed40dee8727272b9f29907143c4bc698ecb53f19
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # Mountfd
2
+
3
+ Mountfd exposes Linux's file-descriptor-based mount API to Ruby. It builds a
4
+ mount while detached, applies its final attributes, and only then attaches it
5
+ to the mount namespace. This avoids the visibility and option-string problems
6
+ of the legacy `mount(2)` API.
7
+
8
+ ```text
9
+ FsContext (fsopen) -> superblock (fsconfig) -> DetachedMount (fsmount)
10
+ |
11
+ existing mount ---------------- open_tree(CLONE) ---+
12
+ |
13
+ move_mount
14
+ v
15
+ mounted path
16
+ ```
17
+
18
+ The native extension calls `fsopen`, `fsconfig`, `fsmount`, `fspick`,
19
+ `open_tree`, `move_mount`, `mount_setattr`, `statmount`, `listmount`, and
20
+ `umount2` directly. It does not use Fiddle or FFI.
21
+
22
+ ## Requirements
23
+
24
+ - Ruby 3.2 or newer
25
+ - Linux 5.2 or newer for the new mount API
26
+ - Linux 5.12 or newer for `mount_setattr` and idmapped mounts
27
+ - Linux 6.8 or newer for `statmount`/`listmount`; older kernels use
28
+ `/proc/self/mountinfo`
29
+ - `CAP_SYS_ADMIN` in the owning user namespace, normally obtained through an
30
+ unprivileged user namespace or a privileged container
31
+
32
+ `require "mountfd"` succeeds on unsupported platforms so applications can use
33
+ feature detection:
34
+
35
+ ```ruby
36
+ Mountfd.supported? # false on macOS or a kernel older than 5.2
37
+ Mountfd.features # [:new_mount_api, :mount_setattr, :idmap, ...]
38
+ ```
39
+
40
+ Known unsupported or restricted environments:
41
+
42
+ | Environment | Result |
43
+ |---|---|
44
+ | macOS and Windows | The gem builds and loads, but mount operations raise `UnsupportedError`. |
45
+ | WSL2 with a kernel older than 5.2 | The new mount syscalls are unavailable. |
46
+ | Docker Desktop | Operations affect the Linux VM/container namespace, never the host filesystem. |
47
+ | Unprivileged Docker | The default seccomp/capability policy commonly rejects mount and user-namespace operations. |
48
+ | Ubuntu 24.04+ | AppArmor may block unprivileged user namespaces via `kernel.apparmor_restrict_unprivileged_userns=1`. |
49
+ | GitHub-hosted runners | Unit tests work; system tests depend on the runner's user-namespace policy. |
50
+
51
+ Filesystem support for idmapped mounts is kernel-dependent. The safe,
52
+ source-free probe produced:
53
+
54
+ | kernel | tmpfs | ramfs | hugetlbfs |
55
+ |---|---|---|---|
56
+ | 5.10 | no/unavailable | no/unavailable | no/unavailable |
57
+ | 5.15 | no/unavailable | no/unavailable | no/unavailable |
58
+ | 6.1 | no/unavailable | no/unavailable | no/unavailable |
59
+ | 6.6 | yes | no/unavailable | no/unavailable |
60
+ | 6.8 | yes | no/unavailable | no/unavailable |
61
+ | 6.12 | yes | no/unavailable | no/unavailable |
62
+
63
+ Run `rake research:idmap_support` in the target environment. Filesystems that
64
+ need a block device or mount options are deliberately excluded from this safe
65
+ probe; the system suite separately verifies ext4 on a loop device on Linux
66
+ 5.15 and 6.8.
67
+
68
+ ## Installation
69
+
70
+ ```sh
71
+ bundle add mountfd
72
+ ```
73
+
74
+ Or install it directly with `gem install mountfd`.
75
+
76
+ ## Create and attach a mount
77
+
78
+ The block form closes the filesystem context while returning ownership of the
79
+ detached mount:
80
+
81
+ ```ruby
82
+ mount = Mountfd::FsContext.open("tmpfs") do |context|
83
+ context.set("size", "64M")
84
+ context.set_flag("noswap")
85
+ context.create!
86
+ context.mount(attrs: {nosuid: true, nodev: true})
87
+ end
88
+
89
+ mount.attach("/mnt/tmp")
90
+ ```
91
+
92
+ Configuration failures include diagnostics read from the filesystem context:
93
+
94
+ ```ruby
95
+ context = Mountfd::FsContext.new("tmpfs")
96
+ context.set("sizee", "64M")
97
+ # Mountfd::ConfigError: fsconfig: Invalid argument
98
+ # error: tmpfs: Unknown parameter 'sizee'
99
+ ```
100
+
101
+ The high-level form performs the same lifecycle:
102
+
103
+ ```ruby
104
+ Mountfd.mount(
105
+ "tmpfs", "/mnt/tmp",
106
+ options: {size: "64M"},
107
+ attrs: {nosuid: true, nodev: true, atime: :noatime}
108
+ )
109
+ Mountfd.umount("/mnt/tmp")
110
+ ```
111
+
112
+ ## Bind and idmapped mounts
113
+
114
+ `bind` uses `open_tree(OPEN_TREE_CLONE)`. Recursive attributes are applied to
115
+ the detached tree before it becomes visible:
116
+
117
+ ```ruby
118
+ Mountfd.bind("/src", "/dst", recursive: true, attrs: {rdonly: true})
119
+ ```
120
+
121
+ Pass an existing user namespace or a mapping. Mapping ranges may use a hash or
122
+ an array of `[inside, outside, length]` triples:
123
+
124
+ ```ruby
125
+ Mountfd.bind(
126
+ "/data", "/container/data", recursive: true,
127
+ idmap: {
128
+ uid: {0 => [100_000, 65_536]},
129
+ gid: {0 => [100_000, 65_536]},
130
+ helper: :auto
131
+ }
132
+ )
133
+ ```
134
+
135
+ The triples are written to `uid_map`/`gid_map` in kernel order. When the caller
136
+ uses the initial user namespace, mapping an on-disk UID 100000 so it is reported
137
+ as UID 0 uses `{100_000 => [0, 1]}`. A container that itself maps root to host
138
+ UID 100000 normally uses `{0 => [100_000, 65_536]}` for both its user namespace
139
+ and mount.
140
+
141
+ `:auto` uses `newuidmap` and `newgidmap` for non-root callers when both are
142
+ installed. Requested ranges must also be delegated in `/etc/subuid` and
143
+ `/etc/subgid`.
144
+
145
+ ## Mount discovery
146
+
147
+ ```ruby
148
+ Mountfd.mounts # Array<Mountfd::MountInfo>
149
+ Mountfd.mount_at("/home") # MountInfo or nil
150
+ Mountfd.mounts_backend # :statmount or :mountinfo
151
+ Mountfd.mounts(ns: 1234) # parses /proc/1234/mountinfo
152
+ File.open("/proc/1234/ns/mnt") { Mountfd.mounts(ns: _1) } # statmount, Linux 6.11+
153
+ ```
154
+
155
+ The mountinfo fallback decodes octal path escapes and handles the variable
156
+ optional-field section. A disappearing mount during `statmount` enumeration is
157
+ ignored as a normal race. An open mount namespace descriptor can be passed on
158
+ Linux 6.11 or newer; an integer namespace argument remains a process ID.
159
+ Generic attributes and propagation are normalized across both backends.
160
+ `source` or filesystem-specific `options` may be nil or empty when the running
161
+ kernel does not return the corresponding statmount field.
162
+
163
+ ## Namespace helpers
164
+
165
+ Namespace changes affect the calling OS thread and are intended for a
166
+ single-threaded setup phase:
167
+
168
+ ```ruby
169
+ Mountfd::Namespace.reexec_user! # robust entry path, including Ruby 3.4+
170
+ Mountfd::Namespace.unshare_user!(map_root: true)
171
+ Mountfd::Namespace.unshare_mount!(propagation: :private)
172
+ Mountfd.pivot_root(new_root, put_old)
173
+ ```
174
+
175
+ `reexec_user!` restarts the current command through `unshare -Ur`; use it before
176
+ creating threads. The in-process `unshare_user!` is available when the Ruby
177
+ process has only one OS thread.
178
+
179
+ See `examples/` for an overlay mini-container, idmapped volume, read-only
180
+ sandbox with a writable tmpfs at `/tmp`, and `MOVE_MOUNT_BENEATH` atomic swap.
181
+ Set `MOUNTFD_LANDLOCK=1` when running `readonly_sandbox.rb` with the optional
182
+ `landlock` gem installed to restrict filesystem writes to `/tmp` (plus `/dev/null`)
183
+ as defense in depth. The exec-based examples use a supervising parent so their
184
+ temporary mount trees are removed after the command exits, including on a
185
+ nonzero exit status.
186
+
187
+ ## Development
188
+
189
+ ```sh
190
+ bundle install
191
+ bundle exec rake test:unit
192
+ bundle exec rbs -I sig validate
193
+ ```
194
+
195
+ System tests change mount namespaces and must run on Linux with user namespaces
196
+ enabled:
197
+
198
+ ```sh
199
+ bundle exec rake test:system
200
+ bundle exec rake test:adversarial # 270-mount pagination and long statmount data
201
+ bundle exec rake test:ext4 # root plus loop-device access
202
+ ```
203
+
204
+ For kernel-matrix testing, install `virtme-ng` and run:
205
+
206
+ ```sh
207
+ make -C tools/vm KVER=6.12.20
208
+ ```
209
+
210
+ The source-free idmap probe prints a Markdown table for the current kernel:
211
+
212
+ ```sh
213
+ bundle exec rake research:idmap_support
214
+ bundle exec rake benchmark:mounts # defaults to a namespace with 1000 mounts
215
+ ```
216
+
217
+ ## Scope
218
+
219
+ Mountfd intentionally does not wrap legacy `mount(2)`, FUSE mount helpers, or
220
+ systemd `.mount` units. Filesystem-specific `fsconfig` keys are passed to the
221
+ kernel without duplicating kernel validation.
222
+
223
+ ## License
224
+
225
+ Mountfd is available under the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/extensiontask"
5
+ require "rspec/core/rake_task"
6
+ require "yard"
7
+
8
+ Rake::ExtensionTask.new("mountfd") do |extension|
9
+ extension.lib_dir = "lib/mountfd"
10
+ end
11
+
12
+ RSpec::Core::RakeTask.new(:spec) do |task|
13
+ task.pattern = "spec/mountfd_spec.rb"
14
+ end
15
+
16
+ RSpec::Core::RakeTask.new("spec:system") do |task|
17
+ task.pattern = "spec/system/**/*_spec.rb"
18
+ end
19
+
20
+ RSpec::Core::RakeTask.new("spec:ext4") do |task|
21
+ task.pattern = "spec/ext4/**/*_spec.rb"
22
+ end
23
+
24
+ YARD::Rake::YardocTask.new(:yard)
25
+
26
+ desc "Validate RBS signatures"
27
+ task :rbs do
28
+ sh "rbs", "-I", "sig", "validate"
29
+ end
30
+
31
+ namespace :test do
32
+ task unit: :spec
33
+ task system: :compile do
34
+ ENV["MOUNTFD_SYSTEM"] = "1"
35
+ Rake::Task["spec:system"].invoke
36
+ end
37
+ task adversarial: :compile do
38
+ ENV["MOUNTFD_SYSTEM"] = ENV["MOUNTFD_EXTENSIVE"] = "1"
39
+ Rake::Task["spec:system"].invoke
40
+ end
41
+ task ext4: :compile do
42
+ ENV["MOUNTFD_EXT4"] = "1"
43
+ Rake::Task["spec:ext4"].invoke
44
+ end
45
+ end
46
+
47
+ namespace :research do
48
+ desc "Probe idmapped-mount support for source-free filesystems"
49
+ task idmap_support: :compile do
50
+ ruby "-Ilib", "tools/research_idmap.rb"
51
+ end
52
+ end
53
+
54
+ namespace :benchmark do
55
+ desc "Compare statmount with mountinfo parsing in a 1000-mount namespace"
56
+ task mounts: :compile do
57
+ ruby "-Ilib", "benchmark/mounts.rb"
58
+ end
59
+ end
60
+
61
+ namespace :gen do
62
+ desc "Generate mount constants from the installed Linux UAPI headers"
63
+ task :constants do
64
+ ruby "tools/generate_constants.rb"
65
+ end
66
+ end
67
+
68
+ task spec: :compile
69
+ task default: "test:unit"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "mountfd"
5
+ require "tmpdir"
6
+
7
+ abort "Linux statmount support is required" unless Mountfd.features.include?(:statmount)
8
+
9
+ Mountfd::Namespace.reexec_user!
10
+ Mountfd::Namespace.unshare_mount!
11
+ count = Integer(ENV.fetch("MOUNTFD_BENCH_MOUNTS", "1000"))
12
+ iterations = Integer(ENV.fetch("MOUNTFD_BENCH_ITERATIONS", "10"))
13
+
14
+ measure = lambda do |label, &work|
15
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
16
+ work.call
17
+ puts format("%-20s %8.3f ms", label, (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000)
18
+ end
19
+
20
+ Dir.mktmpdir do |directory|
21
+ targets = count.times.map { File.join(directory, _1.to_s) }
22
+ targets.each { Dir.mkdir(_1); Mountfd.mount("tmpfs", _1) }
23
+ puts "#{Mountfd.mounts(backend: :statmount).length} visible mounts (#{count} created)"
24
+ measure.call("statmount/listmount") { iterations.times { Mountfd.mounts(backend: :statmount) } }
25
+ measure.call("parse mountinfo") do
26
+ iterations.times { Mountfd::MountInfoParser.parse(File.read("/proc/self/mountinfo")) }
27
+ end
28
+ ensure
29
+ targets&.reverse_each do |target|
30
+ Mountfd.umount(target)
31
+ rescue Errno::EINVAL, Errno::ENOENT
32
+ nil
33
+ end
34
+ end
@@ -0,0 +1,48 @@
1
+ # Kernel notes
2
+
3
+ Results recorded on 2026-08-25 and 2026-08-26 while implementing the initial release.
4
+
5
+ | Environment | Result |
6
+ |---|---|
7
+ | macOS arm64, Ruby 4.0 | Native extension builds; `require` and unit tests pass; feature detection returns unsupported. |
8
+ | Linux arm64 container, Ruby 3.2/3.3/3.4 | Native extension builds; unit tests and RBS validation pass on every version. |
9
+ | Privileged Linux container | `fsopen(tmpfs)` → `fsconfig` → `fsmount` → `move_mount`, file I/O, and `umount2` pass in a private mount namespace. |
10
+ | Privileged Linux container | A misspelled tmpfs option returns `error: tmpfs: Unknown parameter 'sizee'`. |
11
+ | Privileged Linux container | `open_tree(CLONE)` plus `mount_setattr(MOUNT_ATTR_RDONLY)` rejects writes with `EROFS`. |
12
+ | Privileged Linux container | C-level user-namespace keeper returns a usable namespace fd and releases the keeper process. |
13
+ | Privileged Linux container | A tmpfs file owned by UID 100000 is reported as UID 0 through an idmapped bind using `100000 0 1`. |
14
+ | Linux 6.8.0-64 arm64 | `statmount` and mountinfo return the same visible mounts, including overmount filtering. |
15
+ | Linux 6.8.0-64 arm64 | `listmount` pagination passes with 270 new mounts; `statmount` buffer growth passes with more than 4 KiB of path data. |
16
+ | Linux 6.8.0-64 arm64 | 1000 explicit fd closes and `GC.stress` leave `/proc/self/fd` unchanged. |
17
+ | Linux 6.8.0-64 arm64 | Recursive read-only, attribute set/clear, propagation, detached discard, and atomic replacement tests pass. |
18
+ | Linux 6.8.0-64 arm64 | `MOVE_MOUNT_SET_GROUP`, close-error fd reuse, compacting-GC syscall arguments, and native binary-length validation pass. |
19
+ | Linux 6.8.0-64 arm64 | Invalid idmap fd/flag pairs are rejected before the syscall; the helper path passes through `posix_spawnp`. |
20
+ | Linux 6.8.0-64 arm64 | An ext4 loopback file owned by UID 1000 is UID 0 through an idmapped bind; an unmapped UID is 65534. |
21
+ | Linux 6.8.0-64 arm64, non-root | `newuidmap`/`newgidmap` create a namespace fd with delegated `100000:65536` sub-ID ranges. |
22
+ | Linux 6.8.0-64 arm64 | Safe source-free idmap probe: tmpfs yes; ramfs and hugetlbfs no/unavailable. |
23
+ | Linux 6.8.0-64 arm64 | 1000-mount benchmark, 10 iterations: statmount/listmount 30 ms; mountinfo parse 37 ms. |
24
+ | Linux 6.8.0-64 arm64, Ruby 3.4 | Adversarial suite: 24 examples, 0 failures, 2 expected pending. |
25
+ | Linux 6.8.0-64 arm64, Ruby 3.4, ASan/UBSan | Unit and system suites plus repeated syscall/GC-compaction stress pass without sanitizer findings. The intentional SIGSTOP helper test runs separately because ASan cannot supervise the stopped fork child. |
26
+ | Linux 5.10.0 arm64, virtme-ng/QEMU | System suite: 21 examples, 0 failures, 13 expected pending; `mount_setattr` and exclusive creation report `UnsupportedError`. |
27
+ | Linux 5.15.0 arm64, virtme-ng/QEMU | System suite: 21 examples, 0 failures, 8 expected pending; `MOVE_MOUNT_SET_GROUP` and ext4 loopback idmap pass. |
28
+ | Linux 6.1.0 arm64, virtme-ng/QEMU | System suite: 17 examples, 0 failures, 8 expected pending. |
29
+ | Linux 6.6.0 arm64, virtme-ng/QEMU | System suite: 17 examples, 0 failures, 6 expected pending; exclusive filesystem-context creation passes. |
30
+ | Linux 6.12.20 arm64, virtme-ng/QEMU | System suite: 17 examples, 0 failures, 4 expected pending; namespace-selected `listmount`/`statmount` pass. |
31
+ | Linux 5.10/5.15/6.1 arm64 | Source-free idmap probe: tmpfs, ramfs, and hugetlbfs no/unavailable. |
32
+ | Linux 6.6/6.12.20 arm64 | Source-free idmap probe: tmpfs yes; ramfs and hugetlbfs no/unavailable. |
33
+ | Linux 6.12.0 arm64, QEMU/TCG | A static initramfs probe passes `NS_GET_MNTNS_ID`, namespace-selected `listmount`, and namespace-selected `statmount`. |
34
+ | Linux 6.8.0-64 arm64 | The read-only sandbox preserves command status and removes its temporary tree; the idmapped-volume and overlay-container examples pass on ext4-backed test data. |
35
+
36
+ The checked-in VM harness remains the source of truth for older-kernel
37
+ regressions. The tested Ubuntu 6.12.0 image panics in netfs/9p before userspace,
38
+ so its new namespace ABI was isolated with a static initramfs; the full Ruby
39
+ suite passes on the 6.12.20 stable update. The harness disables PSI only for
40
+ 5.10 because that kernel panics while current Ubuntu userspace initializes PSI.
41
+ Re-run the matrix before publishing a release.
42
+
43
+ ```sh
44
+ make -C tools/vm KVER=5.15
45
+ make -C tools/vm KVER=6.1
46
+ make -C tools/vm KVER=6.6
47
+ make -C tools/vm KVER=6.12.20
48
+ ```
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mountfd"
4
+
5
+ target = File.expand_path(ARGV.fetch(0) { abort "usage: #{$PROGRAM_NAME} TARGET" })
6
+ Mountfd.replace(target) do
7
+ Mountfd::FsContext.open("tmpfs") do |context|
8
+ context.set("size", "64M")
9
+ context.create!
10
+ context.mount(attrs: {nosuid: true, nodev: true})
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "mountfd"
5
+
6
+ source, target, outside = ARGV
7
+ abort "usage: #{$PROGRAM_NAME} SOURCE TARGET OUTSIDE_UID" unless outside
8
+ outside = Integer(outside)
9
+ FileUtils.mkdir_p(target)
10
+
11
+ Mountfd.bind(
12
+ File.expand_path(source), File.expand_path(target), recursive: true,
13
+ idmap: {
14
+ uid: {outside => [0, 65_536]},
15
+ gid: {outside => [0, 65_536]}
16
+ }
17
+ )
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "mountfd"
5
+ require "rbconfig"
6
+ require "tmpdir"
7
+
8
+ lower = File.expand_path(ARGV.fetch(0) { abort "usage: #{$PROGRAM_NAME} ROOTFS [COMMAND ...]" })
9
+ command = ARGV.drop(1)
10
+ command = ["/bin/sh"] if command.empty?
11
+
12
+ Mountfd::Namespace.reexec_user!
13
+ directory = ENV["MOUNTFD_CONTAINER_ROOT"]
14
+ unless directory
15
+ status = Dir.mktmpdir("mountfd-container") do |root|
16
+ command = begin
17
+ File.binread("/proc/self/cmdline").split("\0")
18
+ rescue Errno::EACCES, Errno::ENOENT
19
+ [RbConfig.ruby, $PROGRAM_NAME, *ARGV]
20
+ end
21
+ command = [RbConfig.ruby, $PROGRAM_NAME, *ARGV] if command.empty?
22
+ pid = Process.spawn({"MOUNTFD_CONTAINER_ROOT" => root}, *command)
23
+ Process.wait2(pid).last
24
+ end
25
+ exit(status.exitstatus || 128 + status.termsig)
26
+ end
27
+
28
+ Mountfd::Namespace.unshare_mount!
29
+ upper, work, root = %w[upper work root].map { |name| File.join(directory, name) }
30
+ FileUtils.mkdir_p([upper, work, root])
31
+ Mountfd.mount(
32
+ "overlay", root,
33
+ options: {lowerdir: lower, upperdir: upper, workdir: work},
34
+ attrs: {nosuid: true, nodev: true}
35
+ )
36
+ Dir.chdir(root)
37
+ Mountfd.pivot_root(".", ".")
38
+ Mountfd.umount(".")
39
+ Dir.chdir("/")
40
+ exec(*command)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mountfd"
4
+ require "rbconfig"
5
+ require "tmpdir"
6
+ require "landlock" if ENV["MOUNTFD_LANDLOCK"] == "1"
7
+
8
+ rootfs = File.expand_path(ARGV.fetch(0) { abort "usage: #{$PROGRAM_NAME} ROOTFS [COMMAND ...]" })
9
+ command = ARGV.drop(1)
10
+ command = ["/bin/sh"] if command.empty?
11
+ abort "#{rootfs}/tmp must exist" unless File.directory?(File.join(rootfs, "tmp"))
12
+
13
+ Mountfd::Namespace.reexec_user!
14
+ root = ENV["MOUNTFD_SANDBOX_ROOT"]
15
+ unless root
16
+ status = Dir.mktmpdir("mountfd-sandbox") do |directory|
17
+ command = begin
18
+ File.binread("/proc/self/cmdline").split("\0")
19
+ rescue Errno::EACCES, Errno::ENOENT
20
+ [RbConfig.ruby, $PROGRAM_NAME, *ARGV]
21
+ end
22
+ command = [RbConfig.ruby, $PROGRAM_NAME, *ARGV] if command.empty?
23
+ pid = Process.spawn({"MOUNTFD_SANDBOX_ROOT" => directory}, *command)
24
+ Process.wait2(pid).last
25
+ end
26
+ exit(status.exitstatus || 128 + status.termsig)
27
+ end
28
+
29
+ Mountfd::Namespace.unshare_mount!
30
+ Mountfd.bind(rootfs, root, recursive: true, attrs: {rdonly: true})
31
+ Mountfd.mount("tmpfs", File.join(root, "tmp"), attrs: {nosuid: true, nodev: true})
32
+ Dir.chdir(root)
33
+ Mountfd.pivot_root(".", ".")
34
+ Mountfd.umount(".")
35
+ Dir.chdir("/")
36
+ if ENV["MOUNTFD_LANDLOCK"] == "1"
37
+ Landlock.restrict!(read: ["/"], write: ["/tmp", "/dev/null"], execute: ["/"], allow_all_known: true)
38
+ end
39
+ exec(*command)