kobako 0.2.1 → 0.3.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/Cargo.lock +1 -1
- data/README.md +123 -57
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +2 -2
- data/ext/kobako/src/wasm/cache.rs +3 -3
- data/ext/kobako/src/wasm/dispatch.rs +87 -36
- data/ext/kobako/src/wasm/host_state.rs +189 -52
- data/ext/kobako/src/wasm/instance.rs +367 -152
- data/ext/kobako/src/wasm.rs +19 -5
- data/lib/kobako/capture.rb +12 -10
- data/lib/kobako/codec/decoder.rb +3 -2
- data/lib/kobako/codec/encoder.rb +1 -1
- data/lib/kobako/codec/error.rb +3 -2
- data/lib/kobako/codec/factory.rb +11 -7
- data/lib/kobako/codec/utils.rb +3 -2
- data/lib/kobako/codec.rb +2 -1
- data/lib/kobako/errors.rb +22 -10
- data/lib/kobako/invocation.rb +112 -0
- data/lib/kobako/outcome/panic.rb +2 -2
- data/lib/kobako/outcome.rb +20 -13
- data/lib/kobako/rpc/dispatcher.rb +9 -9
- data/lib/kobako/rpc/envelope.rb +3 -3
- data/lib/kobako/rpc/fault.rb +3 -2
- data/lib/kobako/rpc/handle.rb +3 -2
- data/lib/kobako/rpc/handle_table.rb +7 -7
- data/lib/kobako/rpc/namespace.rb +3 -3
- data/lib/kobako/rpc/server.rb +14 -12
- data/lib/kobako/sandbox.rb +147 -125
- data/lib/kobako/sandbox_options.rb +73 -0
- data/lib/kobako/snippet/binary.rb +30 -0
- data/lib/kobako/snippet/source.rb +28 -0
- data/lib/kobako/snippet/table.rb +174 -0
- data/lib/kobako/snippet.rb +20 -0
- data/lib/kobako/version.rb +1 -1
- data/sig/kobako/errors.rbs +3 -0
- data/sig/kobako/invocation.rbs +23 -0
- data/sig/kobako/sandbox.rbs +17 -18
- data/sig/kobako/sandbox_options.rbs +32 -0
- data/sig/kobako/snippet/binary.rbs +12 -0
- data/sig/kobako/snippet/source.rbs +13 -0
- data/sig/kobako/snippet/table.rbs +36 -0
- data/sig/kobako/snippet.rbs +4 -0
- data/sig/kobako/wasm.rbs +3 -1
- metadata +13 -1
|
@@ -3,32 +3,37 @@
|
|
|
3
3
|
//! Constructed via [`Instance::from_path`]; the wasmtime [`Engine`] and
|
|
4
4
|
//! compiled [`Module`] are owned by the [`super::cache`] singletons and
|
|
5
5
|
//! never surface to Ruby. The instance wraps a [`StoreCell`] (interior-
|
|
6
|
-
//! mutability around `wasmtime::Store<HostState>`) plus
|
|
7
|
-
//! [`TypedFunc`] handles for the
|
|
8
|
-
//! run path.
|
|
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
9
|
//!
|
|
10
10
|
//! The Ruby surface intentionally exposes intent, not the underlying ABI
|
|
11
|
-
//! (SPEC.md "Code Organization"). The
|
|
12
|
-
//!
|
|
13
|
-
//!
|
|
14
|
-
//!
|
|
15
|
-
//!
|
|
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=`.
|
|
16
20
|
//!
|
|
17
|
-
//! WASI stdout/stderr capture (
|
|
18
|
-
//! route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`]
|
|
19
|
-
//! rebuilt at the start of every [`Instance::
|
|
20
|
-
//! is enforced directly on the
|
|
21
|
-
//!
|
|
22
|
-
//! that exceeded the cap, and
|
|
23
|
-
//! bytes back to `cap` before
|
|
24
|
-
//!
|
|
25
|
-
//! `
|
|
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.
|
|
26
31
|
//!
|
|
27
|
-
//! Per-run cap enforcement (
|
|
28
|
-
//! installs an epoch-deadline callback for wall-clock timeout and
|
|
29
|
-
//! [`ResourceLimiter`] for the linear-memory cap. Wasmtime turns
|
|
30
|
-
//! limiter / callback errors into traps;
|
|
31
|
-
//! trap source to surface as `Kobako::Wasm::TimeoutError` or
|
|
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
|
|
32
37
|
//! `Kobako::Wasm::MemoryLimitError` so the `Sandbox` layer can map them
|
|
33
38
|
//! to the named `Kobako::TrapError` subclasses.
|
|
34
39
|
//!
|
|
@@ -53,7 +58,7 @@ use wasmtime_wasi::WasiCtxBuilder;
|
|
|
53
58
|
use super::cache::{cached_module, shared_engine};
|
|
54
59
|
use super::dispatch;
|
|
55
60
|
use super::host_state::{HostState, MemoryLimitTrap, StoreCell, TimeoutTrap};
|
|
56
|
-
use super::{memory_limit_err, timeout_err, wasm_err};
|
|
61
|
+
use super::{memory_limit_err, rstring_to_vec, timeout_err, wasm_err};
|
|
57
62
|
|
|
58
63
|
#[magnus::wrap(class = "Kobako::Wasm::Instance", free_immediately, size)]
|
|
59
64
|
pub(crate) struct Instance {
|
|
@@ -66,14 +71,16 @@ pub(crate) struct Instance {
|
|
|
66
71
|
//
|
|
67
72
|
// `__kobako_alloc` is NOT cached here — only `dispatch.rs` calls it,
|
|
68
73
|
// and it does so through `Caller::get_export` on the wasmtime side.
|
|
69
|
-
|
|
74
|
+
eval: Option<TypedFunc<(), ()>>,
|
|
75
|
+
run: Option<TypedFunc<(i32, i32), ()>>,
|
|
70
76
|
take_outcome: Option<TypedFunc<(), u64>>,
|
|
71
|
-
// Wall-clock cap for one guest `#run` (
|
|
77
|
+
// Wall-clock cap for one guest `#run` (docs/behavior.md B-01); `None` disables
|
|
72
78
|
// the cap. Translated into an `Instant`-based deadline stamped into
|
|
73
|
-
// [`HostState`] at the top of every `Instance::
|
|
79
|
+
// [`HostState`] at the top of every `Instance::eval`.
|
|
74
80
|
timeout: Option<Duration>,
|
|
75
|
-
// Per-channel byte caps for guest stdout / stderr capture
|
|
76
|
-
// B-01 / B-04). `None` disables the cap on that
|
|
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
|
|
77
84
|
// [`Instance::refresh_wasi`] to size the MemoryOutputPipe and by
|
|
78
85
|
// [`Instance::stdout`] / [`Instance::stderr`] to compute the
|
|
79
86
|
// truncation flag. See the module-level note above for the `cap + 1`
|
|
@@ -91,10 +98,10 @@ impl Instance {
|
|
|
91
98
|
/// constructor for `Kobako::Wasm::Instance` — Engine and Module are
|
|
92
99
|
/// never visible to Ruby.
|
|
93
100
|
///
|
|
94
|
-
/// `timeout_seconds` is the
|
|
101
|
+
/// `timeout_seconds` is the docs/behavior.md B-01 wall-clock cap in seconds
|
|
95
102
|
/// (`None` disables); `memory_limit` is the linear-memory cap in
|
|
96
103
|
/// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
|
|
97
|
-
/// are the per-channel output caps (
|
|
104
|
+
/// are the per-channel output caps (docs/behavior.md B-01 / B-04; `None`
|
|
98
105
|
/// disables). All four are validated by the caller
|
|
99
106
|
/// (`Kobako::Sandbox`); this method only refuses non-finite or
|
|
100
107
|
/// non-positive timeouts as a defence in depth.
|
|
@@ -151,7 +158,7 @@ impl Instance {
|
|
|
151
158
|
|
|
152
159
|
// Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2
|
|
153
160
|
// to the MemoryOutputPipes set up before each run via
|
|
154
|
-
// `Instance::
|
|
161
|
+
// `Instance::eval`. The closure pulls a `&mut WasiP1Ctx` out of
|
|
155
162
|
// HostState; the panic semantics live inside `HostState::wasi_mut`
|
|
156
163
|
// so the wiring stays honest about its precondition.
|
|
157
164
|
p1::add_to_linker_sync(&mut linker, |state: &mut HostState| state.wasi_mut())
|
|
@@ -186,22 +193,28 @@ impl Instance {
|
|
|
186
193
|
// (test fixture is a bare module); the host enforces presence at
|
|
187
194
|
// invocation time by raising a Ruby `Kobako::Wasm::Error` when the
|
|
188
195
|
// cached Option is None. Only the SPEC ABI `() -> ()` shape is
|
|
189
|
-
// accepted for `__kobako_run
|
|
190
|
-
|
|
196
|
+
// accepted for `__kobako_eval`; `__kobako_run` takes
|
|
197
|
+
// `(env_ptr, env_len) -> ()` per docs/wire-codec.md § ABI
|
|
198
|
+
// Signatures.
|
|
199
|
+
let (eval, run, take_outcome) = {
|
|
191
200
|
let mut store_ref = store_cell.borrow_mut();
|
|
192
201
|
let mut ctx = store_ref.as_context_mut();
|
|
202
|
+
let eval = instance
|
|
203
|
+
.get_typed_func::<(), ()>(&mut ctx, "__kobako_eval")
|
|
204
|
+
.ok();
|
|
193
205
|
let run = instance
|
|
194
|
-
.get_typed_func::<(), ()>(&mut ctx, "__kobako_run")
|
|
206
|
+
.get_typed_func::<(i32, i32), ()>(&mut ctx, "__kobako_run")
|
|
195
207
|
.ok();
|
|
196
208
|
let take_outcome = instance
|
|
197
209
|
.get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
|
|
198
210
|
.ok();
|
|
199
|
-
(run, take_outcome)
|
|
211
|
+
(eval, run, take_outcome)
|
|
200
212
|
};
|
|
201
213
|
|
|
202
214
|
Ok(Self {
|
|
203
215
|
inner: instance,
|
|
204
216
|
store: store_cell,
|
|
217
|
+
eval,
|
|
205
218
|
run,
|
|
206
219
|
take_outcome,
|
|
207
220
|
timeout,
|
|
@@ -227,25 +240,63 @@ impl Instance {
|
|
|
227
240
|
// taxonomy.
|
|
228
241
|
// -----------------------------------------------------------------
|
|
229
242
|
|
|
230
|
-
/// Execute one guest
|
|
243
|
+
/// Execute one guest invocation (`__kobako_eval` — one-shot source).
|
|
231
244
|
///
|
|
232
245
|
/// Rebuilds the WASI context with fresh stdin / stdout / stderr pipes
|
|
233
|
-
/// (the
|
|
234
|
-
///
|
|
235
|
-
/// caps (
|
|
236
|
-
///
|
|
237
|
-
///
|
|
238
|
-
|
|
246
|
+
/// (the three-frame stdin protocol carries +preamble+, +source+, then
|
|
247
|
+
/// +snippets+ — docs/wire-codec.md § Invocation channels), then
|
|
248
|
+
/// invokes `__kobako_eval`. Per-invocation caps (docs/behavior.md
|
|
249
|
+
/// B-01) are primed here: the wall-clock deadline is stamped into
|
|
250
|
+
/// [`HostState`] and the epoch deadline is set to fire at the next
|
|
251
|
+
/// ticker tick; the memory-cap limiter is already wired.
|
|
252
|
+
pub(crate) fn eval(
|
|
253
|
+
&self,
|
|
254
|
+
preamble: RString,
|
|
255
|
+
source: RString,
|
|
256
|
+
snippets: RString,
|
|
257
|
+
) -> Result<(), MagnusError> {
|
|
258
|
+
let ruby = Ruby::get().expect("Ruby thread");
|
|
259
|
+
let eval = require_export(&ruby, self.eval.as_ref(), "__kobako_eval")?;
|
|
260
|
+
self.refresh_wasi(&[
|
|
261
|
+
rstring_to_vec(preamble),
|
|
262
|
+
rstring_to_vec(source),
|
|
263
|
+
rstring_to_vec(snippets),
|
|
264
|
+
]);
|
|
265
|
+
self.prime_caps();
|
|
266
|
+
let result = {
|
|
267
|
+
let mut store_ref = self.store.borrow_mut();
|
|
268
|
+
eval.call(store_ref.as_context_mut(), ())
|
|
269
|
+
};
|
|
270
|
+
self.disarm_caps();
|
|
271
|
+
result.map_err(|e| call_err(&ruby, "__kobako_eval", e))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// Execute one entrypoint dispatch (`__kobako_run`).
|
|
275
|
+
///
|
|
276
|
+
/// Rebuilds the WASI context with the two-frame stdin protocol
|
|
277
|
+
/// (preamble + snippets; no user source frame — docs/wire-codec.md
|
|
278
|
+
/// § Invocation channels), copies +envelope+ bytes into guest linear
|
|
279
|
+
/// memory via `__kobako_alloc`, and calls `__kobako_run(env_ptr,
|
|
280
|
+
/// env_len)`. Per-invocation cap semantics match [`Instance::eval`].
|
|
281
|
+
/// Returns +Kobako::Wasm::Error+ ("alloc returned 0") when guest
|
|
282
|
+
/// allocation fails (docs/behavior.md E-31).
|
|
283
|
+
pub(crate) fn run(
|
|
284
|
+
&self,
|
|
285
|
+
preamble: RString,
|
|
286
|
+
snippets: RString,
|
|
287
|
+
envelope: RString,
|
|
288
|
+
) -> Result<(), MagnusError> {
|
|
239
289
|
let ruby = Ruby::get().expect("Ruby thread");
|
|
240
|
-
let run = self
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
.ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_run"))?;
|
|
244
|
-
self.refresh_wasi(preamble, source)?;
|
|
290
|
+
let run = require_export(&ruby, self.run.as_ref(), "__kobako_run")?;
|
|
291
|
+
self.refresh_wasi(&[rstring_to_vec(preamble), rstring_to_vec(snippets)]);
|
|
292
|
+
let (env_ptr, env_len) = self.write_envelope(&ruby, envelope)?;
|
|
245
293
|
self.prime_caps();
|
|
246
|
-
let result =
|
|
294
|
+
let result = {
|
|
295
|
+
let mut store_ref = self.store.borrow_mut();
|
|
296
|
+
run.call(store_ref.as_context_mut(), (env_ptr, env_len))
|
|
297
|
+
};
|
|
247
298
|
self.disarm_caps();
|
|
248
|
-
result.map_err(|e|
|
|
299
|
+
result.map_err(|e| call_err(&ruby, "__kobako_run", e))
|
|
249
300
|
}
|
|
250
301
|
|
|
251
302
|
/// Return the stdout capture from the most recent run as a Ruby
|
|
@@ -286,11 +337,18 @@ impl Instance {
|
|
|
286
337
|
// Private helpers.
|
|
287
338
|
// -----------------------------------------------------------------
|
|
288
339
|
|
|
289
|
-
/// Stamp the per-
|
|
290
|
-
/// the wasmtime epoch deadline so the next ticker tick
|
|
291
|
-
/// epoch-deadline callback. When `timeout` is disabled,
|
|
292
|
-
/// is set far enough in the future that the callback
|
|
293
|
-
/// never fires.
|
|
340
|
+
/// Stamp the per-invocation wall-clock deadline into [`HostState`]
|
|
341
|
+
/// and prime the wasmtime epoch deadline so the next ticker tick
|
|
342
|
+
/// wakes the epoch-deadline callback. When `timeout` is disabled,
|
|
343
|
+
/// the deadline is set far enough in the future that the callback
|
|
344
|
+
/// effectively never fires.
|
|
345
|
+
///
|
|
346
|
+
/// Also captures the current linear-memory size as the baseline
|
|
347
|
+
/// for the docs/behavior.md E-20 per-invocation memory delta cap.
|
|
348
|
+
/// The mruby image's declared initial allocation and the high-water
|
|
349
|
+
/// mark left by prior invocations on the same Sandbox are folded
|
|
350
|
+
/// into the baseline rather than the budget — only `memory.grow`
|
|
351
|
+
/// past +baseline+ counts against `memory_limit`.
|
|
294
352
|
fn prime_caps(&self) {
|
|
295
353
|
let mut store_ref = self.store.borrow_mut();
|
|
296
354
|
match self.timeout {
|
|
@@ -304,7 +362,11 @@ impl Instance {
|
|
|
304
362
|
store_ref.set_epoch_deadline(u64::MAX);
|
|
305
363
|
}
|
|
306
364
|
}
|
|
307
|
-
|
|
365
|
+
let baseline = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
366
|
+
Some(Extern::Memory(m)) => m.data_size(store_ref.as_context_mut()),
|
|
367
|
+
_ => 0,
|
|
368
|
+
};
|
|
369
|
+
store_ref.data_mut().arm_memory_cap(baseline);
|
|
308
370
|
}
|
|
309
371
|
|
|
310
372
|
/// Drop the memory cap as soon as the guest call returns so that
|
|
@@ -312,44 +374,60 @@ impl Instance {
|
|
|
312
374
|
/// which can grow guest memory transiently) is not attributed to
|
|
313
375
|
/// the user script. Paired with [`Instance::prime_caps`].
|
|
314
376
|
fn disarm_caps(&self) {
|
|
315
|
-
self.store
|
|
316
|
-
.borrow_mut()
|
|
317
|
-
.data_mut()
|
|
318
|
-
.limiter_mut()
|
|
319
|
-
.deactivate();
|
|
377
|
+
self.store.borrow_mut().data_mut().disarm_memory_cap();
|
|
320
378
|
}
|
|
321
379
|
|
|
322
|
-
///
|
|
323
|
-
///
|
|
324
|
-
///
|
|
325
|
-
///
|
|
326
|
-
///
|
|
327
|
-
fn
|
|
380
|
+
/// Allocate a +len+-byte buffer in guest linear memory via
|
|
381
|
+
/// `__kobako_alloc`, copy +envelope+ into it, and return +(ptr, len)+
|
|
382
|
+
/// as +i32+ values matching the `__kobako_run(env_ptr, env_len)` ABI.
|
|
383
|
+
/// Raises +Kobako::Wasm::Error+ when the guest export is missing or
|
|
384
|
+
/// allocation fails (docs/behavior.md E-31).
|
|
385
|
+
fn write_envelope(&self, ruby: &Ruby, envelope: RString) -> Result<(i32, i32), MagnusError> {
|
|
386
|
+
let bytes = rstring_to_vec(envelope);
|
|
387
|
+
let len_i32 = envelope_len_to_i32(bytes.len()).map_err(|msg| wasm_err(ruby, msg))?;
|
|
388
|
+
|
|
328
389
|
let mut store_ref = self.store.borrow_mut();
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
let
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
390
|
+
let alloc: TypedFunc<u32, u32> = self
|
|
391
|
+
.inner
|
|
392
|
+
.get_typed_func(store_ref.as_context_mut(), "__kobako_alloc")
|
|
393
|
+
.map_err(|_| wasm_err(ruby, "guest does not export __kobako_alloc"))?;
|
|
394
|
+
let ptr = alloc
|
|
395
|
+
.call(store_ref.as_context_mut(), bytes.len() as u32)
|
|
396
|
+
.map_err(|e| wasm_err(ruby, format!("__kobako_alloc(): {}", e)))?;
|
|
397
|
+
if ptr == 0 {
|
|
398
|
+
return Err(wasm_err(ruby, "__kobako_alloc returned 0 (out of memory)"));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let memory: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
402
|
+
Some(Extern::Memory(m)) => m,
|
|
403
|
+
_ => return Err(wasm_err(ruby, "guest does not export 'memory'")),
|
|
404
|
+
};
|
|
405
|
+
let data = memory.data_mut(store_ref.as_context_mut());
|
|
406
|
+
let range = guest_buffer_range(ptr as usize, bytes.len(), data.len())
|
|
407
|
+
.map_err(|msg| wasm_err(ruby, msg))?;
|
|
408
|
+
data[range].copy_from_slice(&bytes);
|
|
409
|
+
|
|
410
|
+
Ok((ptr as i32, len_i32))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/// Rebuild the WASI context with fresh stdin (carrying every frame in
|
|
414
|
+
/// +frames+, each prefixed by its 4-byte big-endian u32 length —
|
|
415
|
+
/// docs/wire-codec.md § Invocation channels) plus fresh stdout / stderr
|
|
416
|
+
/// pipes. Called at the top of every guest invocation: +#eval+ passes
|
|
417
|
+
/// three frames (preamble, source, snippets), +#run+ passes two
|
|
418
|
+
/// (preamble, snippets — the invocation envelope arrives via linear
|
|
419
|
+
/// memory instead). Each output pipe is sized at `cap + 1` so
|
|
420
|
+
/// [`Instance::stdout`] / [`Instance::stderr`] can distinguish "wrote
|
|
421
|
+
/// exactly cap bytes" from "exceeded cap"; uncapped channels fall back
|
|
422
|
+
/// to `usize::MAX` and rely on `memory_limit` (docs/behavior.md E-20)
|
|
423
|
+
/// for the real ceiling.
|
|
424
|
+
fn refresh_wasi(&self, frames: &[Vec<u8>]) {
|
|
425
|
+
let total: usize = frames.iter().map(|f| 4 + f.len()).sum();
|
|
426
|
+
let mut stdin_content: Vec<u8> = Vec::with_capacity(total);
|
|
427
|
+
for frame in frames {
|
|
428
|
+
stdin_content.extend_from_slice(&(frame.len() as u32).to_be_bytes());
|
|
429
|
+
stdin_content.extend_from_slice(frame);
|
|
430
|
+
}
|
|
353
431
|
|
|
354
432
|
let stdin_pipe = MemoryInputPipe::new(stdin_content);
|
|
355
433
|
let stdout_pipe = MemoryOutputPipe::new(pipe_capacity(self.stdout_limit_bytes));
|
|
@@ -365,8 +443,6 @@ impl Instance {
|
|
|
365
443
|
.borrow_mut()
|
|
366
444
|
.data_mut()
|
|
367
445
|
.install_wasi(wasi, stdout_pipe, stderr_pipe);
|
|
368
|
-
|
|
369
|
-
Ok(())
|
|
370
446
|
}
|
|
371
447
|
|
|
372
448
|
/// Invoke `__kobako_take_outcome`, decode the packed +(ptr<<32)|len+
|
|
@@ -375,39 +451,72 @@ impl Instance {
|
|
|
375
451
|
/// arithmetic overflows, the slice falls outside live memory, or the
|
|
376
452
|
/// `memory` export itself is absent.
|
|
377
453
|
fn fetch_outcome_bytes(&self, ruby: &Ruby) -> Result<Vec<u8>, MagnusError> {
|
|
378
|
-
let take = self
|
|
379
|
-
.take_outcome
|
|
380
|
-
.as_ref()
|
|
381
|
-
.ok_or_else(|| wasm_err(ruby, "guest does not export __kobako_take_outcome"))?;
|
|
454
|
+
let take = require_export(ruby, self.take_outcome.as_ref(), "__kobako_take_outcome")?;
|
|
382
455
|
|
|
383
456
|
let mut store_ref = self.store.borrow_mut();
|
|
384
457
|
let packed = take
|
|
385
458
|
.call(store_ref.as_context_mut(), ())
|
|
386
459
|
.map_err(|e| wasm_err(ruby, format!("__kobako_take_outcome(): {}", e)))?;
|
|
387
|
-
let ptr = (
|
|
388
|
-
let len = (packed & 0xffff_ffff) as usize;
|
|
460
|
+
let (ptr, len) = unpack_outcome_packed(packed);
|
|
389
461
|
|
|
390
462
|
let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
|
|
391
463
|
Some(Extern::Memory(m)) => m,
|
|
392
464
|
_ => return Err(wasm_err(ruby, "guest does not export 'memory'")),
|
|
393
465
|
};
|
|
394
466
|
let data = mem.data(store_ref.as_context_mut());
|
|
395
|
-
let
|
|
396
|
-
.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
467
|
+
let range = guest_buffer_range(ptr, len, data.len())
|
|
468
|
+
.map_err(|msg| wasm_err(ruby, format!("outcome: {}", msg)))?;
|
|
469
|
+
Ok(data[range].to_vec())
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/// Return the cached +TypedFunc+ for an ABI export, or raise
|
|
474
|
+
/// +Kobako::Wasm::Error+ when the option is +None+. The run-path
|
|
475
|
+
/// methods (+#eval+, +#run+, +#outcome!+) all share the same
|
|
476
|
+
/// "missing export → Ruby error" boilerplate; this helper collapses
|
|
477
|
+
/// the three sites onto one safe entry.
|
|
478
|
+
fn require_export<'a, Params, Results>(
|
|
479
|
+
ruby: &Ruby,
|
|
480
|
+
export: Option<&'a TypedFunc<Params, Results>>,
|
|
481
|
+
name: &str,
|
|
482
|
+
) -> Result<&'a TypedFunc<Params, Results>, MagnusError>
|
|
483
|
+
where
|
|
484
|
+
Params: wasmtime::WasmParams,
|
|
485
|
+
Results: wasmtime::WasmResults,
|
|
486
|
+
{
|
|
487
|
+
export.ok_or_else(|| wasm_err(ruby, format!("guest does not export {}", name)))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/// Validate the invocation envelope length and return it as +i32+ — the
|
|
491
|
+
/// signed wasm wire-ABI parameter type for `__kobako_run`. Rejects sizes
|
|
492
|
+
/// above +i32::MAX+ so the downstream cast cannot silently wrap.
|
|
493
|
+
fn envelope_len_to_i32(len: usize) -> Result<i32, &'static str> {
|
|
494
|
+
i32::try_from(len).map_err(|_| "invocation envelope exceeds i32::MAX bytes")
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/// Compute the half-open range `[ptr, ptr + len)` for a guest linear-memory
|
|
498
|
+
/// copy, validating that the arithmetic does not overflow and the range
|
|
499
|
+
/// fits inside `mem_size`. Shared by [`Instance::write_envelope`] (write
|
|
500
|
+
/// side) and [`Instance::fetch_outcome_bytes`] (read side).
|
|
501
|
+
fn guest_buffer_range(
|
|
502
|
+
ptr: usize,
|
|
503
|
+
len: usize,
|
|
504
|
+
mem_size: usize,
|
|
505
|
+
) -> Result<core::ops::Range<usize>, &'static str> {
|
|
506
|
+
let end = ptr.checked_add(len).ok_or("ptr + len overflow")?;
|
|
507
|
+
if end > mem_size {
|
|
508
|
+
return Err("range exceeds guest memory size");
|
|
410
509
|
}
|
|
510
|
+
Ok(ptr..end)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/// Unpack the `(ptr, len)` u64 returned by `__kobako_take_outcome`:
|
|
514
|
+
/// high 32 bits = ptr, low 32 bits = len. Mirrors the guest-side
|
|
515
|
+
/// `crate::abi::unpack_u64` in `wasm/kobako-wasm/src/abi.rs`.
|
|
516
|
+
fn unpack_outcome_packed(packed: u64) -> (usize, usize) {
|
|
517
|
+
let ptr = (packed >> 32) as u32 as usize;
|
|
518
|
+
let len = packed as u32 as usize;
|
|
519
|
+
(ptr, len)
|
|
411
520
|
}
|
|
412
521
|
|
|
413
522
|
/// Translate a per-channel byte cap into the MemoryOutputPipe capacity:
|
|
@@ -446,6 +555,83 @@ fn capture_pair(ruby: &Ruby, raw: &[u8], cap: Option<usize>) -> Result<RArray, M
|
|
|
446
555
|
Ok(arr)
|
|
447
556
|
}
|
|
448
557
|
|
|
558
|
+
/// Epoch-deadline callback installed on every Store. Read the per-run
|
|
559
|
+
/// wall-clock deadline from [`HostState`] (docs/behavior.md B-01) and trap with
|
|
560
|
+
/// [`TimeoutTrap`] once the deadline has passed; otherwise extend the
|
|
561
|
+
/// next check by one tick of the process-wide epoch ticker. When the
|
|
562
|
+
/// deadline is `None` the callback should not fire under normal
|
|
563
|
+
/// `Instance::eval` / `Instance::run` flow because
|
|
564
|
+
/// `set_epoch_deadline(u64::MAX)` is used; returning a long extension
|
|
565
|
+
/// keeps the callback inert as a defence in depth.
|
|
566
|
+
fn epoch_deadline_callback(
|
|
567
|
+
ctx: StoreContextMut<'_, HostState>,
|
|
568
|
+
) -> wasmtime::Result<UpdateDeadline> {
|
|
569
|
+
match ctx.data().deadline() {
|
|
570
|
+
Some(deadline) if Instant::now() >= deadline => Err(wasmtime::Error::new(TimeoutTrap)),
|
|
571
|
+
Some(_) => Ok(UpdateDeadline::Continue(1)),
|
|
572
|
+
None => Ok(UpdateDeadline::Continue(u64::MAX / 2)),
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/// Configured-cap path classification for a wasmtime error. The
|
|
577
|
+
/// downcast logic stays in a pure helper so the
|
|
578
|
+
/// `Kobako::Wasm::TimeoutError` / `MemoryLimitError` /
|
|
579
|
+
/// `Kobako::Wasm::Error` mapping can be exercised from `cargo test`
|
|
580
|
+
/// without the magnus surface.
|
|
581
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
582
|
+
enum TrapClass {
|
|
583
|
+
/// docs/behavior.md E-19 wall-clock cap path.
|
|
584
|
+
Timeout,
|
|
585
|
+
/// docs/behavior.md E-20 linear-memory cap path.
|
|
586
|
+
MemoryLimit,
|
|
587
|
+
/// Any other wasmtime error — surfaces as the base
|
|
588
|
+
/// `Kobako::Wasm::Error`.
|
|
589
|
+
Other,
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/// Inspect a wasmtime error to decide which `Kobako::Wasm::*` class it
|
|
593
|
+
/// should map to. Pure function — operates on the error's downcast
|
|
594
|
+
/// chain only, no magnus / Ruby state required.
|
|
595
|
+
fn classify_trap(err: &wasmtime::Error) -> TrapClass {
|
|
596
|
+
if err.downcast_ref::<TimeoutTrap>().is_some() {
|
|
597
|
+
TrapClass::Timeout
|
|
598
|
+
} else if err.downcast_ref::<MemoryLimitTrap>().is_some() {
|
|
599
|
+
TrapClass::MemoryLimit
|
|
600
|
+
} else {
|
|
601
|
+
TrapClass::Other
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/// Map a wasmtime call error to the right `Kobako::Wasm::*` Ruby
|
|
606
|
+
/// exception class. `__kobako_eval` / `__kobako_run` traps are routed
|
|
607
|
+
/// through [`classify_trap`]; +export+ is the failing export name and
|
|
608
|
+
/// appears in the trap message so the Sandbox layer can attribute the
|
|
609
|
+
/// fault to the right verb.
|
|
610
|
+
fn call_err(ruby: &Ruby, export: &str, err: wasmtime::Error) -> MagnusError {
|
|
611
|
+
let msg = format!("{}(): {}", export, err);
|
|
612
|
+
match classify_trap(&err) {
|
|
613
|
+
TrapClass::Timeout => timeout_err(ruby, msg),
|
|
614
|
+
TrapClass::MemoryLimit => memory_limit_err(ruby, msg),
|
|
615
|
+
TrapClass::Other => wasm_err(ruby, msg),
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/// Map an instantiation error to the right `Kobako::Wasm::*` Ruby
|
|
620
|
+
/// exception. The memory cap is dormant during instantiation by design
|
|
621
|
+
/// (see [`HostState::arm_memory_cap`] / [`HostState::disarm_memory_cap`]),
|
|
622
|
+
/// but [`MemoryLimitTrap`] is still possible if a future Sandbox
|
|
623
|
+
/// configuration enables it during instantiation — keep the mapping
|
|
624
|
+
/// symmetric with [`call_err`]. [`TrapClass::Timeout`] is unreachable on
|
|
625
|
+
/// the instantiation path (the epoch deadline is not armed yet) but
|
|
626
|
+
/// folding it into the same `match` keeps the two paths visually paired.
|
|
627
|
+
fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
|
|
628
|
+
let msg = format!("instantiate: {}", err);
|
|
629
|
+
match classify_trap(&err) {
|
|
630
|
+
TrapClass::MemoryLimit => memory_limit_err(ruby, msg),
|
|
631
|
+
TrapClass::Timeout | TrapClass::Other => wasm_err(ruby, msg),
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
449
635
|
#[cfg(test)]
|
|
450
636
|
mod tests {
|
|
451
637
|
//! Host-side unit tests for the pure capture helpers. The Ruby-
|
|
@@ -453,7 +639,11 @@ mod tests {
|
|
|
453
639
|
//! allowlist excludes guest fd 2 writes); these tests pin the
|
|
454
640
|
//! channel-agnostic slicing so a regression that only breaks one
|
|
455
641
|
//! channel cannot sneak through.
|
|
456
|
-
use super::{
|
|
642
|
+
use super::{
|
|
643
|
+
classify_trap, clip_capture, envelope_len_to_i32, guest_buffer_range, pipe_capacity,
|
|
644
|
+
unpack_outcome_packed, TrapClass,
|
|
645
|
+
};
|
|
646
|
+
use super::{MemoryLimitTrap, TimeoutTrap};
|
|
457
647
|
|
|
458
648
|
#[test]
|
|
459
649
|
fn pipe_capacity_adds_one_when_cap_is_set() {
|
|
@@ -508,49 +698,74 @@ mod tests {
|
|
|
508
698
|
assert_eq!(bytes, b"");
|
|
509
699
|
assert!(!truncated);
|
|
510
700
|
}
|
|
511
|
-
}
|
|
512
701
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
/// deadline is `None` the callback should not fire under normal
|
|
518
|
-
/// `Instance::run` flow because `set_epoch_deadline(u64::MAX)` is used;
|
|
519
|
-
/// returning a long extension keeps the callback inert as a defence in
|
|
520
|
-
/// depth.
|
|
521
|
-
fn epoch_deadline_callback(
|
|
522
|
-
ctx: StoreContextMut<'_, HostState>,
|
|
523
|
-
) -> wasmtime::Result<UpdateDeadline> {
|
|
524
|
-
match ctx.data().deadline() {
|
|
525
|
-
Some(deadline) if Instant::now() >= deadline => Err(wasmtime::Error::new(TimeoutTrap)),
|
|
526
|
-
Some(_) => Ok(UpdateDeadline::Continue(1)),
|
|
527
|
-
None => Ok(UpdateDeadline::Continue(u64::MAX / 2)),
|
|
702
|
+
#[test]
|
|
703
|
+
fn envelope_len_to_i32_accepts_zero_and_max() {
|
|
704
|
+
assert_eq!(envelope_len_to_i32(0), Ok(0));
|
|
705
|
+
assert_eq!(envelope_len_to_i32(i32::MAX as usize), Ok(i32::MAX));
|
|
528
706
|
}
|
|
529
|
-
}
|
|
530
707
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
fn run_call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
|
|
536
|
-
if err.downcast_ref::<TimeoutTrap>().is_some() {
|
|
537
|
-
return timeout_err(ruby, format!("__kobako_run(): {}", err));
|
|
708
|
+
#[test]
|
|
709
|
+
fn envelope_len_to_i32_rejects_past_i32_max() {
|
|
710
|
+
assert!(envelope_len_to_i32(i32::MAX as usize + 1).is_err());
|
|
711
|
+
assert!(envelope_len_to_i32(usize::MAX).is_err());
|
|
538
712
|
}
|
|
539
|
-
|
|
540
|
-
|
|
713
|
+
|
|
714
|
+
#[test]
|
|
715
|
+
fn guest_buffer_range_returns_half_open_range() {
|
|
716
|
+
// Standard case: ptr + len fits inside memory.
|
|
717
|
+
assert_eq!(guest_buffer_range(10, 5, 100), Ok(10..15));
|
|
541
718
|
}
|
|
542
|
-
wasm_err(ruby, format!("__kobako_run(): {}", err))
|
|
543
|
-
}
|
|
544
719
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
720
|
+
#[test]
|
|
721
|
+
fn guest_buffer_range_accepts_zero_length_at_any_in_bounds_ptr() {
|
|
722
|
+
// Zero-length writes / reads must succeed as long as ptr is in
|
|
723
|
+
// bounds — both reactor calls hand zero-length frames through
|
|
724
|
+
// (e.g. an empty Frame 3 snippets list).
|
|
725
|
+
assert_eq!(guest_buffer_range(0, 0, 0), Ok(0..0));
|
|
726
|
+
assert_eq!(guest_buffer_range(42, 0, 100), Ok(42..42));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
#[test]
|
|
730
|
+
fn guest_buffer_range_rejects_ptr_plus_len_overflow() {
|
|
731
|
+
assert!(guest_buffer_range(usize::MAX, 1, usize::MAX).is_err());
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
#[test]
|
|
735
|
+
fn guest_buffer_range_rejects_end_past_memory() {
|
|
736
|
+
assert!(guest_buffer_range(10, 100, 50).is_err());
|
|
737
|
+
// End exactly equal to mem_size is in-bounds.
|
|
738
|
+
assert_eq!(guest_buffer_range(0, 50, 50), Ok(0..50));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
#[test]
|
|
742
|
+
fn unpack_outcome_packed_extracts_high_ptr_low_len() {
|
|
743
|
+
assert_eq!(
|
|
744
|
+
unpack_outcome_packed(0xAABB_CCDD_1122_3344),
|
|
745
|
+
(0xAABB_CCDD, 0x1122_3344)
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
#[test]
|
|
750
|
+
fn unpack_outcome_packed_zero_decodes_to_zero_pair() {
|
|
751
|
+
assert_eq!(unpack_outcome_packed(0), (0, 0));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
#[test]
|
|
755
|
+
fn classify_trap_routes_timeout_trap_to_timeout() {
|
|
756
|
+
let err = wasmtime::Error::new(TimeoutTrap);
|
|
757
|
+
assert_eq!(classify_trap(&err), TrapClass::Timeout);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
#[test]
|
|
761
|
+
fn classify_trap_routes_memory_limit_trap_to_memory_limit() {
|
|
762
|
+
let err = wasmtime::Error::new(MemoryLimitTrap::new(1 << 20, 1 << 19));
|
|
763
|
+
assert_eq!(classify_trap(&err), TrapClass::MemoryLimit);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#[test]
|
|
767
|
+
fn classify_trap_falls_back_to_other_for_unknown_errors() {
|
|
768
|
+
let err = wasmtime::Error::msg("some other wasmtime fault");
|
|
769
|
+
assert_eq!(classify_trap(&err), TrapClass::Other);
|
|
554
770
|
}
|
|
555
|
-
wasm_err(ruby, format!("instantiate: {}", err))
|
|
556
771
|
}
|