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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +123 -57
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +2 -2
  6. data/ext/kobako/src/wasm/cache.rs +3 -3
  7. data/ext/kobako/src/wasm/dispatch.rs +87 -36
  8. data/ext/kobako/src/wasm/host_state.rs +189 -52
  9. data/ext/kobako/src/wasm/instance.rs +367 -152
  10. data/ext/kobako/src/wasm.rs +19 -5
  11. data/lib/kobako/capture.rb +12 -10
  12. data/lib/kobako/codec/decoder.rb +3 -2
  13. data/lib/kobako/codec/encoder.rb +1 -1
  14. data/lib/kobako/codec/error.rb +3 -2
  15. data/lib/kobako/codec/factory.rb +11 -7
  16. data/lib/kobako/codec/utils.rb +3 -2
  17. data/lib/kobako/codec.rb +2 -1
  18. data/lib/kobako/errors.rb +22 -10
  19. data/lib/kobako/invocation.rb +112 -0
  20. data/lib/kobako/outcome/panic.rb +2 -2
  21. data/lib/kobako/outcome.rb +20 -13
  22. data/lib/kobako/rpc/dispatcher.rb +9 -9
  23. data/lib/kobako/rpc/envelope.rb +3 -3
  24. data/lib/kobako/rpc/fault.rb +3 -2
  25. data/lib/kobako/rpc/handle.rb +3 -2
  26. data/lib/kobako/rpc/handle_table.rb +7 -7
  27. data/lib/kobako/rpc/namespace.rb +3 -3
  28. data/lib/kobako/rpc/server.rb +14 -12
  29. data/lib/kobako/sandbox.rb +147 -125
  30. data/lib/kobako/sandbox_options.rb +73 -0
  31. data/lib/kobako/snippet/binary.rb +30 -0
  32. data/lib/kobako/snippet/source.rb +28 -0
  33. data/lib/kobako/snippet/table.rb +174 -0
  34. data/lib/kobako/snippet.rb +20 -0
  35. data/lib/kobako/version.rb +1 -1
  36. data/sig/kobako/errors.rbs +3 -0
  37. data/sig/kobako/invocation.rbs +23 -0
  38. data/sig/kobako/sandbox.rbs +17 -18
  39. data/sig/kobako/sandbox_options.rbs +32 -0
  40. data/sig/kobako/snippet/binary.rbs +12 -0
  41. data/sig/kobako/snippet/source.rbs +13 -0
  42. data/sig/kobako/snippet/table.rbs +36 -0
  43. data/sig/kobako/snippet.rbs +4 -0
  44. data/sig/kobako/wasm.rbs +3 -1
  45. 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 two cached
7
- //! [`TypedFunc`] handles for the SPEC ABI exports used by the host-driven
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 two-frame stdin protocol, packed-u64
12
- //! outcome encoding, and `__kobako_alloc` / `__kobako_take_outcome` /
13
- //! `__kobako_run` exports are all wrapped inside [`Instance::run`] and
14
- //! [`Instance::outcome`]; Ruby callers see only `#run(preamble, source)`,
15
- //! `#stdout`, `#stderr`, `#outcome!`, and `#server=`.
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 (SPEC.md B-04): wasmtime-wasi p1 bindings
18
- //! route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`] instances
19
- //! rebuilt at the start of every [`Instance::run`]. The per-channel cap
20
- //! is enforced directly on the pipe — the pipe is sized at `cap + 1` so
21
- //! a guest that writes exactly `cap` bytes is distinguishable from one
22
- //! that exceeded the cap, and `#stdout` / `#stderr` slice the captured
23
- //! bytes back to `cap` before returning them paired with a truncation
24
- //! flag. Uncapped channels (`None`) build the pipe at `usize::MAX`;
25
- //! `memory_limit` provides the real upper bound in that case.
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 (SPEC.md B-01, E-19, E-20): every Store
28
- //! installs an epoch-deadline callback for wall-clock timeout and a
29
- //! [`ResourceLimiter`] for the linear-memory cap. Wasmtime turns
30
- //! limiter / callback errors into traps; `Instance::run` downcasts the
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
- run: Option<TypedFunc<(), ()>>,
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` (SPEC.md B-01); `None` disables
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::run`.
79
+ // [`HostState`] at the top of every `Instance::eval`.
74
80
  timeout: Option<Duration>,
