kobako 0.1.2 → 0.2.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +95 -60
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +1 -1
  6. data/ext/kobako/src/wasm/cache.rs +39 -1
  7. data/ext/kobako/src/wasm/dispatch.rs +20 -20
  8. data/ext/kobako/src/wasm/host_state.rs +261 -34
  9. data/ext/kobako/src/wasm/instance.rs +467 -272
  10. data/ext/kobako/src/wasm.rs +50 -19
  11. data/lib/kobako/capture.rb +46 -0
  12. data/lib/kobako/codec/decoder.rb +66 -0
  13. data/lib/kobako/codec/encoder.rb +37 -0
  14. data/lib/kobako/codec/error.rb +33 -0
  15. data/lib/kobako/codec/factory.rb +155 -0
  16. data/lib/kobako/codec/utils.rb +55 -0
  17. data/lib/kobako/codec.rb +27 -0
  18. data/lib/kobako/errors.rb +24 -1
  19. data/lib/kobako/outcome/panic.rb +42 -0
  20. data/lib/kobako/outcome.rb +133 -0
  21. data/lib/kobako/rpc/dispatcher.rb +169 -0
  22. data/lib/kobako/rpc/envelope.rb +118 -0
  23. data/lib/kobako/{wire/exception.rb → rpc/fault.rb} +6 -4
  24. data/lib/kobako/{wire → rpc}/handle.rb +4 -2
  25. data/lib/kobako/{registry → rpc}/handle_table.rb +9 -9
  26. data/lib/kobako/{registry/service_group.rb → rpc/namespace.rb} +20 -11
  27. data/lib/kobako/rpc/server.rb +156 -0
  28. data/lib/kobako/rpc.rb +11 -0
  29. data/lib/kobako/sandbox.rb +149 -69
  30. data/lib/kobako/version.rb +1 -1
  31. data/lib/kobako/wasm.rb +6 -16
  32. data/lib/kobako.rb +2 -0
  33. data/sig/kobako/capture.rbs +13 -0
  34. data/sig/kobako/codec/decoder.rbs +11 -0
  35. data/sig/kobako/codec/encoder.rbs +7 -0
  36. data/sig/kobako/codec/error.rbs +18 -0
  37. data/sig/kobako/codec/factory.rbs +31 -0
  38. data/sig/kobako/codec/utils.rbs +9 -0
  39. data/sig/kobako/errors.rbs +52 -0
  40. data/sig/kobako/outcome/panic.rbs +34 -0
  41. data/sig/kobako/outcome.rbs +24 -0
  42. data/sig/kobako/rpc/dispatcher.rbs +33 -0
  43. data/sig/kobako/rpc/envelope.rbs +51 -0
  44. data/sig/kobako/rpc/fault.rbs +20 -0
  45. data/sig/kobako/rpc/handle.rbs +19 -0
  46. data/sig/kobako/rpc/handle_table.rbs +25 -0
  47. data/sig/kobako/rpc/namespace.rbs +24 -0
  48. data/sig/kobako/rpc/server.rbs +37 -0
  49. data/sig/kobako/rpc.rbs +4 -0
  50. data/sig/kobako/sandbox.rbs +53 -0
  51. data/sig/kobako/wasm.rbs +37 -0
  52. data/sig/kobako.rbs +0 -1
  53. metadata +37 -17
  54. data/lib/kobako/registry/dispatcher.rb +0 -168
  55. data/lib/kobako/registry.rb +0 -160
  56. data/lib/kobako/sandbox/outcome_decoder.rb +0 -100
  57. data/lib/kobako/sandbox/output_buffer.rb +0 -79
  58. data/lib/kobako/wire/codec/decoder.rb +0 -87
  59. data/lib/kobako/wire/codec/encoder.rb +0 -41
  60. data/lib/kobako/wire/codec/error.rb +0 -35
  61. data/lib/kobako/wire/codec/factory.rb +0 -136
  62. data/lib/kobako/wire/codec.rb +0 -44
  63. data/lib/kobako/wire/envelope/payloads.rb +0 -145
  64. data/lib/kobako/wire/envelope.rb +0 -147
  65. data/lib/kobako/wire.rb +0 -40
@@ -3,56 +3,86 @@
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 three cached
7
- //! [`TypedFunc`] handles for the SPEC ABI exports.
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.
9
+ //!
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=`.
8
16
  //!
9
17
  //! WASI stdout/stderr capture (SPEC.md B-04): wasmtime-wasi p1 bindings
10
- //! route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`] instances.
11
- //! After each run the host drains the pipes via [`Instance::take_stdout`]
12
- //! / [`Instance::take_stderr`] and pushes the raw bytes through Ruby's
13
- //! OutputBuffer (which enforces the cap and `[truncated]` marker).
14
- //! Stdin carries the two-frame length-prefixed protocol: Frame 1
15
- //! (preamble msgpack) followed by Frame 2 (user script UTF-8), each
16
- //! prefixed by a 4-byte big-endian u32 length. Written via
17
- //! [`Instance::setup_wasi_pipes`] before each run (SPEC.md ABI
18
- //! Signatures).
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.
26
+ //!
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
+ //! `Kobako::Wasm::MemoryLimitError` so the `Sandbox` layer can map them
33
+ //! to the named `Kobako::TrapError` subclasses.
19
34
  //!
