mxc 0.3.0 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 988895e92e027354db083acc4b7fefd8b48d6ac8a5e4bfed22a734f4c020dd3d
4
- data.tar.gz: 4d014ede1e4b3c35551304d1b92fdf49889587b009bb0fb0df39c96fb8718926
3
+ metadata.gz: 3b22a8e3054e56f63421b06b11d5db03c5a4d2a78302759b069207b6f57e0881
4
+ data.tar.gz: 2425c817778256ec3bb937d9587f8d7513d8aa4106cd3727c9163806893b8d26
5
5
  SHA512:
6
- metadata.gz: f9330fd9f89ad26e9ce2494969b6734afb4fdb3f6e4a8f3712421e1d5f28d0e4efbc40965520a6ed3c0211b7ccf2c915fd74b05daed107ef02168e9b4b5f18f1
7
- data.tar.gz: 44a17cc95d03e7a24b2fe2cc71fb32137c2983f01c155dbd2a9e1fb0051bd9193e3c22754acdbe050881ec0a3f64f0fcb4b2e131d09c13e4ea02038fc2fa4ed0
6
+ metadata.gz: ad1387eafb7076d2a18e954e94cc5641a4a7bb9be532ffbba8ad3e496ca0021f7693e41d3fd4301108454d2863c469e415f6f8c6b9a1045cbd1c0ce4e1305544
7
+ data.tar.gz: 200a97933411bf815af791fb136f94c53d3270ff409318e0364a78fe9a7007fcf709d7eadfcae5a59054e53a088ce9af80fc563585b07b4f58f185135b2bed23
data/Cargo.lock CHANGED
@@ -593,7 +593,7 @@ dependencies = [
593
593
 
594
594
  [[package]]
595
595
  name = "mxc"
596
- version = "0.3.0"
596
+ version = "0.4.1"
597
597
  dependencies = [
598
598
  "magnus",
599
599
  "mxc-sdk",
@@ -605,7 +605,7 @@ dependencies = [
605
605
  [[package]]
606
606
  name = "mxc-sdk"
607
607
  version = "1.0.0"
608
- source = "git+https://github.com/crmne/microsoft-mxc?rev=d57b0d1aa3a2d552001fa54ef4ac4baaa733c26d#d57b0d1aa3a2d552001fa54ef4ac4baaa733c26d"
608
+ source = "git+https://github.com/crmne/microsoft-mxc?rev=d7ba62b299fbcd7bdd4d7876cefcd68ada9a469b#d7ba62b299fbcd7bdd4d7876cefcd68ada9a469b"
609
609
  dependencies = [
610
610
  "anyhow",
611
611
  "base64",
data/README.md CHANGED
@@ -128,6 +128,8 @@ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
128
128
 
129
129
  On Windows, a single String runs through `cmd.exe` instead of `sh`. See [Running Commands](https://crmne.github.io/mxc/running-commands/#windows) and [Policies](https://crmne.github.io/mxc/policies/#windows) for what else differs.
130
130
 
131
+ A sandbox controls files, network, and resources. It does not always hide other processes: on some backends a command can read their command lines, and on macOS the environment your application started with. [Policies](https://crmne.github.io/mxc/policies/#what-the-command-can-see-of-other-processes) lists what each backend shows.
132
+
131
133
  Docker blocks the namespaces Bubblewrap needs by default, so a default container runs sandboxes on Landlock. [Running in Docker](https://crmne.github.io/mxc/running-in-docker/) shows what that gives up, and the smallest set of changes that makes Bubblewrap work, with a seccomp profile and a Kamal configuration for a job role.
132
134
 
133
135
  ## Documentation
data/ext/mxc/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "mxc"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  edition = "2021"
5
5
  authors = ["Carmine Paolino <carmine@paolino.me>"]
6
6
  license = "MIT"
@@ -13,7 +13,7 @@ crate-type = ["cdylib"]
13
13
  magnus = "0.8.2"
14
14
  # A fork of mxc-sdk 1.0.0 that adds resource limits (microsoft/mxc#1498) and
15
15
  # a Landlock backend (microsoft/mxc#1494), until MXC releases them.
16
- mxc-sdk = { git = "https://github.com/crmne/microsoft-mxc", rev = "d57b0d1aa3a2d552001fa54ef4ac4baaa733c26d" }
16
+ mxc-sdk = { git = "https://github.com/crmne/microsoft-mxc", rev = "d7ba62b299fbcd7bdd4d7876cefcd68ada9a469b" }
17
17
  rb-sys = { version = "0.9.130", features = ["stable-api-compiled-fallback"] }
18
18
  serde_json = "1"
19
19
 
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+
6
+ module MXC
7
+ # Finds the shared library files programs load, directly or through
8
+ # other libraries, so a sandbox can read them and nothing beside them. Only macOS needs this: Seatbelt
9
+ # sandboxes read the system's libraries but not those a package manager
10
+ # installs, such as Homebrew's, which a program like Ruby may link. The
11
+ # libraries come from the programs' load commands, read with otool from
12
+ # the Command Line Tools. Without them nothing is found, because
13
+ # /usr/bin/otool would offer to install them instead.
14
+ module Libraries # :nodoc:
15
+ OTOOL = '/usr/bin/otool'
16
+ XCODE_SELECT = '/usr/bin/xcode-select'
17
+ SYSTEM = %w[/usr/lib/ /System/ /Library/].freeze
18
+ TOKEN = %r{\A@(loader_path|executable_path|rpath)/}
19
+
20
+ @links = {}
21
+ @lock = Mutex.new
22
+
23
+ class << self
24
+ # Returns the real paths of the library files +binaries+ load outside
25
+ # the system's own, empty where none are needed or otool is missing.
26
+ # +executable+ is the program that loads them, for libraries named
27
+ # relative to it.
28
+ def files(binaries, executable: nil)
29
+ return [] if binaries.empty? || !available?
30
+
31
+ libraries(binaries, executable)
32
+ end
33
+
34
+ # Returns the path of the program +arguments+ start: the first argument, or the first
35
+ # word of a command for the shell, found on +path+ or under +directory+. Returns nil
36
+ # when there is none.
37
+ def program(arguments, path:, directory:)
38
+ name = program_name(arguments)
39
+ return if name.nil?
40
+ return File.expand_path(name, directory || '/') if name.include?('/')
41
+
42
+ path.to_s.split(File::PATH_SEPARATOR).map { |entry| File.join(entry, name) }
43
+ .find { |candidate| File.file?(candidate) && File.executable?(candidate) }
44
+ end
45
+
46
+ # Returns true when this host can list a program's libraries.
47
+ def available?
48
+ return @available if defined?(@available)
49
+
50
+ @available = RUBY_PLATFORM.include?('darwin') && File.executable?(OTOOL) && developer_tools?
51
+ end
52
+
53
+ private
54
+
55
+ def program_name(arguments)
56
+ arguments.one? ? Shellwords.split(arguments.first).first : arguments.first
57
+ rescue ArgumentError
58
+ nil
59
+ end
60
+
61
+ def libraries(binaries, executable)
62
+ roots = binaries.filter_map { |binary| real_path(binary) }
63
+ links_of(roots)
64
+ found = {}
65
+ roots.each { |root| visit(root, executable || root, [], found) }
66
+ found.keys
67
+ end
68
+
69
+ def visit(path, executable, inherited, found)
70
+ dependencies(path, links_of([path]).fetch(path), executable, inherited).each do |library, rpaths|
71
+ next if found.key?(library)
72
+
73
+ found[library] = true
74
+ visit(library, executable, rpaths, found)
75
+ end
76
+ end
77
+
78
+ def developer_tools?
79
+ system(XCODE_SELECT, '-p', out: File::NULL, err: File::NULL) == true
80
+ end
81
+
82
+ def dependencies(path, links, executable, inherited)
83
+ rpaths = links[:rpaths].filter_map { |rpath| expand(rpath, path, executable) } + inherited
84
+ links[:libraries].filter_map do |name|
85
+ next if system?(name)
86
+
87
+ library = locate(name, path, executable, rpaths)
88
+ [library, rpaths] if library && !system?(library)
89
+ end
90
+ end
91
+
92
+ def locate(name, loader, executable, rpaths)
93
+ relative = name.delete_prefix('@rpath/')
94
+ candidates = if relative == name
95
+ [expand(name, loader, executable)]
96
+ else
97
+ rpaths.map { |rpath| File.join(rpath, relative) }
98
+ end
99
+ candidates.compact.lazy.filter_map { |candidate| real_path(candidate) }.first
100
+ end
101
+
102
+ def expand(name, loader, executable)
103
+ token = name[TOKEN, 1]
104
+ return name unless token
105
+ return if token == 'rpath'
106
+
107
+ base = token == 'loader_path' ? loader : executable
108
+ File.expand_path(name.sub(TOKEN, ''), File.dirname(base))
109
+ end
110
+
111
+ def system?(path)
112
+ path.start_with?(*SYSTEM)
113
+ end
114
+
115
+ def real_path(path)
116
+ path = File.realpath(path)
117
+ path if File.file?(path)
118
+ rescue SystemCallError
119
+ nil
120
+ end
121
+
122
+ def links_of(paths)
123
+ @lock.synchronize do
124
+ missing = paths.reject { |path| @links.key?(path) }
125
+ @links.merge!(LoadCommands.parse(otool(missing), missing)) if missing.any?
126
+ @links.slice(*paths)
127
+ end
128
+ end
129
+
130
+ def otool(paths)
131
+ output, = Open3.capture2(OTOOL, '-l', *paths, err: File::NULL)
132
+ output
133
+ rescue SystemCallError
134
+ ''
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # Reads the libraries and run paths of Mach-O files from the load
5
+ # commands <tt>otool -l</tt> lists, with every architecture of a
6
+ # universal file together.
7
+ module LoadCommands # :nodoc:
8
+ LOAD = /\A\s*cmd LC_(?:LOAD_DYLIB|LOAD_WEAK_DYLIB|REEXPORT_DYLIB|LAZY_LOAD_DYLIB|LOAD_UPWARD_DYLIB)\z/
9
+ RPATH = /\A\s*cmd LC_RPATH\z/
10
+ COMMAND = /\A\s*cmd /
11
+ VALUE = /\A\s*(?:name|path) (.+) \(offset \d+\)\z/
12
+ ARCHITECTURE = / \(architecture [^)]+\)\z/
13
+
14
+ class << self
15
+ # Returns, for each of +paths+, the install names of the libraries it
16
+ # loads and its run paths, as <tt>{ libraries: [...], rpaths: [...] }</tt>.
17
+ def parse(output, paths)
18
+ links = paths.to_h { |path| [path, { libraries: [], rpaths: [] }] }
19
+ current = kind = nil
20
+ output.each_line(chomp: true) do |line|
21
+ if links.key?(header = header_path(line))
22
+ current = links[header]
23
+ kind = nil
24
+ elsif current
25
+ kind = record(current, kind, line)
26
+ end
27
+ end
28
+ links
29
+ end
30
+
31
+ private
32
+
33
+ def record(links, kind, line)
34
+ return :libraries if line.match?(LOAD)
35
+ return :rpaths if line.match?(RPATH)
36
+ return if line.match?(COMMAND)
37
+
38
+ value = line[VALUE, 1] if kind
39
+ return kind unless value
40
+
41
+ links[kind] << value
42
+ nil
43
+ end
44
+
45
+ def header_path(line)
46
+ line.delete_suffix(':').sub(ARCHITECTURE, '') if line.end_with?(':') && !line.start_with?(' ', "\t")
47
+ end
48
+ end
49
+ end
50
+ end
data/lib/mxc/sandbox.rb CHANGED
@@ -30,7 +30,8 @@ module MXC
30
30
  class Sandbox
31
31
  BACKENDS = %i[auto bubblewrap landlock seatbelt process_container].freeze # :nodoc:
32
32
  LANDLOCK_FALLBACK = /runs on Landlock instead/ # :nodoc:
33
- OPTIONS = %i[readable writable denied network env directory timeout backend limits priority].freeze # :nodoc:
33
+ OPTIONS = %i[readable writable denied libraries libraries_of network env directory timeout backend limits
34
+ priority].freeze # :nodoc:
34
35
  HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
35
36
  WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
36
37
  PROCESSOR_ARCHITECTURE NUMBER_OF_PROCESSORS OS].freeze # :nodoc:
@@ -40,8 +41,8 @@ module MXC
40
41
  PRIORITIES = { normal: nil, low: 'low', lowest: 'idle' }.freeze # :nodoc:
41
42
  MEGABYTE = 1024 * 1024 # :nodoc:
42
43
  DEFAULTS = {
43
- readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil,
44
- limits: {}, priority: nil
44
+ readable: [], writable: [], denied: [], libraries: true, libraries_of: [], env: {}, network: false,
45
+ directory: nil, timeout: nil, backend: nil, limits: {}, priority: nil
45
46
  }.freeze # :nodoc:
46
47
 
47
48
  class << self
@@ -54,6 +55,13 @@ module MXC
54
55
  # Adds paths every instance hides, even inside readable or writable paths.
55
56
  def denied(*paths) = configure { |sandbox| sandbox.with_denied(*paths) }
56
57
 
58
+ # Adds programs and libraries whose shared libraries every instance can load.
59
+ # Takes the same arguments as #with_libraries_of.
60
+ def libraries_of(*paths) = configure { |sandbox| sandbox.with_libraries_of(*paths) }
61
+
62
+ # Sets whether every instance can read the libraries its programs load. See #with_libraries.
63
+ def libraries(enabled) = configure { |sandbox| sandbox.with_libraries(enabled) }
64
+
57
65
  # Sets every instance's network access. Takes the same arguments as #with_network.
58
66
  def network(...) = configure { |sandbox| sandbox.with_network(...) }
59
67
 
@@ -135,6 +143,12 @@ module MXC
135
143
  # Returns the paths hidden from the command, as given.
136
144
  def denied = @settings[:denied].dup.freeze
137
145
 
146
+ # Returns the programs and libraries whose shared libraries the command can load, as given.
147
+ def libraries_of = @settings[:libraries_of].dup.freeze
148
+
149
+ # Returns true when the command can read the libraries its program loads, the default.
150
+ def libraries = @settings[:libraries]
151
+
138
152
  # Returns the environment variables set for the command.
139
153
  def env = @settings[:env].dup.freeze
140
154
 
@@ -163,7 +177,8 @@ module MXC
163
177
  add_paths(:readable, paths)
164
178
  end
165
179
 
166
- # Adds paths the command can read and write.
180
+ # Adds paths the command can read and write. A path that is also #readable is writable,
181
+ # whichever was added first. A readable path inside a writable one stays read-only.
167
182
  def with_writable(*paths)
168
183
  add_paths(:writable, paths)
169
184
  end
@@ -173,6 +188,32 @@ module MXC
173
188
  add_paths(:denied, paths)
174
189
  end
175
190
 
191
+ # Adds programs and libraries whose shared libraries the command loads, such as the
192
+ # extensions an interpreter opens. On macOS, the command can read those library files,
193
+ # and the libraries they load in turn, when they are not the system's own. The program
194
+ # the command runs needs no entry: #run grants its libraries already. Elsewhere, on a
195
+ # Mac without the Command Line Tools, and with <tt>with_libraries(false)</tt>, this
196
+ # grants nothing.
197
+ #
198
+ # sandbox.with_libraries_of(*Dir[File.join(RbConfig::CONFIG["archdir"], "**", "*.bundle")])
199
+ def with_libraries_of(*paths)
200
+ add_paths(:libraries_of, paths)
201
+ end
202
+
203
+ # Sets whether the command can read the shared libraries its program and #libraries_of
204
+ # load, on macOS. +true+ is the default; +false+ grants no library, so the command reads
205
+ # only the system's and those under #readable. +nil+ resets it to +true+.
206
+ #
207
+ # sandbox.with_libraries(false)
208
+ def with_libraries(enabled)
209
+ unless [true, false, nil].include?(enabled)
210
+ raise ArgumentError, "libraries must be true, false or nil, got #{enabled.inspect}"
211
+ end
212
+
213
+ @settings[:libraries] = enabled != false
214
+ self
215
+ end
216
+
176
217
  # Sets network access. The default, +false+, gives the command no network at all.
177
218
  # +true+ allows any destination. <tt>allow:</tt> takes a list of hosts the command
178
219
  # may reach over HTTP and HTTPS: <tt>"pypi.org"</tt>, <tt>"*.pythonhosted.org"</tt>
@@ -273,6 +314,12 @@ module MXC
273
314
  #
274
315
  # With a block, yields each Chunk of output as it arrives.
275
316
  #
317
+ # On macOS, the command can read the shared library files its program links outside
318
+ # the system's own, such as Homebrew's, so programs built against them start. The
319
+ # program is the first argument, or the first word of a command for the shell, found
320
+ # on the +PATH+ of #env. See #with_libraries_of for libraries the program opens later,
321
+ # and #with_libraries to turn this off.
322
+ #
276
323
  # sandbox.run("python3 build.py && ls dist")
277
324
  # sandbox.run("python3", "convert.py", params[:filename])
278
325
  # sandbox.run("python3", "build.py") { |chunk| print chunk.content }
@@ -283,7 +330,8 @@ module MXC
283
330
  def run(*command, &)
284
331
  return stream(*command, &) if block_given?
285
332
 
286
- outcome = StartupFailure.check!(Native.run(command_line(command), request))
333
+ arguments = command_arguments(command)
334
+ outcome = StartupFailure.check!(Native.run(command_line(arguments), request(arguments)))
287
335
  Result.new(**outcome, backend: resolved_backend(outcome[:warnings]))
288
336
  end
289
337
 
@@ -292,7 +340,8 @@ module MXC
292
340
  #
293
341
  # sandbox.spawn("python3", "-i", "-q")
294
342
  def spawn(*command)
295
- native = Native.spawn(command_line(command), request)
343
+ arguments = command_arguments(command)
344
+ native = Native.spawn(command_line(arguments), request(arguments))
296
345
  Process.new(native, backend: resolved_backend(native.warnings))
297
346
  end
298
347
 
@@ -315,7 +364,8 @@ module MXC
315
364
  def inspect # :nodoc:
316
365
  resources = " limits=#{limits.inspect} priority=#{priority.inspect}" if self.resources
317
366
  "#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
318
- "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}>"
367
+ "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}" \
368
+ "#{libraries_inspect}>"
319
369
  end
320
370
 
321
371
  protected
@@ -330,7 +380,7 @@ module MXC
330
380
 
331
381
  def apply(name, value)
332
382
  case name
333
- when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
383
+ when :readable, :writable, :denied, :libraries_of then public_send(:"with_#{name}", *Array(value))
334
384
  when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
335
385
  when :limits then value.nil? ? with_limits(nil) : with_limits(**value)
336
386
  else public_send(:"with_#{name}", value)
@@ -384,8 +434,7 @@ module MXC
384
434
  process&.kill unless finished
385
435
  end
386
436
 
387
- def command_line(command)
388
- arguments = command_arguments(command)
437
+ def command_line(arguments)
389
438
  return command_line_for_windows(arguments) if Preflight.windows?
390
439
 
391
440
  arguments.one? ? arguments.first : Shellwords.join(arguments)
@@ -420,11 +469,13 @@ module MXC
420
469
  warnings.any?(LANDLOCK_FALLBACK) ? :landlock : :bubblewrap
421
470
  end
422
471
 
423
- def request
472
+ def request(arguments)
424
473
  Preflight.check!(network:, denied:, resources:, backend:)
474
+ readwrite = expand(writable)
425
475
  {
426
- readonly: expand(readable),
427
- readwrite: expand(writable),
476
+ # MXC resolves a path in both lists to read-only.
477
+ readonly: (expand(readable) | library_files(arguments)) - readwrite,
478
+ readwrite:,
428
479
  denied: expand(denied) | hidden_resolver_sockets,
429
480
  environment:,
430
481
  working_directory:,
@@ -474,6 +525,17 @@ module MXC
474
525
  system.reject { |name, _| env.keys.any? { |own| own.casecmp?(name) } }.merge(env)
475
526
  end
476
527
 
528
+ def libraries_inspect
529
+ libraries ? (" libraries_of=#{libraries_of.inspect}" if libraries_of.any?) : ' libraries=false'
530
+ end
531
+
532
+ def library_files(arguments)
533
+ return [] unless libraries && Libraries.available?
534
+
535
+ program = Libraries.program(arguments, path: env['PATH'], directory: working_directory)
536
+ Libraries.files([*program, *expand(libraries_of)], executable: program)
537
+ end
538
+
477
539
  def hidden_resolver_sockets
478
540
  return [] if network == true
479
541
 
data/lib/mxc/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module MXC
4
4
  # The gem version.
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.1'
6
6
  end
data/lib/mxc.rb CHANGED
@@ -12,6 +12,8 @@ require 'mxc/backend'
12
12
  require 'mxc/availability'
13
13
  require 'mxc/process'
14
14
  require 'mxc/windows_arguments'
15
+ require 'mxc/load_commands'
16
+ require 'mxc/libraries'
15
17
  require 'mxc/sandbox'
16
18
 
17
19
  # Runs commands in sandboxes through Microsoft's MXC.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mxc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
@@ -49,6 +49,8 @@ files:
49
49
  - lib/mxc/chunk.rb
50
50
  - lib/mxc/configuration.rb
51
51
  - lib/mxc/error.rb
52
+ - lib/mxc/libraries.rb
53
+ - lib/mxc/load_commands.rb
52
54
  - lib/mxc/native.rb
53
55
  - lib/mxc/preflight.rb
54
56
  - lib/mxc/process.rb