microsandbox-rb 0.16.0 → 0.17.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.
@@ -38,7 +38,7 @@ module Microsandbox
38
38
 
39
39
  # @return [Time, nil]
40
40
  def created_at
41
- @created_at_ms && Time.at(@created_at_ms / 1000.0)
41
+ Microsandbox.time_from_ms(@created_at_ms)
42
42
  end
43
43
 
44
44
  # A host-side filesystem view over this volume (read/write its contents
@@ -55,8 +55,10 @@ module Microsandbox
55
55
 
56
56
  # A host-side filesystem view over a named volume, from {Volume.fs} or
57
57
  # {VolumeInfo#fs}. Reads and writes the volume's contents directly on the host,
58
- # without booting a sandbox. All paths are relative to the volume root. Mirrors
59
- # the `VolumeFs` of the official Python/Node SDKs.
58
+ # without booting a sandbox. All paths are relative to the volume root (a
59
+ # leading `/` is the volume root) and may not contain a `..` segment — every
60
+ # operation raises {FilesystemError} for one. Mirrors the `VolumeFs` of the
61
+ # official Python/Node SDKs.
60
62
  class VolumeFs
61
63
  def initialize(native)
62
64
  @native = native
@@ -65,13 +67,13 @@ module Microsandbox
65
67
  # Read a file as raw bytes (ASCII-8BIT).
66
68
  # @return [String]
67
69
  def read(path)
68
- @native.read(path.to_s)
70
+ @native.read(contained(path))
69
71
  end
70
72
 
71
73
  # Read a file as a UTF-8 string.
72
74
  # @return [String]
73
75
  def read_text(path)
74
- @native.read_text(path.to_s)
76
+ @native.read_text(contained(path))
75
77
  end
76
78
 
77
79
  # Write data to a file, creating parent directories as needed.
@@ -80,60 +82,75 @@ module Microsandbox
80
82
  # @return [nil]
81
83
  def write(path, data)
82
84
  bytes = Microsandbox.coerce_write_bytes(data)
83
- @native.write(path.to_s, bytes)
85
+ @native.write(contained(path), bytes)
84
86
  nil
85
87
  end
86
88
 
87
89
  # List the entries of a directory.
88
90
  # @return [Array<FsEntry>]
89
91
  def list(path)
90
- @native.list(path.to_s).map { |entry| FsEntry.new(entry) }
92
+ @native.list(contained(path)).map { |entry| FsEntry.new(entry) }
91
93
  end
92
94
 
93
95
  # Create a directory (and any missing parents).
94
96
  # @return [nil]
95
97
  def mkdir(path)
96
- @native.mkdir(path.to_s)
98
+ @native.mkdir(contained(path))
97
99
  nil
98
100
  end
99
101
 
100
102
  # Remove a single file.
101
103
  # @return [nil]
102
104
  def remove_file(path)
103
- @native.remove_file(path.to_s)
105
+ @native.remove_file(contained(path))
104
106
  nil
105
107
  end
106
108
 
107
109
  # Remove a directory recursively.
108
110
  # @return [nil]
109
111
  def remove_dir(path)
110
- @native.remove_dir(path.to_s)
112
+ @native.remove_dir(contained(path))
111
113
  nil
112
114
  end
113
115
 
114
116
  # @return [Boolean] whether the path exists in the volume
115
117
  def exists?(path)
116
- @native.exists(path.to_s)
118
+ @native.exists(contained(path))
117
119
  end
118
120
 
119
121
  # Copy a file within the volume.
120
122
  # @return [nil]
121
123
  def copy(src, dst)
122
- @native.copy(src.to_s, dst.to_s)
124
+ @native.copy(contained(src), contained(dst))
123
125
  nil
124
126
  end
125
127
 
126
128
  # Rename/move a file or directory within the volume.
127
129
  # @return [nil]
128
130
  def rename(src, dst)
129
- @native.rename(src.to_s, dst.to_s)
131
+ @native.rename(contained(src), contained(dst))
130
132
  nil
131
133
  end
132
134
 
