mxc 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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ module Preflight # :nodoc:
5
+ NETWORK_TOOLS = %w[slirp4netns iptables ip6tables iptables-restore ip6tables-restore nsenter unshare].freeze
6
+ TUN = '/dev/net/tun'
7
+ INSTALL = 'Install slirp4netns, iptables and util-linux ' \
8
+ '(Debian/Ubuntu: apt-get install slirp4netns iptables util-linux; ' \
9
+ 'Fedora: dnf install slirp4netns iptables-nft util-linux; ' \
10
+ 'Arch: pacman -S slirp4netns iptables util-linux)'
11
+ WINDOWS_ALLOW_LIST = 'Use network: false, or network: true for unrestricted access. On Windows, MXC confines ' \
12
+ 'a sandbox to a proxy only by also accepting inbound connections to it'
13
+ WINDOWS_DENIED = 'Leave the paths out of readable and writable instead, or run on a Windows build where ' \
14
+ 'MXC.supports?(:filesystem_denied_paths) is true'
15
+
16
+ module_function
17
+
18
+ def check!(network:, denied: [])
19
+ error = problem(network:, denied:)
20
+ raise error if error
21
+ end
22
+
23
+ def problem(network:, denied: [], path: ENV.fetch('PATH', ''), tun: TUN)
24
+ return windows_problem(network, denied) if windows?
25
+ return unless network && linux?
26
+
27
+ missing = NETWORK_TOOLS.reject { |tool| executable?(tool, path) }
28
+ if missing.any?
29
+ return UnsupportedError.new("Network access from a sandbox needs #{missing.join(', ')} on PATH",
30
+ code: :backend_unavailable, remediation: INSTALL)
31
+ end
32
+ return if tun_usable?(tun)
33
+
34
+ UnsupportedError.new("Network access from a sandbox needs a usable #{tun}",
35
+ code: :backend_unavailable, remediation: tun_remediation)
36
+ end
37
+
38
+ def linux?
39
+ RUBY_PLATFORM.include?('linux')
40
+ end
41
+
42
+ def windows?
43
+ Gem.win_platform?
44
+ end
45
+
46
+ def windows_problem(network, denied)
47
+ if network.is_a?(Hash)
48
+ UnsupportedError.new('Host allow-lists are not supported on Windows',
49
+ code: :backend_unavailable, remediation: WINDOWS_ALLOW_LIST)
50
+ elsif denied.any? && !denied_paths_enforced?
51
+ UnsupportedError.new("MXC's AppContainer sandbox on this Windows build does not enforce denied paths",
52
+ code: :backend_unavailable, remediation: WINDOWS_DENIED)
53
+ end
54
+ end
55
+
56
+ def denied_paths_enforced?
57
+ MXC.backends.any? { |backend| backend.supports?(:filesystem_denied_paths) }
58
+ end
59
+
60
+ def executable?(tool, path)
61
+ path.split(File::PATH_SEPARATOR).any? do |dir|
62
+ file = File.join(dir, tool)
63
+ File.file?(file) && File.executable?(file)
64
+ end
65
+ end
66
+
67
+ def tun_usable?(tun)
68
+ File.open(tun, File::RDWR) { true }
69
+ rescue SystemCallError
70
+ false
71
+ end
72
+
73
+ def tun_remediation
74
+ if File.exist?('/.dockerenv') || File.exist?('/run/.containerenv')
75
+ 'Pass the TUN device to the container: docker run --device /dev/net/tun ' \
76
+ '(Kamal: options: { device: /dev/net/tun } on the role)'
77
+ else
78
+ 'Load the tun kernel module: sudo modprobe tun'
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # A command running in a sandbox, as returned by Sandbox#spawn.
5
+ #
6
+ # process = MXC.sandbox.spawn("python3 -i")
7
+ # process.write("print(6 * 7)\n")
8
+ # process.close_write
9
+ # process.each { |chunk| print chunk.content }
10
+ # process.wait # => #<MXC::Result exit_code=0 ...>
11
+ #
12
+ # Output is read in the background from the start, so the command never blocks on a full pipe,
13
+ # and the timeout applies whether or not anything waits.
14
+ class Process
15
+ READ_SIZE = 16 * 1024 # :nodoc:
16
+ STREAMS = %i[stdout stderr].freeze # :nodoc:
17
+
18
+ def initialize(native, backend:) # :nodoc:
19
+ @native = native
20
+ @backend = backend
21
+ @chunks = Thread::Queue.new
22
+ @output = STREAMS.to_h { |stream| [stream, String.new(encoding: Encoding::BINARY)] }
23
+ @lock = Mutex.new
24
+ @streams_lock = Mutex.new
25
+ @open_streams = STREAMS.size
26
+ @readers = STREAMS.map { |stream| background { read(stream) } }
27
+ @waiter = background { @native.wait }
28
+ end
29
+
30
+ # Returns the operating system process id of the sandbox.
31
+ def pid
32
+ @native.pid
33
+ end
34
+
35
+ # Writes +data+ to the command's standard input and returns the number of bytes written.
36
+ def write(data)
37
+ @native.write(data.to_s)
38
+ end
39
+
40
+ # Closes the command's standard input, so it reads end of file.
41
+ def close_write
42
+ @native.close_stdin
43
+ nil
44
+ end
45
+
46
+ # Yields each Chunk of output as it arrives, until the command closes both output streams.
47
+ # Returns an Enumerator without a block.
48
+ def each
49
+ return enum_for(:each) unless block_given?
50
+
51
+ while (chunk = @chunks.pop)
52
+ yield chunk
53
+ end
54
+ self
55
+ end
56
+
57
+ # Stops the command and everything it started.
58
+ def kill
59
+ @native.kill
60
+ nil
61
+ end
62
+
63
+ # Closes standard input, waits for the command to finish, and returns its Result.
64
+ #
65
+ # Raises Error, or UnsupportedError for a host problem, when the sandbox itself could not start.
66
+ def wait
67
+ @lock.synchronize { @result || (@result = finish) }
68
+ end
69
+
70
+ def inspect # :nodoc:
71
+ "#<MXC::Process pid=#{pid} backend=#{@backend.inspect}>"
72
+ end
73
+
74
+ private
75
+
76
+ def background(&)
77
+ Thread.new(&).tap { |thread| thread.report_on_exception = false }
78
+ end
79
+
80
+ def read(stream)
81
+ reader = :"read_#{stream}"
82
+ while (data = @native.public_send(reader, READ_SIZE))
83
+ @output[stream] << data
84
+ @chunks << Chunk.new(stream:, content: data.dup.force_encoding(Encoding.default_external).freeze)
85
+ end
86
+ rescue IOError
87
+ nil
88
+ ensure
89
+ @streams_lock.synchronize { @chunks.close if (@open_streams -= 1).zero? }
90
+ end
91
+
92
+ def finish
93
+ close_write
94
+ outcome = @waiter.value
95
+ @readers.each(&:join)
96
+ outcome = outcome.merge(stdout: @output[:stdout], stderr: @output[:stderr])
97
+ Result.new(**StartupFailure.check!(outcome), backend: @backend)
98
+ end
99
+ end
100
+ end
data/lib/mxc/result.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # The outcome of a command that ran in a sandbox.
5
+ #
6
+ # result = MXC.run("python3 build.py")
7
+ # result.success? # => true
8
+ # result.stdout # => "built 3 files\n"
9
+ class Result
10
+ # Returns everything the command wrote to standard output.
11
+ attr_reader :stdout
12
+
13
+ # Returns everything the command wrote to standard error.
14
+ attr_reader :stderr
15
+
16
+ # Returns the exit code, or nil when the command timed out.
17
+ attr_reader :exit_code
18
+
19
+ # Returns MXC's warnings about the sandbox, as Strings.
20
+ attr_reader :warnings
21
+
22
+ # Returns the <tt>host:port</tt> destinations the network allow-list refused.
23
+ attr_reader :blocked
24
+
25
+ # Returns the backend that ran the command, such as <tt>:bubblewrap</tt>.
26
+ attr_reader :backend
27
+
28
+ def initialize(stdout:, stderr:, exit_code:, timed_out:, warnings:, blocked:, backend:) # :nodoc:
29
+ @stdout = text(stdout)
30
+ @stderr = text(stderr)
31
+ @exit_code = exit_code
32
+ @timed_out = timed_out
33
+ @warnings = warnings.freeze
34
+ @blocked = blocked.freeze
35
+ @backend = backend
36
+ end
37
+
38
+ # Returns true when the command exited with status 0 before its timeout.
39
+ def success?
40
+ !timed_out? && exit_code.zero?
41
+ end
42
+
43
+ # Returns true when the command ran out of time and was stopped.
44
+ def timed_out?
45
+ @timed_out
46
+ end
47
+
48
+ # Returns the result as a Hash.
49
+ def to_h
50
+ { stdout:, stderr:, exit_code:, timed_out: timed_out?, warnings:, blocked:, backend: }
51
+ end
52
+
53
+ def inspect # :nodoc:
54
+ "#<MXC::Result exit_code=#{exit_code.inspect} timed_out=#{timed_out?} backend=#{backend.inspect}>"
55
+ end
56
+
57
+ private
58
+
59
+ def text(bytes)
60
+ bytes.dup.force_encoding(Encoding.default_external).freeze
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module MXC
6
+ # A sandbox policy: what a command may read, write and reach, and how long it may run.
7
+ #
8
+ # Setters return the sandbox, so they chain, and +nil+ resets a setting to its default:
9
+ #
10
+ # sandbox = MXC.sandbox
11
+ # .with_readable("app/skills")
12
+ # .with_writable(Rails.root.join("tmp"))
13
+ # .with_network(allow: ["pypi.org", "*.pythonhosted.org"])
14
+ # .with_timeout(30)
15
+ # sandbox.run("python3 app/skills/build.py")
16
+ # sandbox.run("python3", "app/skills/convert.py", params[:filename])
17
+ #
18
+ # Subclasses declare the same settings with class macros:
19
+ #
20
+ # class SkillSandbox < MXC::Sandbox
21
+ # readable "app/skills"
22
+ # writable "tmp/skills"
23
+ # network false
24
+ # timeout 30
25
+ # end
26
+ #
27
+ # SkillSandbox.new.run("python3 app/skills/build.py")
28
+ class Sandbox
29
+ BACKENDS = %i[auto bubblewrap seatbelt process_container].freeze # :nodoc:
30
+ OPTIONS = %i[readable writable denied network env directory timeout backend].freeze # :nodoc:
31
+ HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
32
+ WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
33
+ PROCESSOR_ARCHITECTURE NUMBER_OF_PROCESSORS OS].freeze # :nodoc:
34
+ BATCH_FILE = /\.(bat|cmd)\z/i # :nodoc:
35
+ MAX_TIMEOUT_MS = (2**32) - 1 # :nodoc:
36
+ DEFAULTS = {
37
+ readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil
38
+ }.freeze # :nodoc:
39
+
40
+ class << self
41
+ # Adds paths every instance can read.
42
+ def readable(*paths) = configure { |sandbox| sandbox.with_readable(*paths) }
43
+
44
+ # Adds paths every instance can read and write.
45
+ def writable(*paths) = configure { |sandbox| sandbox.with_writable(*paths) }
46
+
47
+ # Adds paths every instance hides, even inside readable or writable paths.
48
+ def denied(*paths) = configure { |sandbox| sandbox.with_denied(*paths) }
49
+
50
+ # Sets every instance's network access. Takes the same arguments as #with_network.
51
+ def network(...) = configure { |sandbox| sandbox.with_network(...) }
52
+
53
+ # Adds environment variables for every instance.
54
+ def env(variables) = configure { |sandbox| sandbox.with_env(variables) }
55
+
56
+ # Sets every instance's working directory.
57
+ def directory(path) = configure { |sandbox| sandbox.with_directory(path) }
58
+
59
+ # Sets every instance's timeout in seconds.
60
+ def timeout(seconds) = configure { |sandbox| sandbox.with_timeout(seconds) }
61
+
62
+ # Sets every instance's backend.
63
+ def backend(name) = configure { |sandbox| sandbox.with_backend(name) }
64
+
65
+ def defaults # :nodoc:
66
+ @defaults ||= allocate.tap { |sandbox| sandbox.send(:inherit, self == Sandbox ? nil : superclass.defaults) }
67
+ end
68
+
69
+ def backend_name(name) # :nodoc:
70
+ name = name.to_sym
71
+ return name if BACKENDS.include?(name)
72
+
73
+ raise ArgumentError, "unknown backend #{name.inspect}, expected one of #{BACKENDS.map(&:inspect).join(', ')}"
74
+ end
75
+
76
+ def duration(seconds) # :nodoc:
77
+ return if seconds.nil?
78
+
79
+ value = seconds.to_f
80
+ unless value.positive?
81
+ raise ArgumentError,
82
+ "timeout must be a positive number of seconds, got #{seconds.inspect}"
83
+ end
84
+
85
+ seconds
86
+ end
87
+
88
+ private
89
+
90
+ def configure
91
+ yield defaults
92
+ nil
93
+ end
94
+ end
95
+
96
+ # Returns a new sandbox with the class's defaults, then applies +options+ through
97
+ # the matching setters: <tt>readable:</tt>, <tt>writable:</tt>, <tt>denied:</tt>,
98
+ # <tt>network:</tt>, <tt>env:</tt>, <tt>directory:</tt>, <tt>timeout:</tt> and <tt>backend:</tt>.
99
+ #
100
+ # MXC::Sandbox.new(readable: "app/skills", network: { allow: ["pypi.org"] }, timeout: 30)
101
+ def initialize(**options)
102
+ unknown = options.keys - OPTIONS
103
+ raise ArgumentError, "unknown option#{'s' if unknown.size > 1}: #{unknown.join(', ')}" if unknown.any?
104
+
105
+ inherit(self.class.defaults)
106
+ options.each { |name, value| apply(name, value) }
107
+ end
108
+
109
+ def initialize_copy(source) # :nodoc:
110
+ super
111
+ @settings = @settings.transform_values(&:dup)
112
+ end
113
+
114
+ # Returns the paths the command can read, as given.
115
+ def readable = @settings[:readable].dup.freeze
116
+
117
+ # Returns the paths the command can read and write, as given.
118
+ def writable = @settings[:writable].dup.freeze
119
+
120
+ # Returns the paths hidden from the command, as given.
121
+ def denied = @settings[:denied].dup.freeze
122
+
123
+ # Returns the environment variables set for the command.
124
+ def env = @settings[:env].dup.freeze
125
+
126
+ # Returns the network access: +false+, +true+, or <tt>{ allow: [...] }</tt> for an allow-list.
127
+ def network = @settings[:network]
128
+
129
+ # Returns the working directory, or nil for the default.
130
+ def directory = @settings[:directory]
131
+
132
+ # Returns the timeout in seconds, falling back to MXC.config.timeout.
133
+ def timeout = @settings[:timeout] || MXC.config.timeout
134
+
135
+ # Returns the backend, falling back to MXC.config.backend.
136
+ def backend = @settings[:backend] || MXC.config.backend
137
+
138
+ # Adds paths the command can read. Accepts Strings and Pathnames; relative paths
139
+ # are expanded from the current directory when the command runs.
140
+ def with_readable(*paths)
141
+ add_paths(:readable, paths)
142
+ end
143
+
144
+ # Adds paths the command can read and write.
145
+ def with_writable(*paths)
146
+ add_paths(:writable, paths)
147
+ end
148
+
149
+ # Adds paths hidden from the command, even inside readable or writable paths.
150
+ def with_denied(*paths)
151
+ add_paths(:denied, paths)
152
+ end
153
+
154
+ # Sets network access. The default, +false+, gives the command no network at all.
155
+ # +true+ allows any destination. <tt>allow:</tt> takes a list of hosts the command
156
+ # may reach over HTTP and HTTPS: <tt>"pypi.org"</tt>, <tt>"*.pythonhosted.org"</tt>
157
+ # (subdomains only), or <tt>"example.com:8443"</tt> for another port.
158
+ #
159
+ # sandbox.with_network(allow: ["pypi.org", "*.pythonhosted.org"])
160
+ def with_network(enabled = true, allow: nil)
161
+ if allow
162
+ raise ArgumentError, 'pass allow: or true/false, not both' unless enabled == true
163
+
164
+ @settings[:network] = { allow: Array(allow).map(&:to_s).freeze }.freeze
165
+ else
166
+ raise ArgumentError, "network must be true, false or nil, got #{enabled.inspect}" unless [true, false,
167
+ nil].include?(enabled)
168
+
169
+ @settings[:network] = enabled || false
170
+ end
171
+ self
172
+ end
173
+
174
+ # Adds environment variables for the command. The sandbox starts from a minimal
175
+ # environment, not the parent's. On Windows, that environment keeps the system
176
+ # variables programs need to start, such as +SYSTEMROOT+ and +PATH+.
177
+ #
178
+ # sandbox.with_env("LANG" => "C.UTF-8")
179
+ def with_env(variables)
180
+ env = @settings[:env]
181
+ variables.nil? ? env.clear : variables.each { |name, value| env[name.to_s] = value.to_s }
182
+ self
183
+ end
184
+
185
+ # Sets the directory the command starts in. The sandbox must be able to read it.
186
+ # Without one, the command starts in <tt>/</tt>, or <tt>%SystemRoot%</tt> on Windows.
187
+ def with_directory(path)
188
+ @settings[:directory] = path && File.path(path)
189
+ self
190
+ end
191
+
192
+ # Sets the timeout in seconds. Accepts anything that responds to +to_f+, such as
193
+ # <tt>30</tt>, <tt>2.5</tt> or <tt>1.minute</tt>.
194
+ def with_timeout(seconds)
195
+ @settings[:timeout] = self.class.duration(seconds)
196
+ self
197
+ end
198
+
199
+ # Sets the backend: <tt>:auto</tt> (Bubblewrap on Linux, Seatbelt on macOS,
200
+ # ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:seatbelt</tt>, or
201
+ # <tt>:process_container</tt>.
202
+ def with_backend(name)
203
+ @settings[:backend] = name && self.class.backend_name(name)
204
+ self
205
+ end
206
+
207
+ # Runs +command+ and returns its Result once it finishes. Like Kernel#system, one
208
+ # String runs through the platform shell: <tt>sh -c</tt>, or <tt>cmd.exe /d /s /c</tt>
209
+ # on Windows. Two or more Strings are a program and its arguments, and each argument
210
+ # reaches the program unchanged, without a shell.
211
+ #
212
+ # With a block, yields each Chunk of output as it arrives.
213
+ #
214
+ # sandbox.run("python3 build.py && ls dist")
215
+ # sandbox.run("python3", "convert.py", params[:filename])
216
+ # sandbox.run("python3", "build.py") { |chunk| print chunk.content }
217
+ #
218
+ # Raises ArgumentError without a command or when given an Array. On Windows, it also
219
+ # raises ArgumentError for a program and arguments that start a +.bat+ or +.cmd+ file,
220
+ # because Windows runs those through +cmd.exe+, which splits the arguments again.
221
+ def run(*command, &)
222
+ return stream(*command, &) if block_given?
223
+
224
+ outcome = StartupFailure.check!(Native.run(command_line(command), request))
225
+ Result.new(**outcome, backend: resolved_backend)
226
+ end
227
+
228
+ # Starts +command+ and returns its Process without waiting for it. Takes the command
229
+ # the same way as #run.
230
+ #
231
+ # sandbox.spawn("python3", "-i", "-q")
232
+ def spawn(*command)
233
+ Process.new(Native.spawn(command_line(command), request), backend: resolved_backend)
234
+ end
235
+
236
+ # Returns true when this host can run this sandbox, including its network policy.
237
+ def supported?
238
+ availability.supported?
239
+ end
240
+
241
+ # Returns the Availability of this sandbox on this host, with the reason and
242
+ # remediation when it cannot run.
243
+ def availability
244
+ problem = Preflight.problem(network:, denied:)
245
+ return MXC.availability(backend:) unless problem
246
+
247
+ Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
248
+ end
249
+
250
+ def inspect # :nodoc:
251
+ "#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
252
+ "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}>"
253
+ end
254
+
255
+ protected
256
+
257
+ attr_reader :settings # :nodoc:
258
+
259
+ private
260
+
261
+ def inherit(source)
262
+ @settings = source ? source.settings.transform_values(&:dup) : DEFAULTS.transform_values(&:dup)
263
+ end
264
+
265
+ def apply(name, value)
266
+ case name
267
+ when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
268
+ when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
269
+ else public_send(:"with_#{name}", value)
270
+ end
271
+ end
272
+
273
+ def add_paths(name, paths)
274
+ list = @settings[name]
275
+ paths == [nil] ? list.clear : list.concat(paths.flatten.map { |path| File.path(path) })
276
+ self
277
+ end
278
+
279
+ def stream(*command, &)
280
+ process = spawn(*command)
281
+ finished = false
282
+ process.each(&)
283
+ finished = true
284
+ process.wait
285
+ ensure
286
+ process&.kill unless finished
287
+ end
288
+
289
+ def command_line(command)
290
+ arguments = command_arguments(command)
291
+ return command_line_for_windows(arguments) if Preflight.windows?
292
+
293
+ arguments.one? ? arguments.first : Shellwords.join(arguments)
294
+ end
295
+
296
+ def command_arguments(command)
297
+ raise ArgumentError, 'no command given' if command.empty?
298
+ if command.any?(Array)
299
+ raise ArgumentError, 'pass the program and its arguments as separate Strings, ' \
300
+ 'as in run("python3", "build.py"), not as an Array'
301
+ end
302
+
303
+ command.map { |argument| String.try_convert(argument) || File.path(argument) }
304
+ end
305
+
306
+ def command_line_for_windows(arguments)
307
+ return %(cmd.exe /d /s /c "#{arguments.first}") if arguments.one?
308
+
309
+ if arguments.first.match?(BATCH_FILE)
310
+ raise ArgumentError, 'Windows runs batch files through cmd.exe, which does not keep arguments apart. ' \
311
+ 'Run them with one String and quote untrusted input yourself'
312
+ end
313
+
314
+ arguments.map { |argument| WindowsArguments.quote(argument) }.join(' ')
315
+ end
316
+
317
+ def resolved_backend
318
+ return backend unless backend == :auto
319
+ return :process_container if Preflight.windows?
320
+
321
+ Preflight.linux? ? :bubblewrap : :seatbelt
322
+ end
323
+
324
+ def request
325
+ Preflight.check!(network:, denied:)
326
+ {
327
+ readonly: expand(readable),
328
+ readwrite: expand(writable),
329
+ denied: expand(denied) | hidden_resolver_sockets,
330
+ environment:,
331
+ working_directory:,
332
+ timeout_ms: timeout && [(timeout.to_f * 1000).ceil, MAX_TIMEOUT_MS].min,
333
+ backend:,
334
+ network: network == true,
335
+ allow: network.is_a?(Hash) ? network[:allow] : nil
336
+ }
337
+ end
338
+
339
+ def expand(paths)
340
+ paths.map { |path| native_path(File.expand_path(path)) }
341
+ end
342
+
343
+ def native_path(path)
344
+ Preflight.windows? ? path.tr('/', '\\') : path
345
+ end
346
+
347
+ def working_directory
348
+ return native_path(File.expand_path(directory)) if directory
349
+
350
+ ENV.fetch('SystemRoot', nil) if Preflight.windows?
351
+ end
352
+
353
+ def environment
354
+ return env unless Preflight.windows?
355
+
356
+ system = ENV.to_h.select { |name, _| WINDOWS_ENV.include?(name.upcase) }
357
+ system.reject { |name, _| env.keys.any? { |own| own.casecmp?(name) } }.merge(env)
358
+ end
359
+
360
+ def hidden_resolver_sockets
361
+ return [] if network == true
362
+
363
+ HOST_RESOLVER_SOCKETS.select { |path| File.exist?(path) }
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ module StartupFailure # :nodoc:
5
+ DOCKER_GUIDE = 'https://crmne.github.io/mxc/running-in-docker/'
6
+ NAMESPACES = Regexp.union(/No permissions to create a?\s*new namespace/i, /Creating new namespace failed/i,
7
+ /setting up uid map/i, /unshare.*Operation not permitted/i)
8
+ PROC = /Can't mount proc|mount proc on/i
9
+ HOST_PROBLEMS = {
10
+ NAMESPACES => 'Unprivileged user namespaces are blocked. On a host, enable kernel.unprivileged_userns_clone=1 ' \
11
+ 'and, on Ubuntu 23.10 and later, allow bwrap in AppArmor ' \
12
+ '(kernel.apparmor_restrict_unprivileged_userns). ' \
13
+ "In Docker, run with a seccomp profile that allows namespaces: #{DOCKER_GUIDE}",
14
+ PROC => 'bwrap cannot mount a fresh /proc because the container masks /proc paths. ' \
15
+ "In Docker, see #{DOCKER_GUIDE}"
16
+ }.freeze
17
+ CHDIR = /Can't chdir/i
18
+
19
+ module_function
20
+
21
+ def detect(outcome)
22
+ return unless outcome[:exit_code] == 1 && outcome[:stdout].to_s.empty?
23
+
24
+ stderr = outcome[:stderr].to_s
25
+ return unless stderr.match?(/\Abwrap: [^\n]*\n?\z/)
26
+
27
+ message = "Bubblewrap could not start the sandbox: #{stderr.strip}"
28
+ _, hint = HOST_PROBLEMS.find { |pattern, _| stderr.match?(pattern) }
29
+ return UnsupportedError.new(message, code: :backend_unavailable, remediation: hint, operation: 'bwrap') if hint
30
+
31
+ remediation = 'Make the directory readable or writable in the sandbox' if stderr.match?(CHDIR)
32
+ Error.new(message, code: :backend_error, remediation:, operation: 'bwrap')
33
+ end
34
+
35
+ def check!(outcome)
36
+ error = detect(outcome)
37
+ raise error if error
38
+
39
+ outcome
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # The gem version.
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ module WindowsArguments # :nodoc:
5
+ SPECIAL = /[\s"]/
6
+
7
+ module_function
8
+
9
+ # Quotes +argument+ so CommandLineToArgvW and the C runtime read it back unchanged.
10
+ def quote(argument)
11
+ return '""' if argument.empty?
12
+ return argument unless argument.match?(SPECIAL)
13
+
14
+ quoted = argument.gsub(/(\\*)"/) { "#{Regexp.last_match(1) * 2}\\\"" }
15
+ "\"#{quoted.sub(/(\\+)\z/) { Regexp.last_match(1) * 2 }}\""
16
+ end
17
+ end
18
+ end