20
35
  //! [`Engine`]: wasmtime::Engine
21
36
  //! [`Module`]: wasmtime::Module
22
37
  //! [`TypedFunc`]: wasmtime::TypedFunc
23
38
  //! [`MemoryOutputPipe`]: wasmtime_wasi::p2::pipe::MemoryOutputPipe
39
+ //! [`ResourceLimiter`]: wasmtime::ResourceLimiter
24
40
 
25
- use std::cell::RefCell;
26
41
  use std::path::Path;
42
+ use std::time::{Duration, Instant};
27
43
 
28
- use magnus::RString;
29
- use magnus::{value::Opaque, Error as MagnusError, Ruby, Value};
44
+ use magnus::{value::Opaque, Error as MagnusError, RArray, RString, Ruby, Value};
30
45
  use wasmtime::{
31
- AsContextMut, Caller, Engine as WtEngine, Extern, Instance as WtInstance, Linker, Memory,
32
- Module as WtModule, Store as WtStore, TypedFunc,
46
+ AsContextMut, Caller, Extern, Instance as WtInstance, Linker, Memory, Module as WtModule,
47
+ ResourceLimiter, Store as WtStore, StoreContextMut, TypedFunc, UpdateDeadline,
33
48
  };
34
49
  use wasmtime_wasi::p1;
35
50
  use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
36
51
  use wasmtime_wasi::WasiCtxBuilder;
37
52
 
38
53
  use super::cache::{cached_module, shared_engine};
39
- use super::dispatch::dispatch_rpc;
40
- use super::host_state::{HostState, StoreCell};
41
- use super::wasm_err;
54
+ use super::dispatch;
55
+ use super::host_state::{HostState, MemoryLimitTrap, StoreCell, TimeoutTrap};
56
+ use super::{memory_limit_err, timeout_err, wasm_err};
42
57
 
43
58
  #[magnus::wrap(class = "Kobako::Wasm::Instance", free_immediately, size)]
44
59
  pub(crate) struct Instance {
45
60
  inner: WtInstance,
46
61
  store: StoreCell,
47
- // Cached TypedFunc handles for the three guest exports. Optional because
48
- // test fixtures (a minimal "ping" module) need not provide them; real
49
- // kobako.wasm always does, and the host enforces presence at run time.
62
+ // Cached TypedFunc handles for the two host-driven ABI exports.
63
+ // Optional because test fixtures (a minimal "ping" module) need not
64
+ // provide them; real kobako.wasm always does, and the run-path methods
65
+ // raise a Ruby `Kobako::Wasm::Error` when an export is missing.
50
66
  //
51
- // Exactly the SPEC ABI shape: `__kobako_run() -> ()`. Source delivery
52
- // uses the WASI stdin two-frame protocol (see `setup_wasi_frames`).
67
+ // `__kobako_alloc` is NOT cached here only `dispatch.rs` calls it,
68
+ // and it does so through `Caller::get_export` on the wasmtime side.
53
69
  run: Option<TypedFunc<(), ()>>,
54
70
  take_outcome: Option<TypedFunc<(), u64>>,
55
- alloc: Option<TypedFunc<i32, i32>>,
71
+ // Wall-clock cap for one guest `#run` (SPEC.md B-01); `None` disables
72
+ // the cap. Translated into an `Instant`-based deadline stamped into
73
+ // [`HostState`] at the top of every `Instance::run`.
74
+ 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
77
+ // [`Instance::refresh_wasi`] to size the MemoryOutputPipe and by
78
+ // [`Instance::stdout`] / [`Instance::stderr`] to compute the
79
+ // truncation flag. See the module-level note above for the `cap + 1`
80
+ // sizing rationale. Unlike `memory_limit` (which lives on
81
+ // [`HostState`] because the wasmtime [`ResourceLimiter`] callback
82
+ // consumes it from within the wasm engine), these caps are read only
83
+ // by Instance methods, so they live on Instance itself.
84
+ stdout_limit_bytes: Option<usize>,
85
+ stderr_limit_bytes: Option<usize>,
56
86
  }
57
87
 