133
135
  # Stat a path.
134
136
  # @return [FsMetadata]
135
137
  def stat(path)
136
- FsMetadata.new(@native.stat(path.to_s))
138
+ FsMetadata.new(@native.stat(contained(path)))
139
+ end
140
+
141
+ private
142
+
143
+ # Volume paths are relative to the volume root and never need a `..`
144
+ # segment, so one is refused here with a clear message instead of being
145
+ # handed to the resolver. The native layer enforces the same rule; this
146
+ # check only exists so the error names the cause.
147
+ def contained(path)
148
+ s = path.to_s
149
+ if s.split("/").include?("..")
150
+ raise Microsandbox::FilesystemError,
151
+ "path traversal outside volume root (`..` segments are not allowed in volume paths)"
152
+ end
153
+ s
137
154
  end
138
155
  end
139
156
 
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point named after the gem. `require "microsandbox"` resolves to
4
+ # whichever installed gem ships that file first, and the separate
5
+ # `microsandbox` gem on RubyGems ships one too; outside Bundler,
6
+ # `require "microsandbox-rb"` always loads this gem.
7
+ require_relative "microsandbox"
data/lib/microsandbox.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  require_relative "microsandbox/version"
4
6
 
5
7
  # Load the compiled native extension. Precompiled platform gems stage the
@@ -30,6 +32,7 @@ require_relative "microsandbox/snapshot"
30
32
  require_relative "microsandbox/patch"
31
33
  require_relative "microsandbox/root_disk"
32
34
  require_relative "microsandbox/network"
35
+ require_relative "microsandbox/outbound_proxy"
33
36
  require_relative "microsandbox/agent"
34
37
  require_relative "microsandbox/ssh"
35
38
  require_relative "microsandbox/modification"
@@ -46,6 +49,13 @@ require_relative "microsandbox/sandbox"
46
49
  # puts sb.exec("python", ["-c", "print('Hello, World!')"]).stdout
47
50
  # end
48
51
  module Microsandbox
52
+ # Serializes runtime provisioning within this process: {install}, {setup} and
53
+ # the first-use check in {ensure_runtime!}. The installer replaces files in
54
+ # place, so two threads racing through a first `Sandbox.create` must not both
55
+ # run it. A Monitor (re-entrant) because ensure_runtime! calls install.
56
+ RUNTIME_SETUP_LOCK = Monitor.new
57
+ private_constant :RUNTIME_SETUP_LOCK
58
+
49
59
  class << self
50
60
  # @return [String] the gem version
51
61
  def version
@@ -73,7 +83,7 @@ module Microsandbox
73
83
  # gem supplies the runtime.
74
84
  # @return [nil]
75
85
  def install
76
- Native.install
86
+ RUNTIME_SETUP_LOCK.synchronize { Native.install }
77
87
  nil
78
88
  end
79
89
 
@@ -92,7 +102,7 @@ module Microsandbox
92
102
  opts["version"] = version.to_s if version
93
103
  opts["force"] = true if force
94
104
  opts["skip_verify"] = true if skip_verify
95
- Native.setup(opts)
105
+ RUNTIME_SETUP_LOCK.synchronize { Native.setup(opts) }
96
106
  nil
97
107
  end
98
108
 
@@ -129,34 +139,38 @@ module Microsandbox
129
139
  # @return [nil]
130
140
  def ensure_runtime!
131
141
  return if @runtime_ready
