microsandbox-rb 0.5.7

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.
data/README.md ADDED
@@ -0,0 +1,328 @@
1
+ # microsandbox-rb
2
+
3
+ Lightweight microVM sandboxes for Ruby — run AI agents and untrusted code with hardware-level isolation.
4
+
5
+ The `microsandbox-rb` gem provides native bindings to the [microsandbox](https://github.com/superradcompany/microsandbox) runtime via a Rust extension (magnus). It spins up real microVMs (not containers) in under 100 ms, runs standard OCI (Docker) images, and gives you full control over command execution, the guest filesystem, networking, and metrics — all from an idiomatic, **synchronous** Ruby API. There is no daemon to install and no server to connect to: the runtime is embedded directly in your process.
6
+
7
+ This is an **unofficial, community-maintained** Ruby implementation — not part of the official SDK family ([Rust](https://github.com/superradcompany/microsandbox/tree/main/sdk), TypeScript, Python, Go) — though it wraps the same core engine.
8
+
9
+ ## Features
10
+
11
+ - **Hardware isolation** — each sandbox is a real VM with its own Linux kernel
12
+ - **Sub-100 ms boot** — no daemon, no server setup, embedded directly in your app
13
+ - **OCI image support** — pull and run images from Docker Hub, GHCR, ECR, or any OCI registry
14
+ - **Command execution** — run commands or shell scripts and collect output
15
+ - **Guest filesystem access** — read, write, list, copy, stat files inside a running sandbox
16
+ - **Metrics & logs** — CPU, memory, disk and network I/O; captured stdout/stderr/system logs
17
+ - **Idiomatic Ruby** — keyword arguments, block-scoped lifecycle, a typed error hierarchy
18
+ - **Thread-friendly** — the GVL is released during sandbox calls, so other Ruby threads keep running
19
+
20
+ ## Requirements
21
+
22
+ - **Ruby** >= 3.1
23
+ - **Linux** with KVM enabled, or **macOS** on Apple Silicon (M-series)
24
+ - Building from source additionally needs a **Rust** toolchain (stable >= 1.91)
25
+
26
+ ## Installation
27
+
28
+ The gem is published as **`microsandbox-rb`**, but you still `require "microsandbox"`
29
+ (the `microsandbox` package name was already taken on RubyGems):
30
+
31
+ ```ruby
32
+ # Gemfile
33
+ gem "microsandbox-rb", require: "microsandbox"
34
+ ```
35
+
36
+ ```bash
37
+ bundle install
38
+ # or
39
+ gem install microsandbox-rb
40
+ ```
41
+
42
+ The first build downloads the `msb` runtime and `libkrunfw` firmware into
43
+ `~/.microsandbox`. You can (re)provision them explicitly at any time:
44
+
45
+ ```ruby
46
+ Microsandbox.install unless Microsandbox.installed?
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ ```ruby
52
+ require "microsandbox"
53
+
54
+ Microsandbox::Sandbox.create("hello", image: "python") do |sb|
55
+ output = sb.exec("python", ["-c", "print('Hello, World!')"])
56
+ puts output.stdout # => "Hello, World!\n"
57
+ puts output.success? # => true
58
+ end
59
+ # the sandbox is stopped automatically when the block returns
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ### Lifecycle
65
+
66
+ ```ruby
67
+ # Block form — recommended; stops the sandbox automatically (even on error)
68
+ Microsandbox::Sandbox.create("box", image: "alpine") do |sb|
69
+ # ...
70
+ end
71
+
72
+ # Manual form — you are responsible for stopping it
73
+ sb = Microsandbox::Sandbox.create("box", image: "alpine")
74
+ begin
75
+ # ...
76
+ ensure
77
+ sb.stop # graceful (sb.stop(timeout: 5) to bound the wait)
78
+ # sb.kill # force (SIGKILL)
79
+ end
80
+
81
+ # Inspect / manage existing sandboxes
82
+ Microsandbox::Sandbox.list # => [Microsandbox::SandboxInfo, ...]
83
+ Microsandbox::Sandbox.get("box") # => Microsandbox::SandboxInfo
84
+ Microsandbox::Sandbox.start("box") # restart a stopped sandbox
85
+ Microsandbox::Sandbox.remove("box") # remove a stopped sandbox
86
+ ```
87
+
88
+ ### Configuration
89
+
90
+ ```ruby
91
+ Microsandbox::Sandbox.create(
92
+ "configured",
93
+ image: "python",
94
+ cpus: 2,
95
+ memory: 1024, # MiB
96
+ env: { "API_BASE" => "https://example.com" },
97
+ workdir: "/app",
98
+ labels: { "team" => "research" },
99
+ ports: { 8080 => 80 }, # host => guest (TCP)
100
+ network: "public_only", # or "none" for airgapped
101
+ replace: true # replace an existing sandbox of the same name
102
+ ) do |sb|
103
+ # ...
104
+ end
105
+ ```
106
+
107
+ ### Executing commands
108
+
109
+ ```ruby
110
+ Microsandbox::Sandbox.create("exec-demo", image: "alpine") do |sb|
111
+ # Direct command (no shell)
112
+ out = sb.exec("ls", ["-la", "/etc"], cwd: "/", timeout: 30)
113
+ out.exit_code # => 0
114
+ out.success? # => true
115
+ out.stdout # => "..." (UTF-8)
116
+ out.stderr_bytes # => raw ASCII-8BIT bytes
117
+
118
+ # Shell script (pipes, redirects, &&)
119
+ sb.shell("cat /etc/os-release | grep VERSION").stdout
120
+
121
+ # Environment, stdin, working directory
122
+ sb.exec("cat", [], stdin: "piped data")
123
+ sb.exec("sh", ["-c", "echo $GREETING"], env: { "GREETING" => "hi" })
124
+ end
125
+ ```
126
+
127
+ A non-zero exit is **not** an error — inspect `exit_code`/`success?`. Spawn-time
128
+ failures (e.g. command not found) and timeouts raise typed errors (see below).
129
+
130
+ ### Guest filesystem
131
+
132
+ ```ruby
133
+ Microsandbox::Sandbox.create("fs-demo", image: "alpine") do |sb|
134
+ sb.fs.write("/tmp/data.txt", "hello")
135
+ sb.fs.read_text("/tmp/data.txt") # => "hello" (UTF-8)
136
+ sb.fs.read("/tmp/data.txt") # => raw bytes (ASCII-8BIT)
137
+ sb.fs.exists?("/tmp/data.txt") # => true
138
+
139
+ sb.fs.mkdir("/tmp/sub")
140
+ sb.fs.copy("/tmp/data.txt", "/tmp/sub/copy.txt")
141
+ sb.fs.rename("/tmp/sub/copy.txt", "/tmp/sub/renamed.txt")
142
+ sb.fs.list("/tmp/sub") # => [Microsandbox::FsEntry, ...]
143
+ sb.fs.stat("/tmp/data.txt") # => Microsandbox::FsMetadata
144
+
145
+ # Host <-> guest copies
146
+ sb.fs.copy_from_host("./local.txt", "/tmp/local.txt")
147
+ sb.fs.copy_to_host("/tmp/out.txt", "./out.txt")
148
+ end
149
+ ```
150
+
151
+ ### Metrics & logs
152
+
153
+ ```ruby
154
+ Microsandbox::Sandbox.create("obs", image: "alpine") do |sb|
155
+ m = sb.metrics # => Microsandbox::Metrics
156
+ m.cpu_percent
157
+ m.memory_bytes
158
+ m.uptime_secs
159
+
160
+ sb.logs(tail: 100, sources: ["stdout", "stderr"]).each do |entry|
161
+ puts "[#{entry.source}] #{entry.text}"
162
+ end
163
+ end
164
+ ```
165
+
166
+ ### Streaming output
167
+
168
+ For long-running commands, stream events as they arrive instead of waiting:
169
+
170
+ ```ruby
171
+ Microsandbox::Sandbox.create("stream", image: "python") do |sb|
172
+ handle = sb.exec_stream("python", ["-u", "-c", "import time\nfor i in range(3): print(i); time.sleep(1)"])
173
+ handle.each do |event| # ExecHandle is Enumerable
174
+ print event.text if event.stdout?
175
+ end
176
+ # or: out = handle.collect → ExecOutput (drain to the end)
177
+ # interactive stdin:
178
+ # sink = handle.stdin; sink.write("data\n"); sink.close
179
+ # control: handle.signal(15), handle.kill, handle.resize(rows, cols)
180
+ end
181
+ ```
182
+
183
+ ### Images
184
+
185
+ Manage the local OCI image cache (images are pulled automatically on `create`):
186
+
187
+ ```ruby
188
+ Microsandbox::Image.list # => [Microsandbox::ImageInfo, ...]
189
+ Microsandbox::Image.get("alpine") # => Microsandbox::ImageInfo
190
+ Microsandbox::Image.inspect("alpine").layers # => [{...}, ...]
191
+ Microsandbox::Image.remove("alpine", force: true)
192
+ report = Microsandbox::Image.prune
193
+ report.bytes_reclaimed
194
+ ```
195
+
196
+ ### Named volumes
197
+
198
+ Persistent storage that outlives individual sandboxes:
199
+
200
+ ```ruby
201
+ Microsandbox::Volume.create("cache", kind: "disk", size_mib: 512)
202
+ Microsandbox::Volume.list # => [Microsandbox::VolumeInfo, ...]
203
+
204
+ Microsandbox::Sandbox.create("with-vol", image: "alpine",
205
+ volumes: { "/data" => { named: "cache" } }) do |sb|
206
+ sb.fs.write("/data/state.txt", "persisted")
207
+ end
208
+
209
+ Microsandbox::Volume.remove("cache")
210
+ ```
211
+
212
+ `volumes:` accepts a host path String (bind mount) or `{ bind: "/host" }` /
213
+ `{ named: "volume-name" }` per guest path. Boot from a snapshot with
214
+ `Sandbox.create(name, from_snapshot: "snap-name-or-path")`.
215
+
216
+ ### Error handling
217
+
218
+ All errors descend from `Microsandbox::Error` and carry a stable `#code`:
219
+
220
+ ```ruby
221
+ begin
222
+ Microsandbox::Sandbox.create("dup", image: "alpine")
223
+ Microsandbox::Sandbox.create("dup", image: "alpine") # name clash
224
+ rescue Microsandbox::SandboxAlreadyExistsError => e
225
+ warn "#{e.code}: #{e.message}" # => "sandbox-already-exists: ..."
226
+ rescue Microsandbox::Error => e
227
+ warn "microsandbox failed: #{e.message}"
228
+ end
229
+ ```
230
+
231
+ | Class | `#code` |
232
+ |-------|---------|
233
+ | `InvalidConfigError` | `invalid-config` |
234
+ | `SandboxNotFoundError` | `sandbox-not-found` |
235
+ | `SandboxAlreadyExistsError` | `sandbox-already-exists` |
236
+ | `SandboxStillRunningError` | `sandbox-still-running` |
237
+ | `ExecTimeoutError` | `exec-timeout` |
238
+ | `ExecFailedError` | `exec-failed` |
239
+ | `FilesystemError` | `filesystem-error` |
240
+ | `ImageNotFoundError` | `image-not-found` |
241
+ | `MetricsDisabledError` / `MetricsUnavailableError` | `metrics-disabled` / `metrics-unavailable` |
242
+ | … | (see `lib/microsandbox/errors.rb`) |
243
+
244
+ ## Runtime configuration
245
+
246
+ The `msb` runtime path is resolved in this order: the `MSB_PATH` environment
247
+ variable → an SDK-set override → the config file → `~/.microsandbox/bin/msb` →
248
+ `msb` on `PATH`.
249
+
250
+ ```ruby
251
+ Microsandbox.installed? # => true/false
252
+ Microsandbox.install # download + install the runtime (idempotent)
253
+ Microsandbox.runtime_path # => "/Users/you/.microsandbox/bin/msb"
254
+ Microsandbox.runtime_path = "/opt/microsandbox/bin/msb" # override
255
+ ```
256
+
257
+ ## Development
258
+
259
+ ```bash
260
+ bin/setup # bundle install
261
+ bundle exec rake compile # build the native extension (debug)
262
+ bundle exec rake compile:release # build optimized
263
+ bundle exec rake spec # run unit specs (no runtime needed)
264
+ MICROSANDBOX_INTEGRATION=1 bundle exec rake spec:all # + real microVM specs
265
+ ```
266
+
267
+ Unit specs run without a runtime. Integration specs boot real microVMs and are
268
+ opt-in via `MICROSANDBOX_INTEGRATION=1` (override the test image with
269
+ `MICROSANDBOX_TEST_IMAGE`).
270
+
271
+ The native extension depends on the `microsandbox` core crate via a pinned git
272
+ tag, so it builds without an adjacent checkout. To develop against a sibling
273
+ `microsandbox/` checkout instead (faster, reflects local runtime changes):
274
+
275
+ ```bash
276
+ cp .cargo/config.toml.example .cargo/config.toml # gitignored path override
277
+ bundle exec rake compile
278
+ ```
279
+
280
+ ## Releasing
281
+
282
+ Releases are automated by `.github/workflows/release.yml` via RubyGems
283
+ **Trusted Publishing** (OIDC) — there is no API key to store as a secret.
284
+
285
+ **One-time setup** (before the first release), create a *pending* trusted
286
+ publisher at <https://rubygems.org/profile/oidc/pending_trusted_publishers>:
287
+
288
+ | Field | Value |
289
+ |-------|-------|
290
+ | RubyGems gem name | `microsandbox-rb` |
291
+ | Repository owner | `ya-luotao` |
292
+ | Repository name | `microsandbox-rb` |
293
+ | Workflow filename | `release.yml` |
294
+ | Environment | *(leave blank)* |
295
+
296
+ On the first successful push the pending publisher auto-converts to a permanent
297
+ one bound to the gem.
298
+
299
+ **Each release:**
300
+
301
+ 1. Bump `Microsandbox::VERSION` (and the `tag = "vX.Y.Z"` on the core-crate
302
+ dependency in `ext/microsandbox/Cargo.toml`) to match the upstream runtime,
303
+ update `CHANGELOG.md`.
304
+ 2. *(Recommended first time)* run the workflow's manual `dry_run` dispatch to
305
+ build all platform gems without publishing — confirms the `arm64-darwin`
306
+ cross-build succeeds.
307
+ 3. Push a `vX.Y.Z` tag. CI builds precompiled, multi-ABI platform gems
308
+ (`x86_64-linux`, `aarch64-linux`, `arm64-darwin`) with `rake-compiler-dock`,
309
+ plus the source gem, and pushes them to RubyGems. The publish job uses
310
+ `rubygems/configure-rubygems-credentials` (OIDC) with `id-token: write` — no
311
+ `RUBYGEMS_API_KEY` secret required.
312
+
313
+ See [DESIGN.md](DESIGN.md) for the architecture and the implemented-surface
314
+ section for what's covered today vs. on the roadmap. Covered: full sandbox
315
+ lifecycle (including the async `request_stop`/`request_kill`/`request_drain`/
316
+ `wait_until_stopped`/`detach`/`owns_lifecycle?` controls and label-filtered
317
+ `list_with`), `exec`/`shell` (collected and streaming), the full guest
318
+ filesystem, metrics (per-sandbox, `Microsandbox.all_sandbox_metrics`, and
319
+ streaming `metrics_stream`/`log_stream`), logs, OCI image-cache management,
320
+ named volumes, and snapshots (create/list/verify/export/import +
321
+ boot-from-snapshot). Create options span resources, network policy presets,
322
+ `log_level`/`security`/`rlimits`/`pull_policy`/`secrets` and more; `exec`/`shell`
323
+ take per-call `rlimits`. Still on the roadmap: custom per-rule network policies,
324
+ file patches, registry auth, interactive `attach`, SSH, and the raw agent client.
325
+
326
+ ## License
327
+
328
+ Apache-2.0. See [LICENSE](LICENSE).
@@ -0,0 +1,45 @@
1
+ [package]
2
+ # Distinct from the core `microsandbox` package: Cargo refuses two packages with
3
+ # the same name in one lockfile. The native artifact is loaded internally as
4
+ # `microsandbox/microsandbox_rb` and stays hidden behind `require "microsandbox"`.
5
+ name = "microsandbox_rb"
6
+ description = "Ruby SDK native extension for microsandbox — secure, fast microVM-based sandboxing."
7
+ version = "0.5.7"
8
+ authors = ["Super Rad Company <development@superrad.company>"]
9
+ repository = "https://github.com/superradcompany/microsandbox"
10
+ license = "Apache-2.0"
11
+ edition = "2021"
12
+ publish = false
13
+
14
+ [lib]
15
+ # Produces libmicrosandbox_rb.{dylib,so}; rb-sys renames it to
16
+ # microsandbox/microsandbox_rb.{bundle,so}. The magnus #[init] entrypoint is
17
+ # therefore Init_microsandbox_rb (matches the loaded basename).
18
+ name = "microsandbox_rb"
19
+ crate-type = ["cdylib"]
20
+
21
+ [dependencies]
22
+ magnus = "0.8"
23
+ # Low-level Ruby C API — used directly for rb_thread_call_without_gvl (GVL release
24
+ # during blocking sandbox calls). Kept in lock-step with magnus's own rb-sys.
25
+ rb-sys = "0.9"
26
+
27
+ # The microsandbox core runtime, wrapped via FFI exactly like the official
28
+ # Python (pyo3) and Node (napi) SDKs. Pinned to the matching upstream tag so the
29
+ # gem is self-contained and buildable anywhere (CI, release containers, end-user
30
+ # source installs) without an adjacent checkout. For local development against a
31
+ # sibling checkout, use the `paths` override in `.cargo/config.toml` (see
32
+ # `.cargo/config.toml.example`). "ssh" matches the feature set the Python/Node
33
+ # SDKs ship with; default features add "prebuilt" (provisions msb + libkrunfw at
34
+ # build time), "net", and "keyring".
35
+ microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.5.7", default-features = true, features = ["ssh"] }
36
+ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.5.7" }
37
+
38
+ # Async core bridged to Ruby's synchronous API via a blocking tokio runtime.
39
+ tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
40
+ futures = "0.3"
41
+
42
+ # Shared (de)serialization + value types used across the binding surface.
43
+ serde_json = "1"
44
+ chrono = "0.4"
45
+ ipnetwork = { version = "0.21", features = ["serde"] }
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ # Builds the Rust cdylib and installs it as lib/microsandbox/microsandbox.{bundle,so}.
7
+ # The Cargo profile is selected via the RB_SYS_CARGO_PROFILE env var (defaults to
8
+ # release for installed gems, dev for `rake compile`).
9
+ #
10
+ # Cargo features for the embedded microsandbox runtime (e.g. "ssh") are enabled
11
+ # directly on the dependency in ext/microsandbox/Cargo.toml, mirroring the official
12
+ # Python/Node SDKs. The crate's "prebuilt" default feature provisions the msb
13
+ # runtime + libkrunfw into ~/.microsandbox at build time.
14
+ create_rust_makefile("microsandbox/microsandbox_rb")
@@ -0,0 +1,74 @@
1
+ //! Helpers for reading a Ruby options `Hash` (string keys) into Rust values.
2
+ //!
3
+ //! The Ruby layer normalizes keyword arguments into a string-keyed Hash before
4
+ //! handing it to the native layer, so lookups here are by `&str`.
5
+
6
+ use std::collections::HashMap;
7
+
8
+ use magnus::{value::ReprValue, Error, RHash, TryConvert, Value};
9
+
10
+ /// Fetch a non-nil value for `key`, if present.
11
+ fn get(hash: RHash, key: &str) -> Option<Value> {
12
+ match hash.get(key) {
13
+ Some(v) if !v.is_nil() => Some(v),
14
+ _ => None,
15
+ }
16
+ }
17
+
18
+ /// Generic typed fetch: `None` if the key is absent/nil, else converted.
19
+ pub fn opt<T: TryConvert>(hash: RHash, key: &str) -> Result<Option<T>, Error> {
20
+ match get(hash, key) {
21
+ Some(v) => Ok(Some(T::try_convert(v)?)),
22
+ None => Ok(None),
23
+ }
24
+ }
25
+
26
+ pub fn opt_string(hash: RHash, key: &str) -> Result<Option<String>, Error> {
27
+ opt(hash, key)
28
+ }
29
+
30
+ pub fn opt_bool(hash: RHash, key: &str) -> Result<bool, Error> {
31
+ Ok(opt::<bool>(hash, key)?.unwrap_or(false))
32
+ }
33
+
34
+ pub fn opt_u8(hash: RHash, key: &str) -> Result<Option<u8>, Error> {
35
+ opt(hash, key)
36
+ }
37
+
38
+ pub fn opt_u32(hash: RHash, key: &str) -> Result<Option<u32>, Error> {
39
+ opt(hash, key)
40
+ }
41
+
42
+ pub fn opt_f64(hash: RHash, key: &str) -> Result<Option<f64>, Error> {
43
+ opt(hash, key)
44
+ }
45
+
46
+ /// String→String map (e.g. `env`, `labels`, `scripts`). Empty if absent.
47
+ pub fn opt_string_map(hash: RHash, key: &str) -> Result<Vec<(String, String)>, Error> {
48
+ match get(hash, key) {
49
+ Some(v) => {
50
+ let map = HashMap::<String, String>::try_convert(v)?;
51
+ Ok(map.into_iter().collect())
52
+ }
53
+ None => Ok(Vec::new()),
54
+ }
55
+ }
56
+
57
+ /// Array of strings (e.g. `entrypoint`, `args`). Empty if absent.
58
+ pub fn opt_string_vec(hash: RHash, key: &str) -> Result<Vec<String>, Error> {
59
+ match get(hash, key) {
60
+ Some(v) => Ok(Vec::<String>::try_convert(v)?),
61
+ None => Ok(Vec::new()),
62
+ }
63
+ }
64
+
65
+ /// `u16`→`u16` port map (host→guest TCP). Empty if absent.
66
+ pub fn opt_port_map(hash: RHash, key: &str) -> Result<Vec<(u16, u16)>, Error> {
67
+ match get(hash, key) {
68
+ Some(v) => {
69
+ let map = HashMap::<u16, u16>::try_convert(v)?;
70
+ Ok(map.into_iter().collect())
71
+ }
72
+ None => Ok(Vec::new()),
73
+ }
74
+ }
@@ -0,0 +1,72 @@
1
+ //! Map the core `MicrosandboxError` enum onto the Ruby exception hierarchy.
2
+ //!
3
+ //! Mirrors `sdk/python/src/error.rs`: each handled variant is routed to a
4
+ //! specific `Microsandbox::*Error` class (defined in `lib/microsandbox/errors.rb`),
5
+ //! every other variant falls back to the base `Microsandbox::Error`. The message
6
+ //! is always the core error's `to_string()`.
7
+
8
+ use magnus::{value::ReprValue, Error, ExceptionClass, Module, RClass, RModule, Ruby};
9
+ use microsandbox::{AgentClientError, MicrosandboxError};
10
+
11
+ /// The Ruby class (relative to the `Microsandbox` module) for a core error.
12
+ /// `"Error"` is the base class; anything else is a named subclass.
13
+ fn class_name(err: &MicrosandboxError) -> &'static str {
14
+ use MicrosandboxError::*;
15
+ match err {
16
+ InvalidConfig(_) => "InvalidConfigError",
17
+ SandboxNotFound(_) => "SandboxNotFoundError",
18
+ SandboxAlreadyExists(_) => "SandboxAlreadyExistsError",
19
+ SandboxStillRunning(_) => "SandboxStillRunningError",
20
+ ExecTimeout(_) => "ExecTimeoutError",
21
+ ExecFailed(_) => "ExecFailedError",
22
+ SandboxFsOps(_) => "FilesystemError",
23
+ ImageNotFound(_) => "ImageNotFoundError",
24
+ ImageInUse(_) => "ImageInUseError",
25
+ VolumeNotFound(_) => "VolumeNotFoundError",
26
+ VolumeAlreadyExists(_) => "VolumeAlreadyExistsError",
27
+ Io(_) => "IoError",
28
+ MetricsDisabled(_) => "MetricsDisabledError",
29
+ MetricsUnavailable(_) => "MetricsUnavailableError",
30
+ AgentClient(AgentClientError::UnsupportedOperation { .. }) => "UnsupportedOperationError",
31
+ _ => "Error",
32
+ }
33
+ }
34
+
35
+ /// Look up `Microsandbox::<name>` as an exception class.
36
+ fn exception_class(ruby: &Ruby, name: &str) -> Option<ExceptionClass> {
37
+ let module: RModule = ruby.class_object().const_get("Microsandbox").ok()?;
38
+ let class: RClass = module.const_get(name).ok()?;
39
+ ExceptionClass::from_value(class.as_value())
40
+ }
41
+
42
+ /// Convert a core error into a Ruby exception, preserving the typed class.
43
+ // The `exception::runtime_error()` fallbacks fire only off a Ruby thread (which
44
+ // never happens from a bound method); there is no handle-based alternative there.
45
+ #[allow(deprecated)]
46
+ pub fn to_ruby(err: MicrosandboxError) -> Error {
47
+ let message = err.to_string();
48
+ let ruby = match Ruby::get() {
49
+ Ok(ruby) => ruby,
50
+ // Not on a Ruby thread (should never happen from a bound method).
51
+ Err(_) => return Error::new(magnus::exception::runtime_error(), message),
52
+ };
53
+
54
+ match exception_class(&ruby, class_name(&err)) {
55
+ Some(class) => Error::new(class, message),
56
+ None => Error::new(ruby.exception_runtime_error(), message),
57
+ }
58
+ }
59
+
60
+ /// A plain `Microsandbox::Error` (base) with a custom message — used for
61
+ /// binding-level validation errors that have no core counterpart.
62
+ #[allow(deprecated)]
63
+ pub fn base_error(message: impl Into<String>) -> Error {
64
+ let message = message.into();
65
+ match Ruby::get() {
66
+ Ok(ruby) => match exception_class(&ruby, "Error") {
67
+ Some(class) => Error::new(class, message),
68
+ None => Error::new(ruby.exception_runtime_error(), message),
69
+ },
70
+ Err(_) => Error::new(magnus::exception::runtime_error(), message),
71
+ }
72
+ }