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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +69 -0
- data/benchmark/mounts.rb +34 -0
- data/docs/kernel-notes.md +48 -0
- data/examples/atomic_swap.rb +12 -0
- data/examples/idmapped_volume.rb +17 -0
- data/examples/mini_container.rb +40 -0
- data/examples/readonly_sandbox.rb +39 -0
- data/ext/mountfd/compat.h +197 -0
- data/ext/mountfd/constants.c +53 -0
- data/ext/mountfd/extconf.rb +12 -0
- data/ext/mountfd/mount_info.c +166 -0
- data/ext/mountfd/mountfd.c +393 -0
- data/ext/mountfd/mountfd.h +17 -0
- data/ext/mountfd/namespace.c +74 -0
- data/ext/mountfd/user_namespace.c +220 -0
- data/lib/mountfd/attributes.rb +65 -0
- data/lib/mountfd/core.rb +346 -0
- data/lib/mountfd/mount_info.rb +78 -0
- data/lib/mountfd/namespace.rb +39 -0
- data/lib/mountfd/user_namespace.rb +84 -0
- data/lib/mountfd/version.rb +5 -0
- data/lib/mountfd.rb +32 -0
- data/sig/mountfd.rbs +119 -0
- data/tools/dump_constants.c +54 -0
- data/tools/generate_constants.rb +32 -0
- data/tools/research_idmap.rb +37 -0
- data/tools/vm/Makefile +8 -0
- data/tools/vm/config-fragment +7 -0
- data/tools/vm/run.sh +18 -0
- metadata +118 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mountfd
|
|
4
|
+
module Attributes
|
|
5
|
+
VALUES = {
|
|
6
|
+
rdonly: Native::MOUNT_ATTR_RDONLY,
|
|
7
|
+
nosuid: Native::MOUNT_ATTR_NOSUID,
|
|
8
|
+
nodev: Native::MOUNT_ATTR_NODEV,
|
|
9
|
+
noexec: Native::MOUNT_ATTR_NOEXEC,
|
|
10
|
+
nodiratime: Native::MOUNT_ATTR_NODIRATIME,
|
|
11
|
+
nosymfollow: Native::MOUNT_ATTR_NOSYMFOLLOW,
|
|
12
|
+
idmap: Native::MOUNT_ATTR_IDMAP
|
|
13
|
+
}.freeze
|
|
14
|
+
ATIME = {
|
|
15
|
+
relatime: Native::MOUNT_ATTR_RELATIME,
|
|
16
|
+
noatime: Native::MOUNT_ATTR_NOATIME,
|
|
17
|
+
strictatime: Native::MOUNT_ATTR_STRICTATIME
|
|
18
|
+
}.freeze
|
|
19
|
+
PROPAGATION = {
|
|
20
|
+
unbindable: Native::MS_UNBINDABLE,
|
|
21
|
+
private: Native::MS_PRIVATE,
|
|
22
|
+
slave: Native::MS_SLAVE,
|
|
23
|
+
shared: Native::MS_SHARED
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def self.build(attributes)
|
|
27
|
+
set = clr = 0
|
|
28
|
+
seen = {}
|
|
29
|
+
attributes.each do |name, value|
|
|
30
|
+
key = name.respond_to?(:to_sym) ? name.to_sym : name
|
|
31
|
+
raise ArgumentError, "duplicate mount attribute: #{name.inspect}" if seen[key]
|
|
32
|
+
|
|
33
|
+
seen[key] = true
|
|
34
|
+
if key == :atime
|
|
35
|
+
mode = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
36
|
+
raise ArgumentError, "unknown atime mode: #{value.inspect}" unless ATIME.key?(mode)
|
|
37
|
+
|
|
38
|
+
clr |= Native::MOUNT_ATTR__ATIME
|
|
39
|
+
set |= ATIME.fetch(mode)
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
flag = VALUES.fetch(key) { raise ArgumentError, "unknown mount attribute: #{name.inspect}" }
|
|
44
|
+
value ? set |= flag : clr |= flag
|
|
45
|
+
end
|
|
46
|
+
[set, clr]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.flags(names)
|
|
50
|
+
return names if names.is_a?(Integer)
|
|
51
|
+
|
|
52
|
+
Array(names).reduce(0) do |flags, name|
|
|
53
|
+
key = name.respond_to?(:to_sym) ? name.to_sym : name
|
|
54
|
+
flags | VALUES.fetch(key) { raise ArgumentError, "unknown mount attribute: #{name.inspect}" }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.propagation(value)
|
|
59
|
+
return 0 if value.nil?
|
|
60
|
+
|
|
61
|
+
key = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
62
|
+
PROPAGATION.fetch(key) { raise ArgumentError, "unknown propagation: #{value.inspect}" }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/mountfd/core.rb
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Mountfd
|
|
6
|
+
AT_FDCWD = -100
|
|
7
|
+
|
|
8
|
+
Diagnostic = Data.define(:level, :text) do
|
|
9
|
+
PREFIXES = {"e" => :error, "w" => :warning, "i" => :info}.freeze
|
|
10
|
+
|
|
11
|
+
def self.parse(raw)
|
|
12
|
+
raw.lines(chomp: true).filter_map do |line|
|
|
13
|
+
prefix, separator, text = line.partition(" ")
|
|
14
|
+
level = PREFIXES[prefix]
|
|
15
|
+
new(level || :info, level && !separator.empty? ? text : line) unless line.empty?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s = "#{level}: #{text}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class FsContext
|
|
23
|
+
attr_reader :diagnostics
|
|
24
|
+
|
|
25
|
+
def self.open(filesystem, flags: 0)
|
|
26
|
+
context = new(filesystem, flags: flags)
|
|
27
|
+
return context unless block_given?
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
yield context
|
|
31
|
+
ensure
|
|
32
|
+
context.close unless context.closed?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.pick(path, flags: 0)
|
|
37
|
+
new(nil, handle: Native.fspick(AT_FDCWD, File.path(path), flags | Native::FSPICK_CLOEXEC))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(filesystem, flags: 0, handle: nil)
|
|
41
|
+
@handle = handle || Native.fsopen(filesystem.to_s, flags | Native::FSOPEN_CLOEXEC)
|
|
42
|
+
@diagnostics = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fileno = @handle.fileno
|
|
46
|
+
def closed? = @handle.closed?
|
|
47
|
+
def warnings = diagnostics.select { _1.level == :warning }
|
|
48
|
+
|
|
49
|
+
def set(key, value = nil)
|
|
50
|
+
return set_flag(key) if value.nil? || value == true
|
|
51
|
+
|
|
52
|
+
configure(Native::FSCONFIG_SET_STRING, key, value.to_s, 0)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def set_flag(key) = configure(Native::FSCONFIG_SET_FLAG, key, nil, 0)
|
|
56
|
+
def set_path(key, path, dfd: AT_FDCWD) = configure(Native::FSCONFIG_SET_PATH, key, File.path(path), dfd)
|
|
57
|
+
def set_fd(key, io) = configure(Native::FSCONFIG_SET_FD, key, nil, Mountfd.fileno(io))
|
|
58
|
+
def set_binary(key, bytes)
|
|
59
|
+
value = String(bytes)
|
|
60
|
+
configure(Native::FSCONFIG_SET_BINARY, key, value, value.bytesize)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def create!(exclusive: false)
|
|
64
|
+
if exclusive && !Mountfd.features.include?(:create_excl)
|
|
65
|
+
raise UnsupportedError, "exclusive fsconfig creation requires Linux 6.6 or newer"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
command = exclusive ? Native::FSCONFIG_CMD_CREATE_EXCL : Native::FSCONFIG_CMD_CREATE
|
|
69
|
+
configure(command, nil, nil, 0)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reconfigure! = configure(Native::FSCONFIG_CMD_RECONFIGURE, nil, nil, 0)
|
|
73
|
+
|
|
74
|
+
def mount(attrs: {})
|
|
75
|
+
attr_set, attr_clr = Attributes.build(attrs)
|
|
76
|
+
raise ArgumentError, "idmap must be applied to a detached mount with a user namespace" if
|
|
77
|
+
((attr_set | attr_clr) & Native::MOUNT_ATTR_IDMAP).positive?
|
|
78
|
+
|
|
79
|
+
handle = with_diagnostics("fsmount", MountError) do
|
|
80
|
+
Native.fsmount(@handle, Native::FSMOUNT_CLOEXEC, attr_set)
|
|
81
|
+
end
|
|
82
|
+
DetachedMount.new(handle)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def close = @handle.close
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def configure(command, key, value, aux)
|
|
90
|
+
with_diagnostics("fsconfig") { Native.fsconfig(@handle, command, key&.to_s, value, aux) }
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def with_diagnostics(operation, error_class = ConfigError)
|
|
95
|
+
result = yield
|
|
96
|
+
drain_diagnostics
|
|
97
|
+
result
|
|
98
|
+
rescue SystemCallError => error
|
|
99
|
+
begin
|
|
100
|
+
result.close if result.respond_to?(:close)
|
|
101
|
+
rescue SystemCallError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
begin
|
|
105
|
+
drain_diagnostics
|
|
106
|
+
rescue SystemCallError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
raise error_class.new("#{operation}: #{error.message}", diagnostics), cause: error
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def drain_diagnostics
|
|
113
|
+
@diagnostics.concat(Diagnostic.parse(Native.read_diagnostics(@handle)))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
class DetachedMount
|
|
118
|
+
def initialize(handle)
|
|
119
|
+
@handle = handle
|
|
120
|
+
Mountfd.__send__(:track, self)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fileno = @handle.fileno
|
|
124
|
+
def closed? = @handle.closed?
|
|
125
|
+
def discard = close
|
|
126
|
+
|
|
127
|
+
def set_attributes(set: [], clr: [], propagation: nil, idmap: nil, recursive: false)
|
|
128
|
+
flags = Native::AT_EMPTY_PATH
|
|
129
|
+
flags |= Native::AT_RECURSIVE if recursive
|
|
130
|
+
set_flags = Attributes.flags(set)
|
|
131
|
+
clear_flags = Attributes.flags(clr)
|
|
132
|
+
has_idmap = (set_flags & Native::MOUNT_ATTR_IDMAP).positive?
|
|
133
|
+
raise ArgumentError, "an idmapped mount cannot be cleared" if
|
|
134
|
+
(clear_flags & Native::MOUNT_ATTR_IDMAP).positive?
|
|
135
|
+
raise ArgumentError, "MOUNT_ATTR_IDMAP and a user namespace must be provided together" if
|
|
136
|
+
has_idmap == idmap.nil?
|
|
137
|
+
|
|
138
|
+
Native.mount_setattr(
|
|
139
|
+
@handle, "", flags, set_flags, clear_flags,
|
|
140
|
+
Attributes.propagation(propagation), idmap && Mountfd.fileno(idmap)
|
|
141
|
+
)
|
|
142
|
+
self
|
|
143
|
+
rescue SystemCallError => error
|
|
144
|
+
exception = idmap ? IdmapError : MountError
|
|
145
|
+
raise exception, "mount_setattr: #{error.message}", cause: error
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def idmap!(userns) = set_attributes(set: [:idmap], idmap: userns)
|
|
149
|
+
|
|
150
|
+
def attach(path, beneath: false)
|
|
151
|
+
target = File.path(path)
|
|
152
|
+
flags = Native::MOVE_MOUNT_F_EMPTY_PATH
|
|
153
|
+
flags |= Native::MOVE_MOUNT_BENEATH if beneath
|
|
154
|
+
begin
|
|
155
|
+
Native.move_mount(@handle, "", AT_FDCWD, target, flags)
|
|
156
|
+
rescue SystemCallError => error
|
|
157
|
+
raise MountError, "move_mount: #{error.message}", cause: error
|
|
158
|
+
end
|
|
159
|
+
close
|
|
160
|
+
AttachedMount.new(target)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def close
|
|
164
|
+
@handle.close
|
|
165
|
+
nil
|
|
166
|
+
ensure
|
|
167
|
+
Mountfd.__send__(:untrack, self) if @handle.closed?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
AttachedMount = Data.define(:path)
|
|
172
|
+
|
|
173
|
+
class << self
|
|
174
|
+
def supported? = Native.syscall_available?("fsopen")
|
|
175
|
+
|
|
176
|
+
def features
|
|
177
|
+
return [] unless supported?
|
|
178
|
+
|
|
179
|
+
values = [:new_mount_api]
|
|
180
|
+
values.concat([:mount_setattr, :idmap]) if Native.syscall_available?("mount_setattr")
|
|
181
|
+
values << :statmount if %w[statmount listmount].all? { Native.syscall_available?(_1) }
|
|
182
|
+
values << :move_mount_beneath if kernel_at_least?(6, 5)
|
|
183
|
+
values << :create_excl if kernel_at_least?(6, 6)
|
|
184
|
+
values
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def pending_mounts = (@pending_mounts || []).reject(&:closed?).dup
|
|
188
|
+
|
|
189
|
+
def mounts(ns: nil, backend: nil)
|
|
190
|
+
selected = backend || preferred_mounts_backend(ns)
|
|
191
|
+
if selected == :statmount
|
|
192
|
+
@mounts_backend = :statmount
|
|
193
|
+
values = if ns.nil?
|
|
194
|
+
Native.statmounts(nil)
|
|
195
|
+
elsif ns.respond_to?(:fileno)
|
|
196
|
+
Native.statmounts(fileno(ns))
|
|
197
|
+
else
|
|
198
|
+
File.open("/proc/#{Integer(ns)}/ns/mnt") { Native.statmounts(_1.fileno) }
|
|
199
|
+
end
|
|
200
|
+
return values.map { mount_info_from_statmount(_1) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
raise ArgumentError, "backend must be :statmount or :mountinfo" unless selected == :mountinfo
|
|
204
|
+
if ns&.respond_to?(:fileno)
|
|
205
|
+
raise UnsupportedError, "mount namespace descriptors require statmount on Linux 6.11 or newer"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
@mounts_backend = :mountinfo
|
|
209
|
+
pid = ns.nil? ? "self" : Integer(ns)
|
|
210
|
+
read_mountinfo(pid)
|
|
211
|
+
rescue SystemCallError, UnsupportedError
|
|
212
|
+
raise if backend == :statmount || ns&.respond_to?(:fileno)
|
|
213
|
+
|
|
214
|
+
@mounts_backend = :mountinfo
|
|
215
|
+
read_mountinfo(ns ? Integer(ns) : "self")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def mounts_backend = @mounts_backend || preferred_mounts_backend(nil)
|
|
219
|
+
|
|
220
|
+
def mount_at(path, **options)
|
|
221
|
+
target = File.expand_path(File.path(path))
|
|
222
|
+
mounts(**options).find { File.expand_path(_1.mount_point) == target }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def open_tree(path, recursive: false)
|
|
226
|
+
flags = Native::OPEN_TREE_CLONE | Native::OPEN_TREE_CLOEXEC
|
|
227
|
+
flags |= Native::AT_RECURSIVE if recursive
|
|
228
|
+
mount = DetachedMount.new(Native.open_tree(AT_FDCWD, File.path(path), flags))
|
|
229
|
+
return mount unless block_given?
|
|
230
|
+
|
|
231
|
+
begin
|
|
232
|
+
yield mount
|
|
233
|
+
ensure
|
|
234
|
+
mount.discard unless mount.closed?
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def mount(source, target, type: source, options: {}, attrs: {})
|
|
239
|
+
detached = nil
|
|
240
|
+
FsContext.open(type) do |context|
|
|
241
|
+
context.set("source", source) unless source.to_s == type.to_s
|
|
242
|
+
options.each { |key, value| context.set(key, value) }
|
|
243
|
+
context.create!
|
|
244
|
+
detached = context.mount(attrs: attrs)
|
|
245
|
+
detached.attach(target)
|
|
246
|
+
end
|
|
247
|
+
rescue StandardError
|
|
248
|
+
detached&.discard unless detached&.closed?
|
|
249
|
+
raise
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def bind(source, target, recursive: false, attrs: {}, idmap: nil)
|
|
253
|
+
namespace = UserNamespace.create(**idmap) if idmap.is_a?(Hash)
|
|
254
|
+
detached = open_tree(source, recursive: recursive)
|
|
255
|
+
apply_attributes(detached, attrs, recursive: recursive)
|
|
256
|
+
detached.idmap!(namespace || idmap) if idmap
|
|
257
|
+
detached.attach(target)
|
|
258
|
+
rescue StandardError
|
|
259
|
+
detached&.discard unless detached&.closed?
|
|
260
|
+
raise
|
|
261
|
+
ensure
|
|
262
|
+
namespace&.close unless namespace&.closed?
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def umount(path, detach: true, force: false)
|
|
266
|
+
flags = (detach ? 2 : 0) | (force ? 1 : 0)
|
|
267
|
+
Native.umount2(File.path(path), flags)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def pivot_root(new_root, put_old) = Native.pivot_root(File.path(new_root), File.path(put_old))
|
|
271
|
+
|
|
272
|
+
def set_attributes(path, attrs: {}, propagation: nil, recursive: false)
|
|
273
|
+
set, clr = Attributes.build(attrs)
|
|
274
|
+
flags = recursive ? Native::AT_RECURSIVE : 0
|
|
275
|
+
Native.mount_setattr(
|
|
276
|
+
AT_FDCWD, File.path(path), flags, set, clr, Attributes.propagation(propagation), nil
|
|
277
|
+
)
|
|
278
|
+
nil
|
|
279
|
+
rescue SystemCallError => error
|
|
280
|
+
raise MountError, "mount_setattr: #{error.message}", cause: error
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def replace(path)
|
|
284
|
+
target = File.path(path)
|
|
285
|
+
detached = yield
|
|
286
|
+
raise ArgumentError, "replace block must return a DetachedMount" unless detached.is_a?(DetachedMount)
|
|
287
|
+
|
|
288
|
+
detached.attach(target, beneath: true)
|
|
289
|
+
umount(target)
|
|
290
|
+
AttachedMount.new(target)
|
|
291
|
+
rescue StandardError
|
|
292
|
+
detached&.discard unless detached&.closed?
|
|
293
|
+
raise
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def fileno(value) = value.respond_to?(:fileno) ? value.fileno : Integer(value)
|
|
297
|
+
|
|
298
|
+
private
|
|
299
|
+
|
|
300
|
+
def track(mount) = (@pending_mounts ||= []) << mount
|
|
301
|
+
def untrack(mount) = @pending_mounts&.delete(mount)
|
|
302
|
+
|
|
303
|
+
def apply_attributes(mount, attributes, recursive: false)
|
|
304
|
+
set, clr = Attributes.build(attributes)
|
|
305
|
+
mount.set_attributes(set: set, clr: clr, recursive: recursive) if (set | clr).positive?
|
|
306
|
+
mount
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def preferred_mounts_backend(namespace)
|
|
310
|
+
(namespace.nil? || kernel_at_least?(6, 11)) && Native.syscall_available?("statmount") &&
|
|
311
|
+
Native.syscall_available?("listmount") ? :statmount : :mountinfo
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def read_mountinfo(pid)
|
|
315
|
+
mounts = MountInfoParser.parse(File.read("/proc/#{pid}/mountinfo"))
|
|
316
|
+
mounts.reverse.uniq(&:mount_point).reverse
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def mount_info_from_statmount(value)
|
|
320
|
+
options = value[:options]&.split(",") || []
|
|
321
|
+
attrs = Attributes::VALUES.filter_map do |name, flag|
|
|
322
|
+
name if (value[:attrs] & flag).positive?
|
|
323
|
+
end
|
|
324
|
+
Attributes::ATIME.each do |name, flag|
|
|
325
|
+
attrs << name if flag.positive? && (value[:attrs] & flag).positive?
|
|
326
|
+
end
|
|
327
|
+
propagation = {}
|
|
328
|
+
Attributes::PROPAGATION.each do |name, flag|
|
|
329
|
+
propagation[name] = true if (value[:propagation] & flag).positive?
|
|
330
|
+
end
|
|
331
|
+
propagation[:shared] = value[:peer_group] if value[:peer_group].positive?
|
|
332
|
+
propagation[:master] = value[:master] if value[:master].positive?
|
|
333
|
+
propagation[:propagate_from] = value[:propagate_from] if value[:propagate_from].positive?
|
|
334
|
+
MountInfo.new(
|
|
335
|
+
value[:mnt_id], value[:parent_id], value[:mnt_root], value[:mount_point],
|
|
336
|
+
value[:fs_type], value[:source], options.freeze, propagation.freeze, attrs.freeze,
|
|
337
|
+
value[:dev_major], value[:dev_minor]
|
|
338
|
+
)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def kernel_at_least?(major, minor)
|
|
342
|
+
current = Etc.uname[:release].scan(/\A(\d+)\.(\d+)/).flatten.map(&:to_i)
|
|
343
|
+
Native.linux? && (current <=> [major, minor]) >= 0
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mountfd
|
|
4
|
+
MountInfo = Data.define(
|
|
5
|
+
:mnt_id, :parent_id, :mnt_root, :mount_point, :fs_type, :source,
|
|
6
|
+
:options, :propagation, :attrs, :dev_major, :dev_minor
|
|
7
|
+
) do
|
|
8
|
+
def idmapped? = attrs.include?(:idmap) || options.include?("idmapped")
|
|
9
|
+
def readonly? = attrs.include?(:rdonly) || options.include?("ro")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module MountInfoParser
|
|
13
|
+
ESCAPE = /\\([0-3][0-7]{2})/
|
|
14
|
+
UINT32_MAX = 2**32 - 1
|
|
15
|
+
UINT64_MAX = 2**64 - 1
|
|
16
|
+
|
|
17
|
+
def self.parse(content)
|
|
18
|
+
content.b.lines.filter_map { parse_line(_1) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.parse_line(line)
|
|
22
|
+
fields = line.split
|
|
23
|
+
separator = fields.index("-")
|
|
24
|
+
return unless separator && separator >= 6 && fields.length >= separator + 4
|
|
25
|
+
|
|
26
|
+
device = fields[2].split(":", -1)
|
|
27
|
+
return unless device.length == 2
|
|
28
|
+
|
|
29
|
+
mnt_id, parent_id = fields.first(2).map { Integer(_1, 10) }
|
|
30
|
+
major, minor = device.map { Integer(_1, 10) }
|
|
31
|
+
return unless mnt_id.between?(0, UINT64_MAX) && parent_id.between?(0, UINT64_MAX) &&
|
|
32
|
+
major.between?(0, UINT32_MAX) && minor.between?(0, UINT32_MAX)
|
|
33
|
+
|
|
34
|
+
mount_options = fields[5].split(",")
|
|
35
|
+
super_options = fields[separator + 3].split(",")
|
|
36
|
+
optional = fields[6...separator]
|
|
37
|
+
MountInfo.new(
|
|
38
|
+
mnt_id, parent_id, decode(fields[3]), decode(fields[4]),
|
|
39
|
+
decode(fields[separator + 1]), decode(fields[separator + 2]),
|
|
40
|
+
(mount_options + super_options).uniq.freeze, parse_propagation(optional),
|
|
41
|
+
parse_attrs(mount_options + super_options, optional), major, minor
|
|
42
|
+
)
|
|
43
|
+
rescue ArgumentError, TypeError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.decode(value)
|
|
48
|
+
value.gsub(ESCAPE) { Regexp.last_match(1).to_i(8).chr(Encoding::BINARY) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.parse_propagation(fields)
|
|
52
|
+
propagation = fields.filter_map do |field|
|
|
53
|
+
name, value = field.split(":", 2)
|
|
54
|
+
case name
|
|
55
|
+
when "shared", "master", "propagate_from"
|
|
56
|
+
[name.to_sym, Integer(value, 10)] if value
|
|
57
|
+
when "unbindable"
|
|
58
|
+
[:unbindable, true]
|
|
59
|
+
end
|
|
60
|
+
end.to_h
|
|
61
|
+
propagation[:slave] = true if propagation.key?(:master)
|
|
62
|
+
propagation[:private] = true if propagation.empty?
|
|
63
|
+
propagation.freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.parse_attrs(options, optional)
|
|
67
|
+
names = {
|
|
68
|
+
"ro" => :rdonly, "nosuid" => :nosuid, "nodev" => :nodev,
|
|
69
|
+
"noexec" => :noexec, "nodiratime" => :nodiratime,
|
|
70
|
+
"nosymfollow" => :nosymfollow, "noatime" => :noatime,
|
|
71
|
+
"strictatime" => :strictatime
|
|
72
|
+
}
|
|
73
|
+
values = options.filter_map { names[_1] }
|
|
74
|
+
values << :idmap if optional.include?("idmapped")
|
|
75
|
+
values.uniq.freeze
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Mountfd
|
|
6
|
+
module Namespace
|
|
7
|
+
def self.reexec_user!
|
|
8
|
+
return if ENV["MOUNTFD_IN_USERNS"]
|
|
9
|
+
raise UnsupportedError, "user namespaces are unavailable on this platform" unless Native.linux?
|
|
10
|
+
|
|
11
|
+
command = begin
|
|
12
|
+
File.binread("/proc/self/cmdline").split("\0")
|
|
13
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
14
|
+
[]
|
|
15
|
+
end
|
|
16
|
+
command = [RbConfig.ruby, $PROGRAM_NAME, *ARGV] if command.empty?
|
|
17
|
+
exec({"MOUNTFD_IN_USERNS" => "1"}, "unshare", "-Ur", *command)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.unshare_mount!(propagation: :private)
|
|
21
|
+
raise ArgumentError, "propagation must be :private or nil" unless propagation.nil? || propagation == :private
|
|
22
|
+
|
|
23
|
+
warn_unless_main_thread
|
|
24
|
+
Native.unshare(Native::CLONE_NEWNS)
|
|
25
|
+
Native.change_propagation("/", Native::MS_PRIVATE | Native::MS_REC) if propagation == :private
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.unshare_user!(map_root: true)
|
|
30
|
+
warn_unless_main_thread
|
|
31
|
+
Native.unshare_user(map_root)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.warn_unless_main_thread
|
|
35
|
+
warn("Mountfd namespace changes affect only the calling OS thread") unless Thread.current == Thread.main
|
|
36
|
+
end
|
|
37
|
+
private_class_method :warn_unless_main_thread
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mountfd
|
|
4
|
+
class UserNamespace
|
|
5
|
+
MAX_RANGES = 340
|
|
6
|
+
|
|
7
|
+
def self.create(uid: {0 => Process.uid}, gid: {0 => Process.gid}, helper: :auto)
|
|
8
|
+
uid_map = normalize(uid)
|
|
9
|
+
gid_map = normalize(gid)
|
|
10
|
+
use_helper = case helper
|
|
11
|
+
when :auto then helpers_available? && !Process.euid.zero?
|
|
12
|
+
when true, false then helper
|
|
13
|
+
else raise ArgumentError, "helper must be :auto, true, or false"
|
|
14
|
+
end
|
|
15
|
+
new(Native.user_namespace(format(uid_map), format(gid_map), use_helper))
|
|
16
|
+
rescue SystemCallError => error
|
|
17
|
+
raise IdmapError, "user namespace: #{error.message}", cause: error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from_pid(pid) = from_path("/proc/#{Integer(pid)}/ns/user")
|
|
21
|
+
def self.from_path(path) = new(Native.open_handle(File.path(path)))
|
|
22
|
+
|
|
23
|
+
def self.normalize(mapping)
|
|
24
|
+
ranges = if mapping.is_a?(Hash)
|
|
25
|
+
mapping.map do |inside, outside|
|
|
26
|
+
if outside.is_a?(Array) && outside.length != 2
|
|
27
|
+
raise ArgumentError, "hash mapping values must be an ID or [ID, length]"
|
|
28
|
+
end
|
|
29
|
+
outside, length = outside.is_a?(Array) ? outside : [outside, 1]
|
|
30
|
+
[inside, outside, length]
|
|
31
|
+
end
|
|
32
|
+
elsif mapping.is_a?(Array) && mapping.length == 3 && mapping.none? { _1.is_a?(Array) }
|
|
33
|
+
[mapping]
|
|
34
|
+
else
|
|
35
|
+
Array(mapping)
|
|
36
|
+
end
|
|
37
|
+
ranges = ranges.map do |range|
|
|
38
|
+
raise ArgumentError, "each namespace mapping must contain three integers" unless range.is_a?(Array) && range.length == 3
|
|
39
|
+
|
|
40
|
+
range.map { Integer(_1) }
|
|
41
|
+
end.sort_by(&:first)
|
|
42
|
+
raise ArgumentError, "a namespace mapping requires at least one range" if ranges.empty?
|
|
43
|
+
raise ArgumentError, "namespace mappings are limited to #{MAX_RANGES} ranges" if ranges.length > MAX_RANGES
|
|
44
|
+
|
|
45
|
+
ranges.each do |inside, outside, length|
|
|
46
|
+
raise ArgumentError, "mapping IDs must be non-negative" if inside.negative? || outside.negative?
|
|
47
|
+
raise ArgumentError, "mapping length must be positive" unless length.positive?
|
|
48
|
+
if inside + length > 2**32 || outside + length > 2**32
|
|
49
|
+
raise ArgumentError, "mapping ranges must fit unsigned 32-bit IDs"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
validate_non_overlapping!(ranges, 0, "inside")
|
|
53
|
+
validate_non_overlapping!(ranges, 1, "outside")
|
|
54
|
+
ranges
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.format(ranges) = ranges.map { _1.join(" ") }.join("\n") << "\n"
|
|
58
|
+
|
|
59
|
+
def self.validate_non_overlapping!(ranges, index, label)
|
|
60
|
+
sorted = ranges.sort_by { _1[index] }
|
|
61
|
+
sorted.each_cons(2) do |left, right|
|
|
62
|
+
raise ArgumentError, "overlapping #{label} mapping ranges" if left[index] + left[2] > right[index]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
private_class_method :format, :validate_non_overlapping!
|
|
66
|
+
|
|
67
|
+
def self.helpers_available?
|
|
68
|
+
%w[newuidmap newgidmap].all? do |program|
|
|
69
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
|
|
70
|
+
File.executable?(File.join(directory, program))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
private_class_method :helpers_available?
|
|
75
|
+
|
|
76
|
+
def initialize(handle)
|
|
77
|
+
@handle = handle
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fileno = @handle.fileno
|
|
81
|
+
def closed? = @handle.closed?
|
|
82
|
+
def close = @handle.close
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/mountfd.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mountfd/version"
|
|
4
|
+
|
|
5
|
+
module Mountfd
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class UnsupportedError < Error; end
|
|
8
|
+
|
|
9
|
+
class ConfigError < Error
|
|
10
|
+
attr_reader :diagnostics
|
|
11
|
+
|
|
12
|
+
def initialize(message, diagnostics = [])
|
|
13
|
+
@diagnostics = diagnostics
|
|
14
|
+
super([message, *diagnostics.map(&:to_s)].join("\n"))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class MountError < ConfigError; end
|
|
19
|
+
class IdmapError < Error; end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
require "mountfd/mountfd"
|
|
24
|
+
rescue LoadError
|
|
25
|
+
require_relative "../ext/mountfd/mountfd"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
require_relative "mountfd/attributes"
|
|
29
|
+
require_relative "mountfd/user_namespace"
|
|
30
|
+
require_relative "mountfd/mount_info"
|
|
31
|
+
require_relative "mountfd/namespace"
|
|
32
|
+
require_relative "mountfd/core"
|