microsandbox-rb 0.6.0 → 0.7.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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Microsandbox
4
6
  # A controllable handle to a sandbox, returned by {Sandbox.get}, {Sandbox.list},
5
7
  # and {Sandbox.list_with}. Carries a metadata snapshot (captured when fetched)
@@ -99,6 +101,37 @@ module Microsandbox
99
101
  SandboxStopResult.new(@native.wait_until_stopped)
100
102
  end
101
103
 
104
+ # The sandbox's stored configuration as a raw JSON string (synchronous — the
105
+ # handle already carries it, no runtime round-trip). Mirrors the Python/Node
106
+ # `config_json`.
107
+ # @return [String]
108
+ def config_json
109
+ @native.config_json
110
+ end
111
+
112
+ # The sandbox's stored configuration, parsed into a Hash (image, cpus,
113
+ # memory, mounts, …). Mirrors the Python/Node `config`.
114
+ # @return [Hash]
115
+ def config
116
+ JSON.parse(@native.config_json)
117
+ end
118
+
119
+ # Snapshot this (stopped) sandbox under a bare name, resolved under the
120
+ # snapshots dir. Convenience equivalent of
121
+ # `Snapshot.create(name, name: <snapshot-name>)` addressed by this handle.
122
+ # @param name [String] destination snapshot name
123
+ # @return [SnapshotInfo]
124
+ def snapshot(name)
125
+ SnapshotInfo.new(@native.snapshot(name.to_s))
126
+ end
127
+
128
+ # Snapshot this (stopped) sandbox to an explicit filesystem path.
129
+ # @param path [String] destination directory
130
+ # @return [SnapshotInfo]
131
+ def snapshot_to(path)
132
+ SnapshotInfo.new(@native.snapshot_to(path.to_s))
133
+ end
134
+
102
135
  def inspect
103
136
  "#<Microsandbox::SandboxHandle name=#{name.inspect} status=#{status}>"
104
137
  end
@@ -165,6 +198,11 @@ module Microsandbox
165
198
  # sb.stop
166
199
  # end
167
200
  class Sandbox
201
+ # Recognized disk-image rootfs extensions, mirroring the upstream
202
+ # `DiskImageFormat::from_extension`/`FromStr` set. Used by {disk_image_rootfs?}
203
+ # to gate the `fstype:`-vs-OCI check; keep in sync on a runtime-tag bump.
204
+ DISK_IMAGE_EXTENSIONS = %w[raw qcow2 vmdk].freeze
205
+
168
206
  class << self
169
207
  # Create and boot a sandbox.
170
208
  #
@@ -186,12 +224,40 @@ module Microsandbox
186
224
  # @param entrypoint [Array<String>, nil] image entrypoint override
187
225
  # @param ports [Hash, nil] host_port => guest_port TCP publications
188
226
  # @param ports_udp [Hash, nil] host_port => guest_port UDP publications
227
+ # @param volumes [Hash, nil] guest_path => mount spec. Each value is a host
228
+ # path String (a bind mount), or a Hash: `{ bind: "/host" }`,
229
+ # `{ named: "vol" }`, `{ tmpfs: true, size_mib: 64 }`, or
230
+ # `{ disk: "/img.raw", format: "raw", fstype: "ext4" }`. Any mount may add
231
+ # flags `ro:`/`readonly:`, `noexec:`, `nosuid:`, `nodev:`, and (bind/named
232
+ # only) `stat_virtualization:` (:strict/:relaxed/:off) and
233
+ # `host_permissions:` (:private/:mirror).
189
234
  # @param network [String, Symbol, NetworkPolicy, Hash, nil] network policy.
190
235
  # A preset name ("public_only" (default), "none", "allow_all",
191
236
  # "non_local"), a {NetworkPolicy} (e.g. {NetworkPolicy.custom}), or a Hash
192
237
  # describing a custom policy (`default_egress:`, `default_ingress:`,
193
238
  # `rules:`, `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy}
194
239
  # and {Rule}.