58
88
  impl Instance {
@@ -60,302 +90,467 @@ impl Instance {
60
90
  /// shared Engine and per-path Module cache. The single Ruby-facing
61
91
  /// constructor for `Kobako::Wasm::Instance` — Engine and Module are
62
92
  /// never visible to Ruby.
63
- pub(crate) fn from_path(path: String) -> Result<Self, MagnusError> {
93
+ ///
94
+ /// `timeout_seconds` is the SPEC.md B-01 wall-clock cap in seconds
95
+ /// (`None` disables); `memory_limit` is the linear-memory cap in
96
+ /// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
97
+ /// are the per-channel output caps (SPEC.md B-01 / B-04; `None`
98
+ /// disables). All four are validated by the caller
99
+ /// (`Kobako::Sandbox`); this method only refuses non-finite or
100
+ /// non-positive timeouts as a defence in depth.
101
+ pub(crate) fn from_path(
102
+ path: String,
103
+ timeout_seconds: Option<f64>,
104
+ memory_limit: Option<usize>,
105
+ stdout_limit_bytes: Option<usize>,
106
+ stderr_limit_bytes: Option<usize>,
107
+ ) -> Result<Self, MagnusError> {
108
+ let ruby = Ruby::get().expect("Ruby thread");
109
+ let timeout = match timeout_seconds {
110
+ None => None,
111
+ Some(secs) if secs.is_finite() && secs > 0.0 => Some(Duration::from_secs_f64(secs)),
112
+ Some(secs) => {
113
+ return Err(wasm_err(
114
+ &ruby,
115
+ format!("timeout_seconds must be > 0 and finite, got {secs}"),
116
+ ));
117
+ }
118
+ };
119
+
64
120
  let engine = shared_engine()?;
65
121
  let module = cached_module(Path::new(&path))?;
66
- let store = WtStore::new(engine, HostState::default());
67
- let store_cell = StoreCell(RefCell::new(store));
68
- build_instance(engine, &module, store_cell)
122
+
123
+ let mut store = WtStore::new(engine, HostState::new(memory_limit));
124
+ store.limiter(|state: &mut HostState| -> &mut dyn ResourceLimiter { state.limiter_mut() });
125
+ store.epoch_deadline_callback(epoch_deadline_callback);
126
+
127
+ let store_cell = StoreCell::new(store);
128
+ Self::build(
129
+ engine,
130
+ &module,
131
+ store_cell,
132
+ timeout,
133
+ stdout_limit_bytes,
134
+ stderr_limit_bytes,
135
+ )
69
136
  }
70
137
 
71
- /// Install the Ruby-side `Kobako::Registry` into HostState. Called by
72
- /// `Kobako::Sandbox` after constructing the Registry; from this point
73
- /// on, every `__kobako_rpc_call` import invocation routes through
74
- /// `registry.dispatch(req_bytes)`.
75
- pub(crate) fn set_registry(&self, registry: Value) -> Result<(), MagnusError> {
76
- let mut store_ref = self.store.0.borrow_mut();
77
- store_ref.data_mut().registry = Some(Opaque::from(registry));
138
+ /// Build an `Instance` from an engine, module, and store cell. The
139
+ /// store cell is moved in and ends up owned by the returned Instance.
140
+ /// Wires the WASI p1 imports plus the `__kobako_dispatch` host import.
141
+ fn build(
142
+ engine: &wasmtime::Engine,
143
+ module: &WtModule,
144
+ store_cell: StoreCell,
145
+ timeout: Option<Duration>,
146
+ stdout_limit_bytes: Option<usize>,
147
+ stderr_limit_bytes: Option<usize>,
148
+ ) -> Result<Self, MagnusError> {
149
+ let ruby = Ruby::get().expect("Ruby thread");
150
+ let mut linker: Linker<HostState> = Linker::new(engine);
151
+
152
+ // Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2
153
+ // to the MemoryOutputPipes set up before each run via
154
+ // `Instance::run`. The closure pulls a `&mut WasiP1Ctx` out of
155
+ // HostState; the panic semantics live inside `HostState::wasi_mut`
156
+ // so the wiring stays honest about its precondition.
157
+ p1::add_to_linker_sync(&mut linker, |state: &mut HostState| state.wasi_mut())
158
+ .map_err(|e| wasm_err(&ruby, format!("add WASI p1 to linker: {}", e)))?;
159
+
160
+ // `__kobako_dispatch` host import. Signature per SPEC Wire ABI:
161
+ // (req_ptr: i32, req_len: i32) -> i64
162
+ // Decodes the Request bytes, dispatches via the Ruby-side
163
+ // `Kobako::RPC::Server` (set per-run via `set_server`), allocates a
164
+ // guest buffer through `__kobako_alloc`, writes the Response bytes
165
+ // there, and returns the packed `(ptr<<32)|len`. The dispatcher
166
+ // returns 0 on any wire-layer fault (including a missing
167
+ // Server); see `dispatch::handle`.
168
+ linker
169
+ .func_wrap(
170
+ "env",
171
+ "__kobako_dispatch",
172
+ |mut caller: Caller<'_, HostState>, req_ptr: i32, req_len: i32| -> i64 {
173
+ dispatch::handle(&mut caller, req_ptr, req_len)
174
+ },
175
+ )
176
+ .map_err(|e| wasm_err(&ruby, format!("define __kobako_dispatch: {}", e)))?;
177
+
178
+ let instance = {
179
+ let mut store_ref = store_cell.borrow_mut();
180
+ linker
181
+ .instantiate(store_ref.as_context_mut(), module)
182
+ .map_err(|e| instantiate_err(&ruby, e))?
183
+ };
184
+
185
+ // Best-effort export lookup. Missing exports are not an error here
186
+ // (test fixture is a bare module); the host enforces presence at
187
+ // invocation time by raising a Ruby `Kobako::Wasm::Error` when the
188
+ // cached Option is None. Only the SPEC ABI `() -> ()` shape is
189
+ // accepted for `__kobako_run`.
190
+ let (run, take_outcome) = {
191
+ let mut store_ref = store_cell.borrow_mut();
192
+ let mut ctx = store_ref.as_context_mut();
193
+ let run = instance
194
+ .get_typed_func::<(), ()>(&mut ctx, "__kobako_run")
195
+ .ok();
196
+ let take_outcome = instance
197
+ .get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
198
+ .ok();
199
+ (run, take_outcome)
200
+ };
201
+
202
+ Ok(Self {
203
+ inner: instance,
204
+ store: store_cell,
205
+ run,
206
+ take_outcome,
207
+ timeout,
208
+ stdout_limit_bytes,
209
+ stderr_limit_bytes,
210
+ })
211
+ }
212
+
213
+ /// Install the Ruby-side `Kobako::RPC::Server` into HostState. Bound to
214
+ /// Ruby as `Instance#server=`. From this point on, every
215
+ /// `__kobako_dispatch` import invocation routes through
216
+ /// `server.dispatch(req_bytes)`.
217
+ pub(crate) fn set_server(&self, server: Value) -> Result<(), MagnusError> {
218
+ let mut store_ref = self.store.borrow_mut();
219
+ store_ref.data_mut().bind_server(Opaque::from(server));
78
220
  Ok(())
79
221
  }
80
222
 
81
223
  // -----------------------------------------------------------------
82
- // Run-path methods. These drive the alloc write source run →
83
- // take_outcome flow from Ruby. Each method is best-effort — it raises
84
- // a Ruby `Kobako::Wasm::Error` when the corresponding export is
85
- // missing or fails so the Sandbox layer can map errors to the
86
- // three-class taxonomy.
224
+ // Run-path methods. Each method is best-effort it raises a Ruby
225
+ // `Kobako::Wasm::Error` when the corresponding export is missing or
226
+ // fails so the Sandbox layer can map errors to the three-class
227
+ // taxonomy.
87
228
  // -----------------------------------------------------------------
88
229
 
89
- /// Invoke the guest's `__kobako_alloc(size)` export and return the
90
- /// resulting linear-memory offset. A return of 0 indicates an
91
- /// allocation failure (caller should treat as a wire violation /
92
- /// trap).
93
- pub(crate) fn alloc(&self, size: i32) -> Result<i32, MagnusError> {
230
+ /// Execute one guest run.
231
+ ///
232
+ /// 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> {
94
239
  let ruby = Ruby::get().expect("Ruby thread");
95
- let alloc = self
96
- .alloc
240
+ let run = self
241
+ .run
97
242
  .as_ref()
98
- .ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_alloc"))?;
99
- let mut store_ref = self.store.0.borrow_mut();
100
- alloc
101
- .call(store_ref.as_context_mut(), size)
102
- .map_err(|e| wasm_err(&ruby, format!("__kobako_alloc({}): {}", size, e)))
243
+ .ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_run"))?;
244
+ self.refresh_wasi(preamble, source)?;
245
+ self.prime_caps();
246
+ let result = self.call_guest(run);
247
+ self.disarm_caps();
248
+ result.map_err(|e| run_call_err(&ruby, e))
103
249
  }
104
250
 
105
- /// Write +bytes+ into the guest's linear memory starting at +ptr+.
106
- /// Raises `Kobako::Wasm::Error` if the instance has no `memory`
107
- /// export or the slice is out of bounds.
108
- pub(crate) fn write_memory(&self, ptr: i32, bytes: RString) -> Result<(), MagnusError> {
251
+ /// Return the stdout capture from the most recent run as a Ruby
252
+ /// `[bytes, truncated]` Array — `bytes` is a binary String containing
253
+ /// the captured prefix (clipped to `stdout_limit_bytes` when set),
254
+ /// and `truncated` is a boolean that is `true` only when the guest
255
+ /// wrote strictly more than the cap. The pair is recomputed from the
256
+ /// underlying pipe contents on every call; the pipe itself is not
257
+ /// drained until the next `#run` rebuilds it.
258
+ pub(crate) fn stdout(&self) -> Result<RArray, MagnusError> {
109
259
  let ruby = Ruby::get().expect("Ruby thread");
110
- let mut store_ref = self.store.0.borrow_mut();
111
- let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
112
- Some(Extern::Memory(m)) => m,
113
- _ => return Err(wasm_err(&ruby, "guest does not export 'memory'")),
114
- };
260
+ let raw = self.store.borrow().data().stdout_bytes();
261
+ capture_pair(&ruby, &raw, self.stdout_limit_bytes)
262
+ }
115
263
 
116
- // SAFETY: RString::as_slice on a frozen-on-read borrow.
117
- let src: &[u8] = unsafe { bytes.as_slice() };
118
- let data = mem.data_mut(store_ref.as_context_mut());
119
- let start = ptr as usize;
120
- let end = start
121
- .checked_add(src.len())
122
- .ok_or_else(|| wasm_err(&ruby, "write_memory: ptr + len overflow"))?;
123
- if end > data.len() {
124
- return Err(wasm_err(
125
- &ruby,
126
- format!(
127
- "write_memory: range [{}, {}) exceeds memory size {}",
128
- start,
129
- end,
130
- data.len()
131
- ),
132
- ));
264
+ /// Return the stderr capture from the most recent run. Same shape
265
+ /// and semantics as [`Instance::stdout`].
266
+ pub(crate) fn stderr(&self) -> Result<RArray, MagnusError> {
267
+ let ruby = Ruby::get().expect("Ruby thread");
268
+ let raw = self.store.borrow().data().stderr_bytes();
269
+ capture_pair(&ruby, &raw, self.stderr_limit_bytes)
270
+ }
271
+
272
+ /// Read OUTCOME_BUFFER bytes captured during the most recent run.
273
+ /// Bound to Ruby as `Instance#outcome!`. The bang signals that the
274
+ /// underlying `__kobako_take_outcome` export is guest-side destructive
275
+ /// the buffer pointer is invalidated after this call, so a second
276
+ /// invocation within the same run is undefined — and that any failure
277
+ /// (missing export, length overflow, OOB read) raises
278
+ /// `Kobako::Wasm::Error`.
279
+ pub(crate) fn outcome(&self) -> Result<RString, MagnusError> {
280
+ let ruby = Ruby::get().expect("Ruby thread");
281
+ let bytes = self.fetch_outcome_bytes(&ruby)?;
282
+ Ok(ruby.str_from_slice(&bytes))
283
+ }
284
+
285
+ // -----------------------------------------------------------------
286
+ // Private helpers.
287
+ // -----------------------------------------------------------------
288
+
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.
294
+ fn prime_caps(&self) {
295
+ let mut store_ref = self.store.borrow_mut();
296
+ match self.timeout {
297
+ Some(timeout) => {
298
+ let deadline = Instant::now() + timeout;
299
+ store_ref.data_mut().set_deadline(Some(deadline));
300
+ store_ref.set_epoch_deadline(1);
301
+ }
302
+ None => {
303
+ store_ref.data_mut().set_deadline(None);
304
+ store_ref.set_epoch_deadline(u64::MAX);
305
+ }
133
306
  }
134
- data[start..end].copy_from_slice(src);
307
+ store_ref.data_mut().limiter_mut().activate();
308
+ }
309
+
310
+ /// Drop the memory cap as soon as the guest call returns so that
311
+ /// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
312
+ /// which can grow guest memory transiently) is not attributed to
313
+ /// the user script. Paired with [`Instance::prime_caps`].
314
+ fn disarm_caps(&self) {
315
+ self.store
316
+ .borrow_mut()
317
+ .data_mut()
318
+ .limiter_mut()
319
+ .deactivate();
320
+ }
321
+
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<()> {
328
+ 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);
353
+
354
+ let stdin_pipe = MemoryInputPipe::new(stdin_content);
355
+ let stdout_pipe = MemoryOutputPipe::new(pipe_capacity(self.stdout_limit_bytes));
356
+ let stderr_pipe = MemoryOutputPipe::new(pipe_capacity(self.stderr_limit_bytes));
357
+
358
+ let mut builder = WasiCtxBuilder::new();
359
+ builder.stdin(stdin_pipe);
360
+ builder.stdout(stdout_pipe.clone());
361
+ builder.stderr(stderr_pipe.clone());
362
+ let wasi = builder.build_p1();
363
+
364
+ self.store
365
+ .borrow_mut()
366
+ .data_mut()
367
+ .install_wasi(wasi, stdout_pipe, stderr_pipe);
368
+
135
369
  Ok(())
136
370
  }
137
371
 
138
- /// Read +len+ bytes from the guest's linear memory starting at
139
- /// +ptr+. Returns a binary-encoded Ruby String.
140
- pub(crate) fn read_memory(&self, ptr: i32, len: i32) -> Result<RString, MagnusError> {
141
- let ruby = Ruby::get().expect("Ruby thread");
142
- let mut store_ref = self.store.0.borrow_mut();
372
+ /// Invoke `__kobako_take_outcome`, decode the packed +(ptr<<32)|len+
373
+ /// u64, and copy the OUTCOME_BUFFER slice out of guest memory. Raises
374
+ /// `Kobako::Wasm::Error` when the export is missing, the +ptr+/+len+
375
+ /// arithmetic overflows, the slice falls outside live memory, or the
376
+ /// `memory` export itself is absent.
377
+ 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"))?;
382
+
383
+ let mut store_ref = self.store.borrow_mut();
384
+ let packed = take
385
+ .call(store_ref.as_context_mut(), ())
386
+ .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;
389
+
143
390
  let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
144
391
  Some(Extern::Memory(m)) => m,
145
- _ => return Err(wasm_err(&ruby, "guest does not export 'memory'")),
392
+ _ => return Err(wasm_err(ruby, "guest does not export 'memory'")),
146
393
  };
147
394
  let data = mem.data(store_ref.as_context_mut());
148
- let start = ptr as usize;
149
- let end = start
150
- .checked_add(len as usize)
151
- .ok_or_else(|| wasm_err(&ruby, "read_memory: ptr + len overflow"))?;
395
+ let end = ptr
396
+ .checked_add(len)
397
+ .ok_or_else(|| wasm_err(ruby, "outcome: ptr + len overflow"))?;
152
398
  if end > data.len() {
153
399
  return Err(wasm_err(
154
- &ruby,
400
+ ruby,
155
401
  format!(
156
- "read_memory: range [{}, {}) exceeds memory size {}",
157
- start,
402
+ "outcome: range [{}, {}) exceeds memory size {}",
403
+ ptr,
158
404
  end,
159
405
  data.len()
160
406
  ),
161
407
  ));
162
408
  }
163
- Ok(ruby.str_from_slice(&data[start..end]))
409
+ Ok(data[ptr..end].to_vec())
164
410
  }
411
+ }
165
412
 
166
- /// Invoke `__kobako_run` with the SPEC `() -> ()` shape. Source is
167
- /// delivered via the WASI stdin two-frame protocol written by
168
- /// `setup_wasi_frames` before this call.
169
- pub(crate) fn run_call(&self) -> Result<(), MagnusError> {
170
- let ruby = Ruby::get().expect("Ruby thread");
171
- let run = self
172
- .run
173
- .as_ref()
174
- .ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_run"))?;
175
- let mut store_ref = self.store.0.borrow_mut();
176
- run.call(store_ref.as_context_mut(), ())
177
- .map_err(|e| wasm_err(&ruby, format!("__kobako_run(): {}", e)))
413
+ /// Translate a per-channel byte cap into the MemoryOutputPipe capacity:
414
+ /// `cap + 1` (saturated against `usize::MAX`) when a cap is set so the
415
+ /// "wrote exactly cap" and "exceeded cap" cases stay distinguishable;
416
+ /// `usize::MAX` when the channel is uncapped.
417
+ fn pipe_capacity(cap: Option<usize>) -> usize {
418
+ match cap {
419
+ Some(c) => c.saturating_add(1),
420
+ None => usize::MAX,
178
421
  }
422
+ }
179
423
 
180
- /// Invoke `__kobako_take_outcome`. Returns the packed u64
181
- /// `(ptr << 32) | len`; the Ruby caller unpacks.
182
- pub(crate) fn take_outcome(&self) -> Result<u64, MagnusError> {
183
- let ruby = Ruby::get().expect("Ruby thread");
184
- let take = self
185
- .take_outcome
186
- .as_ref()
187
- .ok_or_else(|| wasm_err(&ruby, "guest does not export __kobako_take_outcome"))?;
188
- let mut store_ref = self.store.0.borrow_mut();
189
- take.call(store_ref.as_context_mut(), ())
190
- .map_err(|e| wasm_err(&ruby, format!("__kobako_take_outcome(): {}", e)))
424
+ /// Pure slicing core shared by [`Instance::stdout`] / [`Instance::stderr`]:
425
+ /// given the unclipped pipe snapshot and the configured cap, return the
426
+ /// bytes Ruby should observe (clipped to `cap`) plus the truncation flag.
427
+ /// `truncated` is `true` only when the snapshot strictly exceeded the cap
428
+ /// this is the "wrote `cap + 1` bytes into a `cap + 1`-sized pipe" case;
429
+ /// "wrote exactly `cap` bytes" stays `false`.
430
+ fn clip_capture(raw: &[u8], cap: Option<usize>) -> (&[u8], bool) {
431
+ match cap {
432
+ Some(c) if raw.len() > c => (&raw[..c], true),
433
+ _ => (raw, false),
191
434
  }
435
+ }
192
436
 
193
- // -----------------------------------------------------------------
194
- // WASI capture path (SPEC.md B-04). Called by Ruby's Sandbox#run.
195
- // -----------------------------------------------------------------
437
+ /// Build the `[bytes, truncated]` Ruby Array surfaced by
438
+ /// [`Instance::stdout`] / [`Instance::stderr`]. Delegates the slicing
439
+ /// to [`clip_capture`] so the channel-agnostic logic stays unit-
440
+ /// testable from `cargo test`.
441
+ fn capture_pair(ruby: &Ruby, raw: &[u8], cap: Option<usize>) -> Result<RArray, MagnusError> {
442
+ let (visible, truncated) = clip_capture(raw, cap);
443
+ let arr = ruby.ary_new_capa(2);
444
+ arr.push(ruby.str_from_slice(visible))?;
445
+ arr.push(truncated)?;
446
+ Ok(arr)
447
+ }
196
448
 
197
- /// Initialise fresh WASI pipes with the two-frame stdin content.
198
- ///
199
- /// Must be called before each `run` invocation. Creates:
200
- /// * A MemoryInputPipe for stdin with Frame 1 (preamble) + Frame 2
201
- /// (user script) encoded as length-prefixed frames: each frame is a
202
- /// 4-byte big-endian u32 length prefix followed by the payload bytes.
203
- /// The guest reads both frames from WASI stdin (SPEC.md ABI Signatures).
204
- /// * A MemoryOutputPipe for fd 1 (stdout) — transport-layer pipe.
205
- /// * A MemoryOutputPipe for fd 2 (stderr) — transport-layer pipe.
206
- ///
207
- /// `stdout_cap` and `stderr_cap` are accepted but the transport pipes are
208
- /// uncapped: SPEC.md B-04 requires that overflowing the OutputBuffer limit
209
- /// is a non-error outcome. A capped WASI pipe would produce a real trap.
210
- pub(crate) fn setup_wasi_pipes(
211
- &self,
212
- stdout_cap: i64,
213
- stderr_cap: i64,
214
- preamble_bytes: RString,
215
- source_bytes: RString,
216
- ) -> Result<(), MagnusError> {
217
- let _ = (stdout_cap, stderr_cap);
218
-
219
- // Build the two-frame stdin content. Each frame: 4-byte BE u32 length
220
- // prefix + payload bytes (SPEC.md ABI Signatures — two-frame protocol).
221
- let preamble: &[u8] = unsafe { preamble_bytes.as_slice() };
222
- let source: &[u8] = unsafe { source_bytes.as_slice() };
223
-
224
- let mut stdin_content: Vec<u8> = Vec::with_capacity(4 + preamble.len() + 4 + source.len());
225
- // Frame 1 — preamble
226
- stdin_content.extend_from_slice(&(preamble.len() as u32).to_be_bytes());
227
- stdin_content.extend_from_slice(preamble);
228
- // Frame 2 — user script
229
- stdin_content.extend_from_slice(&(source.len() as u32).to_be_bytes());
230
- stdin_content.extend_from_slice(source);
449
+ #[cfg(test)]
450
+ mod tests {
451
+ //! Host-side unit tests for the pure capture helpers. The Ruby-
452
+ //! facing E2E suite exercises stdout only (the kobako mrbgem
453
+ //! allowlist excludes guest fd 2 writes); these tests pin the
454
+ //! channel-agnostic slicing so a regression that only breaks one
455
+ //! channel cannot sneak through.
456
+ use super::{clip_capture, pipe_capacity};
231
457
 
232
- let stdin_pipe = MemoryInputPipe::new(stdin_content);
233
- let stdout_pipe = MemoryOutputPipe::new(usize::MAX);
234
- let stderr_pipe = MemoryOutputPipe::new(usize::MAX);
458
+ #[test]
459
+ fn pipe_capacity_adds_one_when_cap_is_set() {
460
+ assert_eq!(pipe_capacity(Some(5)), 6);
461
+ assert_eq!(pipe_capacity(Some(0)), 1);
462
+ }
235
463
 
236
- let mut builder = WasiCtxBuilder::new();
237
- builder.stdin(stdin_pipe);
238
- builder.stdout(stdout_pipe.clone());
239
- builder.stderr(stderr_pipe.clone());
240
- let wasi = builder.build_p1();
464
+ #[test]
465
+ fn pipe_capacity_falls_back_to_usize_max_when_uncapped() {
466
+ assert_eq!(pipe_capacity(None), usize::MAX);
467
+ }
241
468
 
242
- {
243
- let mut store_ref = self.store.0.borrow_mut();
244
- let data = store_ref.data_mut();
245
- data.wasi = Some(wasi);
246
- data.stdout_pipe = Some(stdout_pipe);
247
- data.stderr_pipe = Some(stderr_pipe);
248
- }
469
+ #[test]
470
+ fn pipe_capacity_saturates_at_usize_max() {
471
+ assert_eq!(pipe_capacity(Some(usize::MAX)), usize::MAX);
472
+ }
249
473
 
250
- Ok(())
474
+ #[test]
475
+ fn clip_capture_returns_full_bytes_when_under_cap() {
476
+ let (bytes, truncated) = clip_capture(b"abc", Some(5));
477
+ assert_eq!(bytes, b"abc");
478
+ assert!(!truncated);
251
479
  }
252
480
 
253
- /// Drain the stdout bytes captured during the most recent run.
254
- ///
255
- /// Returns a binary Ruby String containing raw bytes from guest fd 1.
256
- /// The MemoryOutputPipe clone in HostState retains its contents between
257
- /// calls — it is replaced on the next `setup_wasi_pipes`.
258
- pub(crate) fn take_stdout(&self) -> Result<RString, MagnusError> {
259
- let ruby = Ruby::get().expect("Ruby thread");
260
- let store_ref = self.store.0.borrow();
261
- let bytes = store_ref
262
- .data()
263
- .stdout_pipe
264
- .as_ref()
265
- .map(|p| p.contents())
266
- .unwrap_or_default();
267
- Ok(ruby.str_from_slice(&bytes))
481
+ #[test]
482
+ fn clip_capture_does_not_flag_truncation_at_exactly_cap_bytes() {
483
+ let (bytes, truncated) = clip_capture(b"abcde", Some(5));
484
+ assert_eq!(bytes, b"abcde");
485
+ assert!(!truncated);
268
486
  }
269
487
 
270
- /// Drain the stderr bytes captured during the most recent run.
271
- ///
272
- /// Returns a binary Ruby String containing raw bytes from guest fd 2.
273
- pub(crate) fn take_stderr(&self) -> Result<RString, MagnusError> {
274
- let ruby = Ruby::get().expect("Ruby thread");
275
- let store_ref = self.store.0.borrow();
276
- let bytes = store_ref
277
- .data()
278
- .stderr_pipe
279
- .as_ref()
280
- .map(|p| p.contents())
281
- .unwrap_or_default();
282
- Ok(ruby.str_from_slice(&bytes))
488
+ #[test]
489
+ fn clip_capture_clips_to_cap_and_flags_truncation_on_overflow() {
490
+ // The pipe is sized `cap + 1`, so the snapshot can be at most
491
+ // 6 bytes when `cap == 5`; that surface is what triggers the
492
+ // truncation flag.
493
+ let (bytes, truncated) = clip_capture(b"abcdef", Some(5));
494
+ assert_eq!(bytes, b"abcde");
495
+ assert!(truncated);
496
+ }
497
+
498
+ #[test]
499
+ fn clip_capture_treats_none_as_uncapped() {
500
+ let (bytes, truncated) = clip_capture(b"abcdef", None);
501
+ assert_eq!(bytes, b"abcdef");
502
+ assert!(!truncated);
503
+ }
504
+
505
+ #[test]
506
+ fn clip_capture_handles_empty_input() {
507
+ let (bytes, truncated) = clip_capture(b"", Some(5));
508
+ assert_eq!(bytes, b"");
509
+ assert!(!truncated);
283
510
  }
284
511
  }
285
512
 
286
- /// Build an `Instance` from an engine, module, and store cell. The store
287
- /// cell is moved in and ends up owned by the returned Instance. Wires
288
- /// the WASI p1 imports plus the `__kobako_rpc_call` host import.
289
- fn build_instance(
290
- engine: &WtEngine,
291
- module: &WtModule,
292
- store_cell: StoreCell,
293
- ) -> Result<Instance, MagnusError> {
294
- let ruby = Ruby::get().expect("Ruby thread");
295
- let mut linker: Linker<HostState> = Linker::new(engine);
296
-
297
- // Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2 to
298
- // the MemoryOutputPipes set up before each run via `setup_wasi_pipes`.
299
- // The closure extracts `&mut WasiP1Ctx` from HostState; if none is set
300
- // (e.g. a test module that never invokes WASI functions), the Option
301
- // unwrap will panic — but `setup_wasi_pipes` is always called before any
302
- // WASI-enabled run.
303
- p1::add_to_linker_sync(&mut linker, |state: &mut HostState| {
304
- state
305
- .wasi
306
- .as_mut()
307
- .expect("WASI context not initialised — call setup_wasi_pipes before run")
308
- })
309
- .map_err(|e| wasm_err(&ruby, format!("add WASI p1 to linker: {}", e)))?;
310
-
311
- // `__kobako_rpc_call` host import. Signature per SPEC Wire ABI:
312
- // (req_ptr: i32, req_len: i32) -> i64
313
- // Decodes the Request bytes, dispatches via the Ruby-side
314
- // `Kobako::Registry` (set per-run via `set_registry`), allocates a guest
315
- // buffer through `__kobako_alloc`, writes the Response bytes there, and
316
- // returns the packed `(ptr<<32)|len`. The dispatcher returns 0 on any
317
- // wire-layer fault (including a missing Registry); see `dispatch_rpc`.
318
- linker
319
- .func_wrap(
320
- "env",
321
- "__kobako_rpc_call",
322
- |mut caller: Caller<'_, HostState>, req_ptr: i32, req_len: i32| -> i64 {
323
- dispatch_rpc(&mut caller, req_ptr, req_len)
324
- },
325
- )
326
- .map_err(|e| wasm_err(&ruby, format!("define __kobako_rpc_call: {}", e)))?;
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)),
528
+ }
529
+ }
327
530
 
328
- let instance = {
329
- let mut store_ref = store_cell.0.borrow_mut();
330
- linker
331
- .instantiate(store_ref.as_context_mut(), module)
332
- .map_err(|e| wasm_err(&ruby, format!("instantiate: {}", e)))?
333
- };
334
-
335
- // Best-effort export lookup. Missing exports are not an error here
336
- // (test fixture is a bare module); the host enforces presence before
337
- // invocation. Only the SPEC ABI `() -> ()` shape is accepted for
338
- // `__kobako_run` — the transitional `(ptr, len) -> ()` shape is gone.
339
- let (run, take_outcome, alloc) = {
340
- let mut store_ref = store_cell.0.borrow_mut();
341
- let mut ctx = store_ref.as_context_mut();
342
- let run = instance
343
- .get_typed_func::<(), ()>(&mut ctx, "__kobako_run")
344
- .ok();
345
- let take_outcome = instance
346
- .get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
347
- .ok();
348
- let alloc = instance
349
- .get_typed_func::<i32, i32>(&mut ctx, "__kobako_alloc")
350
- .ok();
351
- (run, take_outcome, alloc)
352
- };
353
-
354
- Ok(Instance {
355
- inner: instance,
356
- store: store_cell,
357
- run,
358
- take_outcome,
359
- alloc,
360
- })
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));
538
+ }
539
+ if err.downcast_ref::<MemoryLimitTrap>().is_some() {
540
+ return memory_limit_err(ruby, format!("__kobako_run(): {}", err));
541
+ }
542
+ wasm_err(ruby, format!("__kobako_run(): {}", err))
543
+ }
544
+
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));
554
+ }
555
+ wasm_err(ruby, format!("instantiate: {}", err))
361
556
  }