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
|
@@ -1,873 +0,0 @@
|
|
|
1
|
-
//! `Kobako::Wasm::Instance` — the only Ruby-visible wasmtime wrapper.
|
|
2
|
-
//!
|
|
3
|
-
//! Constructed via [`Instance::from_path`]; the wasmtime [`Engine`] and
|
|
4
|
-
//! compiled [`Module`] are owned by the [`super::cache`] singletons and
|
|
5
|
-
//! never surface to Ruby. The instance wraps a [`StoreCell`] (interior-
|
|
6
|
-
//! mutability around `wasmtime::Store<HostState>`) plus three cached
|
|
7
|
-
//! [`TypedFunc`] handles for the docs/wire-codec.md ABI exports used by
|
|
8
|
-
//! the host-driven run path.
|
|
9
|
-
//!
|
|
10
|
-
//! The Ruby surface intentionally exposes intent, not the underlying ABI
|
|
11
|
-
//! (SPEC.md "Code Organization"). The length-prefixed stdin frame
|
|
12
|
-
//! protocol (three frames for `#eval`: preamble + source + snippets;
|
|
13
|
-
//! two for `#run`: preamble + snippets), packed-u64 outcome encoding,
|
|
14
|
-
//! and the `__kobako_eval` / `__kobako_run` / `__kobako_alloc` /
|
|
15
|
-
//! `__kobako_take_outcome` exports are all wrapped inside
|
|
16
|
-
//! [`Instance::eval`], [`Instance::run`], and [`Instance::outcome`];
|
|
17
|
-
//! Ruby callers see only `#eval(preamble, source, snippets)`,
|
|
18
|
-
//! `#run(preamble, snippets, envelope)`, `#stdout`, `#stderr`,
|
|
19
|
-
//! `#outcome!`, and `#server=`.
|
|
20
|
-
//!
|
|
21
|
-
//! WASI stdout/stderr capture (docs/behavior.md B-04): wasmtime-wasi p1
|
|
22
|
-
//! bindings route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`]
|
|
23
|
-
//! instances rebuilt at the start of every [`Instance::eval`] /
|
|
24
|
-
//! [`Instance::run`]. The per-channel cap is enforced directly on the
|
|
25
|
-
//! pipe — the pipe is sized at `cap + 1` so a guest that writes exactly
|
|
26
|
-
//! `cap` bytes is distinguishable from one that exceeded the cap, and
|
|
27
|
-
//! `#stdout` / `#stderr` slice the captured bytes back to `cap` before
|
|
28
|
-
//! returning them paired with a truncation flag. Uncapped channels
|
|
29
|
-
//! (`None`) build the pipe at `usize::MAX`; `memory_limit` provides
|
|
30
|
-
//! the real upper bound in that case.
|
|
31
|
-
//!
|
|
32
|
-
//! Per-run cap enforcement (docs/behavior.md B-01, E-19, E-20): every
|
|
33
|
-
//! Store installs an epoch-deadline callback for wall-clock timeout and
|
|
34
|
-
//! a [`ResourceLimiter`] for the linear-memory cap. Wasmtime turns
|
|
35
|
-
//! limiter / callback errors into traps; the run-path methods downcast
|
|
36
|
-
//! the trap source to surface as `Kobako::Wasm::TimeoutError` or
|
|
37
|
-
//! `Kobako::Wasm::MemoryLimitError` so the `Sandbox` layer can map them
|
|
38
|
-
//! to the named `Kobako::TrapError` subclasses.
|
|
39
|
-
//!
|
|
40
|
-
//! [`Engine`]: wasmtime::Engine
|
|
41
|
-
//! [`Module`]: wasmtime::Module
|
|
42
|
-
//! [`TypedFunc`]: wasmtime::TypedFunc
|
|
43
|
-
//! [`MemoryOutputPipe`]: wasmtime_wasi::p2::pipe::MemoryOutputPipe
|
|
44
|
-
//! [`ResourceLimiter`]: wasmtime::ResourceLimiter
|
|
45
|
-
|
|
46
|
-
use std::path::Path;
|
|
47
|
-
use std::time::{Duration, Instant};
|
|
48
|
-
|
|
49
|
-
use magnus::{value::Opaque, Error as MagnusError, RArray, RString, Ruby, Value};
|
|
50
|
-
use wasmtime::{
|
|
51
|
-
AsContextMut, Caller, Extern, Instance as WtInstance, Linker, Memory, Module as WtModule,
|
|
52
|
-
ResourceLimiter, Store as WtStore, StoreContextMut, TypedFunc, UpdateDeadline,
|
|
53
|
-
};
|
|
54
|
-
use wasmtime_wasi::p1;
|
|
55
|
-
use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
|
|
56
|
-
use wasmtime_wasi::WasiCtxBuilder;
|
|
57
|
-
|
|
58
|
-
use super::cache::{cached_module, shared_engine};
|
|
59
|
-
use super::dispatch;
|
|
60
|
-
use super::host_state::{HostState, MemoryLimitTrap, StoreCell, TimeoutTrap};
|
|
61
|
-
use super::{memory_limit_err, rstring_to_vec, timeout_err, wasm_err};
|
|
62
|
-
|
|
63
|
-
#[magnus::wrap(class = "Kobako::Wasm::Instance", free_immediately, size)]
|
|
64
|
-
pub(crate) struct Instance {
|
|
65
|
-
inner: WtInstance,
|
|
66
|
-
store: StoreCell,
|
|
67
|
-
// Cached TypedFunc handles for the two host-driven ABI exports.
|
|
68
|
-
// Optional because test fixtures (a minimal "ping" module) need not
|
|
69
|
-
// provide them; real kobako.wasm always does, and the run-path methods
|
|
70
|
-
// raise a Ruby `Kobako::Wasm::Error` when an export is missing.
|
|
71
|
-
//
|
|
72
|
-
// `__kobako_alloc` is NOT cached here — only `dispatch.rs` calls it,
|
|
73
|
-
// and it does so through `Caller::get_export` on the wasmtime side.
|
|
74
|
-
eval: Option<TypedFunc<(), ()>>,
|
|
75
|
-
run: Option<TypedFunc<(i32, i32), ()>>,
|
|
76
|
-
take_outcome: Option<TypedFunc<(), u64>>,
|
|
77
|
-
// Wall-clock cap for one guest `#run` (docs/behavior.md B-01); `None` disables
|
|
78
|
-
// the cap. Translated into an `Instant`-based deadline stamped into
|
|
79
|
-
// [`HostState`] at the top of every `Instance::eval`.
|
|
80
|
-
timeout: Option<Duration>,
|
|
81
|
-
// Per-channel byte caps for guest stdout / stderr capture
|
|
82
|
-
// (docs/behavior.md B-01 / B-04). `None` disables the cap on that
|
|
83
|
-
// channel. Read by
|
|
84
|
-
// [`Instance::refresh_wasi`] to size the MemoryOutputPipe and by
|
|
85
|
-
// [`Instance::stdout`] / [`Instance::stderr`] to compute the
|
|
86
|
-
// truncation flag. See the module-level note above for the `cap + 1`
|
|
87
|
-
// sizing rationale. Unlike `memory_limit` (which lives on
|
|
88
|
-
// [`HostState`] because the wasmtime [`ResourceLimiter`] callback
|
|
89
|
-
// consumes it from within the wasm engine), these caps are read only
|
|
90
|
-
// by Instance methods, so they live on Instance itself.
|
|
91
|
-
stdout_limit_bytes: Option<usize>,
|
|
92
|
-
stderr_limit_bytes: Option<usize>,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
impl Instance {
|
|
96
|
-
/// Construct an Instance from a wasm file path, using the process-wide
|
|
97
|
-
/// shared Engine and per-path Module cache. The single Ruby-facing
|
|
98
|
-
/// constructor for `Kobako::Wasm::Instance` — Engine and Module are
|
|
99
|
-
/// never visible to Ruby.
|
|
100
|
-
///
|
|
101
|
-
/// `timeout_seconds` is the docs/behavior.md B-01 wall-clock cap in seconds
|
|
102
|
-
/// (`None` disables); `memory_limit` is the linear-memory cap in
|
|
103
|
-
/// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
|
|
104
|
-
/// are the per-channel output caps (docs/behavior.md B-01 / B-04; `None`
|
|
105
|
-
/// disables). All four are validated by the caller
|
|
106
|
-
/// (`Kobako::Sandbox`); this method only refuses non-finite or
|
|
107
|
-
/// non-positive timeouts as a defence in depth.
|
|
108
|
-
pub(crate) fn from_path(
|
|
109
|
-
path: String,
|
|
110
|
-
timeout_seconds: Option<f64>,
|
|
111
|
-
memory_limit: Option<usize>,
|
|
112
|
-
stdout_limit_bytes: Option<usize>,
|
|
113
|
-
stderr_limit_bytes: Option<usize>,
|
|
114
|
-
) -> Result<Self, MagnusError> {
|
|
115
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
116
|
-
let timeout = match timeout_seconds {
|
|
117
|
-
None => None,
|
|
118
|
-
Some(secs) if secs.is_finite() && secs > 0.0 => Some(Duration::from_secs_f64(secs)),
|
|
119
|
-
Some(secs) => {
|
|
120
|
-
return Err(wasm_err(
|
|
121
|
-
&ruby,
|
|
122
|
-
format!("timeout must be > 0 and finite, got {secs} seconds"),
|
|
123
|
-
));
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
let engine = shared_engine()?;
|
|
128
|
-
let module = cached_module(Path::new(&path))?;
|
|
129
|
-
|
|
130
|
-
let mut store = WtStore::new(engine, HostState::new(memory_limit));
|
|
131
|
-
store.limiter(|state: &mut HostState| -> &mut dyn ResourceLimiter { state.limiter_mut() });
|
|
132
|
-
store.epoch_deadline_callback(epoch_deadline_callback);
|
|
133
|
-
|
|
134
|
-
let store_cell = StoreCell::new(store);
|
|
135
|
-
Self::build(
|
|
136
|
-
engine,
|
|
137
|
-
&module,
|
|
138
|
-
store_cell,
|
|
139
|
-
timeout,
|
|
140
|
-
stdout_limit_bytes,
|
|
141
|
-
stderr_limit_bytes,
|
|
142
|
-
)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/// Build an `Instance` from an engine, module, and store cell. The
|
|
146
|
-
/// store cell is moved in and ends up owned by the returned Instance.
|
|
147
|
-
/// Wires the WASI p1 imports plus the `__kobako_dispatch` host import.
|
|
148
|
-
fn build(
|
|
149
|
-
engine: &wasmtime::Engine,
|
|
150
|
-
module: &WtModule,
|
|
151
|
-
store_cell: StoreCell,
|
|
152
|
-
timeout: Option<Duration>,
|
|
153
|
-
stdout_limit_bytes: Option<usize>,
|
|
154
|
-
stderr_limit_bytes: Option<usize>,
|
|
155
|
-
) -> Result<Self, MagnusError> {
|
|
156
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
157
|
-
let mut linker: Linker<HostState> = Linker::new(engine);
|
|
158
|
-
|
|
159
|
-
// Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2
|
|
160
|
-
// to the MemoryOutputPipes set up before each run via
|
|
161
|
-
// `Instance::eval`. The closure pulls a `&mut WasiP1Ctx` out of
|
|
162
|
-
// HostState; the panic semantics live inside `HostState::wasi_mut`
|
|
163
|
-
// so the wiring stays honest about its precondition.
|
|
164
|
-
p1::add_to_linker_sync(&mut linker, |state: &mut HostState| state.wasi_mut()).map_err(
|
|
165
|
-
|e| {
|
|
166
|
-
wasm_err(
|
|
167
|
-
&ruby,
|
|
168
|
-
format!("failed to wire WASI runtime into Sandbox: {}", e),
|
|
169
|
-
)
|
|
170
|
-
},
|
|
171
|
-
)?;
|
|
172
|
-
|
|
173
|
-
// `__kobako_dispatch` host import. Signature per docs/wire-codec.md
|
|
174
|
-
// § ABI Signatures:
|
|
175
|
-
// (req_ptr: i32, req_len: i32) -> i64
|
|
176
|
-
// Decodes the Request bytes, dispatches via the Ruby-side
|
|
177
|
-
// `Kobako::RPC::Server` (set per-run via `set_server`), allocates a
|
|
178
|
-
// guest buffer through `__kobako_alloc`, writes the Response bytes
|
|
179
|
-
// there, and returns the packed `(ptr<<32)|len`. The dispatcher
|
|
180
|
-
// returns 0 on any wire-layer fault (including a missing
|
|
181
|
-
// Server); see `dispatch::handle`.
|
|
182
|
-
linker
|
|
183
|
-
.func_wrap(
|
|
184
|
-
"env",
|
|
185
|
-
"__kobako_dispatch",
|
|
186
|
-
|mut caller: Caller<'_, HostState>, req_ptr: i32, req_len: i32| -> i64 {
|
|
187
|
-
dispatch::handle(&mut caller, req_ptr, req_len)
|
|
188
|
-
},
|
|
189
|
-
)
|
|
190
|
-
.map_err(|e| {
|
|
191
|
-
wasm_err(
|
|
192
|
-
&ruby,
|
|
193
|
-
format!("failed to register host RPC dispatch import: {}", e),
|
|
194
|
-
)
|
|
195
|
-
})?;
|
|
196
|
-
|
|
197
|
-
let instance = {
|
|
198
|
-
let mut store_ref = store_cell.borrow_mut();
|
|
199
|
-
linker
|
|
200
|
-
.instantiate(store_ref.as_context_mut(), module)
|
|
201
|
-
.map_err(|e| instantiate_err(&ruby, e))?
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
// Best-effort export lookup. Missing exports are not an error here
|
|
205
|
-
// (test fixture is a bare module); the host enforces presence at
|
|
206
|
-
// invocation time by raising a Ruby `Kobako::Wasm::Error` when the
|
|
207
|
-
// cached Option is None. Only the SPEC ABI `() -> ()` shape is
|
|
208
|
-
// accepted for `__kobako_eval`; `__kobako_run` takes
|
|
209
|
-
// `(env_ptr, env_len) -> ()` per docs/wire-codec.md § ABI
|
|
210
|
-
// Signatures.
|
|
211
|
-
let (eval, run, take_outcome) = {
|
|
212
|
-
let mut store_ref = store_cell.borrow_mut();
|
|
213
|
-
let mut ctx = store_ref.as_context_mut();
|
|
214
|
-
let eval = instance
|
|
215
|
-
.get_typed_func::<(), ()>(&mut ctx, "__kobako_eval")
|
|
216
|
-
.ok();
|
|
217
|
-
let run = instance
|
|
218
|
-
.get_typed_func::<(i32, i32), ()>(&mut ctx, "__kobako_run")
|
|
219
|
-
.ok();
|
|
220
|
-
let take_outcome = instance
|
|
221
|
-
.get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
|
|
222
|
-
.ok();
|
|
223
|
-
(eval, run, take_outcome)
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
Ok(Self {
|
|
227
|
-
inner: instance,
|
|
228
|
-
store: store_cell,
|
|
229
|
-
eval,
|
|
230
|
-
run,
|
|
231
|
-
take_outcome,
|
|
232
|
-
timeout,
|
|
233
|
-
stdout_limit_bytes,
|
|
234
|
-
stderr_limit_bytes,
|
|
235
|
-
})
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/// Install the Ruby-side `Kobako::RPC::Server` into HostState. Bound to
|
|
239
|
-
/// Ruby as `Instance#server=`. From this point on, every
|
|
240
|
-
/// `__kobako_dispatch` import invocation routes through
|
|
241
|
-
/// `server.dispatch(req_bytes)`.
|
|
242
|
-
pub(crate) fn set_server(&self, server: Value) -> Result<(), MagnusError> {
|
|
243
|
-
let mut store_ref = self.store.borrow_mut();
|
|
244
|
-
store_ref.data_mut().bind_server(Opaque::from(server));
|
|
245
|
-
Ok(())
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// -----------------------------------------------------------------
|
|
249
|
-
// Run-path methods. Each method is best-effort — it raises a Ruby
|
|
250
|
-
// `Kobako::Wasm::Error` when the corresponding export is missing or
|
|
251
|
-
// fails so the Sandbox layer can map errors to the three-class
|
|
252
|
-
// taxonomy.
|
|
253
|
-
// -----------------------------------------------------------------
|
|
254
|
-
|
|
255
|
-
/// Execute one guest invocation (`__kobako_eval` — one-shot source).
|
|
256
|
-
///
|
|
257
|
-
/// Rebuilds the WASI context with fresh stdin / stdout / stderr pipes
|
|
258
|
-
/// (the three-frame stdin protocol carries +preamble+, +source+, then
|
|
259
|
-
/// +snippets+ — docs/wire-codec.md § Invocation channels), then
|
|
260
|
-
/// invokes `__kobako_eval`. Per-invocation caps (docs/behavior.md
|
|
261
|
-
/// B-01) are primed here: the wall-clock deadline is stamped into
|
|
262
|
-
/// [`HostState`] and the epoch deadline is set to fire at the next
|
|
263
|
-
/// ticker tick; the memory-cap limiter is already wired.
|
|
264
|
-
pub(crate) fn eval(
|
|
265
|
-
&self,
|
|
266
|
-
preamble: RString,
|
|
267
|
-
source: RString,
|
|
268
|
-
snippets: RString,
|
|
269
|
-
) -> Result<(), MagnusError> {
|
|
270
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
271
|
-
let eval = require_export(&ruby, self.eval.as_ref(), "__kobako_eval")?;
|
|
272
|
-
self.refresh_wasi(&[
|
|
273
|
-
rstring_to_vec(preamble),
|
|
274
|
-
rstring_to_vec(source),
|
|
275
|
-
rstring_to_vec(snippets),
|
|
276
|
-
]);
|
|
277
|
-
self.prime_caps();
|
|
278
|
-
let result = {
|
|
279
|
-
let mut store_ref = self.store.borrow_mut();
|
|
280
|
-
eval.call(store_ref.as_context_mut(), ())
|
|
281
|
-
};
|
|
282
|
-
self.disarm_caps();
|
|
283
|
-
result.map_err(|e| call_err(&ruby, e))
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/// Execute one entrypoint dispatch (`__kobako_run`).
|
|
287
|
-
///
|
|
288
|
-
/// Rebuilds the WASI context with the two-frame stdin protocol
|
|
289
|
-
/// (preamble + snippets; no user source frame — docs/wire-codec.md
|
|
290
|
-
/// § Invocation channels), copies +envelope+ bytes into guest linear
|
|
291
|
-
/// memory via `__kobako_alloc`, and calls `__kobako_run(env_ptr,
|
|
292
|
-
/// env_len)`. Per-invocation cap semantics match [`Instance::eval`].
|
|
293
|
-
/// Returns +Kobako::Wasm::Error+ ("alloc returned 0") when guest
|
|
294
|
-
/// allocation fails (docs/behavior.md E-31).
|
|
295
|
-
pub(crate) fn run(
|
|
296
|
-
&self,
|
|
297
|
-
preamble: RString,
|
|
298
|
-
snippets: RString,
|
|
299
|
-
envelope: RString,
|
|
300
|
-
) -> Result<(), MagnusError> {
|
|
301
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
302
|
-
let run = require_export(&ruby, self.run.as_ref(), "__kobako_run")?;
|
|
303
|
-
self.refresh_wasi(&[rstring_to_vec(preamble), rstring_to_vec(snippets)]);
|
|
304
|
-
let (env_ptr, env_len) = self.write_envelope(&ruby, envelope)?;
|
|
305
|
-
self.prime_caps();
|
|
306
|
-
let result = {
|
|
307
|
-
let mut store_ref = self.store.borrow_mut();
|
|
308
|
-
run.call(store_ref.as_context_mut(), (env_ptr, env_len))
|
|
309
|
-
};
|
|
310
|
-
self.disarm_caps();
|
|
311
|
-
result.map_err(|e| call_err(&ruby, e))
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/// Return the stdout capture from the most recent run as a Ruby
|
|
315
|
-
/// `[bytes, truncated]` Array — `bytes` is a binary String containing
|
|
316
|
-
/// the captured prefix (clipped to `stdout_limit_bytes` when set),
|
|
317
|
-
/// and `truncated` is a boolean that is `true` only when the guest
|
|
318
|
-
/// wrote strictly more than the cap. The pair is recomputed from the
|
|
319
|
-
/// underlying pipe contents on every call; the pipe itself is not
|
|
320
|
-
/// drained until the next `#run` rebuilds it.
|
|
321
|
-
pub(crate) fn stdout(&self) -> Result<RArray, MagnusError> {
|
|
322
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
323
|
-
let raw = self.store.borrow().data().stdout_bytes();
|
|
324
|
-
capture_pair(&ruby, &raw, self.stdout_limit_bytes)
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/// Return the stderr capture from the most recent run. Same shape
|
|
328
|
-
/// and semantics as [`Instance::stdout`].
|
|
329
|
-
pub(crate) fn stderr(&self) -> Result<RArray, MagnusError> {
|
|
330
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
331
|
-
let raw = self.store.borrow().data().stderr_bytes();
|
|
332
|
-
capture_pair(&ruby, &raw, self.stderr_limit_bytes)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/// Read OUTCOME_BUFFER bytes captured during the most recent run.
|
|
336
|
-
/// Bound to Ruby as `Instance#outcome!`. The bang signals that the
|
|
337
|
-
/// underlying `__kobako_take_outcome` export is guest-side destructive
|
|
338
|
-
/// — the buffer pointer is invalidated after this call, so a second
|
|
339
|
-
/// invocation within the same run is undefined — and that any failure
|
|
340
|
-
/// (missing export, length overflow, OOB read) raises
|
|
341
|
-
/// `Kobako::Wasm::Error`.
|
|
342
|
-
pub(crate) fn outcome(&self) -> Result<RString, MagnusError> {
|
|
343
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
344
|
-
let bytes = self.fetch_outcome_bytes(&ruby)?;
|
|
345
|
-
Ok(ruby.str_from_slice(&bytes))
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/// Return the docs/behavior.md B-35 per-last-invocation usage as a
|
|
349
|
-
/// Ruby 2-tuple `[wall_time, memory_peak]`. The element order
|
|
350
|
-
/// matches the `Kobako::Usage` field order declared in
|
|
351
|
-
/// `lib/kobako/usage.rb`; reorder both sides together if the field
|
|
352
|
-
/// list ever grows.
|
|
353
|
-
///
|
|
354
|
-
/// * `wall_time` (Float seconds) — the wall-clock duration the
|
|
355
|
-
/// most recent invocation spent inside the guest export call.
|
|
356
|
-
/// Bracket opens in [`Instance::prime_caps`] and closes in
|
|
357
|
-
/// [`Instance::disarm_caps`], so the value mirrors the
|
|
358
|
-
/// `timeout` deadline accounting and excludes everything that
|
|
359
|
-
/// runs after the guest export returns — the post-export
|
|
360
|
-
/// `OUTCOME_BUFFER` fetch and decode, plus stdout / stderr
|
|
361
|
-
/// capture readout. `0.0` before the first invocation.
|
|
362
|
-
/// * `memory_peak` (Integer bytes) — the high-water mark of the
|
|
363
|
-
/// per-invocation `memory.grow` delta past the linear-memory
|
|
364
|
-
/// size captured at invocation entry. `0` before the first
|
|
365
|
-
/// invocation.
|
|
366
|
-
///
|
|
367
|
-
/// Packing both readers into one ext call mirrors the
|
|
368
|
-
/// [`Instance::stdout`] / [`Instance::stderr`] pattern: one
|
|
369
|
-
/// `store.borrow()` per readout and a single magnus binding to
|
|
370
|
-
/// extend when B-35's field list grows past two.
|
|
371
|
-
pub(crate) fn usage(&self) -> Result<RArray, MagnusError> {
|
|
372
|
-
let ruby = Ruby::get().expect("Ruby thread");
|
|
373
|
-
let state = self.store.borrow();
|
|
374
|
-
let data = state.data();
|
|
375
|
-
let arr = ruby.ary_new_capa(2);
|
|
376
|
-
arr.push(data.wall_time().as_secs_f64())?;
|
|
377
|
-
arr.push(data.memory_peak())?;
|
|
378
|
-
Ok(arr)
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// -----------------------------------------------------------------
|
|
382
|
-
// Private helpers.
|
|
383
|
-
// -----------------------------------------------------------------
|
|
384
|
-
|
|
385
|
-
/// Stamp the per-invocation wall-clock deadline into [`HostState`]
|
|
386
|
-
/// and prime the wasmtime epoch deadline so the next ticker tick
|
|
387
|
-
/// wakes the epoch-deadline callback. When `timeout` is disabled,
|
|
388
|
-
/// the deadline is set far enough in the future that the callback
|
|
389
|
-
/// effectively never fires.
|
|
390
|
-
///
|
|
391
|
-
/// Also captures the current linear-memory size as the baseline
|
|
392
|
-
/// for the docs/behavior.md E-20 per-invocation memory delta cap.
|
|
393
|
-
/// The mruby image's declared initial allocation and the high-water
|
|
394
|
-
/// mark left by prior invocations on the same Sandbox are folded
|
|
395
|
-
/// into the baseline rather than the budget — only `memory.grow`
|
|
396
|
-
/// past +baseline+ counts against `memory_limit`.
|
|
397
|
-
///
|
|
398
|
-
/// Also stamps the wall-clock entry instant for the
|
|
399
|
-
/// docs/behavior.md B-35 `wall_time` measurement. The bracket
|
|
400
|
-
/// closes in [`Instance::disarm_caps`] so it matches the
|
|
401
|
-
/// `timeout` deadline window and excludes `OUTCOME_BUFFER`
|
|
402
|
-
/// decoding and stdout / stderr capture readout.
|
|
403
|
-
fn prime_caps(&self) {
|
|
404
|
-
let mut store_ref = self.store.borrow_mut();
|
|
405
|
-
match self.timeout {
|
|
406
|
-
Some(timeout) => {
|
|
407
|
-
let deadline = Instant::now() + timeout;
|
|
408
|
-
store_ref.data_mut().set_deadline(Some(deadline));
|
|
409
|
-
store_ref.set_epoch_deadline(1);
|
|
410
|
-
}
|
|
411
|
-
None => {
|
|
412
|
-
store_ref.data_mut().set_deadline(None);
|
|
413
|
-
store_ref.set_epoch_deadline(u64::MAX);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
let baseline = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
417
|
-
Some(Extern::Memory(m)) => m.data_size(store_ref.as_context_mut()),
|
|
418
|
-
_ => 0,
|
|
419
|
-
};
|
|
420
|
-
store_ref.data_mut().arm_memory_cap(baseline);
|
|
421
|
-
store_ref.data_mut().start_wall_clock();
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/// Drop the memory cap as soon as the guest call returns so that
|
|
425
|
-
/// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
|
|
426
|
-
/// which can grow guest memory transiently) is not attributed to
|
|
427
|
-
/// the user script. Also closes the docs/behavior.md B-35
|
|
428
|
-
/// `wall_time` bracket opened by [`Instance::prime_caps`]. Paired
|
|
429
|
-
/// with [`Instance::prime_caps`].
|
|
430
|
-
fn disarm_caps(&self) {
|
|
431
|
-
let mut store_ref = self.store.borrow_mut();
|
|
432
|
-
store_ref.data_mut().stop_wall_clock();
|
|
433
|
-
store_ref.data_mut().disarm_memory_cap();
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/// Allocate a +len+-byte buffer in guest linear memory via
|
|
437
|
-
/// `__kobako_alloc`, copy +envelope+ into it, and return +(ptr, len)+
|
|
438
|
-
/// as +i32+ values matching the `__kobako_run(env_ptr, env_len)` ABI.
|
|
439
|
-
/// Raises +Kobako::Wasm::Error+ when the guest export is missing or
|
|
440
|
-
/// allocation fails (docs/behavior.md E-31).
|
|
441
|
-
fn write_envelope(&self, ruby: &Ruby, envelope: RString) -> Result<(i32, i32), MagnusError> {
|
|
442
|
-
let bytes = rstring_to_vec(envelope);
|
|
443
|
-
let len_i32 = envelope_len_to_i32(bytes.len()).map_err(|msg| wasm_err(ruby, msg))?;
|
|
444
|
-
|
|
445
|
-
let mut store_ref = self.store.borrow_mut();
|
|
446
|
-
let alloc: TypedFunc<u32, u32> = self
|
|
447
|
-
.inner
|
|
448
|
-
.get_typed_func(store_ref.as_context_mut(), "__kobako_alloc")
|
|
449
|
-
.map_err(|_| wasm_err(ruby, SANDBOX_RUNTIME_MISSING_HOOKS))?;
|
|
450
|
-
let ptr = alloc
|
|
451
|
-
.call(store_ref.as_context_mut(), bytes.len() as u32)
|
|
452
|
-
.map_err(|e| wasm_err(ruby, format!("failed to allocate input buffer: {}", e)))?;
|
|
453
|
-
if ptr == 0 {
|
|
454
|
-
return Err(wasm_err(
|
|
455
|
-
ruby,
|
|
456
|
-
"could not allocate input buffer (out of memory)",
|
|
457
|
-
));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
let memory: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
461
|
-
Some(Extern::Memory(m)) => m,
|
|
462
|
-
_ => return Err(wasm_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
|
|
463
|
-
};
|
|
464
|
-
let data = memory.data_mut(store_ref.as_context_mut());
|
|
465
|
-
let range = guest_buffer_range(ptr as usize, bytes.len(), data.len())
|
|
466
|
-
.map_err(|msg| wasm_err(ruby, msg))?;
|
|
467
|
-
data[range].copy_from_slice(&bytes);
|
|
468
|
-
|
|
469
|
-
Ok((ptr as i32, len_i32))
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/// Rebuild the WASI context with fresh stdin (carrying every frame in
|
|
473
|
-
/// +frames+, each prefixed by its 4-byte big-endian u32 length —
|
|
474
|
-
/// docs/wire-codec.md § Invocation channels) plus fresh stdout / stderr
|
|
475
|
-
/// pipes. Called at the top of every guest invocation: +#eval+ passes
|
|
476
|
-
/// three frames (preamble, source, snippets), +#run+ passes two
|
|
477
|
-
/// (preamble, snippets — the invocation envelope arrives via linear
|
|
478
|
-
/// memory instead). Each output pipe is sized at `cap + 1` so
|
|
479
|
-
/// [`Instance::stdout`] / [`Instance::stderr`] can distinguish "wrote
|
|
480
|
-
/// exactly cap bytes" from "exceeded cap"; uncapped channels fall back
|
|
481
|
-
/// to `usize::MAX` and rely on `memory_limit` (docs/behavior.md E-20)
|
|
482
|
-
/// for the real ceiling.
|
|
483
|
-
fn refresh_wasi(&self, frames: &[Vec<u8>]) {
|
|
484
|
-
let total: usize = frames.iter().map(|f| 4 + f.len()).sum();
|
|
485
|
-
let mut stdin_content: Vec<u8> = Vec::with_capacity(total);
|
|
486
|
-
for frame in frames {
|
|
487
|
-
stdin_content.extend_from_slice(&(frame.len() as u32).to_be_bytes());
|
|
488
|
-
stdin_content.extend_from_slice(frame);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
let stdin_pipe = MemoryInputPipe::new(stdin_content);
|
|
492
|
-
let stdout_pipe = MemoryOutputPipe::new(pipe_capacity(self.stdout_limit_bytes));
|
|
493
|
-
let stderr_pipe = MemoryOutputPipe::new(pipe_capacity(self.stderr_limit_bytes));
|
|
494
|
-
|
|
495
|
-
let mut builder = WasiCtxBuilder::new();
|
|
496
|
-
builder.stdin(stdin_pipe);
|
|
497
|
-
builder.stdout(stdout_pipe.clone());
|
|
498
|
-
builder.stderr(stderr_pipe.clone());
|
|
499
|
-
let wasi = builder.build_p1();
|
|
500
|
-
|
|
501
|
-
self.store
|
|
502
|
-
.borrow_mut()
|
|
503
|
-
.data_mut()
|
|
504
|
-
.install_wasi(wasi, stdout_pipe, stderr_pipe);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/// Invoke `__kobako_take_outcome`, decode the packed +(ptr<<32)|len+
|
|
508
|
-
/// u64, and copy the OUTCOME_BUFFER slice out of guest memory. Raises
|
|
509
|
-
/// `Kobako::Wasm::Error` when the export is missing, the +ptr+/+len+
|
|
510
|
-
/// arithmetic overflows, the slice falls outside live memory, or the
|
|
511
|
-
/// `memory` export itself is absent.
|
|
512
|
-
fn fetch_outcome_bytes(&self, ruby: &Ruby) -> Result<Vec<u8>, MagnusError> {
|
|
513
|
-
let take = require_export(ruby, self.take_outcome.as_ref(), "__kobako_take_outcome")?;
|
|
514
|
-
|
|
515
|
-
let mut store_ref = self.store.borrow_mut();
|
|
516
|
-
let packed = take
|
|
517
|
-
.call(store_ref.as_context_mut(), ())
|
|
518
|
-
.map_err(|e| wasm_err(ruby, format!("failed to read invocation result: {}", e)))?;
|
|
519
|
-
let (ptr, len) = unpack_outcome_packed(packed);
|
|
520
|
-
|
|
521
|
-
let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
522
|
-
Some(Extern::Memory(m)) => m,
|
|
523
|
-
_ => return Err(wasm_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
|
|
524
|
-
};
|
|
525
|
-
let data = mem.data(store_ref.as_context_mut());
|
|
526
|
-
let range = guest_buffer_range(ptr, len, data.len()).map_err(|msg| {
|
|
527
|
-
wasm_err(ruby, format!("invocation result is out of bounds: {}", msg))
|
|
528
|
-
})?;
|
|
529
|
-
Ok(data[range].to_vec())
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/// User-facing message for the "Sandbox runtime is missing one of the
|
|
534
|
-
/// internal Kobako hooks" failure mode. Phrased in caller vocabulary —
|
|
535
|
-
/// the underlying ABI symbol names (`__kobako_alloc`, `__kobako_eval`,
|
|
536
|
-
/// `__kobako_take_outcome`) are not actionable to callers, and the
|
|
537
|
-
/// gem itself raises this error so a self-reference like "matches the
|
|
538
|
-
/// kobako gem version" reads as third-person. The actionable
|
|
539
|
-
/// diagnosis is "your data/kobako.wasm is out of sync; rebuild it".
|
|
540
|
-
const SANDBOX_RUNTIME_MISSING_HOOKS: &str = "Sandbox runtime is missing required hooks; \
|
|
541
|
-
rebuild data/kobako.wasm against the installed version";
|
|
542
|
-
|
|
543
|
-
/// User-facing message for the "the loaded Wasm module is not a
|
|
544
|
-
/// Kobako-shaped runtime at all" failure mode (no linear memory
|
|
545
|
-
/// export). Same phrasing philosophy as
|
|
546
|
-
/// [`SANDBOX_RUNTIME_MISSING_HOOKS`].
|
|
547
|
-
const SANDBOX_RUNTIME_NOT_KOBAKO: &str = "Sandbox runtime does not export linear memory; \
|
|
548
|
-
this is not a Kobako-compatible Wasm module";
|
|
549
|
-
|
|
550
|
-
/// Return the cached +TypedFunc+ for an ABI export, or raise
|
|
551
|
-
/// +Kobako::Wasm::Error+ when the option is +None+. The run-path
|
|
552
|
-
/// methods (+#eval+, +#run+, +#outcome!+) all share the same
|
|
553
|
-
/// "missing export → Ruby error" boilerplate; this helper collapses
|
|
554
|
-
/// the three sites onto one safe entry. The +_name+ argument is
|
|
555
|
-
/// retained for future operator-side logging but is deliberately not
|
|
556
|
-
/// spliced into the user-facing message (see
|
|
557
|
-
/// [`SANDBOX_RUNTIME_MISSING_HOOKS`]).
|
|
558
|
-
fn require_export<'a, Params, Results>(
|
|
559
|
-
ruby: &Ruby,
|
|
560
|
-
export: Option<&'a TypedFunc<Params, Results>>,
|
|
561
|
-
_name: &str,
|
|
562
|
-
) -> Result<&'a TypedFunc<Params, Results>, MagnusError>
|
|
563
|
-
where
|
|
564
|
-
Params: wasmtime::WasmParams,
|
|
565
|
-
Results: wasmtime::WasmResults,
|
|
566
|
-
{
|
|
567
|
-
export.ok_or_else(|| wasm_err(ruby, SANDBOX_RUNTIME_MISSING_HOOKS))
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/// Validate the invocation envelope length and return it as +i32+ — the
|
|
571
|
-
/// signed wasm ABI parameter type for the guest-run entrypoint.
|
|
572
|
-
/// Rejects sizes above +i32::MAX+ (2 GiB) so the downstream cast cannot
|
|
573
|
-
/// silently wrap.
|
|
574
|
-
fn envelope_len_to_i32(len: usize) -> Result<i32, &'static str> {
|
|
575
|
-
i32::try_from(len).map_err(|_| "invocation payload exceeds 2 GiB")
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/// Compute the half-open range `[ptr, ptr + len)` for a guest linear-memory
|
|
579
|
-
/// copy, validating that the arithmetic does not overflow and the range
|
|
580
|
-
/// fits inside `mem_size`. Shared by [`Instance::write_envelope`] (write
|
|
581
|
-
/// side) and [`Instance::fetch_outcome_bytes`] (read side).
|
|
582
|
-
fn guest_buffer_range(
|
|
583
|
-
ptr: usize,
|
|
584
|
-
len: usize,
|
|
585
|
-
mem_size: usize,
|
|
586
|
-
) -> Result<core::ops::Range<usize>, &'static str> {
|
|
587
|
-
let end = ptr.checked_add(len).ok_or("ptr + len overflow")?;
|
|
588
|
-
if end > mem_size {
|
|
589
|
-
return Err("range exceeds Sandbox memory size");
|
|
590
|
-
}
|
|
591
|
-
Ok(ptr..end)
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/// Unpack the `(ptr, len)` u64 returned by `__kobako_take_outcome`:
|
|
595
|
-
/// high 32 bits = ptr, low 32 bits = len. Mirrors the guest-side
|
|
596
|
-
/// `crate::abi::unpack_u64` in `wasm/kobako-wasm/src/abi.rs`.
|
|
597
|
-
fn unpack_outcome_packed(packed: u64) -> (usize, usize) {
|
|
598
|
-
let ptr = (packed >> 32) as u32 as usize;
|
|
599
|
-
let len = packed as u32 as usize;
|
|
600
|
-
(ptr, len)
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/// Translate a per-channel byte cap into the MemoryOutputPipe capacity:
|
|
604
|
-
/// `cap + 1` (saturated against `usize::MAX`) when a cap is set so the
|
|
605
|
-
/// "wrote exactly cap" and "exceeded cap" cases stay distinguishable;
|
|
606
|
-
/// `usize::MAX` when the channel is uncapped.
|
|
607
|
-
fn pipe_capacity(cap: Option<usize>) -> usize {
|
|
608
|
-
match cap {
|
|
609
|
-
Some(c) => c.saturating_add(1),
|
|
610
|
-
None => usize::MAX,
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/// Pure slicing core shared by [`Instance::stdout`] / [`Instance::stderr`]:
|
|
615
|
-
/// given the unclipped pipe snapshot and the configured cap, return the
|
|
616
|
-
/// bytes Ruby should observe (clipped to `cap`) plus the truncation flag.
|
|
617
|
-
/// `truncated` is `true` only when the snapshot strictly exceeded the cap
|
|
618
|
-
/// — this is the "wrote `cap + 1` bytes into a `cap + 1`-sized pipe" case;
|
|
619
|
-
/// "wrote exactly `cap` bytes" stays `false`.
|
|
620
|
-
fn clip_capture(raw: &[u8], cap: Option<usize>) -> (&[u8], bool) {
|
|
621
|
-
match cap {
|
|
622
|
-
Some(c) if raw.len() > c => (&raw[..c], true),
|
|
623
|
-
_ => (raw, false),
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/// Build the `[bytes, truncated]` Ruby Array surfaced by
|
|
628
|
-
/// [`Instance::stdout`] / [`Instance::stderr`]. Delegates the slicing
|
|
629
|
-
/// to [`clip_capture`] so the channel-agnostic logic stays unit-
|
|
630
|
-
/// testable from `cargo test`.
|
|
631
|
-
fn capture_pair(ruby: &Ruby, raw: &[u8], cap: Option<usize>) -> Result<RArray, MagnusError> {
|
|
632
|
-
let (visible, truncated) = clip_capture(raw, cap);
|
|
633
|
-
let arr = ruby.ary_new_capa(2);
|
|
634
|
-
arr.push(ruby.str_from_slice(visible))?;
|
|
635
|
-
arr.push(truncated)?;
|
|
636
|
-
Ok(arr)
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/// Epoch-deadline callback installed on every Store. Read the per-run
|
|
640
|
-
/// wall-clock deadline from [`HostState`] (docs/behavior.md B-01) and trap with
|
|
641
|
-
/// [`TimeoutTrap`] once the deadline has passed; otherwise extend the
|
|
642
|
-
/// next check by one tick of the process-wide epoch ticker. When the
|
|
643
|
-
/// deadline is `None` the callback should not fire under normal
|
|
644
|
-
/// `Instance::eval` / `Instance::run` flow because
|
|
645
|
-
/// `set_epoch_deadline(u64::MAX)` is used; returning a long extension
|
|
646
|
-
/// keeps the callback inert as a defence in depth.
|
|
647
|
-
fn epoch_deadline_callback(
|
|
648
|
-
ctx: StoreContextMut<'_, HostState>,
|
|
649
|
-
) -> wasmtime::Result<UpdateDeadline> {
|
|
650
|
-
match ctx.data().deadline() {
|
|
651
|
-
Some(deadline) if Instant::now() >= deadline => Err(wasmtime::Error::new(TimeoutTrap)),
|
|
652
|
-
Some(_) => Ok(UpdateDeadline::Continue(1)),
|
|
653
|
-
None => Ok(UpdateDeadline::Continue(u64::MAX / 2)),
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/// Configured-cap path classification for a wasmtime error. The
|
|
658
|
-
/// downcast logic stays in a pure helper so the
|
|
659
|
-
/// `Kobako::Wasm::TimeoutError` / `MemoryLimitError` /
|
|
660
|
-
/// `Kobako::Wasm::Error` mapping can be exercised from `cargo test`
|
|
661
|
-
/// without the magnus surface.
|
|
662
|
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
663
|
-
enum TrapClass {
|
|
664
|
-
/// docs/behavior.md E-19 wall-clock cap path.
|
|
665
|
-
Timeout,
|
|
666
|
-
/// docs/behavior.md E-20 linear-memory cap path.
|
|
667
|
-
MemoryLimit,
|
|
668
|
-
/// Any other wasmtime error — surfaces as the base
|
|
669
|
-
/// `Kobako::Wasm::Error`.
|
|
670
|
-
Other,
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/// Inspect a wasmtime error to decide which `Kobako::Wasm::*` class it
|
|
674
|
-
/// should map to. Pure function — operates on the error's downcast
|
|
675
|
-
/// chain only, no magnus / Ruby state required.
|
|
676
|
-
fn classify_trap(err: &wasmtime::Error) -> TrapClass {
|
|
677
|
-
if err.downcast_ref::<TimeoutTrap>().is_some() {
|
|
678
|
-
TrapClass::Timeout
|
|
679
|
-
} else if err.downcast_ref::<MemoryLimitTrap>().is_some() {
|
|
680
|
-
TrapClass::MemoryLimit
|
|
681
|
-
} else {
|
|
682
|
-
TrapClass::Other
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
/// Map a wasmtime call error to the right `Kobako::Wasm::*` Ruby
|
|
687
|
-
/// exception class. The ABI export symbol (`__kobako_eval` /
|
|
688
|
-
/// `__kobako_run`) is deliberately omitted from the message — the
|
|
689
|
-
/// Sandbox layer attaches the user-facing verb (`Sandbox#eval` /
|
|
690
|
-
/// `Sandbox#run`) so the message reads in caller vocabulary rather
|
|
691
|
-
/// than ABI vocabulary.
|
|
692
|
-
///
|
|
693
|
-
/// For the configured-cap paths ([`TrapClass::Timeout`] /
|
|
694
|
-
/// [`TrapClass::MemoryLimit`]) the trap's own [`std::fmt::Display`]
|
|
695
|
-
/// carries the user-facing reason (`"wall-clock deadline exceeded"`,
|
|
696
|
-
/// `"linear memory growth exceeded memory_limit: ..."`). The wasmtime
|
|
697
|
-
/// outer wrapper at `format!("{}", err)` would otherwise surface only
|
|
698
|
-
/// the `"error while executing at wasm backtrace: ..."` framing, which
|
|
699
|
-
/// is operator noise on a cap trap. For [`TrapClass::Other`] the
|
|
700
|
-
/// wasmtime wrapper IS the diagnostic (real script trap) so it stays.
|
|
701
|
-
fn call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
|
|
702
|
-
match classify_trap(&err) {
|
|
703
|
-
TrapClass::Timeout => {
|
|
704
|
-
let msg = err
|
|
705
|
-
.downcast_ref::<TimeoutTrap>()
|
|
706
|
-
.map(|t| t.to_string())
|
|
707
|
-
.unwrap_or_else(|| format!("{}", err));
|
|
708
|
-
timeout_err(ruby, msg)
|
|
709
|
-
}
|
|
710
|
-
TrapClass::MemoryLimit => {
|
|
711
|
-
let msg = err
|
|
712
|
-
.downcast_ref::<MemoryLimitTrap>()
|
|
713
|
-
.map(|t| t.to_string())
|
|
714
|
-
.unwrap_or_else(|| format!("{}", err));
|
|
715
|
-
memory_limit_err(ruby, msg)
|
|
716
|
-
}
|
|
717
|
-
TrapClass::Other => wasm_err(ruby, format!("{}", err)),
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/// Map an instantiation error to the right `Kobako::Wasm::*` Ruby
|
|
722
|
-
/// exception. The memory cap is dormant during instantiation by design
|
|
723
|
-
/// (see [`HostState::arm_memory_cap`] / [`HostState::disarm_memory_cap`]),
|
|
724
|
-
/// but [`MemoryLimitTrap`] is still possible if a future Sandbox
|
|
725
|
-
/// configuration enables it during instantiation — keep the mapping
|
|
726
|
-
/// symmetric with [`call_err`]. [`TrapClass::Timeout`] is unreachable on
|
|
727
|
-
/// the instantiation path (the epoch deadline is not armed yet) but
|
|
728
|
-
/// folding it into the same `match` keeps the two paths visually paired.
|
|
729
|
-
fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
|
|
730
|
-
let msg = format!("instantiate: {}", err);
|
|
731
|
-
match classify_trap(&err) {
|
|
732
|
-
TrapClass::MemoryLimit => memory_limit_err(ruby, msg),
|
|
733
|
-
TrapClass::Timeout | TrapClass::Other => wasm_err(ruby, msg),
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
#[cfg(test)]
|
|
738
|
-
mod tests {
|
|
739
|
-
//! Host-side unit tests for the pure capture helpers. The Ruby-
|
|
740
|
-
//! facing E2E suite exercises stdout only (the kobako mrbgem
|
|
741
|
-
//! allowlist excludes guest fd 2 writes); these tests pin the
|
|
742
|
-
//! channel-agnostic slicing so a regression that only breaks one
|
|
743
|
-
//! channel cannot sneak through.
|
|
744
|
-
use super::{
|
|
745
|
-
classify_trap, clip_capture, envelope_len_to_i32, guest_buffer_range, pipe_capacity,
|
|
746
|
-
unpack_outcome_packed, TrapClass,
|
|
747
|
-
};
|
|
748
|
-
use super::{MemoryLimitTrap, TimeoutTrap};
|
|
749
|
-
|
|
750
|
-
#[test]
|
|
751
|
-
fn pipe_capacity_adds_one_when_cap_is_set() {
|
|
752
|
-
assert_eq!(pipe_capacity(Some(5)), 6);
|
|
753
|
-
assert_eq!(pipe_capacity(Some(0)), 1);
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
#[test]
|
|
757
|
-
fn pipe_capacity_falls_back_to_usize_max_when_uncapped() {
|
|
758
|
-
assert_eq!(pipe_capacity(None), usize::MAX);
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
#[test]
|
|
762
|
-
fn pipe_capacity_saturates_at_usize_max() {
|
|
763
|
-
assert_eq!(pipe_capacity(Some(usize::MAX)), usize::MAX);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
#[test]
|
|
767
|
-
fn clip_capture_returns_full_bytes_when_under_cap() {
|
|
768
|
-
let (bytes, truncated) = clip_capture(b"abc", Some(5));
|
|
769
|
-
assert_eq!(bytes, b"abc");
|
|
770
|
-
assert!(!truncated);
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
#[test]
|
|
774
|
-
fn clip_capture_does_not_flag_truncation_at_exactly_cap_bytes() {
|
|
775
|
-
let (bytes, truncated) = clip_capture(b"abcde", Some(5));
|
|
776
|
-
assert_eq!(bytes, b"abcde");
|
|
777
|
-
assert!(!truncated);
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
#[test]
|
|
781
|
-
fn clip_capture_clips_to_cap_and_flags_truncation_on_overflow() {
|
|
782
|
-
// The pipe is sized `cap + 1`, so the snapshot can be at most
|
|
783
|
-
// 6 bytes when `cap == 5`; that surface is what triggers the
|
|
784
|
-
// truncation flag.
|
|
785
|
-
let (bytes, truncated) = clip_capture(b"abcdef", Some(5));
|
|
786
|
-
assert_eq!(bytes, b"abcde");
|
|
787
|
-
assert!(truncated);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
#[test]
|
|
791
|
-
fn clip_capture_treats_none_as_uncapped() {
|
|
792
|
-
let (bytes, truncated) = clip_capture(b"abcdef", None);
|
|
793
|
-
assert_eq!(bytes, b"abcdef");
|
|
794
|
-
assert!(!truncated);
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
#[test]
|
|
798
|
-
fn clip_capture_handles_empty_input() {
|
|
799
|
-
let (bytes, truncated) = clip_capture(b"", Some(5));
|
|
800
|
-
assert_eq!(bytes, b"");
|
|
801
|
-
assert!(!truncated);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
#[test]
|
|
805
|
-
fn envelope_len_to_i32_accepts_zero_and_max() {
|
|
806
|
-
assert_eq!(envelope_len_to_i32(0), Ok(0));
|
|
807
|
-
assert_eq!(envelope_len_to_i32(i32::MAX as usize), Ok(i32::MAX));
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
#[test]
|
|
811
|
-
fn envelope_len_to_i32_rejects_past_i32_max() {
|
|
812
|
-
assert!(envelope_len_to_i32(i32::MAX as usize + 1).is_err());
|
|
813
|
-
assert!(envelope_len_to_i32(usize::MAX).is_err());
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
#[test]
|
|
817
|
-
fn guest_buffer_range_returns_half_open_range() {
|
|
818
|
-
// Standard case: ptr + len fits inside memory.
|
|
819
|
-
assert_eq!(guest_buffer_range(10, 5, 100), Ok(10..15));
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
#[test]
|
|
823
|
-
fn guest_buffer_range_accepts_zero_length_at_any_in_bounds_ptr() {
|
|
824
|
-
// Zero-length writes / reads must succeed as long as ptr is in
|
|
825
|
-
// bounds — both reactor calls hand zero-length frames through
|
|
826
|
-
// (e.g. an empty Frame 3 snippets list).
|
|
827
|
-
assert_eq!(guest_buffer_range(0, 0, 0), Ok(0..0));
|
|
828
|
-
assert_eq!(guest_buffer_range(42, 0, 100), Ok(42..42));
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
#[test]
|
|
832
|
-
fn guest_buffer_range_rejects_ptr_plus_len_overflow() {
|
|
833
|
-
assert!(guest_buffer_range(usize::MAX, 1, usize::MAX).is_err());
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
#[test]
|
|
837
|
-
fn guest_buffer_range_rejects_end_past_memory() {
|
|
838
|
-
assert!(guest_buffer_range(10, 100, 50).is_err());
|
|
839
|
-
// End exactly equal to mem_size is in-bounds.
|
|
840
|
-
assert_eq!(guest_buffer_range(0, 50, 50), Ok(0..50));
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
#[test]
|
|
844
|
-
fn unpack_outcome_packed_extracts_high_ptr_low_len() {
|
|
845
|
-
assert_eq!(
|
|
846
|
-
unpack_outcome_packed(0xAABB_CCDD_1122_3344),
|
|
847
|
-
(0xAABB_CCDD, 0x1122_3344)
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
#[test]
|
|
852
|
-
fn unpack_outcome_packed_zero_decodes_to_zero_pair() {
|
|
853
|
-
assert_eq!(unpack_outcome_packed(0), (0, 0));
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
#[test]
|
|
857
|
-
fn classify_trap_routes_timeout_trap_to_timeout() {
|
|
858
|
-
let err = wasmtime::Error::new(TimeoutTrap);
|
|
859
|
-
assert_eq!(classify_trap(&err), TrapClass::Timeout);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
#[test]
|
|
863
|
-
fn classify_trap_routes_memory_limit_trap_to_memory_limit() {
|
|
864
|
-
let err = wasmtime::Error::new(MemoryLimitTrap::new(1 << 20, 1 << 19));
|
|
865
|
-
assert_eq!(classify_trap(&err), TrapClass::MemoryLimit);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
#[test]
|
|
869
|
-
fn classify_trap_falls_back_to_other_for_unknown_errors() {
|
|
870
|
-
let err = wasmtime::Error::msg("some other wasmtime fault");
|
|
871
|
-
assert_eq!(classify_trap(&err), TrapClass::Other);
|
|
872
|
-
}
|
|
873
|
-
}
|