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.
- checksums.yaml +7 -0
- data/Cargo.lock +1500 -0
- data/Cargo.toml +6 -0
- data/LICENSE +21 -0
- data/NOTICE +34 -0
- data/README.md +140 -0
- data/ext/mxc/Cargo.toml +18 -0
- data/ext/mxc/build.rs +28 -0
- data/ext/mxc/extconf.rb +24 -0
- data/ext/mxc/src/lib.rs +384 -0
- data/ext/mxc/src/proxy.rs +480 -0
- data/lib/mxc/availability.rb +45 -0
- data/lib/mxc/backend.rb +36 -0
- data/lib/mxc/chunk.rb +33 -0
- data/lib/mxc/configuration.rb +28 -0
- data/lib/mxc/error.rb +36 -0
- data/lib/mxc/native.rb +29 -0
- data/lib/mxc/preflight.rb +82 -0
- data/lib/mxc/process.rb +100 -0
- data/lib/mxc/result.rb +63 -0
- data/lib/mxc/sandbox.rb +366 -0
- data/lib/mxc/startup_failure.rb +42 -0
- data/lib/mxc/version.rb +6 -0
- data/lib/mxc/windows_arguments.rb +18 -0
- data/lib/mxc.rb +109 -0
- metadata +87 -0
|
@@ -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
|
data/lib/mxc/process.rb
ADDED
|
@@ -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
|
data/lib/mxc/sandbox.rb
ADDED
|
@@ -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
|
data/lib/mxc/version.rb
ADDED
|
@@ -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
|