microsandbox-rb 0.17.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.
@@ -0,0 +1,99 @@
1
+ # Metrics, logs, and live modification
2
+
3
+ ## Metrics & logs
4
+
5
+ ```ruby
6
+ Microsandbox::Sandbox.create("obs", image: "public.ecr.aws/docker/library/alpine:latest") do |sb|
7
+ # On v0.6.x runtimes the metrics slot goes live a beat after create returns,
8
+ # so `metrics` can briefly raise "no live metrics slot" right after boot —
9
+ # retry for a few hundred ms rather than treating the first failure as fatal.
10
+ m = sb.metrics # => Microsandbox::Metrics
11
+ m.cpu_percent
12
+ m.memory_bytes
13
+ m.uptime_secs
14
+
15
+ sb.logs(tail: 100, sources: ["stdout", "stderr"]).each do |entry|
16
+ puts "[#{entry.source}] #{entry.text}"
17
+ end
18
+ end
19
+ ```
20
+
21
+ Streaming counterparts: `sb.metrics_stream` (`MetricsStream` over `Metrics`)
22
+ and `sb.log_stream` (`LogStream` over `LogEntry`) — both single-pass, see
23
+ [execution.md](execution.md#streams-are-single-pass).
24
+ `Microsandbox.all_sandbox_metrics` reports every running sandbox at once.
25
+
26
+ `MetricsDisabledError` means metrics are turned off for the sandbox. The
27
+ metrics slot goes live a moment *after* `create` returns; a call inside that
28
+ window raises the base `Microsandbox::Error` with a message containing
29
+ `no live metrics slot` (not `MetricsUnavailableError`, which the pinned runtime
30
+ never raises). Retry on that message:
31
+
32
+ ```ruby
33
+ metrics = begin
34
+ sb.metrics
35
+ rescue Microsandbox::Error => e
36
+ raise unless e.message.include?("no live metrics slot")
37
+ sleep 0.1
38
+ retry
39
+ end
40
+ ```
41
+
42
+ `sb.log_stream(follow: true)` keeps waiting for new entries and **does not end
43
+ when the sandbox stops** — the runtime follows the log file, not the sandbox —
44
+ and the wait cannot be interrupted. A thread blocked in it also keeps the
45
+ process from exiting normally. Read what exists with `sb.logs` or
46
+ `sb.log_stream` (no `follow:`), or follow from a separate process you can kill.
47
+
48
+ ## Live modification and health
49
+
50
+ Resize, reconfigure, or rotate secrets on a sandbox **without recreating it**,
51
+ and probe the guest agent's liveness. To leave headroom for a live CPU/memory
52
+ resize, reserve a ceiling at create time with `max_cpus:`/`max_memory:`:
53
+
54
+ ```ruby
55
+ Microsandbox::Sandbox.create("live", image: "public.ecr.aws/docker/library/alpine:latest",
56
+ cpus: 1, max_cpus: 4, memory: 512, max_memory: 2048) do |sb|
57
+ # Health check — does NOT refresh the idle timer:
58
+ ping = sb.ping # => Microsandbox::PingResult
59
+ ping.latency_ms # round-trip latency
60
+
61
+ # Explicitly refresh the idle-activity timer (resets any idle_timeout:):
62
+ sb.touch.activity_seq # => Microsandbox::TouchResult
63
+
64
+ # Preview a change without applying it:
65
+ plan = sb.modify(cpus: 2, memory: 1024, dry_run: true)
66
+ plan.applied? # => false
67
+ plan.changes # => [{ kind: "config", field: "cpus", ... }, ...]
68
+
69
+ # Live resize — applies to the running VM under the default :no_restart policy:
70
+ sb.modify(cpus: 2, memory: 1024)
71
+
72
+ # Grow the root disk (managed upper or flat, MiB — runtime v0.6.9). Applied
73
+ # while stopped; growth-only:
74
+ sb.modify(root_disk_size: 8192, policy: :next_start)
75
+
76
+ # env/labels/workdir changes on a *running* sandbox require a restart, so the
77
+ # default :no_restart policy rejects the whole apply (it raises rather than
78
+ # partially applying). Persist them for the next start — or restart now —
79
+ # by saying so explicitly:
80
+ sb.modify(env: { "TIER" => "prod" }, remove_env: ["DEBUG"],
81
+ labels: { "role" => "worker" }, policy: :next_start) # or policy: :restart
82
+
83
+ # Rotating/removing an *existing* secret (or updating its allowed hosts) is
84
+ # live; *adding* a new secret is restart-required, like env. Specs are keyed
85
+ # by name; env:/store:/value: are mutually exclusive:
86
+ sb.modify(
87
+ secrets: { "API_KEY" => { env: "HOST_API_KEY", allowed_hosts: ["api.example.com"] } },
88
+ remove_secrets: ["OLD_TOKEN"],
89
+ )
90
+ end
91
+ ```
92
+
93
+ The apply is **all-or-nothing**: under a given `policy:` every planned change
94
+ must be applicable, or the whole `modify` raises — nothing is partially
95
+ applied. Use `dry_run: true` to inspect each change's `disposition` (`"live"`,
96
+ `"next start"`, `"requires restart"`) before committing.
97
+
98
+ `SandboxHandle` (from `Sandbox.get`/`list`) carries the same `#ping`/`#touch`/
99
+ `#modify`; on a stopped sandbox `#ping`/`#touch` raise `SandboxNotRunningError`.
data/docs/releasing.md ADDED
@@ -0,0 +1,66 @@
1
+ # Releasing
2
+
3
+ Releases are automated by `.github/workflows/release.yml` via RubyGems
4
+ **Trusted Publishing** (OIDC) — there is no API key to store as a secret. The
5
+ trusted publisher is already configured for this gem, so no per-release secret
6
+ or credential setup is needed.
7
+
8
+ ## Each release
9
+
10
+ 1. Bump the gem's **own** version — `Microsandbox::VERSION` and the matching
11
+ `[package] version` in `ext/microsandbox/Cargo.toml` (they must stay equal) —
12
+ on its independent semver track (see the
13
+ [Versioning](../README.md#versioning) section of the README); **don't** pick
14
+ the number to mirror the upstream tag. If the release also adopts a new
15
+ upstream runtime, bump the `tag = "vX.Y.Z"` on **all three** upstream git
16
+ deps (`microsandbox`, `microsandbox-network`, `microsandbox-runtime`), update
17
+ `Microsandbox::RUNTIME_VERSION` to match, commit the release's
18
+ `checksums.sha256` as `binaries/checksums/<tag>.sha256`, and add a row to the
19
+ Versioning table. Also bump `Microsandbox::Binaries::VERSION` (and, on a
20
+ runtime adoption, `::RUNTIME_VERSION`) in
21
+ `binaries/lib/microsandbox/binaries.rb` — the companion gem ships in
22
+ lockstep and the specs assert both. Update `CHANGELOG.md`.
23
+ 2. Push a `vX.Y.Z` tag. CI builds the **source gem** and pushes it to RubyGems
24
+ via `rubygems/configure-rubygems-credentials` (OIDC, `id-token: write`) — no
25
+ `RUBYGEMS_API_KEY` secret required — then creates the GitHub Release.
26
+
27
+ ## Precompiled per-platform gems
28
+
29
+ Precompiled *extension* gems are built best-effort by the `cross-gems` job,
30
+ gated to manual `workflow_dispatch` so you can iterate
31
+ (`gh workflow run release.yml`) without failing tag releases. They are not
32
+ auto-published on tags yet: a gem that *compiles but can't boot a microVM*
33
+ would be served to users ahead of the source gem, and CI can't boot a VM to
34
+ prove otherwise — so promotion is manual after validating the artifact on each
35
+ platform. A precompiled gem ships the compiled extension (with the guest
36
+ `agentd` baked in by *target* arch); the host-side `msb` + `libkrunfw` runtime
37
+ is **not** in it — that comes from the companion `microsandbox-rb-binaries`
38
+ gem, or, when that isn't installed, is fetched into `~/.microsandbox` on first
39
+ use by `Microsandbox.ensure_runtime!` (libkrunfw is `dlopen`'d by `msb` at
40
+ runtime, never linked into the gem). The real cross-compile work is linking the
41
+ *target* native libraries — `libcap-ng` on Linux (handled via Debian multiarch
42
+ in the workflow) and the Hypervisor + Security frameworks on macOS (via
43
+ osxcross; the one platform left to confirm). Until promoted, users install the
44
+ source gem (which compiles via `rb_sys`).
45
+
46
+ ## Runtime binaries gems
47
+
48
+ `microsandbox-rb-binaries` (source in `binaries/`) is a *separate* artifact from
49
+ the precompiled extension gems above: it carries no Ruby extension, only the
50
+ upstream `msb` + `libkrunfw` for one platform, verified against the release's
51
+ published `checksums.sha256` when vendored. CI's `binaries` job vendors and
52
+ builds all three (`arm64-darwin`, `x86_64-linux-gnu`, `aarch64-linux-gnu`) on
53
+ every run and smoke-tests the host one; `release.yml`'s `binaries-gems` job
54
+ does the same on a tag and a separate `publish-binaries` job pushes them after
55
+ the SDK gem is live (a companion failure is its own red job and never blocks
56
+ the SDK release). They use their own RubyGems trusted-publisher entry (same
57
+ repo + workflow, gem name `microsandbox-rb-binaries`). To build them by hand:
58
+
59
+ ```bash
60
+ rake -C binaries vendor[arm64-darwin] # download + sha256-verify into binaries/vendor
61
+ rake -C binaries build[arm64-darwin] # package into binaries/pkg/*.gem
62
+ rake -C binaries verify # host-only: run the vendored `msb --version`
63
+ ```
64
+
65
+ `binaries/vendor` and `binaries/pkg` are gitignored — binaries are never
66
+ committed; CI rebuilds them from the upstream release.
data/docs/runtime.md ADDED
@@ -0,0 +1,152 @@
1
+ # Runtime binaries, resolution, and backends
2
+
3
+ ## The runtime binaries
4
+
5
+ `microsandbox-rb` is **SDK-only** — it wraps the microVM runtime, it doesn't
6
+ carry it. The host-side `msb` runtime and the `libkrunfw` firmware come from an
7
+ optional companion gem, **`microsandbox-rb-binaries`**, published as one gem per
8
+ platform (`arm64-darwin`, `x86_64-linux-gnu`, `aarch64-linux-gnu`) and
9
+ versioned in lockstep with this gem:
10
+
11
+ ```ruby
12
+ # Gemfile — install both gems at the same version
13
+ gem "microsandbox-rb", require: "microsandbox"
14
+ gem "microsandbox-rb-binaries"
15
+ ```
16
+
17
+ That's all the wiring there is: `require "microsandbox"` finds the companion
18
+ gem, checks it is the same version and built for the same upstream runtime, and
19
+ points the resolver at its vendored `msb` — so your bundle carries the runtime
20
+ and nothing is downloaded at install time or on first call. **Recommended
21
+ whenever you boot local microVMs.** Cloud-only users (`MSB_BACKEND=cloud`)
22
+ should skip it: it is a separate, optional gem precisely so nobody has to fetch
23
+ ~50 MB of binaries they won't run. Neither gem depends on the other.
24
+
25
+ > **Availability.** The binaries gems are published on every release tag
26
+ > alongside `microsandbox-rb` since `0.14.0`. On an older SDK version, or a
27
+ > platform without a bundle, use the fallback below (or build them yourself
28
+ > from `binaries/` — see [binaries/README.md](https://github.com/ya-luotao/microsandbox-rb/blob/main/binaries/README.md)).
29
+
30
+ ### Fallback — first-use download
31
+
32
+ Without the companion gem, the `msb` runtime and `libkrunfw` firmware are
33
+ provisioned into `~/.microsandbox` automatically on first use (the first
34
+ `Sandbox.create`/`start` downloads them if missing). To provision ahead of time
35
+ — e.g. while baking a container image, or to avoid the first-call latency —
36
+ call `install` explicitly:
37
+
38
+ ```ruby
39
+ Microsandbox.install unless Microsandbox.installed?
40
+ ```
41
+
42
+ Set `MICROSANDBOX_NO_AUTO_INSTALL` to disable the automatic first-use download
43
+ (e.g. on air-gapped hosts that provision the runtime out of band). None of this
44
+ applies when the companion gem supplies the runtime: its binaries are already
45
+ the matching version, so `ensure_runtime!` skips the installer entirely and
46
+ nothing is written to `~/.microsandbox`. `Microsandbox.setup` customizes the
47
+ provisioning step itself.
48
+
49
+ ## Runtime path resolution
50
+
51
+ The `msb` runtime path is resolved in this order: the `MSB_PATH` environment
52
+ variable → the `microsandbox-rb-binaries` gem (or another SDK-set override) →
53
+ the config file → `~/.microsandbox/bin/msb` → `msb` on `PATH`.
54
+ `Microsandbox.runtime_path` reports the winner.
55
+
56
+ ```ruby
57
+ Microsandbox.installed? # => true/false
58
+ Microsandbox.install # download + install the runtime (idempotent)
59
+ Microsandbox.runtime_path # => "/Users/you/.microsandbox/bin/msb"
60
+ Microsandbox.runtime_path = "/opt/microsandbox/bin/msb" # override (set-once)
61
+ Microsandbox.libkrunfw_path = "/opt/microsandbox/lib/libkrunfw.dylib" # override (set-once)
62
+ ```
63
+
64
+ When `microsandbox-rb-binaries` is installed, `require "microsandbox"` claims
65
+ that SDK-set slot with the gem's vendored `msb` (the firmware is found alongside
66
+ it), and `runtime_path` points into the gem. The two gems are versioned in
67
+ lockstep and the companion gem must be the same version **and** built for the
68
+ same upstream runtime — a mismatch of either is reported with a warning and
69
+ skipped, and the SDK falls back to `~/.microsandbox` rather than driving a
70
+ runtime it doesn't match. Because the slot is **set-once**,
71
+ `Microsandbox.runtime_path=` is then a no-op: use the `MSB_PATH` environment
72
+ variable, which outranks it, to point at a different runtime.
73
+
74
+ ## Backend routing
75
+
76
+ Every operation runs through a backend. The default is the local libkrun
77
+ backend; without any configuration nothing changes. A backend can be selected
78
+ programmatically or via the environment:
79
+
80
+ ```ruby
81
+ Microsandbox.default_backend_kind # => :local (or :cloud)
82
+ Microsandbox.default_backend_info # => Microsandbox::BackendInfo
83
+ Microsandbox.set_default_backend(:cloud, url: "https://api.example.com", api_key: ENV["MSB_API_KEY"])
84
+ # or a named profile from ~/.microsandbox/config.json:
85
+ Microsandbox.set_default_backend(:cloud, profile: "prod")
86
+
87
+ # Scoped override (restored afterward, even on error):
88
+ Microsandbox.with_backend(:local) { Microsandbox::Sandbox.create("box", image: "alpine") { |sb| ... } }
89
+ ```
90
+
91
+ Resolution order when no backend is set programmatically: `MSB_BACKEND`
92
+ (`local`/`cloud`) → `MSB_PROFILE` → the `active_profile` in
93
+ `~/.microsandbox/config.json` (path overridable via `MSB_CONFIG_PATH`) →
94
+ local. **Cloud intent must be explicit** (since runtime `v0.6.9`): a bare
95
+ `MSB_API_KEY` is treated as credential material, not backend intent, and no
96
+ longer selects the cloud on its own — pair it with `MSB_BACKEND=cloud` (which
97
+ reads `MSB_API_URL`/`MSB_API_KEY`), or select a cloud profile. Invalid cloud
98
+ configuration (e.g. `MSB_BACKEND=cloud` without a usable API key or cloud
99
+ profile) fails closed with `Microsandbox::InvalidConfigError` instead of
100
+ silently running locally. The cloud backend currently supports a subset of
101
+ operations (create/start/stop/remove/get/list, one-shot exec, follow log
102
+ streaming); unsupported operations raise `Microsandbox::UnsupportedError`
103
+ (with `#operation` and `#hint`).
104
+
105
+ ## One runtime line per home
106
+
107
+ The runtime home (`MSB_HOME`, default `~/.microsandbox`) belongs to one
108
+ runtime line at a time. This gem embeds runtime `v0.6.18` and, on first use,
109
+ installs that version of `msb` into the home — replacing a different version
110
+ that is already there, including a newer `msb` installed by the official CLI.
111
+ In the other direction, a `0.7.x` `msb` migrates the catalog to a newer schema
112
+ the first time it opens it, after which every local call from this gem fails
113
+ with `database schema is newer than this msb binary`. The migration is one-way.
114
+
115
+ If the official CLI (or another SDK) on a different runtime line shares the
116
+ machine, give this gem its own home:
117
+
118
+ ```sh
119
+ export MSB_HOME="$HOME/.microsandbox-rb"
120
+ ```
121
+
122
+ ## Known issues at the pinned runtime
123
+
124
+ These are fixed upstream in runtime releases this gem has not adopted yet. See
125
+ [SECURITY.md](../SECURITY.md) for the security advisories.
126
+
127
+ - **macOS hosts: guest `fallocate` can truncate a file on a bind mount or a
128
+ directory-backed named volume.** Preallocating a range that ends inside an
129
+ existing file shrinks the file to the end of that range, and the call
130
+ reports success. Fixed upstream in `v0.7.0`. Until then, keep data that
131
+ guest programs preallocate (databases, download tools) on the guest's OCI
132
+ root disk or a `disk:` volume, not on a host directory.
133
+ - **`modify(secrets:)` adding the first secret** to a sandbox created without
134
+ any does not turn on TLS interception, so the value is not substituted.
135
+ Create the sandbox with at least one secret.
136
+ - **`patches:`** resets the owner and mode of parent directories that already
137
+ exist in the image to `root:root 0755`. Write after boot with `sb.fs`, or
138
+ restore ownership yourself.
139
+
140
+ ## Environment variables
141
+
142
+ | Variable | Effect |
143
+ |----------|--------|
144
+ | `MSB_PATH` | Path to the `msb` runtime binary; outranks every other source, including the binaries gem |
145
+ | `MSB_LIBKRUNFW_PATH` | Path to the `libkrunfw` shared library; outranks `Microsandbox.libkrunfw_path=` |
146
+ | `MSB_HOME` | Runtime home: the catalog, image cache, sandboxes, volumes, snapshots, and the first-use runtime install. Default `~/.microsandbox` |
147
+ | `MSB_BACKEND` | `local` or `cloud`; cloud additionally reads `MSB_API_URL` / `MSB_API_KEY` |
148
+ | `MSB_PROFILE` | Select a named profile from the config file |
149
+ | `MSB_CONFIG_PATH` | Override the `~/.microsandbox/config.json` location |
150
+ | `MICROSANDBOX_NO_AUTO_INSTALL` | Disable the first-use runtime download |
151
+ | `MICROSANDBOX_INTEGRATION` | Set to `1` to run the integration specs (development only) |
152
+ | `MICROSANDBOX_TEST_IMAGE` | Override the integration-spec image (development only) |
data/docs/surface.md ADDED
@@ -0,0 +1,70 @@
1
+ # Supported surface
2
+
3
+ The binding covers the official-SDK surface of the upstream Rust core. This
4
+ page is the authoritative scope statement; `sig/microsandbox.rbs` is the typed
5
+ counterpart, and [DESIGN.md](../DESIGN.md) explains how the layers fit.
6
+
7
+ ## Implemented
8
+
9
+ - **Sandbox lifecycle** — the live `Sandbox` (`stop`/`stop_and_wait`/`kill`/
10
+ `drain`/`wait`/`status`/`detach`/`owns_lifecycle?`), the `SandboxHandle`
11
+ controls from `Sandbox.get` (`stop_with_timeout`/`request_stop`/
12
+ `request_kill`/`request_drain`/`wait_until_stopped`/`config`/`config_json`/
13
+ `snapshot`), the cursor-paginated `list`/`list_with` with label filters, and
14
+ the convergent lifecycle (`connect_or_create`, `#id`, `wait_for_status`,
15
+ `restart`, `destroy`, `SandboxHandle#connect_or_start`,
16
+ `SandboxReplacedError`).
17
+ - **Backend routing** — `set_default_backend`/`with_backend`/
18
+ `default_backend_kind`/`default_backend_info`.
19
+ - **Execution** — `exec`/`shell` (collected and streaming), the default
20
+ workload (`exec_default`/`exec_default_stream`/`attach_default`, `cmd:`),
21
+ interactive `attach`/`attach_shell`, per-call `rlimits`.
22
+ - **Guest filesystem** — the full API including streaming `read_stream`/
23
+ `write_stream`.
24
+ - **Observability** — per-sandbox `metrics`, `Microsandbox.all_sandbox_metrics`,
25
+ streaming `metrics_stream`/`log_stream`, `logs`, `ping`/`touch`.
26
+ - **Live modification** — `modify` with `dry_run:`, `policy:`, CPU/memory
27
+ resize, root-disk growth, env/labels/workdir, secret rotation.
28
+ - **OCI image cache** — `Image.get`/`list`/`inspect`/`remove`/`prune`, image
29
+ archives (`Image.load`/`Image.save`), streaming pull progress
30
+ (`Sandbox.create_with_progress` → `PullSession`).
31
+ - **Named volumes** — `Volume.create`/`get`/`list`/`remove`/`get_default`, plus
32
+ host-side `Volume.fs`/`VolumeInfo#fs` read/write.
33
+ - **Snapshots** — create/open/list/list_dir/reindex/verify/save/load, boot from
34
+ snapshot, opt-in `record_integrity:`.
35
+ - **Rootfs patches** — `Microsandbox::Patch` (text/file/append/copy_file/
36
+ copy_dir/symlink/mkdir/remove) via `create(patches:)`.
37
+ - **Root disk** — structured `root_disk:` (managed / tmpfs / disk / flat via
38
+ `Microsandbox::RootDisk`), `follow_root_symlinks:`.
39
+ - **Network configuration** — composable profiles, custom per-rule
40
+ `Microsandbox::NetworkPolicy`/`Rule`/`Destination`, DNS, TLS interception,
41
+ IPv4/IPv6 pools, `max_connections`, `trust_host_cas`, `strict` hostname mode,
42
+ outbound SOCKS4/SOCKS5 `proxy:` (`OutboundProxy`/`SecretSource`),
43
+ `rate_limiter:`, `vsock:`.
44
+ - **Secrets** — multi-host / wildcard allow-lists, injection toggles,
45
+ per-secret and sandbox-level violation policy.
46
+ - **SSH** — `Sandbox#ssh` → `SshClient`/`SftpClient`/`SshServer`, with
47
+ `inactivity_timeout:`.
48
+ - **Raw agent client** — `Microsandbox::AgentClient` → `AgentStream`/
49
+ `AgentFrame`.
50
+ - **Runtime provisioning** — `install`/`installed?`/`ensure_runtime!`/
51
+ `runtime_path`/`libkrunfw_path`, customizable via `Microsandbox.setup`; the
52
+ companion `microsandbox-rb-binaries` gem.
53
+ - **Registries** — `registry_auth`/`registry_insecure`/`registry_ca_certs` on
54
+ `create`.
55
+ - **Errors** — the typed hierarchy in [errors.md](errors.md).
56
+
57
+ Create options span resources (`cpus`/`memory`/`max_cpus`/`max_memory`),
58
+ `env`/`workdir`/`labels`/`user`/`hostname`/`shell`, `init`/`ephemeral`,
59
+ disk-image `fstype`, `ports`/`ports_udp`, network policy + config,
60
+ `log_level`/`security`/`rlimits`/`pull_policy`/`secrets`/`patches`/`volumes`
61
+ (bind/named/tmpfs/disk with mount policies, `uid`/`gid`, `quota_mib`),
62
+ `idle_timeout`/`max_duration`, `from_snapshot`, `detached`, and
63
+ `replace`/`replace_with_timeout`.
64
+
65
+ ## Not yet exposed
66
+
67
+ A few secondary upstream knobs are not yet exposed: per-published-port host
68
+ bind address (ports always bind loopback), network interface overrides, and
69
+ inline named-volume create-mode (pre-create the volume with `Volume.create`,
70
+ then mount it with `{ named: "…" }`).
@@ -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.6.16).
10
- version = "0.17.0"
10
+ version = "0.17.1"
11
11
  authors = ["Super Rad Company <development@superrad.company>"]
12
12
  repository = "https://github.com/superradcompany/microsandbox"
13
13
  license = "Apache-2.0"
@@ -0,0 +1,108 @@
1
+ //! Pin caller-supplied host paths when a sandbox is created (GHSA-mcw9-9rf6-p4rm).
2
+ //!
3
+ //! The pinned core stores a relative host path exactly as written and resolves
4
+ //! it against the working directory of whichever process later starts the
5
+ //! sandbox, so restarting from another directory mounts a different host
6
+ //! directory than the one the caller meant. Upstream fixed that in v0.7.6 by
7
+ //! resolving host paths once, at the local-backend boundary, before the
8
+ //! configuration is persisted. This module applies the same resolution in the
9
+ //! binding so it also holds on the runtime this gem pins.
10
+ //!
11
+ //! Rules, matching upstream's `resolve_host_paths`:
12
+ //!
13
+ //! - Local backend only: on a cloud backend the paths belong to the remote
14
+ //! host and are forwarded untouched.
15
+ //! - `std::path::absolute`, never `canonicalize`: anchoring must not follow
16
+ //! symlinks (that would defeat the default refusal to follow a symlinked
17
+ //! bind-mount root) and must not require the target to exist.
18
+ //! - Only create-time input is resolved. A sandbox that was already persisted
19
+ //! with a relative path keeps it; nothing here rewrites saved state.
20
+ //!
21
+ //! A vsock `host_socket` is deliberately not routed through here: the core
22
+ //! refuses a relative one outright ("vsock host path must be absolute") before
23
+ //! anything is persisted, so there is no stored relative path to pin.
24
+
25
+ use std::path::PathBuf;
26
+
27
+ use magnus::Error;
28
+ use microsandbox::sandbox::{IntoImage, RootfsSource};
29
+ use microsandbox::{BackendKind, MicrosandboxError};
30
+
31
+ use crate::error;
32
+
33
+ /// How the `image:` text should be handed to the builder.
34
+ pub enum Image {
35
+ /// An OCI reference, an already-absolute path, or text the core rejects —
36
+ /// passed through unchanged so the core classifies (or reports) it.
37
+ Text(String),
38
+ /// A relative local rootfs path, anchored to the current directory.
39
+ Path(PathBuf),
40
+ }
41
+
42
+ /// Resolves host paths for one create call.
43
+ #[derive(Clone, Copy)]
44
+ pub struct HostPaths {
45
+ local: bool,
46
+ }
47
+
48
+ impl HostPaths {
49
+ /// Capture which backend the create call is about to dispatch to.
50
+ pub fn for_default_backend() -> Self {
51
+ Self {
52
+ local: matches!(microsandbox::default_backend().kind(), BackendKind::Local),
53
+ }
54
+ }
55
+
56
+ /// Anchor a relative host path to the current directory. Absolute paths
57
+ /// and the empty string (left for the core to reject) pass through.
58
+ pub fn pin(self, raw: String, label: &str) -> Result<PathBuf, Error> {
59
+ let path = PathBuf::from(raw);
60
+ if !self.local || path.as_os_str().is_empty() || path.is_absolute() {
61
+ return Ok(path);
62
+ }
63
+ std::path::absolute(&path).map_err(|e| {
64
+ error::to_ruby(MicrosandboxError::InvalidConfig(format!(
65
+ "cannot resolve {label} {}: {e}",
66
+ path.display()
67
+ )))
68
+ })
69
+ }
70
+
71
+ /// Classify `image:` text the way the core does and pin it when it names a
72
+ /// relative local path (a directory rootfs or a disk image).
73
+ pub fn pin_image(self, text: String) -> Result<Image, Error> {
74
+ if !self.local {
75
+ return Ok(Image::Text(text));
76
+ }
77
+ let relative_local = matches!(
78
+ text.as_str().into_rootfs_source(),
79
+ Ok(RootfsSource::Bind { ref path, .. } | RootfsSource::DiskImage { ref path, .. })
80
+ if path.is_relative()
81
+ );
82
+ if relative_local {
83
+ self.pin(text, "rootfs path").map(Image::Path)
84
+ } else {
85
+ Ok(Image::Text(text))
86
+ }
87
+ }
88
+
89
+ /// Pin a `from_snapshot:` value when it is a path rather than a snapshot
90
+ /// name or digest. Uses the core's own name-vs-path split: a value that
91
+ /// contains `/` or starts with `.` or `~` is a path. (The core does not
92
+ /// expand `~`; like any other relative path it is anchored as written.)
93
+ pub fn pin_snapshot(self, raw: String) -> Result<String, Error> {
94
+ let path_like = raw.contains('/') || raw.starts_with('.') || raw.starts_with('~');
95
+ if !self.local || !path_like {
96
+ return Ok(raw);
97
+ }
98
+ self.pin(raw, "snapshot path")?
99
+ .into_os_string()
100
+ .into_string()
101
+ .map_err(|path| {
102
+ error::to_ruby(MicrosandboxError::InvalidConfig(format!(
103
+ "cannot resolve snapshot path {}: the current directory is not valid UTF-8",
104
+ PathBuf::from(path).display()
105
+ )))
106
+ })
107
+ }
108
+ }
@@ -14,6 +14,7 @@ mod conv;
14
14
  mod error;
15
15
  mod exec;
16
16
  mod fs_stream;
17
+ mod host_path;
17
18
  mod image;
18
19
  mod runtime;
19
20
  mod sandbox;