kobako 0.4.0 → 0.5.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 +4 -4
- data/.release-please-manifest.json +1 -0
- data/CHANGELOG.md +29 -0
- data/Cargo.lock +1 -1
- data/README.md +0 -1
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/ext/kobako/src/lib.rs +4 -2
- data/ext/kobako/src/{wasm → runtime}/cache.rs +12 -16
- data/ext/kobako/src/runtime/capture.rs +91 -0
- data/ext/kobako/src/runtime/config.rs +26 -0
- data/ext/kobako/src/runtime/dispatch.rs +211 -0
- data/ext/kobako/src/runtime/exports.rs +51 -0
- data/ext/kobako/src/runtime/guest_mem.rs +228 -0
- data/ext/kobako/src/{wasm/host_state.rs → runtime/invocation.rs} +94 -86
- data/ext/kobako/src/runtime/trap.rs +134 -0
- data/ext/kobako/src/runtime.rs +782 -0
- data/ext/kobako/src/snapshot.rs +110 -0
- data/lib/kobako/capture.rb +11 -16
- data/lib/kobako/catalog/handles.rb +107 -0
- data/lib/kobako/catalog/namespaces.rb +99 -0
- data/lib/kobako/{snippet/table.rb → catalog/snippets.rb} +37 -62
- data/lib/kobako/catalog.rb +18 -0
- data/lib/kobako/codec/decoder.rb +13 -5
- data/lib/kobako/codec/factory.rb +12 -12
- data/lib/kobako/codec/utils.rb +56 -59
- data/lib/kobako/codec.rb +6 -3
- data/lib/kobako/errors.rb +45 -28
- data/lib/kobako/fault.rb +40 -0
- data/lib/kobako/handle.rb +4 -6
- data/lib/kobako/namespace.rb +67 -0
- data/lib/kobako/outcome.rb +31 -35
- data/lib/kobako/runtime.rb +30 -0
- data/lib/kobako/sandbox.rb +83 -72
- data/lib/kobako/sandbox_options.rb +6 -9
- data/lib/kobako/snapshot.rb +40 -0
- data/lib/kobako/snippet/binary.rb +6 -7
- data/lib/kobako/snippet/source.rb +8 -8
- data/lib/kobako/snippet.rb +7 -9
- data/lib/kobako/transport/dispatcher.rb +195 -0
- data/lib/kobako/{rpc/wire_error.rb → transport/error.rb} +7 -6
- data/lib/kobako/transport/request.rb +78 -0
- data/lib/kobako/transport/response.rb +69 -0
- data/lib/kobako/transport/run.rb +141 -0
- data/lib/kobako/transport/yield.rb +91 -0
- data/lib/kobako/transport/yielder.rb +89 -0
- data/lib/kobako/transport.rb +24 -0
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako.rb +4 -4
- data/release-please-config.json +24 -0
- data/sig/kobako/capture.rbs +0 -2
- data/sig/kobako/catalog/handles.rbs +19 -0
- data/sig/kobako/catalog/namespaces.rbs +17 -0
- data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
- data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
- data/sig/kobako/codec/decoder.rbs +2 -1
- data/sig/kobako/codec/factory.rbs +2 -2
- data/sig/kobako/codec/utils.rbs +5 -5
- data/sig/kobako/errors.rbs +7 -7
- data/sig/kobako/fault.rbs +19 -0
- data/sig/kobako/handle.rbs +2 -3
- data/sig/kobako/namespace.rbs +19 -0
- data/sig/kobako/outcome.rbs +2 -2
- data/sig/kobako/runtime.rbs +23 -0
- data/sig/kobako/sandbox.rbs +5 -8
- data/sig/kobako/snapshot.rbs +15 -0
- data/sig/kobako/transport/dispatcher.rbs +34 -0
- data/sig/kobako/transport/error.rbs +6 -0
- data/sig/kobako/transport/request.rbs +32 -0
- data/sig/kobako/transport/response.rbs +30 -0
- data/sig/kobako/transport/run.rbs +27 -0
- data/sig/kobako/transport/yield.rbs +34 -0
- data/sig/kobako/transport/yielder.rbs +21 -0
- data/sig/kobako/transport.rbs +4 -0
- metadata +48 -30
- data/ext/kobako/src/wasm/dispatch.rs +0 -162
- data/ext/kobako/src/wasm/instance.rs +0 -873
- data/ext/kobako/src/wasm.rs +0 -126
- data/lib/kobako/handle_table.rb +0 -119
- data/lib/kobako/invocation.rb +0 -143
- data/lib/kobako/rpc/dispatcher.rb +0 -171
- data/lib/kobako/rpc/envelope.rb +0 -118
- data/lib/kobako/rpc/fault.rb +0 -41
- data/lib/kobako/rpc/namespace.rb +0 -74
- data/lib/kobako/rpc/server.rb +0 -146
- data/lib/kobako/rpc.rb +0 -11
- data/lib/kobako/wasm.rb +0 -25
- data/sig/kobako/handle_table.rbs +0 -23
- data/sig/kobako/invocation.rbs +0 -25
- data/sig/kobako/rpc/dispatcher.rbs +0 -33
- data/sig/kobako/rpc/envelope.rbs +0 -51
- data/sig/kobako/rpc/fault.rbs +0 -20
- data/sig/kobako/rpc/namespace.rbs +0 -24
- data/sig/kobako/rpc/server.rbs +0 -31
- data/sig/kobako/rpc/wire_error.rbs +0 -6
- data/sig/kobako/wasm.rbs +0 -41
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
// Host-side wasmtime runtime wrapper.
|
|
2
|
+
//
|
|
3
|
+
// The only Ruby-visible class is
|
|
4
|
+
//
|
|
5
|
+
// Kobako::Runtime — wraps wasmtime::Instance + cached TypedFuncs
|
|
6
|
+
//
|
|
7
|
+
// constructed via `Kobako::Runtime.from_path(path, timeout, memory_limit,
|
|
8
|
+
// stdout_limit, stderr_limit)`.
|
|
9
|
+
// The underlying wasmtime Engine and compiled Module live in a process-scope
|
|
10
|
+
// cache (see the `cache` submodule) and never surface to Ruby (SPEC.md "Code
|
|
11
|
+
// Organization": `ext/` "exposes no Wasm engine types to the Host App or
|
|
12
|
+
// downstream gems").
|
|
13
|
+
//
|
|
14
|
+
// Module layout (per CLAUDE.md principle #2 — one responsibility per file):
|
|
15
|
+
//
|
|
16
|
+
// * `cache` — process-wide Engine + per-path Module cache and the
|
|
17
|
+
// process-singleton epoch ticker thread.
|
|
18
|
+
// * `config` — per-Runtime caps (timeout / stdout / stderr limits).
|
|
19
|
+
// * `exports` — cached `__kobako_eval` / `_run` / `_take_outcome` handles.
|
|
20
|
+
// * `invocation` — Invocation (per-Store context), StoreCell wrapper, the
|
|
21
|
+
// `MemoryLimiter` memory cap, and the trap marker
|
|
22
|
+
// types (`TimeoutTrap` / `MemoryLimitTrap`).
|
|
23
|
+
// * `dispatch` — `__kobako_dispatch` host-import dispatch helpers.
|
|
24
|
+
// * `guest_mem` — Caller-based guest linear-memory alloc / write / read.
|
|
25
|
+
// * `capture` — stdout / stderr pipe sizing + clip helpers.
|
|
26
|
+
// * `trap` — wasmtime-error → `Kobako::*` trap classification.
|
|
27
|
+
//
|
|
28
|
+
// This file owns the `Kobako::Runtime` magnus class itself (the wasmtime
|
|
29
|
+
// instance + Store + cached `Exports` + `Config`, plus the `#eval` /
|
|
30
|
+
// `#run` run path), the Ruby error-class lazy-resolvers, the `trap_err` /
|
|
31
|
+
// `timeout_err` / `memory_limit_err` / `setup_err` constructors shared by
|
|
32
|
+
// every submodule, and the Ruby init() that registers the class.
|
|
33
|
+
|
|
34
|
+
mod cache;
|
|
35
|
+
mod capture;
|
|
36
|
+
mod config;
|
|
37
|
+
mod dispatch;
|
|
38
|
+
mod exports;
|
|
39
|
+
mod guest_mem;
|
|
40
|
+
mod invocation;
|
|
41
|
+
mod trap;
|
|
42
|
+
|
|
43
|
+
use magnus::value::Lazy;
|
|
44
|
+
use magnus::{
|
|
45
|
+
function, method, prelude::*, Error as MagnusError, ExceptionClass, RModule, RString, Ruby,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
use std::cell::Cell;
|
|
49
|
+
use std::path::Path;
|
|
50
|
+
use std::time::{Duration, Instant};
|
|
51
|
+
|
|
52
|
+
use magnus::{gc, typed_data::DataTypeFunctions, value::Opaque, RArray, TypedData, Value};
|
|
53
|
+
|
|
54
|
+
use crate::snapshot::Snapshot;
|
|
55
|
+
use wasmtime::{
|
|
56
|
+
AsContextMut, Caller, Extern, Instance as WtInstance, Linker, Memory, Module as WtModule,
|
|
57
|
+
ResourceLimiter, Store as WtStore, TypedFunc,
|
|
58
|
+
};
|
|
59
|
+
use wasmtime_wasi::p1;
|
|
60
|
+
use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
|
|
61
|
+
use wasmtime_wasi::WasiCtxBuilder;
|
|
62
|
+
|
|
63
|
+
use self::cache::{cached_module, shared_engine};
|
|
64
|
+
use self::config::Config;
|
|
65
|
+
use self::exports::Exports;
|
|
66
|
+
use self::invocation::{Invocation, StoreCell};
|
|
67
|
+
|
|
68
|
+
/// Copy the bytes of +s+ into a fresh `Vec<u8>`. Single safe entry to
|
|
69
|
+
/// what would otherwise be an inline +unsafe { rstring.as_slice() }
|
|
70
|
+
/// .to_vec()+ duplicated at every host-↔-guest boundary. The borrow
|
|
71
|
+
/// does not outlive this call, so no Ruby allocation can move the
|
|
72
|
+
/// underlying RString between the borrow and the copy — the safety
|
|
73
|
+
/// invariant the inline form relied on is established once here.
|
|
74
|
+
pub(crate) fn rstring_to_vec(s: RString) -> Vec<u8> {
|
|
75
|
+
// SAFETY: see item doc.
|
|
76
|
+
unsafe { s.as_slice() }.to_vec()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Error classes (lazy-resolved from Ruby once the top-level Kobako error
|
|
81
|
+
// hierarchy is loaded by `lib/kobako/errors.rb`). The ext raises directly
|
|
82
|
+
// into the invocation-outcome taxonomy (`TrapError` and its subclasses)
|
|
83
|
+
// for run-path failures and into the construction-layer `SetupError`
|
|
84
|
+
// (and its `ModuleNotBuiltError` subclass) for `from_path` setup failures
|
|
85
|
+
// — no engine-specific intermediate layer; the Sandbox layer adds the
|
|
86
|
+
// verb prefix and lets the subclass identity flow through unchanged.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/// Resolve `Kobako::<name>` as an +ExceptionClass+ — the shared body of
|
|
90
|
+
/// every error-class `Lazy` below, which differ only in the constant
|
|
91
|
+
/// name. The constants are guaranteed present by the time any of these
|
|
92
|
+
/// lazies first resolve (`lib/kobako/errors.rb` loads the hierarchy before
|
|
93
|
+
/// the ext raises into it), so a missing constant is a build / wiring bug
|
|
94
|
+
/// and the +unwrap+ is the correct fail-fast.
|
|
95
|
+
fn kobako_error_class(ruby: &Ruby, name: &str) -> ExceptionClass {
|
|
96
|
+
let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
|
|
97
|
+
kobako.const_get(name).unwrap()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
pub(crate) static SETUP_ERROR: Lazy<ExceptionClass> =
|
|
101
|
+
Lazy::new(|ruby| kobako_error_class(ruby, "SetupError"));
|
|
102
|
+
|
|
103
|
+
pub(crate) static MODULE_NOT_BUILT_ERROR: Lazy<ExceptionClass> =
|
|
104
|
+
Lazy::new(|ruby| kobako_error_class(ruby, "ModuleNotBuiltError"));
|
|
105
|
+
|
|
106
|
+
pub(crate) static TRAP_ERROR: Lazy<ExceptionClass> =
|
|
107
|
+
Lazy::new(|ruby| kobako_error_class(ruby, "TrapError"));
|
|
108
|
+
|
|
109
|
+
pub(crate) static TIMEOUT_ERROR: Lazy<ExceptionClass> =
|
|
110
|
+
Lazy::new(|ruby| kobako_error_class(ruby, "TimeoutError"));
|
|
111
|
+
|
|
112
|
+
pub(crate) static MEMORY_LIMIT_ERROR: Lazy<ExceptionClass> =
|
|
113
|
+
Lazy::new(|ruby| kobako_error_class(ruby, "MemoryLimitError"));
|
|
114
|
+
|
|
115
|
+
pub(crate) static SANDBOX_ERROR: Lazy<ExceptionClass> =
|
|
116
|
+
Lazy::new(|ruby| kobako_error_class(ruby, "SandboxError"));
|
|
117
|
+
|
|
118
|
+
/// Build a +MagnusError+ in +class+ carrying +msg+ — the shared body of
|
|
119
|
+
/// the named +*_err+ constructors below, which differ only in which
|
|
120
|
+
/// error-class `Lazy` they target.
|
|
121
|
+
fn error_in(ruby: &Ruby, class: &Lazy<ExceptionClass>, msg: impl Into<String>) -> MagnusError {
|
|
122
|
+
MagnusError::new(ruby.get_inner(class), msg.into())
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Construct a `Kobako::TrapError` magnus error. Used for every
|
|
126
|
+
/// invocation-time wasmtime engine failure that is not a configured-cap
|
|
127
|
+
/// trap — missing exports, allocation faults, memory write/read failures.
|
|
128
|
+
/// Construction-time setup failures use `setup_err`, not this.
|
|
129
|
+
pub(crate) fn trap_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
130
|
+
error_in(ruby, &TRAP_ERROR, msg)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Construct a `Kobako::SetupError` magnus error. Used for every
|
|
134
|
+
/// construction-time failure on the `Runtime.from_path` path before any
|
|
135
|
+
/// invocation runs — unreadable artifact, bytes that are not a valid Wasm
|
|
136
|
+
/// module, or engine / linker / instantiation setup failure (docs/behavior.md
|
|
137
|
+
/// E-41). The `ModuleNotBuiltError` subclass (artifact absent, E-40) is
|
|
138
|
+
/// raised through `MODULE_NOT_BUILT_ERROR` directly.
|
|
139
|
+
pub(crate) fn setup_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
140
|
+
error_in(ruby, &SETUP_ERROR, msg)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Construct a `Kobako::TimeoutError` magnus error. Surfaces the
|
|
144
|
+
/// docs/behavior.md E-19 wall-clock cap path with the verb prefix added
|
|
145
|
+
/// by `Kobako::Sandbox#invoke!`.
|
|
146
|
+
pub(crate) fn timeout_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
147
|
+
error_in(ruby, &TIMEOUT_ERROR, msg)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// Construct a `Kobako::MemoryLimitError` magnus error. Surfaces the
|
|
151
|
+
/// docs/behavior.md E-20 linear-memory cap path with the verb prefix
|
|
152
|
+
/// added by `Kobako::Sandbox#invoke!`.
|
|
153
|
+
pub(crate) fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
154
|
+
error_in(ruby, &MEMORY_LIMIT_ERROR, msg)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Construct a `Kobako::SandboxError` magnus error. Used for the
|
|
158
|
+
/// host-side pre-call faults the SPEC attributes to the sandbox / wire
|
|
159
|
+
/// layer rather than the Wasm engine — currently the `#run` invocation
|
|
160
|
+
/// envelope reservation failure (`__kobako_alloc` returns 0,
|
|
161
|
+
/// docs/behavior.md E-31). The runtime is intact, so this must not be a
|
|
162
|
+
/// `TrapError`: no discard-and-recreate recovery is owed to the caller.
|
|
163
|
+
pub(crate) fn sandbox_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
164
|
+
error_in(ruby, &SANDBOX_ERROR, msg)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Ruby init
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
|
|
172
|
+
// Error hierarchy lives in `lib/kobako/errors.rb` (top-level
|
|
173
|
+
// `Kobako::TrapError` / `TimeoutError` / `MemoryLimitError` /
|
|
174
|
+
// `SetupError` / `ModuleNotBuiltError`). The ext raises directly into
|
|
175
|
+
// those classes through `trap_err` / `timeout_err` / `memory_limit_err`
|
|
176
|
+
// / `sandbox_err` / `setup_err` / `MODULE_NOT_BUILT_ERROR`; no
|
|
177
|
+
// intermediate hierarchy is registered.
|
|
178
|
+
|
|
179
|
+
let runtime = kobako.define_class("Runtime", ruby.class_object())?;
|
|
180
|
+
runtime.define_singleton_method("from_path", function!(Runtime::from_path, 5))?;
|
|
181
|
+
runtime.define_method("on_dispatch=", method!(Runtime::set_on_dispatch, 1))?;
|
|
182
|
+
runtime.define_method(
|
|
183
|
+
"yield_to_active_invocation",
|
|
184
|
+
method!(Runtime::yield_to_active_invocation, 1),
|
|
185
|
+
)?;
|
|
186
|
+
runtime.define_method("eval", method!(Runtime::eval, 3))?;
|
|
187
|
+
runtime.define_method("run", method!(Runtime::run, 3))?;
|
|
188
|
+
runtime.define_method("usage", method!(Runtime::usage, 0))?;
|
|
189
|
+
|
|
190
|
+
Ok(())
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#[derive(TypedData)]
|
|
194
|
+
#[magnus(class = "Kobako::Runtime", free_immediately, size, mark)]
|
|
195
|
+
pub(crate) struct Runtime {
|
|
196
|
+
inner: WtInstance,
|
|
197
|
+
store: StoreCell,
|
|
198
|
+
// Cached host-driven ABI export handles (`__kobako_eval` / `_run` /
|
|
199
|
+
// `_take_outcome`); see `Exports`. `__kobako_alloc` is not among them
|
|
200
|
+
// — only `dispatch.rs` calls it, via `Caller::get_export`.
|
|
201
|
+
exports: Exports,
|
|
202
|
+
// Wall-clock + per-channel capture caps forwarded from the Sandbox;
|
|
203
|
+
// see `Config`. Distinct from the per-invocation `memory_limit`,
|
|
204
|
+
// which lives on `Invocation` because the wasmtime `ResourceLimiter`
|
|
205
|
+
// callback consumes it from inside the wasm engine.
|
|
206
|
+
config: Config,
|
|
207
|
+
// The host-side dispatch Proc (docs/behavior.md B-12), held here only
|
|
208
|
+
// to give `DataTypeFunctions::mark` a Store-free read path so it can
|
|
209
|
+
// pin the Proc across GC. The copy the `__kobako_dispatch` import
|
|
210
|
+
// actually calls lives on `Invocation` (reached through
|
|
211
|
+
// `Caller<Invocation>`, which cannot see this struct); see
|
|
212
|
+
// `Runtime::set_on_dispatch`. Both hold the same `Copy` handle to the
|
|
213
|
+
// one pinned Proc. `Cell` is sound under the GVL (see the `unsafe impl
|
|
214
|
+
// Sync` below).
|
|
215
|
+
on_dispatch: Cell<Option<Opaque<Value>>>,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
impl DataTypeFunctions for Runtime {
|
|
219
|
+
/// Mark — and thereby pin — the host-side dispatch Proc so Ruby's GC
|
|
220
|
+
/// neither collects nor moves it while the ext holds a raw `Opaque`
|
|
221
|
+
/// copy on `Invocation` for the duration of a guest invocation.
|
|
222
|
+
/// `gc::Marker::mark` maps to `rb_gc_mark`, which pins: required because
|
|
223
|
+
/// the Invocation copy is a cached `VALUE` that compaction would
|
|
224
|
+
/// otherwise leave dangling (docs/behavior.md B-12 / B-13). Without
|
|
225
|
+
/// this the Proc has no GC root at all — sweep collects it (SIGSEGV on
|
|
226
|
+
/// the next dispatch) and compaction relocates it (dispatch lands on
|
|
227
|
+
/// the wrong receiver).
|
|
228
|
+
fn mark(&self, marker: &gc::Marker) {
|
|
229
|
+
if let Some(on_dispatch) = self.on_dispatch.get() {
|
|
230
|
+
marker.mark(on_dispatch);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// SAFETY: magnus requires `Send + Sync` on TypedData types. The added
|
|
236
|
+
// `on_dispatch: Cell<…>` makes the auto-derived `Sync` unavailable, but the
|
|
237
|
+
// same GVL invariant that justifies `StoreCell`'s assertion applies here:
|
|
238
|
+
// every access to the Cell happens under the GVL on a single thread at a
|
|
239
|
+
// time — `set_on_dispatch` from a Ruby method call, and `mark` from a GC
|
|
240
|
+
// pass that also holds the GVL. No cross-thread access to the Cell can
|
|
241
|
+
// occur. `Send` stays auto-derived (`Opaque<Value>` is `Send`).
|
|
242
|
+
unsafe impl Sync for Runtime {}
|
|
243
|
+
|
|
244
|
+
impl Runtime {
|
|
245
|
+
/// Construct an Runtime from a wasm file path, using the process-wide
|
|
246
|
+
/// shared Engine and per-path Module cache. The single Ruby-facing
|
|
247
|
+
/// constructor for `Kobako::Runtime` — Engine and Module are never
|
|
248
|
+
/// visible to Ruby.
|
|
249
|
+
///
|
|
250
|
+
/// `timeout_seconds` is the docs/behavior.md B-01 wall-clock cap in seconds
|
|
251
|
+
/// (`None` disables); `memory_limit` is the linear-memory cap in
|
|
252
|
+
/// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
|
|
253
|
+
/// are the per-channel output caps (docs/behavior.md B-01 / B-04; `None`
|
|
254
|
+
/// disables). All four are validated by the caller
|
|
255
|
+
/// (`Kobako::Sandbox`); this method only refuses non-finite or
|
|
256
|
+
/// non-positive timeouts as a defence in depth.
|
|
257
|
+
pub(crate) fn from_path(
|
|
258
|
+
path: String,
|
|
259
|
+
timeout_seconds: Option<f64>,
|
|
260
|
+
memory_limit: Option<usize>,
|
|
261
|
+
stdout_limit_bytes: Option<usize>,
|
|
262
|
+
stderr_limit_bytes: Option<usize>,
|
|
263
|
+
) -> Result<Self, MagnusError> {
|
|
264
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
265
|
+
let timeout = match timeout_seconds {
|
|
266
|
+
None => None,
|
|
267
|
+
Some(secs) if secs.is_finite() && secs > 0.0 => Some(Duration::from_secs_f64(secs)),
|
|
268
|
+
Some(secs) => {
|
|
269
|
+
// docs/behavior.md E-39: an invalid cap argument is a Host App
|
|
270
|
+
// programming error and raises `ArgumentError`, outside the
|
|
271
|
+
// construction-failure `SetupError` branch. `SandboxOptions`
|
|
272
|
+
// is the primary guard (it never lets a bad timeout reach
|
|
273
|
+
// here); this is defence-in-depth for direct `from_path` calls.
|
|
274
|
+
return Err(MagnusError::new(
|
|
275
|
+
ruby.exception_arg_error(),
|
|
276
|
+
format!("timeout must be > 0 and finite, got {secs} seconds"),
|
|
277
|
+
));
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
let engine = shared_engine()?;
|
|
282
|
+
let module = cached_module(Path::new(&path))?;
|
|
283
|
+
|
|
284
|
+
let mut store = WtStore::new(engine, Invocation::new(memory_limit));
|
|
285
|
+
store.limiter(|state: &mut Invocation| -> &mut dyn ResourceLimiter { state.limiter_mut() });
|
|
286
|
+
store.epoch_deadline_callback(trap::epoch_deadline_callback);
|
|
287
|
+
|
|
288
|
+
let store_cell = StoreCell::new(store);
|
|
289
|
+
Self::build(
|
|
290
|
+
engine,
|
|
291
|
+
&module,
|
|
292
|
+
store_cell,
|
|
293
|
+
timeout,
|
|
294
|
+
stdout_limit_bytes,
|
|
295
|
+
stderr_limit_bytes,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/// Build an `Runtime` from an engine, module, and store cell. The
|
|
300
|
+
/// store cell is moved in and ends up owned by the returned Runtime.
|
|
301
|
+
/// Wires the WASI p1 imports plus the `__kobako_dispatch` host import.
|
|
302
|
+
fn build(
|
|
303
|
+
engine: &wasmtime::Engine,
|
|
304
|
+
module: &WtModule,
|
|
305
|
+
store_cell: StoreCell,
|
|
306
|
+
timeout: Option<Duration>,
|
|
307
|
+
stdout_limit_bytes: Option<usize>,
|
|
308
|
+
stderr_limit_bytes: Option<usize>,
|
|
309
|
+
) -> Result<Self, MagnusError> {
|
|
310
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
311
|
+
let mut linker: Linker<Invocation> = Linker::new(engine);
|
|
312
|
+
|
|
313
|
+
// Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2
|
|
314
|
+
// to the MemoryOutputPipes set up before each run via
|
|
315
|
+
// `Runtime::eval`. The closure pulls a `&mut WasiP1Ctx` out of
|
|
316
|
+
// Invocation; the panic semantics live inside `Invocation::wasi_mut`
|
|
317
|
+
// so the wiring stays honest about its precondition.
|
|
318
|
+
p1::add_to_linker_sync(&mut linker, |state: &mut Invocation| state.wasi_mut())
|
|
319
|
+
.map_err(|e| setup_err(&ruby, format!("failed to set up the WASI runtime: {}", e)))?;
|
|
320
|
+
|
|
321
|
+
// `__kobako_dispatch` host import. Signature per docs/wire-codec.md
|
|
322
|
+
// § ABI Signatures:
|
|
323
|
+
// (req_ptr: i32, req_len: i32) -> i64
|
|
324
|
+
// Decodes the Request bytes, dispatches via the Ruby-side
|
|
325
|
+
// dispatch Proc (bound per-Sandbox through `Runtime#on_dispatch=`),
|
|
326
|
+
// allocates a guest buffer through `__kobako_alloc`, writes
|
|
327
|
+
// the Response bytes there, and returns the packed
|
|
328
|
+
// `(ptr<<32)|len`. The dispatcher returns 0 on any wire-layer
|
|
329
|
+
// fault (including no Proc bound); see `dispatch::handle`.
|
|
330
|
+
linker
|
|
331
|
+
.func_wrap(
|
|
332
|
+
"env",
|
|
333
|
+
"__kobako_dispatch",
|
|
334
|
+
|mut caller: Caller<'_, Invocation>, req_ptr: i32, req_len: i32| -> i64 {
|
|
335
|
+
dispatch::handle(&mut caller, req_ptr, req_len)
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
.map_err(|e| {
|
|
339
|
+
setup_err(
|
|
340
|
+
&ruby,
|
|
341
|
+
format!("failed to set up the host callback bridge: {}", e),
|
|
342
|
+
)
|
|
343
|
+
})?;
|
|
344
|
+
|
|
345
|
+
let instance = {
|
|
346
|
+
let mut store_ref = store_cell.borrow_mut();
|
|
347
|
+
linker
|
|
348
|
+
.instantiate(store_ref.as_context_mut(), module)
|
|
349
|
+
.map_err(|e| trap::instantiate_err(&ruby, e))?
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
let exports = Exports::resolve(&instance, &store_cell);
|
|
353
|
+
|
|
354
|
+
Ok(Self {
|
|
355
|
+
inner: instance,
|
|
356
|
+
store: store_cell,
|
|
357
|
+
exports,
|
|
358
|
+
config: Config {
|
|
359
|
+
timeout,
|
|
360
|
+
stdout_limit_bytes,
|
|
361
|
+
stderr_limit_bytes,
|
|
362
|
+
},
|
|
363
|
+
on_dispatch: Cell::new(None),
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/// Register the Ruby-side dispatch +Proc+ on the active Invocation.
|
|
368
|
+
/// Bound to Ruby as +Kobako::Runtime#on_dispatch=+. From this point on,
|
|
369
|
+
/// every +__kobako_dispatch+ host import invocation calls the Proc
|
|
370
|
+
/// with the request bytes and writes the returned Response bytes back
|
|
371
|
+
/// into guest memory (docs/behavior.md B-12).
|
|
372
|
+
pub(crate) fn set_on_dispatch(&self, proc_value: Value) -> Result<(), MagnusError> {
|
|
373
|
+
let on_dispatch = Opaque::from(proc_value);
|
|
374
|
+
// Write both copies of the one Proc handle: the `on_dispatch` Cell
|
|
375
|
+
// gives `DataTypeFunctions::mark` a Store-free read path to pin the
|
|
376
|
+
// Proc across GC, and the `Invocation` copy is what the
|
|
377
|
+
// `__kobako_dispatch` import reads through `Caller<Invocation>`.
|
|
378
|
+
// `mark` cannot reach the Invocation copy itself — the Store is held
|
|
379
|
+
// `borrow_mut` for the whole guest call, exactly when GC may fire
|
|
380
|
+
// during dispatch — so the Cell is the dedicated GC-rooting anchor.
|
|
381
|
+
self.on_dispatch.set(Some(on_dispatch));
|
|
382
|
+
self.store
|
|
383
|
+
.borrow_mut()
|
|
384
|
+
.data_mut()
|
|
385
|
+
.bind_on_dispatch(on_dispatch);
|
|
386
|
+
Ok(())
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Synchronously re-enter the guest's `__kobako_yield_to_block`
|
|
390
|
+
/// export with `args_bytes` as the yield-arguments payload, and
|
|
391
|
+
/// return the YieldResponse bytes the guest produced (B-24).
|
|
392
|
+
///
|
|
393
|
+
/// Bound to Ruby as +Kobako::Runtime#yield_to_active_invocation+.
|
|
394
|
+
/// Recovers the dispatcher's `&mut Caller` from the per-thread
|
|
395
|
+
/// Invocation slot (SPEC.md Single-Invocation Slot) — the host is
|
|
396
|
+
/// already inside a `__kobako_dispatch` callback, so the Caller
|
|
397
|
+
/// parked on the Rust stack is the same one the Sandbox-level
|
|
398
|
+
/// `#eval` / `#run` is driving. Invoked from the host-side yield
|
|
399
|
+
/// proxy that the dispatcher hands to Service methods (B-23 / B-24);
|
|
400
|
+
/// raises +Kobako::TrapError+ when called outside an active dispatch
|
|
401
|
+
/// frame, or when any of the underlying allocation / write / call /
|
|
402
|
+
/// read steps fails.
|
|
403
|
+
pub(crate) fn yield_to_active_invocation(
|
|
404
|
+
&self,
|
|
405
|
+
args_bytes: RString,
|
|
406
|
+
) -> Result<RString, MagnusError> {
|
|
407
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
408
|
+
let _ = self; // The Caller carries its own Store; `self` is only
|
|
409
|
+
// a marker that the method belongs to an Runtime.
|
|
410
|
+
|
|
411
|
+
let bytes = rstring_to_vec(args_bytes);
|
|
412
|
+
let Some(caller) = dispatch::current_caller() else {
|
|
413
|
+
return Err(trap_err(
|
|
414
|
+
&ruby,
|
|
415
|
+
"yield_to_active_invocation called outside an active Sandbox dispatch frame",
|
|
416
|
+
));
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
let resp_bytes =
|
|
420
|
+
guest_mem::drive_yield(caller, &bytes).map_err(|msg| trap_err(&ruby, msg))?;
|
|
421
|
+
Ok(ruby.str_from_slice(&resp_bytes))
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// -----------------------------------------------------------------
|
|
425
|
+
// Run-path methods. Each method is best-effort — it raises a Ruby
|
|
426
|
+
// `Kobako::TrapError` when the corresponding export is missing or
|
|
427
|
+
// fails so the Sandbox layer can map errors to the three-class
|
|
428
|
+
// taxonomy.
|
|
429
|
+
// -----------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/// Execute one guest invocation (`__kobako_eval` — one-shot source)
|
|
432
|
+
/// and return a `Snapshot` bundling every per-invocation observable.
|
|
433
|
+
///
|
|
434
|
+
/// Rebuilds the WASI context with fresh stdin / stdout / stderr pipes
|
|
435
|
+
/// (the three-frame stdin protocol carries +preamble+, +source+, then
|
|
436
|
+
/// +snippets+ — docs/wire-codec.md § Invocation channels), then
|
|
437
|
+
/// invokes `__kobako_eval`. Per-invocation caps (docs/behavior.md
|
|
438
|
+
/// B-01) are primed here: the wall-clock deadline is stamped into
|
|
439
|
+
/// `Invocation` and the epoch deadline is set to fire at the next
|
|
440
|
+
/// ticker tick; the memory-cap limiter is already wired.
|
|
441
|
+
///
|
|
442
|
+
/// On a wasmtime trap the configured-cap path raises
|
|
443
|
+
/// `Kobako::TimeoutError` / `Kobako::MemoryLimitError`; everything
|
|
444
|
+
/// else raises `Kobako::TrapError`. On success the Snapshot carries
|
|
445
|
+
/// the OUTCOME_BUFFER bytes, the per-channel stdout / stderr captures
|
|
446
|
+
/// with their truncation flags, and the B-35 usage figures.
|
|
447
|
+
pub(crate) fn eval(
|
|
448
|
+
&self,
|
|
449
|
+
preamble: RString,
|
|
450
|
+
source: RString,
|
|
451
|
+
snippets: RString,
|
|
452
|
+
) -> Result<Snapshot, MagnusError> {
|
|
453
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
454
|
+
let eval = require_export(&ruby, self.exports.eval.as_ref())?;
|
|
455
|
+
self.refresh_wasi(&[
|
|
456
|
+
rstring_to_vec(preamble),
|
|
457
|
+
rstring_to_vec(source),
|
|
458
|
+
rstring_to_vec(snippets),
|
|
459
|
+
]);
|
|
460
|
+
self.call_with_caps(eval, ())
|
|
461
|
+
.map_err(|e| trap::call_err(&ruby, e))?;
|
|
462
|
+
self.build_snapshot(&ruby)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/// Execute one entrypoint dispatch (`__kobako_run`) and return a
|
|
466
|
+
/// `Snapshot` bundling every per-invocation observable.
|
|
467
|
+
///
|
|
468
|
+
/// Rebuilds the WASI context with the two-frame stdin protocol
|
|
469
|
+
/// (preamble + snippets; no user source frame — docs/wire-codec.md
|
|
470
|
+
/// § Invocation channels), copies +envelope+ bytes into guest linear
|
|
471
|
+
/// memory via `__kobako_alloc`, and calls `__kobako_run(env_ptr,
|
|
472
|
+
/// env_len)`. Per-invocation cap semantics match `Runtime::eval`.
|
|
473
|
+
/// Raises +Kobako::TrapError+ ("alloc returned 0") when guest
|
|
474
|
+
/// allocation fails (docs/behavior.md E-31).
|
|
475
|
+
pub(crate) fn run(
|
|
476
|
+
&self,
|
|
477
|
+
preamble: RString,
|
|
478
|
+
snippets: RString,
|
|
479
|
+
envelope: RString,
|
|
480
|
+
) -> Result<Snapshot, MagnusError> {
|
|
481
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
482
|
+
let run = require_export(&ruby, self.exports.run.as_ref())?;
|
|
483
|
+
self.refresh_wasi(&[rstring_to_vec(preamble), rstring_to_vec(snippets)]);
|
|
484
|
+
let (env_ptr, env_len) = self.write_envelope(&ruby, envelope)?;
|
|
485
|
+
self.call_with_caps(run, (env_ptr, env_len))
|
|
486
|
+
.map_err(|e| trap::call_err(&ruby, e))?;
|
|
487
|
+
self.build_snapshot(&ruby)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/// Collect every per-invocation observable into a fresh `Snapshot`.
|
|
491
|
+
/// Called from the run-path methods after the guest export returns
|
|
492
|
+
/// successfully: drains OUTCOME_BUFFER via `__kobako_take_outcome`,
|
|
493
|
+
/// snapshots the per-channel stdout / stderr pipes (clipped to their
|
|
494
|
+
/// caps), and reads B-35 `wall_time` / `memory_peak` from Invocation.
|
|
495
|
+
fn build_snapshot(&self, ruby: &Ruby) -> Result<Snapshot, MagnusError> {
|
|
496
|
+
let return_bytes = self.fetch_outcome_bytes(ruby)?;
|
|
497
|
+
let (stdout_raw, stderr_raw, wall_time, memory_peak) = {
|
|
498
|
+
let state = self.store.borrow();
|
|
499
|
+
let data = state.data();
|
|
500
|
+
(
|
|
501
|
+
data.stdout_bytes(),
|
|
502
|
+
data.stderr_bytes(),
|
|
503
|
+
data.wall_time(),
|
|
504
|
+
data.memory_peak(),
|
|
505
|
+
)
|
|
506
|
+
};
|
|
507
|
+
let (stdout_visible, stdout_truncated) =
|
|
508
|
+
capture::clip_capture(&stdout_raw, self.config.stdout_limit_bytes);
|
|
509
|
+
let stdout_bytes = stdout_visible.to_vec();
|
|
510
|
+
let (stderr_visible, stderr_truncated) =
|
|
511
|
+
capture::clip_capture(&stderr_raw, self.config.stderr_limit_bytes);
|
|
512
|
+
let stderr_bytes = stderr_visible.to_vec();
|
|
513
|
+
Ok(Snapshot::new(
|
|
514
|
+
return_bytes,
|
|
515
|
+
stdout_bytes,
|
|
516
|
+
stdout_truncated,
|
|
517
|
+
stderr_bytes,
|
|
518
|
+
stderr_truncated,
|
|
519
|
+
wall_time,
|
|
520
|
+
memory_peak,
|
|
521
|
+
))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/// Return the docs/behavior.md B-35 per-last-invocation usage as a
|
|
525
|
+
/// Ruby 2-tuple `[wall_time, memory_peak]`. The element order
|
|
526
|
+
/// matches the `Kobako::Usage` field order declared in
|
|
527
|
+
/// `lib/kobako/usage.rb`; reorder both sides together if the field
|
|
528
|
+
/// list ever grows.
|
|
529
|
+
///
|
|
530
|
+
/// * `wall_time` (Float seconds) — the wall-clock duration the
|
|
531
|
+
/// most recent invocation spent inside the guest export call.
|
|
532
|
+
/// Bracket opens in `Runtime::prime_caps` and closes in
|
|
533
|
+
/// `Runtime::disarm_caps`, so the value mirrors the
|
|
534
|
+
/// `timeout` deadline accounting and excludes everything that
|
|
535
|
+
/// runs after the guest export returns — the post-export
|
|
536
|
+
/// `OUTCOME_BUFFER` fetch and decode, plus stdout / stderr
|
|
537
|
+
/// capture readout. `0.0` before the first invocation.
|
|
538
|
+
/// * `memory_peak` (Integer bytes) — the high-water mark of the
|
|
539
|
+
/// per-invocation `memory.grow` delta past the linear-memory
|
|
540
|
+
/// size captured at invocation entry. `0` before the first
|
|
541
|
+
/// invocation.
|
|
542
|
+
///
|
|
543
|
+
/// Packing both readers into one ext call mirrors the combined
|
|
544
|
+
/// stdout / stderr readout in `Runtime::build_snapshot`: one
|
|
545
|
+
/// `store.borrow()` per readout and a single magnus binding to
|
|
546
|
+
/// extend when B-35's field list grows past two.
|
|
547
|
+
pub(crate) fn usage(&self) -> Result<RArray, MagnusError> {
|
|
548
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
549
|
+
let state = self.store.borrow();
|
|
550
|
+
let data = state.data();
|
|
551
|
+
let arr = ruby.ary_new_capa(2);
|
|
552
|
+
arr.push(data.wall_time().as_secs_f64())?;
|
|
553
|
+
arr.push(data.memory_peak())?;
|
|
554
|
+
Ok(arr)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// -----------------------------------------------------------------
|
|
558
|
+
// Private helpers.
|
|
559
|
+
// -----------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
/// Run one guest export call inside the per-invocation cap window:
|
|
562
|
+
/// `Runtime::prime_caps` before, `Runtime::disarm_caps` after —
|
|
563
|
+
/// the shared bracket for both run-path exports (`__kobako_eval` /
|
|
564
|
+
/// `__kobako_run`). Disarm runs whether the call returns or traps, so
|
|
565
|
+
/// the docs/behavior.md B-35 `wall_time` bracket and the E-20 memory
|
|
566
|
+
/// cap always close — that close-on-trap guarantee is the reason this
|
|
567
|
+
/// bracket lives in one place rather than inline at each call site.
|
|
568
|
+
/// The wasmtime trap is returned unmapped; each caller wraps it
|
|
569
|
+
/// through `trap::call_err` for its own error context.
|
|
570
|
+
fn call_with_caps<Params, Results>(
|
|
571
|
+
&self,
|
|
572
|
+
export: &TypedFunc<Params, Results>,
|
|
573
|
+
params: Params,
|
|
574
|
+
) -> Result<Results, wasmtime::Error>
|
|
575
|
+
where
|
|
576
|
+
Params: wasmtime::WasmParams,
|
|
577
|
+
Results: wasmtime::WasmResults,
|
|
578
|
+
{
|
|
579
|
+
self.prime_caps();
|
|
580
|
+
let result = {
|
|
581
|
+
let mut store_ref = self.store.borrow_mut();
|
|
582
|
+
export.call(store_ref.as_context_mut(), params)
|
|
583
|
+
};
|
|
584
|
+
self.disarm_caps();
|
|
585
|
+
result
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/// Stamp the per-invocation wall-clock deadline into `Invocation`
|
|
589
|
+
/// and prime the wasmtime epoch deadline so the next ticker tick
|
|
590
|
+
/// wakes the epoch-deadline callback. When `timeout` is disabled,
|
|
591
|
+
/// the deadline is set far enough in the future that the callback
|
|
592
|
+
/// effectively never fires.
|
|
593
|
+
///
|
|
594
|
+
/// Also captures the current linear-memory size as the baseline
|
|
595
|
+
/// for the docs/behavior.md E-20 per-invocation memory delta cap.
|
|
596
|
+
/// The mruby image's declared initial allocation and the high-water
|
|
597
|
+
/// mark left by prior invocations on the same Sandbox are folded
|
|
598
|
+
/// into the baseline rather than the budget — only `memory.grow`
|
|
599
|
+
/// past +baseline+ counts against `memory_limit`.
|
|
600
|
+
///
|
|
601
|
+
/// Also stamps the wall-clock entry instant for the
|
|
602
|
+
/// docs/behavior.md B-35 `wall_time` measurement. The bracket
|
|
603
|
+
/// closes in `Runtime::disarm_caps` so it matches the
|
|
604
|
+
/// `timeout` deadline window and excludes `OUTCOME_BUFFER`
|
|
605
|
+
/// decoding and stdout / stderr capture readout.
|
|
606
|
+
fn prime_caps(&self) {
|
|
607
|
+
let mut store_ref = self.store.borrow_mut();
|
|
608
|
+
match self.config.timeout {
|
|
609
|
+
Some(timeout) => {
|
|
610
|
+
let deadline = Instant::now() + timeout;
|
|
611
|
+
store_ref.data_mut().set_deadline(Some(deadline));
|
|
612
|
+
store_ref.set_epoch_deadline(1);
|
|
613
|
+
}
|
|
614
|
+
None => {
|
|
615
|
+
store_ref.data_mut().set_deadline(None);
|
|
616
|
+
store_ref.set_epoch_deadline(u64::MAX);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
let baseline = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
620
|
+
Some(Extern::Memory(m)) => m.data_size(store_ref.as_context_mut()),
|
|
621
|
+
_ => 0,
|
|
622
|
+
};
|
|
623
|
+
store_ref.data_mut().arm_memory_cap(baseline);
|
|
624
|
+
store_ref.data_mut().start_wall_clock();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/// Drop the memory cap as soon as the guest call returns so that
|
|
628
|
+
/// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
|
|
629
|
+
/// which can grow guest memory transiently) is not attributed to
|
|
630
|
+
/// the user script. Also closes the docs/behavior.md B-35
|
|
631
|
+
/// `wall_time` bracket opened by `Runtime::prime_caps`. Paired
|
|
632
|
+
/// with `Runtime::prime_caps`.
|
|
633
|
+
fn disarm_caps(&self) {
|
|
634
|
+
let mut store_ref = self.store.borrow_mut();
|
|
635
|
+
store_ref.data_mut().stop_wall_clock();
|
|
636
|
+
store_ref.data_mut().disarm_memory_cap();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/// Allocate a +len+-byte buffer in guest linear memory via
|
|
640
|
+
/// `__kobako_alloc`, copy +envelope+ into it, and return +(ptr, len)+
|
|
641
|
+
/// as +i32+ values matching the `__kobako_run(env_ptr, env_len)` ABI.
|
|
642
|
+
/// Raises +Kobako::TrapError+ when the allocation hook is missing or
|
|
643
|
+
/// itself traps, and +Kobako::SandboxError+ when the hook runs but
|
|
644
|
+
/// cannot reserve the buffer (`__kobako_alloc` returns 0,
|
|
645
|
+
/// docs/behavior.md E-31) — an intact runtime, not an engine fault.
|
|
646
|
+
fn write_envelope(&self, ruby: &Ruby, envelope: RString) -> Result<(i32, i32), MagnusError> {
|
|
647
|
+
let bytes = rstring_to_vec(envelope);
|
|
648
|
+
let len_i32 =
|
|
649
|
+
guest_mem::checked_payload_len(bytes.len()).map_err(|msg| trap_err(ruby, msg))?;
|
|
650
|
+
|
|
651
|
+
let mut store_ref = self.store.borrow_mut();
|
|
652
|
+
let alloc: TypedFunc<u32, u32> = self
|
|
653
|
+
.inner
|
|
654
|
+
.get_typed_func(store_ref.as_context_mut(), "__kobako_alloc")
|
|
655
|
+
.map_err(|_| trap_err(ruby, SANDBOX_RUNTIME_MISSING_HOOKS))?;
|
|
656
|
+
let ptr = alloc
|
|
657
|
+
.call(store_ref.as_context_mut(), bytes.len() as u32)
|
|
658
|
+
.map_err(|e| trap_err(ruby, format!("failed to allocate input buffer: {}", e)))?;
|
|
659
|
+
if ptr == 0 {
|
|
660
|
+
return Err(sandbox_err(
|
|
661
|
+
ruby,
|
|
662
|
+
"could not allocate input buffer (out of memory)",
|
|
663
|
+
));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
let memory: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
667
|
+
Some(Extern::Memory(m)) => m,
|
|
668
|
+
_ => return Err(trap_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
|
|
669
|
+
};
|
|
670
|
+
let data = memory.data_mut(store_ref.as_context_mut());
|
|
671
|
+
let range = guest_mem::guest_buffer_range(ptr as usize, bytes.len(), data.len())
|
|
672
|
+
.map_err(|msg| trap_err(ruby, msg))?;
|
|
673
|
+
data[range].copy_from_slice(&bytes);
|
|
674
|
+
|
|
675
|
+
Ok((ptr as i32, len_i32))
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/// Rebuild the WASI context with fresh stdin (carrying every frame in
|
|
679
|
+
/// +frames+, each prefixed by its 4-byte big-endian u32 length —
|
|
680
|
+
/// docs/wire-codec.md § Invocation channels) plus fresh stdout / stderr
|
|
681
|
+
/// pipes. Called at the top of every guest invocation: +#eval+ passes
|
|
682
|
+
/// three frames (preamble, source, snippets), +#run+ passes two
|
|
683
|
+
/// (preamble, snippets — the invocation envelope arrives via linear
|
|
684
|
+
/// memory instead). Each output pipe is sized at `cap + 1` so
|
|
685
|
+
/// `capture::clip_capture` can distinguish "wrote exactly cap
|
|
686
|
+
/// bytes" from "exceeded cap"; uncapped channels fall back
|
|
687
|
+
/// to `usize::MAX` and rely on `memory_limit` (docs/behavior.md E-20)
|
|
688
|
+
/// for the real ceiling.
|
|
689
|
+
fn refresh_wasi(&self, frames: &[Vec<u8>]) {
|
|
690
|
+
let total: usize = frames.iter().map(|f| 4 + f.len()).sum();
|
|
691
|
+
let mut stdin_content: Vec<u8> = Vec::with_capacity(total);
|
|
692
|
+
for frame in frames {
|
|
693
|
+
stdin_content.extend_from_slice(&(frame.len() as u32).to_be_bytes());
|
|
694
|
+
stdin_content.extend_from_slice(frame);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
let stdin_pipe = MemoryInputPipe::new(stdin_content);
|
|
698
|
+
let stdout_pipe =
|
|
699
|
+
MemoryOutputPipe::new(capture::pipe_capacity(self.config.stdout_limit_bytes));
|
|
700
|
+
let stderr_pipe =
|
|
701
|
+
MemoryOutputPipe::new(capture::pipe_capacity(self.config.stderr_limit_bytes));
|
|
702
|
+
|
|
703
|
+
let mut builder = WasiCtxBuilder::new();
|
|
704
|
+
builder.stdin(stdin_pipe);
|
|
705
|
+
builder.stdout(stdout_pipe.clone());
|
|
706
|
+
builder.stderr(stderr_pipe.clone());
|
|
707
|
+
let wasi = builder.build_p1();
|
|
708
|
+
|
|
709
|
+
self.store
|
|
710
|
+
.borrow_mut()
|
|
711
|
+
.data_mut()
|
|
712
|
+
.install_wasi(wasi, stdout_pipe, stderr_pipe);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/// Invoke `__kobako_take_outcome`, decode the packed +(ptr<<32)|len+
|
|
716
|
+
/// u64, and copy the OUTCOME_BUFFER slice out of guest memory. Raises
|
|
717
|
+
/// `Kobako::TrapError` when the export is missing, +len+ exceeds the
|
|
718
|
+
/// 16 MiB single-dispatch cap, the +ptr+/+len+ arithmetic overflows,
|
|
719
|
+
/// the slice falls outside live memory, or the `memory` export itself
|
|
720
|
+
/// is absent.
|
|
721
|
+
fn fetch_outcome_bytes(&self, ruby: &Ruby) -> Result<Vec<u8>, MagnusError> {
|
|
722
|
+
let take = require_export(ruby, self.exports.take_outcome.as_ref())?;
|
|
723
|
+
|
|
724
|
+
let mut store_ref = self.store.borrow_mut();
|
|
725
|
+
let packed = take
|
|
726
|
+
.call(store_ref.as_context_mut(), ())
|
|
727
|
+
.map_err(|e| trap_err(ruby, format!("failed to read the Sandbox result: {}", e)))?;
|
|
728
|
+
let (ptr, len) = guest_mem::unpack_outcome_packed(packed);
|
|
729
|
+
if len > guest_mem::MAX_DISPATCH_PAYLOAD {
|
|
730
|
+
return Err(trap_err(ruby, "result payload exceeds the 16 MiB limit"));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
734
|
+
Some(Extern::Memory(m)) => m,
|
|
735
|
+
_ => return Err(trap_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
|
|
736
|
+
};
|
|
737
|
+
let data = mem.data(store_ref.as_context_mut());
|
|
738
|
+
let range = guest_mem::guest_buffer_range(ptr, len, data.len()).map_err(|msg| {
|
|
739
|
+
trap_err(
|
|
740
|
+
ruby,
|
|
741
|
+
format!("the Sandbox result is out of bounds: {}", msg),
|
|
742
|
+
)
|
|
743
|
+
})?;
|
|
744
|
+
Ok(data[range].to_vec())
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/// User-facing message for the "Sandbox runtime is missing one of the
|
|
749
|
+
/// internal Kobako hooks" failure mode. Phrased in caller vocabulary —
|
|
750
|
+
/// the underlying ABI symbol names (`__kobako_alloc`, `__kobako_eval`,
|
|
751
|
+
/// `__kobako_take_outcome`) are not actionable to callers, and the
|
|
752
|
+
/// gem itself raises this error so a self-reference like "matches the
|
|
753
|
+
/// kobako gem version" reads as third-person. The actionable
|
|
754
|
+
/// diagnosis is "your data/kobako.wasm is out of sync; rebuild it".
|
|
755
|
+
const SANDBOX_RUNTIME_MISSING_HOOKS: &str = "Sandbox runtime is missing required hooks; \
|
|
756
|
+
rebuild data/kobako.wasm against the installed version";
|
|
757
|
+
|
|
758
|
+
/// User-facing message for the "the loaded Wasm module is not a
|
|
759
|
+
/// Kobako-shaped runtime at all" failure mode (no linear memory
|
|
760
|
+
/// export). Same phrasing philosophy as
|
|
761
|
+
/// `SANDBOX_RUNTIME_MISSING_HOOKS`.
|
|
762
|
+
const SANDBOX_RUNTIME_NOT_KOBAKO: &str =
|
|
763
|
+
"the loaded Wasm module is not a Kobako-compatible runtime";
|
|
764
|
+
|
|
765
|
+
/// Return the cached +TypedFunc+ for an ABI export, or raise
|
|
766
|
+
/// +Kobako::TrapError+ when the option is +None+. Both run-path
|
|
767
|
+
/// methods (+#eval+, +#run+) plus the +build_snapshot+ readout that
|
|
768
|
+
/// drains +OUTCOME_BUFFER+ share the same "missing export → Ruby
|
|
769
|
+
/// error" boilerplate; this helper collapses those sites onto one
|
|
770
|
+
/// safe entry. The user-facing message is intentionally export-
|
|
771
|
+
/// agnostic (see `SANDBOX_RUNTIME_MISSING_HOOKS`) — the ABI symbol
|
|
772
|
+
/// name is not actionable to callers, so it is not threaded in.
|
|
773
|
+
fn require_export<'a, Params, Results>(
|
|
774
|
+
ruby: &Ruby,
|
|
775
|
+
export: Option<&'a TypedFunc<Params, Results>>,
|
|
776
|
+
) -> Result<&'a TypedFunc<Params, Results>, MagnusError>
|
|
777
|
+
where
|
|
778
|
+
Params: wasmtime::WasmParams,
|
|
779
|
+
Results: wasmtime::WasmResults,
|
|
780
|
+
{
|
|
781
|
+
export.ok_or_else(|| trap_err(ruby, SANDBOX_RUNTIME_MISSING_HOOKS))
|
|
782
|
+
}
|