132
- # A cloud backend has no local msb/libkrunfw runtime to provision: skip the
133
- # presence check and the first-use download entirely. Resolving the kind
134
- # uses the same lazy env/profile/config ladder every operation already
135
- # consults, so this adds no work for local hosts (the common case).
136
- return if default_backend_kind == :cloud
137
- # The binaries gem won the resolver: its vendored msb (and the libkrunfw
138
- # beside it) are exactly the version this gem was built for, so there is
139
- # nothing to verify or download. (If `MSB_PATH` overrides it, the user owns
140
- # the runtime and we fall through to the existing behaviour.)
141
- if bundled_runtime_active?
142
- @runtime_ready = true
143
- return
144
- end
145
- # Opted out: the caller manages the runtime out of band, so don't fetch,
146
- # verify, or repair it here. Memoize the decision (the env var is stable for
147
- # the process); the operation resolves `msb` itself and surfaces any problem.
148
- if auto_install_disabled?
149
- @runtime_ready = true
150
- return
151
- end
142
+ RUNTIME_SETUP_LOCK.synchronize do
143
+ # Re-check under the lock: another thread may have finished meanwhile.
144
+ return if @runtime_ready
145
+ # A cloud backend has no local msb/libkrunfw runtime to provision: skip the
146
+ # presence check and the first-use download entirely. Resolving the kind
147
+ # uses the same lazy env/profile/config ladder every operation already
148
+ # consults, so this adds no work for local hosts (the common case).
149
+ return if default_backend_kind == :cloud
150
+ # The binaries gem won the resolver: its vendored msb (and the libkrunfw
151
+ # beside it) are exactly the version this gem was built for, so there is
152
+ # nothing to verify or download. (If `MSB_PATH` overrides it, the user owns
153
+ # the runtime and we fall through to the existing behaviour.)
154
+ if bundled_runtime_active?
155
+ @runtime_ready = true
156
+ return
157
+ end
158
+ # Opted out: the caller manages the runtime out of band, so don't fetch,
159
+ # verify, or repair it here. Memoize the decision (the env var is stable for
160
+ # the process); the operation resolves `msb` itself and surfaces any problem.
161
+ if auto_install_disabled?
162
+ @runtime_ready = true
163
+ return
164
+ end
152
165
 
153
- unless installed?
154
- warn "[microsandbox] runtime (msb + libkrunfw) not found; " \
155
- "downloading to ~/.microsandbox (set MICROSANDBOX_NO_AUTO_INSTALL to skip, " \
156
- "or install the microsandbox-rb-binaries gem to ship it with your bundle)..."
166
+ unless installed?
167
+ warn "[microsandbox] runtime (msb + libkrunfw) not found; " \
168
+ "downloading to ~/.microsandbox (set MICROSANDBOX_NO_AUTO_INSTALL to skip, " \
169
+ "or install the microsandbox-rb-binaries gem to ship it with your bundle)..."
170
+ end
171
+ install
172
+ @runtime_ready = true
157
173
  end
158
- install
159
- @runtime_ready = true
160
174
  nil
161
175
  end
162
176
 
@@ -175,7 +189,9 @@ module Microsandbox
175
189
  # setter is a no-op — use `MSB_PATH` to override a bundled runtime.
176
190
  # @param path [String]
177
191
  # @return [void]
192
+ # @raise [ArgumentError] for nil or an empty path (the slot cannot be cleared)
178
193
  def runtime_path=(path)
194
+ require_runtime_path!(:runtime_path=, path)
179
195
  if @bundled_msb_path && path.to_s != @bundled_msb_path
180
196
  warn "[microsandbox] runtime_path= ignored: the microsandbox-rb-binaries gem already " \
181
197
  "claimed the set-once SDK slot (#{@bundled_msb_path}); set MSB_PATH to override it"
@@ -189,7 +205,9 @@ module Microsandbox
189
205
  # Mirrors {runtime_path=} for libkrunfw.
190
206
  # @param path [String]
191
207
  # @return [void]
208
+ # @raise [ArgumentError] for nil or an empty path (the slot cannot be cleared)
192
209
  def libkrunfw_path=(path)
210
+ require_runtime_path!(:libkrunfw_path=, path)
193
211
  Native.set_runtime_libkrunfw_path(path.to_s)
194
212
  end
195
213
 
@@ -271,6 +289,36 @@ module Microsandbox
271
289
  raise TypeError, "data must be a String (got #{data.class})"
272
290
  end
273
291
 