240
+ # @param dns [Hash, nil] custom DNS: `{ nameservers: [...],
241
+ # rebind_protection: true, query_timeout_ms: 2000 }`
242
+ # @param tls [Hash, nil] TLS-interception tuning: `{ bypass: [...patterns],
243
+ # verify_upstream: true, intercepted_ports: [443, 8443], block_quic: true,
244
+ # upstream_ca_cert:, intercept_ca_cert:, intercept_ca_key: }` (paths). Use
245
+ # this to inject `secrets:` on non-443 ports or to trust a private CA.
246
+ # @param ipv4_pool [String, nil] guest IPv4 address pool CIDR (e.g. "10.0.0.0/24")
247
+ # @param ipv6_pool [String, nil] guest IPv6 address pool CIDR
248
+ # @param max_connections [Integer, nil] cap on concurrent proxied connections
249
+ # @param trust_host_cas [Boolean, nil] trust the host's CA bundle for upstream TLS
250
+ # @param from_snapshot [String, nil] boot from a snapshot name or digest
251
+ # instead of an image (mutually exclusive with `image:`)
252
+ # @param fstype [String, nil] inner filesystem type (e.g. "ext4") when
253
+ # `image:` is a disk-image rootfs path whose filesystem can't be
254
+ # auto-probed; ignored for OCI images
255
+ # @param init [String, Hash, nil] hand guest PID 1 to an init system: a
256
+ # command path (e.g. "/lib/systemd/systemd" or "auto"), or a Hash
257
+ # `{ cmd:, args:, env: }` when the init binary takes argv/extra env
258
+ # @param ephemeral [Boolean] auto-remove the sandbox's stored state (DB
259
+ # row, disk, logs, captured output) once it reaches a terminal state
260
+ # (default: state is persisted until {.remove})
195
261
  # @param patches [Array<Hash>, nil] rootfs patches applied before boot, each
196
262
  # built with the {Patch} factory (e.g. `Patch.text(...)`, `Patch.mkdir(...)`).
197
263
  # Not compatible with disk-image roots.
@@ -212,23 +278,70 @@ module Microsandbox
212
278
  # instead of HTTPS (for local/self-hosted registries)
213
279
  # @param registry_ca_certs [String, Array<String>, nil] extra PEM-encoded CA
214
280
  # root certificate(s) to trust (for a registry with a private CA)
215
- # @param secrets [Array<Hash>, nil] placeholder-protected secrets, each
216
- # { env:, value:, host: } the value is substituted by the TLS proxy only
217
- # for the allowed host (auto-enables TLS interception)
281
+ # @param secrets [Array<Hash>, nil] placeholder-protected secrets injected by
282
+ # the TLS proxy (auto-enables TLS interception). Each Hash needs `env:` and
283
+ # `value:` plus an allow list — `host:` (single), `hosts:` (Array), and/or
284
+ # `host_patterns:` (wildcards like "*.stripe.com"). Optional per-secret:
285
+ # `placeholder:`, `require_tls:`, injection toggles `inject_headers:` /
286
+ # `inject_basic_auth:` / `inject_query:` / `inject_body:`, and `on_violation:`.
287
+ # @param on_secret_violation [String, Symbol, Hash, nil] sandbox-wide
288
+ # secret-leak policy: "block", "block_and_log", "block_and_terminate",
289
+ # "passthrough" (passthrough-all-hosts), or a Hash
290
+ # `{ passthrough_hosts:, passthrough_host_patterns:, passthrough_all_hosts: }`
218
291
  # @param detached [Boolean] keep running after this process exits
219
292
  # @param replace [Boolean] replace an existing sandbox with the same name
220
293
  # @param replace_with_timeout [Numeric, nil] replace, waiting up to N seconds
221
294
  # @yieldparam sandbox [Sandbox]
222
295
  # @return [Sandbox, Object] the sandbox, or the block's return value
