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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abfe8d0fedb192bc5efa95a741ae971475a2a5cc441879c092108bc8bdf9eed5
4
- data.tar.gz: ffeb9bda17817cc1c58bfe5cc9c7d58543cde155eead9d2e5eb3844c8a48b4dd
3
+ metadata.gz: 158469a5ebe84ee4863c2a141cbeb486407bfc5ee05866be9005f9aee3bd249d
4
+ data.tar.gz: ef08b6519ac87f66c20f704e4c55590f56b3d6c05fa01ab6f9187db46f8b43af
5
5
  SHA512:
6
- metadata.gz: 73379d82644123c717cd7db9391cab9bf72c16b5cf398ea104fb51d2b608353ed9cd6783db55e1490d941ae67728f1c69fe0bd2dcec37c7ad541c56996afea9f
7
- data.tar.gz: b20914b13315697e89ec8a72e7620592a9c00d1e8fa908117ef24d64aea9755b431a2298b85d58ed00ca664065f0b626510c7f11fbb919860d3f839c93268a28
6
+ metadata.gz: d63ed9d45bdf68fb2f8352927412b23debd45e5e011f30a835fd2e75bae206e2ce19d1875c6099ce72c808c7d5e05e62a459f81794589e090ab837b9541d5da7
7
+ data.tar.gz: '09a726c22f8b12ecb0d575f0c4e20ce446aa5761cf5ed8cc15155a6b567e7ee867c4f95545a9595f786b88ec2f6dbd75b1bfe721fb629025ce8bfc19aa63fda0'
data/CHANGELOG.md CHANGED
@@ -8,6 +8,96 @@ wraps, and the README's Versioning section keeps the full gem→runtime map.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.7.0] - 2026-06-23
12
+
13
+ A large parity release closing the binding gaps an audit against the upstream
14
+ Python/Node SDKs (at the wrapped `v0.5.8` runtime) surfaced. The runtime tag is
15
+ unchanged. Two genuine bug fixes; the rest is newly-exposed surface plus a few
16
+ behavior corrections (see **Changed**).
17
+
18
+ ### Fixed
19
+
20
+ - **Lossy UTF-8 decoding.** `LogEntry#text`, `ExecOutput#stdout`/`#stderr`,
21
+ `ExecEvent#text`, `SshOutput#stdout`/`#stderr`, and `SftpClient#read_text` now
22
+ scrub invalid byte sequences (replacing them with U+FFFD) so they always
23
+ return a *valid* UTF-8 String — matching the Python/Node SDKs. Previously they
24
+ re-tagged raw bytes as UTF-8 without transcoding, so captured output
25
+ containing invalid UTF-8 produced strings that raised downstream (regex,
26
+ concatenation, `JSON.generate`). Raw bytes remain available via `#data` /
27
+ `#stdout_bytes` / `#stderr_bytes`.
28
+ - **`runtime_path=` spec** no longer pollutes the process-wide set-once
29
+ `msb`-path `OnceLock` (it now stubs the native setter), removing an
30
+ order-dependent failure in combined unit+integration runs.
31
+
32
+ ### Added
33
+
34
+ - **Streaming image-pull progress** — `Sandbox.create_with_progress` returns a
35
+ `PullSession` (an `Enumerable` of progress-event Hashes) with `#sandbox` for
36
+ the booted sandbox.
37
+ - **Host-side volume filesystem** — `Volume.fs(name)` / `VolumeInfo#fs` return a
38
+ `VolumeFs` (read/read_text/write/list/mkdir/remove_file/remove_dir/exists?/
39
+ copy/rename/stat) that reads and writes a named volume without a running
40
+ sandbox.
41
+ - **Streaming guest filesystem** — `FS#read_stream` / `FS#write_stream`
42
+ (`FsReadStream`/`FsWriteSink`) for files too large to buffer in memory.
43
+ - **Full secrets surface** — `secrets:` entries accept `hosts:` / `host_patterns:`
44
+ (wildcards) allow-lists, `placeholder:`, `require_tls:`, injection toggles
45
+ (`inject_headers:`/`inject_basic_auth:`/`inject_query:`/`inject_body:`), and
46
+ per-secret `on_violation:`; plus a sandbox-level `on_secret_violation:`. The
47
+ block-variant actions accept both the underscore form (`block_and_log`) and the
48
+ upstream kebab-case wire spelling (`block-and-log`) used by the CLI / Go SDK /
49
+ config files; the bare `"passthrough"` string (passthrough-all-hosts, as in the
50
+ Python/Node SDKs) is also accepted, so a policy copied from another SDK ports
51
+ over unchanged.
52
+ - **Network configuration** — `Sandbox.create` now accepts `dns:` (nameservers/
53
+ rebind_protection/query_timeout_ms), `tls:` (interception tuning incl. bypass
54
+ patterns, intercepted ports, block_quic, and CA cert/key paths), `ipv4_pool:`/
55
+ `ipv6_pool:`, `max_connections:`, and `trust_host_cas:`.
56
+ - **Create options** — `init:`/`init_with` (hand guest PID 1 to an init system),
57
+ `ephemeral:` (auto-remove state on terminal), and disk-image `fstype:`.
58
+ `fstype:` is rejected up front unless `image:` is a disk-image path (a local
59
+ path ending in `.raw`/`.qcow2`/`.vmdk`); pairing it with an OCI reference no
60
+ longer routes the ref through the disk-image builder and fails at boot.
61
+ - **Full mount options** — `volumes:` now supports `{ tmpfs: }`, `{ disk:,
62
+ format:, fstype: }`, and per-mount `stat_virtualization:`/`host_permissions:`
63
+ alongside the existing bind/named + ro/noexec/nosuid/nodev flags. The pre-0.7.0
64
+ `options: %w[ro noexec]` array form is still honored (translated onto the
65
+ boolean flags); an unrecognized token now raises rather than being silently
66
+ dropped, so a requested read-only/noexec mount can't quietly become writable.
67
+ - **Snapshots** — `Snapshot.open`/`list_dir`/`reindex`, `SnapshotInfo#open`/
68
+ `#remove`, and `SandboxHandle#snapshot`/`#snapshot_to`. `SnapshotInfo` now
69
+ carries the full manifest (`image_manifest_digest`, `fstype`,
70
+ `source_sandbox`, `labels`) on the artifact-opening paths.
71
+ - **`SandboxHandle#config` / `#config_json`** — read the stored sandbox config.
72
+ - **Metrics** — `upper_used_bytes`, `upper_free_bytes`,
73
+ `upper_host_allocated_bytes` (OCI writable-upper-layer accounting).
74
+ - **`ImageDetail#config["labels"]`** — OCI config labels.
75
+ - **`Microsandbox.setup`** — customizable runtime install (`base_dir:`,
76
+ `version:`, `force:`, `skip_verify:`); `force:` repairs a corrupt install.
77
+
78
+ ### Changed
79
+
80
+ - **`exec`/`shell` stdin** is now a closed set: `nil`/`:null` = no stdin,
81
+ `:pipe` = streaming pipe (streaming variants only), a String = bytes. An
82
+ unrecognized Symbol now raises `ArgumentError` instead of being fed as its
83
+ characters (so a mistaken `stdin: :null` no longer sends the literal `"null"`).
84
+ - **Write methods reject non-Strings.** `FS#write`, `SftpClient#write`,
85
+ `ExecStdin#write`, `VolumeFs#write`, and `FsWriteSink#write` now raise
86
+ `TypeError` for non-String data instead of silently writing its `to_s` form.
87
+ - **Agent connect timeout.** `AgentClient.connect_sandbox`/`connect_path`
88
+ `timeout:` now treats `0` as an immediate deadline and raises on a
89
+ negative/non-finite value, instead of silently falling back to the default.
90
+ - **Secrets shorthand** still accepts `{ env:, value:, host: }`; the validation
91
+ message changed and a host allow-list is now required.
92
+
93
+ ### Docs
94
+
95
+ - README/DESIGN implemented-surface corrected to match the binding (and to list
96
+ the few secondary knobs still not exposed); assorted YARD fixes
97
+ (`runtime_path=` set-once note, `VolumeInfo#kind` `:dir`, `create`'s
98
+ `volumes:`/`from_snapshot:` params, `log_stream` `'all'` source); CHANGELOG
99
+ compare links added for 0.5.9–0.5.12.
100
+
11
101
  ## [0.6.0] - 2026-06-23
