mxc 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 988895e92e027354db083acc4b7fefd8b48d6ac8a5e4bfed22a734f4c020dd3d
4
- data.tar.gz: 4d014ede1e4b3c35551304d1b92fdf49889587b009bb0fb0df39c96fb8718926
3
+ metadata.gz: fccd6da3c4cef2c5422949e1a28b2c38fad41a709a33db744e95c255e32c72fa
4
+ data.tar.gz: 165b13bffc2f44b45468a215647b054413acb392aed297d8f9325a8e9f38d707
5
5
  SHA512:
6
- metadata.gz: f9330fd9f89ad26e9ce2494969b6734afb4fdb3f6e4a8f3712421e1d5f28d0e4efbc40965520a6ed3c0211b7ccf2c915fd74b05daed107ef02168e9b4b5f18f1
7
- data.tar.gz: 44a17cc95d03e7a24b2fe2cc71fb32137c2983f01c155dbd2a9e1fb0051bd9193e3c22754acdbe050881ec0a3f64f0fcb4b2e131d09c13e4ea02038fc2fa4ed0
6
+ metadata.gz: c8296fcb1dacf6434405b6e821a88f69871dc3877f05c3fa04e9140017f564aa773f43276812e9954a09335022732e20b4b88159ce63d4eca6ff58d5cea9281a
7
+ data.tar.gz: 9908ebc2252e66cf4d06e4d23c07fd2975943d60aa4b629c0e6b1127f0c690acbc2eab8a5955ed7bb6ec71fc803d30a82d3fb5aefe398f65a9720eb210ae897e
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.0"
597
597
  dependencies = [
598
598
  "magnus",
599
599
  "mxc-sdk",
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.0"
4
4
  edition = "2021"
5
5
  authors = ["Carmine Paolino <carmine@paolino.me>"]
6
6
  license = "MIT"
@@ -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
 
@@ -173,6 +187,32 @@ module MXC
173
187
  add_paths(:denied, paths)
174
188
  end
175
189
 
190
+ # Adds programs and libraries whose shared libraries the command loads, such as the
191
+ # extensions an interpreter opens. On macOS, the command can read those library files,
192
+ # and the libraries they load in turn, when they are not the system's own. The program
193
+ # the command runs needs no entry: #run grants its libraries already. Elsewhere, on a
194
+ # Mac without the Command Line Tools, and with <tt>with_libraries(false)</tt>, this
195
+ # grants nothing.
196
+ #
197
+ # sandbox.with_libraries_of(*Dir[File.join(RbConfig::CONFIG["archdir"], "**", "*.bundle")])
198
+ def with_libraries_of(*paths)
199
+ add_paths(:libraries_of, paths)
200
+ end
201
+
202
+ # Sets whether the command can read the shared libraries its program and #libraries_of
203
+ # load, on macOS. +true+ is the default; +false+ grants no library, so the command reads
204
+ # only the system's and those under #readable. +nil+ resets it to +true+.
205
+ #
206
+ # sandbox.with_libraries(false)
207
+ def with_libraries(enabled)
208
+ unless [true, false, nil].include?(enabled)
209
+ raise ArgumentError, "libraries must be true, false or nil, got #{enabled.inspect}"
210
+ end
211
+
212
+ @settings[:libraries] = enabled != false
213
+ self
214
+ end
215
+
176
216
  # Sets network access. The default, +false+, gives the command no network at all.
177
217
  # +true+ allows any destination. <tt>allow:</tt> takes a list of hosts the command
178
218
  # may reach over HTTP and HTTPS: <tt>"pypi.org"</tt>, <tt>"*.pythonhosted.org"</tt>
@@ -273,6 +313,12 @@ module MXC
273
313
  #
274
314
  # With a block, yields each Chunk of output as it arrives.
275
315
  #
316
+ # On macOS, the command can read the shared library files its program links outside
317
+ # the system's own, such as Homebrew's, so programs built against them start. The
318
+ # program is the first argument, or the first word of a command for the shell, found
319
+ # on the +PATH+ of #env. See #with_libraries_of for libraries the program opens later,
320
+ # and #with_libraries to turn this off.
321
+ #
276
322
  # sandbox.run("python3 build.py && ls dist")
277
323
  # sandbox.run("python3", "convert.py", params[:filename])
278
324
  # sandbox.run("python3", "build.py") { |chunk| print chunk.content }
@@ -283,7 +329,8 @@ module MXC
283
329
  def run(*command, &)
284
330
  return stream(*command, &) if block_given?
285
331
 
286
- outcome = StartupFailure.check!(Native.run(command_line(command), request))
332
+ arguments = command_arguments(command)
333
+ outcome = StartupFailure.check!(Native.run(command_line(arguments), request(arguments)))
287
334
  Result.new(**outcome, backend: resolved_backend(outcome[:warnings]))
288
335
  end
289
336
 
@@ -292,7 +339,8 @@ module MXC
292
339
  #
293
340
  # sandbox.spawn("python3", "-i", "-q")
294
341
  def spawn(*command)
295
- native = Native.spawn(command_line(command), request)
342
+ arguments = command_arguments(command)
343
+ native = Native.spawn(command_line(arguments), request(arguments))
296
344
  Process.new(native, backend: resolved_backend(native.warnings))
297
345
  end
298
346
 
@@ -315,7 +363,8 @@ module MXC
315
363
  def inspect # :nodoc:
316
364
  resources = " limits=#{limits.inspect} priority=#{priority.inspect}" if self.resources
317
365
  "#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
318
- "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}>"
366
+ "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}" \
367
+ "#{libraries_inspect}>"
319
368
  end
320
369
 
321
370
  protected
@@ -330,7 +379,7 @@ module MXC
330
379
 
331
380
  def apply(name, value)
332
381
  case name
333
- when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
382
+ when :readable, :writable, :denied, :libraries_of then public_send(:"with_#{name}", *Array(value))
334
383
  when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
335
384
  when :limits then value.nil? ? with_limits(nil) : with_limits(**value)
336
385
  else public_send(:"with_#{name}", value)
@@ -384,8 +433,7 @@ module MXC
384
433
  process&.kill unless finished
385
434
  end
386
435
 
387
- def command_line(command)
388
- arguments = command_arguments(command)
436
+ def command_line(arguments)
389
437
  return command_line_for_windows(arguments) if Preflight.windows?
390
438
 
391
439
  arguments.one? ? arguments.first : Shellwords.join(arguments)
@@ -420,10 +468,10 @@ module MXC
420
468
  warnings.any?(LANDLOCK_FALLBACK) ? :landlock : :bubblewrap
421
469
  end
422
470
 
423
- def request
471
+ def request(arguments)
424
472
  Preflight.check!(network:, denied:, resources:, backend:)
425
473
  {
426
- readonly: expand(readable),
474
+ readonly: expand(readable) | library_files(arguments),
427
475
  readwrite: expand(writable),
428
476
  denied: expand(denied) | hidden_resolver_sockets,
429
477
  environment:,
@@ -474,6 +522,17 @@ module MXC
474
522
  system.reject { |name, _| env.keys.any? { |own| own.casecmp?(name) } }.merge(env)
475
523
  end
476
524
 
525
+ def libraries_inspect
526
+ libraries ? (" libraries_of=#{libraries_of.inspect}" if libraries_of.any?) : ' libraries=false'
527
+ end
528
+
529
+ def library_files(arguments)
530
+ return [] unless libraries && Libraries.available?
531
+
532
+ program = Libraries.program(arguments, path: env['PATH'], directory: working_directory)
533
+ Libraries.files([*program, *expand(libraries_of)], executable: program)
534
+ end
535
+
477
536
  def hidden_resolver_sockets
478
537
  return [] if network == true
479
538
 
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.0'
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.0
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