223
- def create(name,
224
- image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
296
+ def create(name, **kwargs, &block)
297
+ opts = build_create_opts(**kwargs)
298
+ sandbox = new(Native::Sandbox.create(name.to_s, opts))
299
+ return sandbox unless block_given?
300
+
301
+ begin
302
+ yield sandbox
303
+ ensure
304
+ begin
305
+ sandbox.stop
306
+ rescue Microsandbox::Error
307
+ # best-effort cleanup; ignore stop failures during teardown
308
+ end
309
+ end
310
+ end
311
+
312
+ # Create a sandbox while streaming image-pull progress. Accepts the same
313
+ # options as {create}; returns a {PullSession} — iterate it (an
314
+ # {Enumerable} of progress-event Hashes, each with a "kind"), then call
315
+ # {PullSession#sandbox} for the booted {Sandbox}. Mirrors the Python
316
+ # `create_with_progress` / Node `createWithPullProgress`.
317
+ # @return [PullSession]
318
+ def create_with_progress(name, **kwargs)
319
+ # Unlike {create}, this has no block form: the booted sandbox is reached
320
+ # via {PullSession#sandbox} (after iterating progress) and stopped by the
321
+ # caller. A block would be silently dropped — and the sandbox leaked — so
322
+ # reject it loudly rather than let a `create`-style block call misfire.
323
+ if block_given?
324
+ raise ArgumentError,
325
+ "create_with_progress takes no block; iterate the returned PullSession " \
326
+ "for progress, then call #sandbox and stop it when done"
327
+ end
328
+ opts = build_create_opts(**kwargs)
329
+ PullSession.new(Native::Sandbox.create_with_progress(name.to_s, opts))
330
+ end
331
+
332
+ # @api private
333
+ # Shared keyword-option builder for {create}/{create_with_progress}.
334
+ def build_create_opts(image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
225
335
  shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
226
336
  entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
337
+ dns: nil, tls: nil, ipv4_pool: nil, ipv6_pool: nil,
338
+ max_connections: nil, trust_host_cas: nil,
227
339
  patches: nil,
228
- from_snapshot: nil, log_level: nil, quiet_logs: false, security: nil,
340
+ from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
341
+ log_level: nil, quiet_logs: false, security: nil,
229
342
  oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
230
343
  pull_policy: nil, registry_auth: nil, registry_insecure: false,
231
- registry_ca_certs: nil, secrets: nil,
344
+ registry_ca_certs: nil, secrets: nil, on_secret_violation: nil,
232
345
  detached: false, replace: false, replace_with_timeout: nil)
233
346
  # A sandbox boots from exactly one rootfs source. The core would reject a
234
347
  # contradictory pair, but only after a runtime round-trip; fail fast and
@@ -237,9 +350,22 @@ module Microsandbox
237
350
  raise ArgumentError, "provide either image: or from_snapshot:, not both"
238
351
  end
239
352
  Microsandbox.ensure_runtime!
353
+ # `fstype:` names the inner filesystem of a disk-image rootfs, so it only
354
+ # applies when `image:` is a disk-image path (a local path ending in
355
+ # .raw/.qcow2/.vmdk). Routing an OCI ref (e.g. "python") through the
356
+ # disk-image builder would make the core treat it as a host disk path and
357
+ # fail at boot, so reject the combination up front instead of forwarding a
358
+ # value the native layer can't honour.
359
+ if fstype && !disk_image_rootfs?(image)
360
+ raise ArgumentError,
361
+ "fstype: only applies to a disk-image rootfs; image: must be a local " \
362
+ "path ending in .raw, .qcow2, or .vmdk (got #{image.inspect}). " \
363
+ "OCI references auto-detect their filesystem — drop fstype:."
364
+ end
240
365
  opts = {}
241
366
  opts["image"] = image.to_s if image
242
367
  opts["from_snapshot"] = from_snapshot.to_s if from_snapshot
368
+ opts["fstype"] = fstype.to_s if fstype
243
369
  opts["cpus"] = Integer(cpus) if cpus
244
370
  opts["memory"] = Integer(memory) if memory
245
371
  opts["workdir"] = workdir.to_s if workdir
@@ -255,6 +381,12 @@ module Microsandbox
255
381
  opts["volumes"] = normalize_volumes(volumes) if volumes
256
382
  opts["patches"] = normalize_patches(patches) if patches
257
383
  apply_network_opts(opts, network) unless network.nil?
384
+ opts["dns"] = normalize_dns(dns) if dns
385
+ opts["tls"] = normalize_tls(tls) if tls
386
+ opts["ipv4_pool"] = ipv4_pool.to_s if ipv4_pool
387
+ opts["ipv6_pool"] = ipv6_pool.to_s if ipv6_pool
388
+ opts["max_connections"] = Integer(max_connections) if max_connections
389
+ set_bool(opts, "trust_host_cas", trust_host_cas)
258
390
  opts["log_level"] = log_level.to_s if log_level
259
391
  opts["quiet_logs"] = true if quiet_logs
260
392
  opts["security"] = security.to_s if security
@@ -265,6 +397,9 @@ module Microsandbox
265
397
  opts["pull_policy"] = pull_policy.to_s if pull_policy
266
398
  apply_registry_opts(opts, registry_auth, registry_insecure, registry_ca_certs)
267
399
  opts["secrets"] = normalize_secrets(secrets) if secrets
400
+ opts["on_secret_violation"] = normalize_violation(on_secret_violation) if on_secret_violation
401
+ opts["init"] = normalize_init(init) unless init.nil?
402
+ opts["ephemeral"] = true if ephemeral
268
403
  opts["detached"] = true if detached
269
404
  if replace_with_timeout
270
405
  opts["replace_with_timeout"] = coerce_duration(replace_with_timeout, "replace_with_timeout")
@@ -272,18 +407,7 @@ module Microsandbox
272
407
  opts["replace"] = true
273
408
  end
274
409
 
275
- sandbox = new(Native::Sandbox.create(name.to_s, opts))
276
- return sandbox unless block_given?
277
-
278
- begin
279
- yield sandbox
280
- ensure
281
- begin
282
- sandbox.stop
283
- rescue Microsandbox::Error
284
- # best-effort cleanup; ignore stop failures during teardown
285
- end
286
- end
410
+ opts
287
411
  end
288
412
 
289
413
  # Restart a previously-defined sandbox by name.
@@ -343,6 +467,28 @@ module Microsandbox
343
467
  ports.each_with_object({}) { |(k, v), acc| acc[Integer(k)] = Integer(v) }
344
468
  end
345
469
 
470
+ # Normalize the `init:` option into the native { "cmd" =>, "args" =>,
471
+ # "env" => } shape. Accepts a bare command (String/Symbol — an init binary
472
+ # path or the literal "auto") or a Hash { cmd:, args:, env: }, mirroring the
473
+ # Python InitConfig / Node init options.
474
+ def normalize_init(init)
475
+ case init
476
+ when String, Symbol
477
+ {"cmd" => init.to_s}
478
+ when Hash
479
+ cmd = init[:cmd] || init["cmd"]
480
+ raise ArgumentError, "init: requires a :cmd" unless cmd
481
+ spec = {"cmd" => cmd.to_s}
482
+ args = init[:args] || init["args"]
483
+ spec["args"] = Array(args).map(&:to_s) if args
484
+ env = init[:env] || init["env"]
485
+ spec["env"] = stringify(env) if env
486
+ spec
487
+ else
488
+ raise ArgumentError, "init: must be a String command or a Hash {cmd:, args:, env:}"
489
+ end
490
+ end
491
+
346
492
  # Flatten the registry options into the native layer's `registry_*` keys.
347
493
  # `auth` is a Hash { username:, password: } (string or symbol keys); both
348
494
  # are required when given. `ca_certs` accepts one PEM string or an Array.
@@ -362,18 +508,144 @@ module Microsandbox
362
508
  opts["registry_ca_certs"] = Array(ca_certs).map(&:to_s) if ca_certs
363
509
  end
364
510
 
365
- # Normalize secrets into [env, value, host] triples for the native layer.
366
- # Each entry is a Hash { env:, value:, host: } (string or symbol keys).
511
+ # Normalize secrets into per-secret string-keyed Hashes for the native
512
+ # layer. Each entry is a Hash (string or symbol keys); required :env and
513
+ # :value, plus at least one allowed host via :host (single), :hosts (Array),
514
+ # or :host_patterns (Array of wildcards like "*.stripe.com"). Optional:
515
+ # :placeholder, :require_tls, the injection toggles :inject_headers /
516
+ # :inject_basic_auth / :inject_query / :inject_body, and :on_violation (see
517
+ # {#normalize_violation}).
367
518
  def normalize_secrets(secrets)
368
- Array(secrets).map do |spec|
369
- env = spec[:env] || spec["env"]
370
- value = spec[:value] || spec["value"]
371
- host = spec[:host] || spec["host"]
372
- unless env && value && host
373
- raise ArgumentError, "secret spec needs :env, :value, and :host (got #{spec.inspect})"
519
+ Array(secrets).map { |spec| normalize_secret(spec) }
520
+ end
521
+
522
+ def normalize_secret(spec)
523
+ env = spec[:env] || spec["env"]
524
+ value = spec[:value] || spec["value"]
525
+ unless env && value
526
+ raise ArgumentError, "secret spec needs :env and :value (got #{spec.inspect})"
527
+ end
528
+ out = {"env" => env.to_s, "value" => value.to_s}
529
+ hosts = Array(spec[:hosts] || spec["hosts"]).map(&:to_s)
530
+ single = spec[:host] || spec["host"]
531
+ hosts << single.to_s if single
532
+ patterns = Array(spec[:host_patterns] || spec["host_patterns"]).map(&:to_s)
533
+ if hosts.empty? && patterns.empty?
534
+ raise ArgumentError,
535
+ "secret spec needs :host, :hosts, or :host_patterns (got #{spec.inspect})"
536
+ end
537
+ out["hosts"] = hosts unless hosts.empty?
538
+ out["host_patterns"] = patterns unless patterns.empty?
539
+ pl = spec[:placeholder] || spec["placeholder"]
540
+ out["placeholder"] = pl.to_s if pl
541
+ # Booleans must honor an explicit `false` (to disable a default-on toggle),
542
+ # so probe key presence rather than truthiness.
543
+ %i[require_tls inject_headers inject_basic_auth inject_query inject_body].each do |k|
544
+ set_bool(out, k.to_s, fetch_opt(spec, k))
545
+ end
546
+ ov = spec[:on_violation] || spec["on_violation"]
547
+ out["on_violation"] = normalize_violation(ov) if ov
548
+ out
549
+ end
550
+
551
+ # Read a symbol-or-string key from a Hash, distinguishing an explicit
552
+ # `false`/`nil` value from an absent key.
553
+ def fetch_opt(spec, sym)
554
+ return spec[sym] if spec.key?(sym)
555
+ spec[sym.to_s]
556
+ end
557
+
558
+ # Write a boolean option onto `out`, honoring an explicit `false` (to
559
+ # disable a default-on toggle) while leaving an absent (`nil`) value unset.
560
+ # Centralizes the "probe presence, not truthiness" rule for all the boolean
561
+ # toggles (require_tls/inject_*/verify_upstream/block_quic/rebind_protection/
562
+ # trust_host_cas) so a future toggle can't silently drop a `false`.
563
+ def set_bool(out, key, value)
564
+ out[key] = (value ? true : false) unless value.nil?
565
+ end
566
+
567
+ # Normalize an `on_violation`/`on_secret_violation` spec for the native
568
+ # layer: a String/Symbol block variant ("block"/"block_and_log"/
569
+ # "block_and_terminate"), or a Hash describing a passthrough action
570
+ # ({ passthrough_hosts:, passthrough_host_patterns:, passthrough_all_hosts: }).
571
+ def normalize_violation(spec)
572
+ case spec
573
+ when String, Symbol
574
+ # The bare "passthrough" string maps to passthrough-all-hosts, matching
575
+ # the Python/Node SDKs (so a string-form policy copied from another SDK
576
+ # ports over unchanged); block variants stay as their action string.
577
+ if spec.to_s.strip.downcase.tr("-", "_") == "passthrough"
578
+ {"passthrough_all_hosts" => true}
579
+ else
580
+ normalize_violation_action(spec)
581
+ end
582
+ when Hash
583
+ hosts = Array(spec[:passthrough_hosts] || spec["passthrough_hosts"]).map(&:to_s)
584
+ patterns = Array(spec[:passthrough_host_patterns] || spec["passthrough_host_patterns"]).map(&:to_s)
585
+ all = !!(spec[:passthrough_all_hosts] || spec["passthrough_all_hosts"])
586
+ # An empty passthrough (no hosts, no patterns, not all) passes nothing
587
+ # through — a no-op the native builder silently degrades to its default
588
+ # action. Reject it with actionable guidance rather than accept a spec
589
+ # that does nothing the caller intended.
590
+ if hosts.empty? && patterns.empty? && !all
591
+ raise ArgumentError,
592
+ "passthrough on_violation needs at least one of :passthrough_hosts, " \
593
+ ":passthrough_host_patterns, or :passthrough_all_hosts (use \"block\" " \
594
+ "if blocking is the intent)"
374
595
  end
375
- [env.to_s, value.to_s, host.to_s]
596
+ out = {}
597
+ out["passthrough_hosts"] = hosts unless hosts.empty?
598
+ out["passthrough_host_patterns"] = patterns unless patterns.empty?
599
+ out["passthrough_all_hosts"] = true if all
600
+ out
601
+ else
602
+ raise ArgumentError, "on_violation must be a String or a Hash (got #{spec.inspect})"
603
+ end
604
+ end
605
+
606
+ # Map a block-variant action onto the canonical underscore spelling the
607
+ # native layer expects. Also accepts the upstream kebab-case wire spellings
608
+ # ("block-and-log"/"block-and-terminate") used by the CLI, Go SDK, and
609
+ # sandbox config files, so a policy copied from another SDK ports over
610
+ # unchanged instead of being rejected.
611
+ def normalize_violation_action(spec)
612
+ action = spec.to_s.strip.downcase.tr("-", "_")
613
+ unless %w[block block_and_log block_and_terminate].include?(action)
614
+ raise ArgumentError,
615
+ "unknown on_violation #{spec.inspect} (expected block, " \
616
+ "block_and_log/block-and-log, block_and_terminate/block-and-terminate, " \
617
+ "passthrough, or a Hash with :passthrough_hosts/:passthrough_host_patterns/:passthrough_all_hosts)"
618
+ end
619
+ action
620
+ end
621
+
622
+ # Normalize the `dns:` config Hash for the native layer.
623
+ def normalize_dns(dns)
624
+ raise ArgumentError, "dns: must be a Hash" unless dns.is_a?(Hash)
625
+ out = {}
626
+ ns = dns[:nameservers] || dns["nameservers"]
627
+ out["nameservers"] = Array(ns).map(&:to_s) if ns
628
+ set_bool(out, "rebind_protection", fetch_opt(dns, :rebind_protection))
629
+ qt = dns[:query_timeout_ms] || dns["query_timeout_ms"]
630
+ out["query_timeout_ms"] = Integer(qt) if qt
631
+ out
632
+ end
633
+
634
+ # Normalize the `tls:` interception-tuning Hash for the native layer.
635
+ def normalize_tls(tls)
636
+ raise ArgumentError, "tls: must be a Hash" unless tls.is_a?(Hash)
637
+ out = {}
638
+ bypass = tls[:bypass] || tls["bypass"]
639
+ out["bypass"] = Array(bypass).map(&:to_s) if bypass
640
+ set_bool(out, "verify_upstream", fetch_opt(tls, :verify_upstream))
641
+ ports = tls[:intercepted_ports] || tls["intercepted_ports"]
642
+ out["intercepted_ports"] = Array(ports).map { |p| Integer(p) } if ports
643
+ set_bool(out, "block_quic", fetch_opt(tls, :block_quic))
644
+ %i[upstream_ca_cert intercept_ca_cert intercept_ca_key].each do |k|
645
+ v = tls[k] || tls[k.to_s]
646
+ out[k.to_s] = v.to_s if v
376
647
  end
648
+ out
377
649
  end
378
650
 
379
651
  # Normalize an rlimits Hash into [resource, soft, hard] triples for the
@@ -386,49 +658,112 @@ module Microsandbox
386
658
  end
387
659
  end
388
660
 
389
- # Normalize volumes (Hash of guest_path => spec) into [guest, kind, source]
390
- # triples (or [guest, kind, source, options] quads) for the native layer. A
391
- # spec is a host path String (read-write bind mount), or a Hash
392
- # { bind: "/host" } / { named: "volume-name" } optionally carrying mount
393
- # options: { bind: "/host", ro: true } / { named: "v", options: %w[ro noexec] }.
661
+ # True when `image` names a disk-image rootfs the way the core auto-detects
662
+ # one: a local-path-looking string (`/`, `./`, `../` prefix) whose extension
663
+ # is a recognized disk-image format. This re-implements two upstream
664
+ # heuristics the native layer can't reach from here (the `microsandbox`
665
+ # crate re-exports neither): `looks_like_local_path_text`
666
+ # (microsandbox/crates/utils/lib/lib.rs) and `DiskImageFormat::from_extension`
667
+ # / `FromStr` (microsandbox/packages/microsandbox-types/rust/lib/domain.rs,
668
+ # qcow2/raw/vmdk). Keep both in sync when bumping the pinned runtime tag —
669
+ # the "disk_image_rootfs? contract" examples in sandbox_spec.rb pin it.
670
+ def disk_image_rootfs?(image)
671
+ s = image.to_s
672
+ return false unless s.start_with?("/", "./", "../")
673
+ DISK_IMAGE_EXTENSIONS.include?(File.extname(s).delete_prefix(".").downcase)
674
+ end
675
+
676
+ # Normalize volumes (Hash of guest_path => spec) into per-mount string-keyed
677
+ # Hashes for the native layer. A spec is a host path String (a read-write
678
+ # bind mount) or a Hash describing the mount:
679
+ # { bind: "/host", ro: true, noexec: true } # host bind mount
680
+ # { named: "vol" } # named volume
681
+ # { tmpfs: true, size_mib: 64 } # memory-backed
682
+ # { disk: "/img.raw", format: "raw", fstype: "ext4" } # disk-image mount
683
+ # Any mount may also carry stat_virtualization: (:strict/:relaxed/:off) and
684
+ # host_permissions: (:private/:mirror).
394
685
  def normalize_volumes(volumes)
395
686
  volumes.map do |guest, spec|
396
- guest = guest.to_s
687
+ mount = {"guest" => guest.to_s}
397
688
  case spec
398
689
  when String
399
- [guest, "bind", spec]
690
+ mount["kind"] = "bind"
691
+ mount["source"] = spec
400
692
  when Hash
401
- triple =
402
- if (named = spec[:named] || spec["named"])
403
- [guest, "named", named.to_s]
404
- elsif (bind = spec[:bind] || spec["bind"])
405
- [guest, "bind", bind.to_s]
406
- else
407
- raise ArgumentError, "volume spec for #{guest.inspect} needs :bind or :named"
408
- end
409
- # Optional 4th element: comma-joined mount options. Omitted when empty
410
- # so existing [guest,kind,source] consumers and the common read-write
411
- # case are byte-for-byte unchanged.
412
- opts = mount_options(spec)
413
- opts.empty? ? triple : triple + [opts.join(",")]
693
+ apply_mount_kind(mount, spec, guest)
694
+ apply_mount_flags(mount, spec)
414
695
  else
415
696
  raise ArgumentError, "invalid volume spec for #{guest.inspect}: #{spec.inspect}"
416
697
  end
698
+ mount
417
699
  end
418
700
  end
419
701
 
420
- # Collect mount options from a volume spec Hash. `ro:`/`readonly:` makes the
421
- # mount read-only (host virtiofs rejects writes + guest kernel returns EROFS);
422
- # `noexec:`/`nosuid:`/`nodev:` set the matching flags; an explicit `options:`
423
- # array passes through verbatim. Unknown options are rejected natively.
424
- def mount_options(spec)
425
- opts = []
426
- opts << "ro" if spec[:ro] || spec["ro"] || spec[:readonly] || spec["readonly"]
427
- opts << "noexec" if spec[:noexec] || spec["noexec"]
428
- opts << "nosuid" if spec[:nosuid] || spec["nosuid"]
429
- opts << "nodev" if spec[:nodev] || spec["nodev"]
430
- Array(spec[:options] || spec["options"]).each { |o| opts << o.to_s }
431
- opts.uniq
702
+ # Resolve a volume spec Hash's mount kind + source/size/format/fstype.
703
+ def apply_mount_kind(mount, spec, guest)
704
+ if (bind = spec[:bind] || spec["bind"])
705
+ mount["kind"] = "bind"
706
+ mount["source"] = bind.to_s
707
+ elsif (named = spec[:named] || spec["named"])
708
+ mount["kind"] = "named"
709
+ mount["source"] = named.to_s
710
+ elsif spec[:tmpfs] || spec["tmpfs"]
711
+ mount["kind"] = "tmpfs"
712
+ elsif (disk = spec[:disk] || spec["disk"])
713
+ mount["kind"] = "disk"
714
+ mount["source"] = disk.to_s
715
+ fmt = spec[:format] || spec["format"]
716
+ mount["format"] = fmt.to_s if fmt
717
+ else
718
+ raise ArgumentError,
719
+ "volume spec for #{guest.inspect} needs :bind, :named, :tmpfs, or :disk"
720
+ end
721
+ size = spec[:size_mib] || spec["size_mib"]
722
+ mount["size_mib"] = Integer(size) if size
723
+ fstype = spec[:fstype] || spec["fstype"]
724
+ mount["fstype"] = fstype.to_s if fstype
725
+ end
726
+
727
+ # Apply a volume spec Hash's mount flags. `ro:`/`readonly:` makes the mount
728
+ # read-only; `noexec:`/`nosuid:`/`nodev:` set the matching flags;
729
+ # `stat_virtualization:`/`host_permissions:` set the passthrough policies
730
+ # (only valid on bind/named — the core rejects them on tmpfs/disk).
731
+ def apply_mount_flags(mount, spec)
732
+ mount["readonly"] = true if spec[:ro] || spec["ro"] || spec[:readonly] || spec["readonly"]
733
+ mount["noexec"] = true if spec[:noexec] || spec["noexec"]
734
+ mount["nosuid"] = true if spec[:nosuid] || spec["nosuid"]
735
+ mount["nodev"] = true if spec[:nodev] || spec["nodev"]
736
+ apply_legacy_mount_options(mount, spec)
737
+ sv = spec[:stat_virtualization] || spec["stat_virtualization"]
738
+ mount["stat_virtualization"] = sv.to_s if sv
739
+ hp = spec[:host_permissions] || spec["host_permissions"]
740
+ mount["host_permissions"] = hp.to_s if hp
741
+ end
742
+
743
+ # Translate the pre-0.7.0 `options:` array form (e.g. options: %w[ro noexec])
744
+ # onto the discrete boolean flags the native layer now consumes. Kept for
745
+ # backward compatibility with configs written against 0.5.11–0.6.0. Unknown
746
+ # tokens raise rather than being silently dropped: a mount requested
747
+ # read-only/noexec that quietly mounted read-write/executable would be a
748
+ # security regression, not a cosmetic one.
749
+ def apply_legacy_mount_options(mount, spec)
750
+ Array(spec[:options] || spec["options"]).each do |opt|
751
+ case opt.to_s
752
+ when "ro", "readonly" then mount["readonly"] = true
753
+ # "rw" is read-write, the default — accepted as a no-op (the pre-0.7.0
754
+ # native validator and the upstream wire form both treat it that way).
755
+ # Dropping it would break a previously-valid `options:` array, which the
756
+ # CHANGELOG promises is still honored.
757
+ when "rw" then nil
758
+ when "noexec" then mount["noexec"] = true
759
+ when "nosuid" then mount["nosuid"] = true
760
+ when "nodev" then mount["nodev"] = true
761
+ else
762
+ raise ArgumentError,
763
+ "unknown mount option #{opt.inspect} in options: — use " \
764
+ "ro/readonly, rw, noexec, nosuid, or nodev (or the matching boolean keys)"
765
+ end
766
+ end
432
767
  end
433
768
 
434
769
  # Normalize a list of patches (each a Hash from the {Patch} factory, or a
@@ -600,7 +935,7 @@ module Microsandbox
600
935
  # Stream captured logs as they appear.
601
936
  #
602
937
  # @param sources [Array<String,Symbol>, nil] filter by source
603
- # ("stdout"/"stderr"/"output"/"system")
938
+ # ("stdout"/"stderr"/"output"/"system"/"all")
604
939
  # @param since_ms [Numeric, nil] start at the first entry at/after this Unix ms
605
940
  # @param from_cursor [String, nil] resume exactly after a prior {LogEntry#cursor}
606
941
  # (mutually exclusive with since_ms; takes precedence if both given)
@@ -692,15 +1027,20 @@ module Microsandbox
692
1027
  opts["env"] = env.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if env
693
1028
  opts["timeout"] = coerce_duration(timeout, "timeout") if timeout
694
1029
  opts["tty"] = true if tty
695
- # `stdin: :pipe` opens a streaming stdin pipe write to it via
696
- # {ExecHandle#stdin} and close to send EOF. It is only meaningful for the
697
- # streaming variants (which return an ExecHandle); a blocking exec/shell
698
- # collects to completion and has nowhere to hand back the sink, so a piped
699
- # process that reads stdin would block forever waiting for EOF. Reject it
700
- # there. Any other truthy value is fed as a fixed byte buffer (closed
701
- # automatically). nil means no stdin.
1030
+ # stdin is a closed set of modes, mirroring the official SDKs:
1031
+ # nil / :null — no stdin (the guest sees /dev/null)
1032
+ # :pipe — open a streaming stdin pipe; write via {ExecHandle#stdin}
1033
+ # and close to send EOF. Only meaningful for the streaming
1034
+ # variants (which return an ExecHandle); a blocking
1035
+ # exec/shell collects to completion and has nowhere to hand
1036
+ # back the sink, so a piped process reading stdin would
1037
+ # block forever waiting for EOF — rejected there.
1038
+ # a String — fed as a fixed byte buffer (closed automatically).
1039
+ # An unrecognized Symbol is a loud error rather than being fed as its bytes
1040
+ # (so a typo'd or mistaken `stdin: :null`-style mode never silently sends
1041
+ # the literal characters of the mode name to the process).
702
1042
  case stdin
703
- when nil then nil
1043
+ when nil, :null then nil
704
1044
  when :pipe
705
1045
  unless pipe_ok
706
1046
  raise ArgumentError,
@@ -708,7 +1048,14 @@ module Microsandbox
708
1048
  "exec/shell cannot expose a writable stdin sink; pass a String to feed bytes"
709
1049
  end
710
1050
  opts["stdin_pipe"] = true
711
- else opts["stdin"] = stdin.to_s
1051
+ when Symbol
1052
+ raise ArgumentError,
1053
+ "unknown stdin mode #{stdin.inspect}; expected nil or :null (no stdin), " \
1054
+ ":pipe (exec_stream/shell_stream only), or a String of bytes to feed"
1055
+ else
1056
+ bytes = String.try_convert(stdin) or
1057
+ raise TypeError, "stdin must be nil, :null, :pipe, or a String (got #{stdin.class})"
1058
+ opts["stdin"] = bytes
712
1059
  end
713
1060
  if rlimits
714
1061
  opts["rlimits"] = rlimits.map do |resource, limit|
@@ -719,4 +1066,48 @@ module Microsandbox
719
1066
  opts
720
1067
  end
721
1068
  end
1069
+
1070
+ # A streaming image-pull + create session, from {Sandbox.create_with_progress}.
1071
+ # Iterate it (it is {Enumerable}) to consume progress-event Hashes as the image
1072
+ # pulls, then call {#sandbox} to get the booted {Sandbox}. Each event Hash has a
1073
+ # "kind" key (e.g. "resolving", "resolved", "layer_download_progress",
1074
+ # "layer_materialize_progress", "complete") plus kind-specific fields.
1075
+ #
1076
+ # @example
1077
+ # session = Microsandbox::Sandbox.create_with_progress("box", image: "python")
1078
+ # session.each { |ev| puts "#{ev["kind"]} #{ev["downloaded_bytes"]}" }
1079
+ # sb = session.sandbox
1080
+ # begin
1081
+ # sb.exec("python", ["-V"])
1082
+ # ensure
1083
+ # sb.stop
1084
+ # end
1085
+ class PullSession
1086
+ include Enumerable
1087
+
1088
+ def initialize(native)
1089
+ @native = native
1090
+ end
1091
+
1092
+ # Yield each progress-event Hash until the pull finishes. Returns an
1093
+ # Enumerator when called without a block.
1094
+ # @yieldparam event [Hash]
1095
+ # @return [self, Enumerator]
1096
+ def each
1097
+ return enum_for(:each) unless block_given?
1098
+
1099
+ while (event = @native.recv)
1100
+ yield event
1101
+ end
1102
+ self
1103
+ end
1104
+
1105
+ # The booted sandbox. Joins the create task (draining any remaining pull
1106
+ # progress first), so call it after iterating progress. The returned
1107
+ # {Sandbox} is live — stop it when done. Memoized; callable once.
1108
+ # @return [Sandbox]
1109
+ def sandbox
1110
+ @sandbox ||= Sandbox.new(@native.result)
1111
+ end
1112
+ end
722
1113
  end