12
102
 
13
103
  This release puts the gem on its **own semantic version**, decoupled from the
@@ -291,7 +381,12 @@ microsandbox runtime, aligned with the official Python/Node/Go SDKs.
291
381
  core crate has Apple-native deps). Until precompiled gems are published,
292
382
  installing from source requires a Rust toolchain (stable >= 1.91).
293
383
 
294
- [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.6.0...HEAD
384
+ [Unreleased]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.7.0...HEAD
385
+ [0.7.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.6.0...v0.7.0
295
386
  [0.6.0]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.5.12...v0.6.0
387
+ [0.5.12]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.5.11...v0.5.12
388
+ [0.5.11]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.5.10...v0.5.11
389
+ [0.5.10]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.5.9...v0.5.10
390
+ [0.5.9]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.5.8...v0.5.9
296
391
  [0.5.8]: https://github.com/ya-luotao/microsandbox-rb/compare/v0.5.7...v0.5.8
297
392
  [0.5.7]: https://github.com/superradcompany/microsandbox/releases/tag/v0.5.7
data/Cargo.lock CHANGED
@@ -3249,7 +3249,7 @@ dependencies = [
3249
3249
 
3250
3250
  [[package]]
3251
3251
  name = "microsandbox_rb"
3252
- version = "0.6.0"
3252
+ version = "0.7.0"
3253
3253
  dependencies = [
3254
3254
  "chrono",
3255
3255
  "futures",
data/DESIGN.md CHANGED
@@ -183,9 +183,19 @@ The binding is verified at four levels:
183
183
  microVM, confirming the gem manifest and source-install path are complete.
184
184
 
185
185
  **Roadmap:** the v1 roadmap (custom per-rule network policies, file patches,
186
- interactive `attach`/`attach_shell`, SSH, and the raw agent client) is now
187
- implemented see the list above. What remains is upstream-gated rather than a
188
- binding gap: surfacing newer core features as the pinned core-crate tag advances
189
- (e.g. additional network knobs like DNS/TLS-proxy tuning and per-mount stat
190
- virtualization), which slot in module-by-module exactly as the existing bindings
191
- do.
186
+ interactive `attach`/`attach_shell`, SSH, and the raw agent client) is
187
+ implemented, and so is the bulk of the v0.5.8 configuration surface that a
188
+ later parity pass added: streaming image-pull progress, host-side `VolumeFs`,
189
+ streaming guest fs (`read_stream`/`write_stream`), the full secrets surface,
190
+ network configuration (DNS, TLS-interception tuning, IPv4/IPv6 pools,
191
+ `max_connections`, `trust_host_cas`), `init`/`ephemeral`/disk-image `fstype`
192
+ create options, full mount options (tmpfs/disk + stat-virtualization/
193
+ host-permissions), and snapshot inspection (`open`/`list_dir`/`reindex`).
194
+
195
+ A few **secondary** upstream knobs remain unexposed (a genuine binding gap, not
196
+ upstream-gated — they exist at the pinned `v0.5.8`): per-published-port host
197
+ **bind address** (ports always bind loopback), network **interface overrides**,
198
+ and inline **named-volume create-mode** (pre-create with `Volume.create`, then
199
+ mount with `{ named: }`). These slot in module-by-module exactly as the existing
200
+ bindings do. Beyond those, surfacing genuinely newer core features is gated on
201
+ advancing the pinned core-crate tag.
data/README.md CHANGED
@@ -347,7 +347,7 @@ variable → an SDK-set override → the config file → `~/.microsandbox/bin/ms
347
347
  Microsandbox.installed? # => true/false
348
348
  Microsandbox.install # download + install the runtime (idempotent)
349
349
  Microsandbox.runtime_path # => "/Users/you/.microsandbox/bin/msb"
350
- Microsandbox.runtime_path = "/opt/microsandbox/bin/msb" # override
350
+ Microsandbox.runtime_path = "/opt/microsandbox/bin/msb" # override (set-once)
351
351
  Microsandbox.libkrunfw_path = "/opt/microsandbox/lib/libkrunfw.dylib" # override (set-once)
352
352
  ```
353
353
 
@@ -470,25 +470,38 @@ or credential setup is needed.
470
470
  > Until promoted, users install the source gem (which compiles via `rb_sys`).
471
471
 
472
472
  See [DESIGN.md](DESIGN.md) for the architecture and the implemented-surface
473
- section. The binding now covers the full official-SDK surface: sandbox
473
+ section. The binding covers the official-SDK surface: sandbox
474
474
  lifecycle (the live `Sandbox` `stop`/`stop_and_wait`/`kill`/`drain`/`wait`/
475
475
  `status`/`detach`/`owns_lifecycle?`, plus the `SandboxHandle` controls
476
476
  `stop_with_timeout`/`request_stop`/`request_kill`/`request_drain`/
477
- `wait_until_stopped` from `Sandbox.get`, and label-filtered `list_with`),
477
+ `wait_until_stopped`/`config`/`config_json`/`snapshot`/`snapshot_to` from
478
+ `Sandbox.get`, and label-filtered `list_with`),
478
479
  backend routing (`set_default_backend`/`with_backend`/`default_backend_kind`),
479
480
  `exec`/`shell` (collected and streaming), interactive `attach`/
480
- `attach_shell`, the full guest filesystem, metrics (per-sandbox,
481
- `Microsandbox.all_sandbox_metrics`, and streaming `metrics_stream`/`log_stream`),
482
- logs, OCI image-cache management, named volumes, snapshots (create/list/verify/
483
- export/import + boot-from-snapshot), **rootfs patches** (`Microsandbox::Patch`),
484
- **custom per-rule network policies** (`Microsandbox::NetworkPolicy`/`Rule`/
485
- `Destination`, alongside the presets), **SSH** (`Sandbox#ssh`
486
- `SshClient`/`SftpClient`/`SshServer`), and the **raw agent client**
487
- (`Microsandbox::AgentClient`). Create options span resources, network policy,
488
- `log_level`/`security`/`rlimits`/`pull_policy`/`secrets`/`patches` and more;
489
- `exec`/`shell` take per-call `rlimits`, and `create` accepts
481
+ `attach_shell`, the full guest filesystem (incl. streaming `read_stream`/
482
+ `write_stream`), metrics (per-sandbox, `Microsandbox.all_sandbox_metrics`, and
483
+ streaming `metrics_stream`/`log_stream`), logs, OCI image-cache management,
484
+ named volumes (incl. host-side `Volume.fs`/`VolumeInfo#fs` read/write),
485
+ snapshots (create/open/list/list_dir/reindex/verify/export/import +
486
+ boot-from-snapshot), streaming image-pull progress
487
+ (`Sandbox.create_with_progress` → `PullSession`), **rootfs patches**
488
+ (`Microsandbox::Patch`), **network configuration** (presets, custom per-rule
489
+ `Microsandbox::NetworkPolicy`/`Rule`/`Destination`, plus DNS, TLS interception,
490
+ IPv4/IPv6 pools, `max_connections`, `trust_host_cas`), **secrets** (multi-host /
491
+ wildcard allow-lists, injection toggles, per-secret + sandbox-level violation
492
+ policy), **SSH** (`Sandbox#ssh` → `SshClient`/`SftpClient`/`SshServer`), and the
493
+ **raw agent client** (`Microsandbox::AgentClient`). Create options span
494
+ resources, `init`/`ephemeral`, disk-image `fstype`, network policy + config,
495
+ `log_level`/`security`/`rlimits`/`pull_policy`/`secrets`/`patches`/`volumes`
496
+ (bind/named/tmpfs/disk with mount policies) and more; `exec`/`shell` take
497
+ per-call `rlimits`, and `create` accepts
490
498
  `registry_auth`/`registry_insecure`/`registry_ca_certs` for private and
491
- authenticated registries.
499
+ authenticated registries, plus customizable provisioning via `Microsandbox.setup`.
500
+
501
+ A few secondary upstream knobs are not yet exposed: per-published-port host bind
502
+ address (ports always bind loopback), network interface overrides, and inline
503
+ named-volume create-mode (pre-create the volume with `Volume.create`, then mount
504
+ it with `{ named: "…" }`).
492
505
 
493
506
  ## Contributing
494
507
 
@@ -7,7 +7,7 @@ description = "Ruby SDK native extension for microsandbox — secure, fast micro
7
7
  # Must equal Microsandbox::VERSION (lib/microsandbox/version.rb) — Native.version
8
8
  # returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
9
9
  # The core-crate dependency below stays pinned at its own tag (v0.5.8).
10
- version = "0.6.0"
10
+ version = "0.7.0"
11
11
  authors = ["Super Rad Company <development@superrad.company>"]
12
12
  repository = "https://github.com/superradcompany/microsandbox"
13
13
  license = "Apache-2.0"
@@ -40,7 +40,7 @@ impl AgentClient {
40
40
 
41
41
  /// Connect to a running sandbox by name. `timeout` is optional seconds.
42
42
  fn connect_sandbox(name: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
43
- let bridge = match dur(timeout) {
43
+ let bridge = match dur(timeout)? {
44
44
  Some(t) => block_on(AgentBridge::connect_sandbox_with_timeout(&name, t)),
45
45
  None => block_on(AgentBridge::connect_sandbox(&name)),
46
46
  }
@@ -50,7 +50,7 @@ impl AgentClient {
50
50
 
51
51
  /// Connect to an agentd relay socket by path. `timeout` is optional seconds.
52
52
  fn connect_path(path: String, timeout: Option<f64>) -> Result<AgentClient, Error> {
53
- let bridge = match dur(timeout) {
53
+ let bridge = match dur(timeout)? {
54
54
  Some(t) => block_on(AgentBridge::connect_path_with_timeout(&path, t)),
55
55
  None => block_on(AgentBridge::connect_path(&path)),
56
56
  }
@@ -125,12 +125,23 @@ impl AgentClient {
125
125
  }
126
126
  }
127
127
 
128
- /// Convert seconds into a `Duration`, treating a non-positive/absent value as
129
- /// "use the default handshake timeout".
130
- fn dur(timeout: Option<f64>) -> Option<Duration> {
128
+ /// Convert an optional `timeout` (seconds) into an optional `Duration`,
129
+ /// mirroring the Python SDK's `timeout_duration`:
130
+ /// - absent (`nil`) `None` → the core's default handshake timeout
131
+ /// - `0` → an explicit zero deadline (fail fast), *not* "use the default"
132
+ /// - negative or non-finite (NaN/Inf) → a caller error (rather than being
133
+ /// silently swallowed into the default)
134
+ /// - finite but out of `Duration` range (e.g. `Float::MAX`) → a caller error
135
+ /// via `try_from_secs_f64`, rather than the panic `from_secs_f64` would raise
136
+ fn dur(timeout: Option<f64>) -> Result<Option<Duration>, Error> {
131
137
  match timeout {
132
- Some(t) if t.is_finite() && t > 0.0 => Some(Duration::from_secs_f64(t)),
133
- _ => None,
138
+ None => Ok(None),
139
+ Some(t) if t.is_finite() && t >= 0.0 => Duration::try_from_secs_f64(t)
140
+ .map(Some)
141
+ .map_err(|e| error::base_error(format!("timeout {t} seconds is out of range: {e}"))),
142
+ Some(t) => Err(error::base_error(format!(
143
+ "timeout must be a non-negative, finite number of seconds (got {t})"
144
+ ))),
134
145
  }
135
146
  }
136
147
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  use std::collections::HashMap;
7
7
 
8
- use magnus::{value::ReprValue, Error, RArray, RHash, TryConvert, Value};
8
+ use magnus::{value::ReprValue, Error, IntoValue, RArray, RHash, TryConvert, Value};
9
9
 
10
10
  /// Fetch a non-nil value for `key`, if present.
11
11
  fn get(hash: RHash, key: &str) -> Option<Value> {
@@ -90,3 +90,39 @@ pub fn opt_port_map(hash: RHash, key: &str) -> Result<Vec<(u16, u16)>, Error> {
90
90
  None => Ok(Vec::new()),
91
91
  }
92
92
  }
93
+
94
+ /// Recursively convert a `serde_json::Value` into a Ruby value. Used for
95
+ /// pass-through JSON whose shape is not fixed — e.g. an image config's OCI
96
+ /// `labels` object, which the Ruby layer hands back verbatim (mirroring the
97
+ /// Python SDK's `dict | None`).
98
+ pub fn json_to_ruby(value: &serde_json::Value) -> Value {
99
+ let ruby = crate::runtime::ruby();
100
+ match value {
101
+ serde_json::Value::Null => ruby.qnil().as_value(),
102
+ serde_json::Value::Bool(b) => b.into_value_with(&ruby),
103
+ serde_json::Value::Number(n) => {
104
+ if let Some(i) = n.as_i64() {
105
+ i.into_value_with(&ruby)
106
+ } else if let Some(u) = n.as_u64() {
107
+ u.into_value_with(&ruby)
108
+ } else {
109
+ n.as_f64().unwrap_or(0.0).into_value_with(&ruby)
110
+ }
111
+ }
112
+ serde_json::Value::String(s) => s.as_str().into_value_with(&ruby),
113
+ serde_json::Value::Array(items) => {
114
+ let arr = ruby.ary_new();
115
+ for item in items {
116
+ let _ = arr.push(json_to_ruby(item));
117
+ }
118
+ arr.into_value_with(&ruby)
119
+ }
120
+ serde_json::Value::Object(map) => {
121
+ let hash = ruby.hash_new();
122
+ for (k, v) in map {
123
+ let _ = hash.aset(k.as_str(), json_to_ruby(v));
124
+ }
125
+ hash.into_value_with(&ruby)
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,92 @@
1
+ //! Streaming guest-filesystem I/O: `Microsandbox::Native::FsReadStream` and
2
+ //! `Microsandbox::Native::FsWriteSink`.
3
+ //!
4
+ //! Wraps the core `SandboxFs::read_stream`/`write_stream` handles so large files
5
+ //! can be moved without buffering the whole thing in memory. The core
6
+ //! `FsReadStream::recv` needs `&mut self` and `FsWriteSink::close` consumes
7
+ //! `self`, so each is held behind a `tokio::Mutex` (the sink as an `Option`, so
8
+ //! `close` can take it and be idempotent). Each call drives the future to
9
+ //! completion with the GVL released; the Ruby layer wraps these as an
10
+ //! `Enumerable` reader and a writer with a block form.
11
+
12
+ use std::sync::Arc;
13
+
14
+ use magnus::{method, prelude::*, Error, RModule, RString, Ruby};
15
+ use microsandbox::sandbox::{FsReadStream, FsWriteSink};
16
+ use tokio::sync::Mutex;
17
+
18
+ use crate::error;
19
+ use crate::runtime::{block_on, ruby};
20
+
21
+ #[magnus::wrap(class = "Microsandbox::Native::FsReadStream", free_immediately, size)]
22
+ pub struct FsReadStreamHandle {
23
+ inner: Arc<Mutex<FsReadStream>>,
24
+ }
25
+
26
+ impl FsReadStreamHandle {
27
+ pub fn new(stream: FsReadStream) -> Self {
28
+ Self {
29
+ inner: Arc::new(Mutex::new(stream)),
30
+ }
31
+ }
32
+
33
+ /// Next chunk of bytes (ASCII-8BIT), or nil at end of stream.
34
+ fn recv(&self) -> Result<Option<RString>, Error> {
35
+ let inner = Arc::clone(&self.inner);
36
+ match block_on(async move { inner.lock().await.recv().await }).map_err(error::to_ruby)? {
37
+ Some(bytes) => Ok(Some(ruby().str_from_slice(bytes.as_ref()))),
38
+ None => Ok(None),
39
+ }
40
+ }
41
+ }
42
+
43
+ #[magnus::wrap(class = "Microsandbox::Native::FsWriteSink", free_immediately, size)]
44
+ pub struct FsWriteSinkHandle {
45
+ inner: Arc<Mutex<Option<FsWriteSink>>>,
46
+ }
47
+
48
+ impl FsWriteSinkHandle {
49
+ pub fn new(sink: FsWriteSink) -> Self {
50
+ Self {
51
+ inner: Arc::new(Mutex::new(Some(sink))),
52
+ }
53
+ }
54
+
55
+ /// Write a chunk of bytes. Errors if the sink is already closed.
56
+ fn write(&self, data: RString) -> Result<(), Error> {
57
+ // Copy out while the GVL is held (GC.compact could move the buffer).
58
+ let bytes = unsafe { data.as_slice() }.to_vec();
59
+ let inner = Arc::clone(&self.inner);
60
+ let result = block_on(async move {
61
+ let guard = inner.lock().await;
62
+ match guard.as_ref() {
63
+ Some(sink) => Some(sink.write(&bytes).await),
64
+ None => None,
65
+ }
66
+ });
67
+ match result {
68
+ Some(r) => r.map_err(error::to_ruby),
69
+ None => Err(error::base_error("write to a closed FsWriteSink")),
70
+ }
71
+ }
72
+
73
+ /// Flush and close the sink. Idempotent.
74
+ fn close(&self) -> Result<(), Error> {
75
+ let inner = Arc::clone(&self.inner);
76
+ let taken = block_on(async move { inner.lock().await.take() });
77
+ match taken {
78
+ Some(sink) => block_on(sink.close()).map_err(error::to_ruby),
79
+ None => Ok(()),
80
+ }
81
+ }
82
+ }
83
+
84
+ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
85
+ let rs = native.define_class("FsReadStream", ruby.class_object())?;
86
+ rs.define_method("recv", method!(FsReadStreamHandle::recv, 0))?;
87
+
88
+ let ws = native.define_class("FsWriteSink", ruby.class_object())?;
89
+ ws.define_method("write", method!(FsWriteSinkHandle::write, 1))?;
90
+ ws.define_method("close", method!(FsWriteSinkHandle::close, 0))?;
91
+ Ok(())
92
+ }
@@ -9,6 +9,7 @@ use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
9
9
  use microsandbox::image::{Image, ImageDetail, ImageHandle, ImagePruneReport};
10
10
 
11
11
  use crate::backend::with_local_backend;
12
+ use crate::conv;
12
13
  use crate::runtime::ruby;
13
14
 
14
15
  fn handle_to_hash(h: &ImageHandle) -> RHash {
@@ -43,6 +44,11 @@ fn detail_to_hash(detail: ImageDetail) -> RHash {
43
44
  let _ = c.aset("entrypoint", config.entrypoint);
44
45
  let _ = c.aset("working_dir", config.working_dir);
45
46
  let _ = c.aset("user", config.user);
47
+ // OCI config labels: a free-form JSON object (or nil). Converted to a
48
+ // Ruby Hash so `ImageDetail#config["labels"]` matches the Python/Node
49
+ // `config.labels` dict. (The pinned v0.5.8 runtime persists this as
50
+ // null today; the key exists for forward-compatibility/parity.)
51
+ let _ = c.aset("labels", config.labels.as_ref().map(conv::json_to_ruby));
46
52
  let _ = c.aset("stop_signal", config.stop_signal);
47
53
  let _ = hash.aset("config", c);
48
54
  } else {
@@ -13,6 +13,7 @@ mod backend;
13
13
  mod conv;
14
14
  mod error;
15
15
  mod exec;
16
+ mod fs_stream;
16
17
  mod image;
17
18
  mod runtime;
18
19
  mod sandbox;
@@ -47,6 +48,43 @@ fn install() -> Result<(), Error> {
47
48
  runtime::block_on(microsandbox::setup::install()).map_err(error::to_ruby)
48
49
  }
49
50
 
51
+ /// Customizable install via the core `Setup` builder. `opts`: base_dir (install
52
+ /// root), version (pin the runtime version), force (re-download even if present
53
+ /// — repairs a corrupt install), skip_verify. Mirrors the Node `Setup` builder.
54
+ fn setup(opts: RHash) -> Result<(), Error> {
55
+ use microsandbox::setup::Setup;
56
+ let base_dir = conv::opt_string(opts, "base_dir")?;
57
+ let version = conv::opt_string(opts, "version")?;
58
+ let skip_verify = conv::opt_bool(opts, "skip_verify")?;
59
+ let force = conv::opt_bool(opts, "force")?;
60
+ // `Setup` uses a typed-builder whose `strip_option` setters change the type
61
+ // on each call, so optional fields can't be set conditionally on one binding
62
+ // — branch on presence instead (matching the Node binding).
63
+ let setup = match (base_dir, version) {
64
+ (Some(d), Some(v)) => Setup::builder()
65
+ .base_dir(d)
66
+ .version(v)
67
+ .skip_verify(skip_verify)
68
+ .force(force)
69
+ .build(),
70
+ (Some(d), None) => Setup::builder()
71
+ .base_dir(d)
72
+ .skip_verify(skip_verify)
73
+ .force(force)
74
+ .build(),
75
+ (None, Some(v)) => Setup::builder()
76
+ .version(v)
77
+ .skip_verify(skip_verify)
78
+ .force(force)
79
+ .build(),
80
+ (None, None) => Setup::builder()
81
+ .skip_verify(skip_verify)
82
+ .force(force)
83
+ .build(),
84
+ };
85
+ runtime::block_on(setup.install()).map_err(error::to_ruby)
86
+ }
87
+
50
88
  /// Whether the `msb` runtime + `libkrunfw` are installed and resolvable.
51
89
  fn is_installed() -> bool {
52
90
  microsandbox::setup::is_installed()
@@ -88,6 +126,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
88
126
 
89
127
  native.define_singleton_method("version", function!(version, 0))?;
90
128
  native.define_singleton_method("install", function!(install, 0))?;
129
+ native.define_singleton_method("setup", function!(setup, 1))?;
91
130
  native.define_singleton_method("installed?", function!(is_installed, 0))?;
92
131
  native.define_singleton_method("set_runtime_msb_path", function!(set_runtime_msb_path, 1))?;
93
132
  native.define_singleton_method(
@@ -101,6 +140,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
101
140
  sandbox::define(ruby, &native)?;
102
141
  exec::define(ruby, &native)?;
103
142
  stream::define(ruby, &native)?;
143
+ fs_stream::define(ruby, &native)?;
104
144
  snapshot::define(ruby, &native)?;
105
145
  image::define(ruby, &native)?;
106
146
  volume::define(ruby, &native)?;