75
- // Per-channel byte caps for guest stdout / stderr capture (SPEC.md
76
- // B-01 / B-04). `None` disables the cap on that channel. Read by
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 SPEC.md B-01 wall-clock cap in seconds
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 (SPEC.md B-01 / B-04; `None`
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::run`. The closure pulls a `&mut WasiP1Ctx` out of
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
- let (run, take_outcome) = {
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 run.
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 two-frame stdin protocol carries +preamble+ then +source+
234
- /// SPEC.md ABI Signatures), then invokes `__kobako_run`. Per-run
235
- /// caps (SPEC.md B-01) are primed here: the wall-clock deadline is
236
- /// stamped into [`HostState`] and the epoch deadline is set to fire
237
- /// at the next ticker tick; the memory-cap limiter is already wired.
238
- pub(crate) fn run(&self, preamble: RString, source: RString) -> Result<(), MagnusError> {
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
- .run
242
- .as_ref()
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 = self.call_guest(run);
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| run_call_err(&ruby, 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-run wall-clock deadline into [`HostState`] and prime
290
- /// the wasmtime epoch deadline so the next ticker tick wakes the
291
- /// epoch-deadline callback. When `timeout` is disabled, the deadline
292
- /// is set far enough in the future that the callback effectively
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
- store_ref.data_mut().limiter_mut().activate();
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
- /// Invoke the cached `__kobako_run` TypedFunc against the live
323
- /// Store. Lives in its own helper so [`Instance::run`] reads as
324
- /// the run-path outline (export check refresh WASI → prime caps
325
- /// call guest disarm caps map errors) without the
326
- /// `RefCell::borrow_mut` boilerplate inline.
327
- fn call_guest(&self, run: &TypedFunc<(), ()>) -> wasmtime::Result<()> {
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
- run.call(store_ref.as_context_mut(), ())
330
- }
331
-
332
- /// Rebuild the WASI context with fresh stdin (two-frame: preamble then
333
- /// source) plus fresh stdout/stderr pipes. Called at the top of every
334
- /// `#run`. Each pipe is sized at `cap + 1` so [`Instance::stdout`] /
335
- /// [`Instance::stderr`] can distinguish "wrote exactly cap bytes"
336
- /// from "exceeded cap"; uncapped channels fall back to `usize::MAX`
337
- /// and rely on `memory_limit` (E-20) for the real ceiling.
338
- fn refresh_wasi(&self, preamble: RString, source: RString) -> Result<(), MagnusError> {
339
- // SAFETY: `as_slice` borrows are scoped to building the stdin Vec
340
- // below no Ruby allocations happen between the borrow and the
341
- // copy, so the underlying RString cannot move.
342
- let preamble_bytes: &[u8] = unsafe { preamble.as_slice() };
343
- let source_bytes: &[u8] = unsafe { source.as_slice() };
344
-
345
- let mut stdin_content: Vec<u8> =
346
- Vec::with_capacity(4 + preamble_bytes.len() + 4 + source_bytes.len());
347
- // Frame 1 — preamble
348
- stdin_content.extend_from_slice(&(preamble_bytes.len() as u32).to_be_bytes());
349
- stdin_content.extend_from_slice(preamble_bytes);
350
- // Frame 2 — user script
351
- stdin_content.extend_from_slice(&(source_bytes.len() as u32).to_be_bytes());
352
- stdin_content.extend_from_slice(source_bytes);
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 = ((packed >> 32) & 0xffff_ffff) as usize;
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 end = ptr
396
- .checked_add(len)
397
- .ok_or_else(|| wasm_err(ruby, "outcome: ptr + len overflow"))?;
398
- if end > data.len() {
399
- return Err(wasm_err(
400
- ruby,
401
- format!(
402
- "outcome: range [{}, {}) exceeds memory size {}",
403
- ptr,
404
- end,
405
- data.len()
406
- ),
407
- ));
408
- }
409
- Ok(data[ptr..end].to_vec())
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::{clip_capture, pipe_capacity};
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
- /// Epoch-deadline callback installed on every Store. Read the per-run
514
- /// wall-clock deadline from [`HostState`] (SPEC.md B-01) and trap with
515
- /// [`TimeoutTrap`] once the deadline has passed; otherwise extend the
516
- /// next check by one tick of the process-wide epoch ticker. When the
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
- /// Map a wasmtime call error to the right `Kobako::Wasm::*` Ruby
532
- /// exception class. `__kobako_run` traps are downcast to identify the
533
- /// configured-cap path (SPEC.md E-19 / E-20); everything else surfaces
534
- /// as the base `Kobako::Wasm::Error`.
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
- if err.downcast_ref::<MemoryLimitTrap>().is_some() {
540
- return memory_limit_err(ruby, format!("__kobako_run(): {}", err));
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
- /// Map an instantiation error to the right `Kobako::Wasm::*` Ruby
546
- /// exception. The memory cap is dormant during instantiation by design
547
- /// (see [`HostState::set_memory_cap_active`]), but [`MemoryLimitTrap`]
548
- /// is still possible if a future Sandbox configuration enables it
549
- /// during instantiation keep the mapping symmetric with
550
- /// [`run_call_err`].
551
- fn instantiate_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
552
- if err.downcast_ref::<MemoryLimitTrap>().is_some() {
553
- return memory_limit_err(ruby, format!("instantiate: {}", err));
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
  }