292
+ # A Time for an epoch-milliseconds Integer from the native layer, or nil.
293
+ # Integer arithmetic on purpose: `Time.at(ms / 1000.0)` goes through a
294
+ # Float and lands one millisecond low for about half of all timestamps.
295
+ # @api private
296
+ # @param ms [Integer, nil]
297
+ # @return [Time, nil]
298
+ def time_from_ms(ms)
299
+ ms && Time.at(ms.div(1000), ms % 1000, :millisecond)
300
+ end
301
+
302
+ # Run the block, then close +resource+ — the shared tail of the block forms
303
+ # that own a resource whose close can fail (a write sink flushes on close).
304
+ # If the block raised, its exception is the one the caller needs: a failure
305
+ # from the close that follows is dropped instead of replacing it. If the
306
+ # block finished (or left via break/throw), a close failure propagates.
307
+ # @api private
308
+ def close_after(resource)
309
+ raised = false
310
+ yield
311
+ rescue Exception # standard:disable Lint/RescueException -- re-raised unchanged
312
+ raised = true
313
+ raise
314
+ ensure
315
+ begin
316
+ resource.close
317
+ rescue
318
+ raise unless raised
319
+ end
320
+ end
321
+
274
322
  private
275
323
 
276
324
  # Auto-provisioning is on by default; any non-empty, non-"0"/"false" value
@@ -357,12 +405,22 @@ module Microsandbox
357
405
  private_constant :TRUSTED_BINARIES_OWNERS
358
406
 
359
407
  # Name of the loaded gem whose files define Microsandbox::Binaries, or nil
360
- # when it came from a bare load path.
408
+ # when it came from a bare load path. Gem roots can nest — a host project
409
+ # that is itself a gem, with its bundle installed under the project
410
+ # directory, is a prefix of every gem in that bundle — so the owner is the
411
+ # gem with the LONGEST matching root, not whichever one is listed first.
361
412
  def bundled_runtime_owner
362
413
  source = Binaries.method(:msb_path).source_location&.first
363
414
  return nil unless source
364
- spec = Gem.loaded_specs.values.find { |s| source.start_with?(File.join(s.full_gem_path, "")) }
365
- spec&.name
415
+ roots = Gem.loaded_specs.values.select { |s| source.start_with?(File.join(s.full_gem_path, "")) }
416
+ roots.max_by { |s| s.full_gem_path.length }&.name
417
+ end
418
+
419
+ # The runtime path slots are set-once in the core and have no "unset": nil
420
+ # or "" would be stored as an empty path for the rest of the process.
421
+ def require_runtime_path!(setter, path)
422
+ return unless path.nil? || path.to_s.empty?
423
+ raise ArgumentError, "#{setter} needs a non-empty path (the slot is set-once and cannot be cleared)"
366
424
  end
367
425
 
368
426
  # Whether the binaries gem's msb is what the resolver actually returns — i.e.
data/sig/microsandbox.rbs CHANGED
@@ -261,8 +261,9 @@ module Microsandbox
261
261
  ?volumes: Hash[untyped, untyped]?, ?network: untyped?,
262
262
  ?dns: Hash[untyped, untyped]?, ?tls: Hash[untyped, untyped]?,
263
263
  ?ipv4_pool: String?, ?ipv6_pool: String?,
264
- ?max_connections: Integer?, ?trust_host_cas: bool?,
264
+ ?max_connections: Integer?, ?trust_host_cas: bool?, ?strict: bool?,
265
265
  ?rate_limiter: Hash[untyped, untyped]?,
266
+ ?proxy: (OutboundProxy | Hash[untyped, untyped])?,
266
267
  ?vsock: (Hash[untyped, untyped] | Array[Hash[untyped, untyped]])?,
267
268
  ?patches: Array[Hash[untyped, untyped]]?, ?from_snapshot: String?,
268
269
  ?fstype: String?, ?init: (String | Symbol | Hash[untyped, untyped])?, ?ephemeral: bool,
@@ -576,6 +577,44 @@ module Microsandbox
576
577
  def cloud?: () -> bool
577
578
  end
578
579
 
