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.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mountfd
4
+ VERSION = "0.1.0"
5
+ 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"