580
+ # Host-side secret source (runtime v0.6.17): only `env` exists.
581
+ class SecretSource
582
+ KINDS: Array[String]
583
+ attr_reader kind: String
584
+ attr_reader var: String
585
+ def self.env: ((String | Symbol) variable) -> SecretSource
586
+ def self.coerce: (untyped value, ?String context) -> SecretSource
587
+ def initialize: ((String | Symbol) kind, (String | Symbol) var) -> void
588
+ def to_h: () -> Hash[String, String]
589
+ def ==: (untyped other) -> bool
590
+ def eql?: (untyped other) -> bool
591
+ def hash: () -> Integer
592
+ end
593
+
594
+ # Outbound SOCKS4/SOCKS5 proxy for the `proxy:` create option (runtime
595
+ # v0.6.17). Immutable: `credentials` returns a new proxy.
596
+ class OutboundProxy
597
+ PROTOCOLS: Array[String]
598
+ attr_reader protocol: String
599
+ attr_reader address: String
600
+ attr_reader user_id: String?
601
+ attr_reader username: String?
602
+ attr_reader password: SecretSource?
603
+ def self.socks4: (String address, ?user_id: String?) -> OutboundProxy
604
+ def self.socks5: (String address) -> OutboundProxy
605
+ def self.coerce: (untyped value) -> Hash[String, untyped]
606
+ def self.from_hash: (Hash[untyped, untyped] hash) -> OutboundProxy
607
+ def initialize: (protocol: (String | Symbol), address: String, ?user_id: String?,
608
+ ?username: String?, ?password: SecretSource?) -> void
609
+ def credentials: (String username, (SecretSource | Hash[untyped, untyped]) password) -> OutboundProxy
610
+ def socks4?: () -> bool
611
+ def socks5?: () -> bool
612
+ def to_h: () -> Hash[String, untyped]
613
+ def ==: (untyped other) -> bool
614
+ def eql?: (untyped other) -> bool
615
+ def hash: () -> Integer
616
+ end
617
+
579
618
  class NetworkPolicy
580
619
  PROFILES: Array[String]
581
620
  PRESET_ALIASES: Hash[String, String]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: microsandbox-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.17.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ya-luotao
@@ -43,6 +43,17 @@ files:
43
43
  - DESIGN.md
44
44
  - LICENSE
45
45
  - README.md
46
+ - SECURITY.md
47
+ - docs/configuration.md
48
+ - docs/errors.md
49
+ - docs/execution.md
50
+ - docs/filesystem.md
51
+ - docs/images.md
52
+ - docs/lifecycle.md
53
+ - docs/observability.md
54
+ - docs/releasing.md
55
+ - docs/runtime.md
56
+ - docs/surface.md
46
57
  - ext/microsandbox/Cargo.toml
47
58
  - ext/microsandbox/extconf.rb
48
59
  - ext/microsandbox/src/agent.rs
@@ -51,6 +62,7 @@ files:
51
62
  - ext/microsandbox/src/error.rs
52
63
  - ext/microsandbox/src/exec.rs
53
64
  - ext/microsandbox/src/fs_stream.rs
65
+ - ext/microsandbox/src/host_path.rs
54
66
  - ext/microsandbox/src/image.rs
55
67
  - ext/microsandbox/src/lib.rs
56
68
  - ext/microsandbox/src/runtime.rs
@@ -59,6 +71,7 @@ files:
59
71
  - ext/microsandbox/src/ssh.rs
60
72
  - ext/microsandbox/src/stream.rs
61
73
  - ext/microsandbox/src/volume.rs
74
+ - lib/microsandbox-rb.rb
62
75
  - lib/microsandbox.rb
63
76
  - lib/microsandbox/agent.rb
64
77
  - lib/microsandbox/backend_info.rb
@@ -71,6 +84,7 @@ files:
71
84
  - lib/microsandbox/metrics.rb
72
85
  - lib/microsandbox/modification.rb
73
86
  - lib/microsandbox/network.rb
87
+ - lib/microsandbox/outbound_proxy.rb
74
88
  - lib/microsandbox/patch.rb
75
89
  - lib/microsandbox/root_disk.rb
76
90
  - lib/microsandbox/sandbox.rb