kobako 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -0
  3. data/CHANGELOG.md +29 -0
  4. data/Cargo.lock +1 -1
  5. data/README.md +85 -6
  6. data/data/kobako.wasm +0 -0
  7. data/ext/kobako/Cargo.toml +1 -1
  8. data/ext/kobako/src/lib.rs +4 -2
  9. data/ext/kobako/src/{wasm → runtime}/cache.rs +22 -18
  10. data/ext/kobako/src/runtime/capture.rs +91 -0
  11. data/ext/kobako/src/runtime/config.rs +26 -0
  12. data/ext/kobako/src/runtime/dispatch.rs +211 -0
  13. data/ext/kobako/src/runtime/exports.rs +51 -0
  14. data/ext/kobako/src/runtime/guest_mem.rs +228 -0
  15. data/ext/kobako/src/{wasm/host_state.rs → runtime/invocation.rs} +195 -81
  16. data/ext/kobako/src/runtime/trap.rs +134 -0
  17. data/ext/kobako/src/runtime.rs +782 -0
  18. data/ext/kobako/src/snapshot.rs +110 -0
  19. data/lib/kobako/capture.rb +11 -16
  20. data/lib/kobako/catalog/handles.rb +107 -0
  21. data/lib/kobako/catalog/namespaces.rb +99 -0
  22. data/lib/kobako/{snippet/table.rb → catalog/snippets.rb} +37 -62
  23. data/lib/kobako/catalog.rb +18 -0
  24. data/lib/kobako/codec/decoder.rb +13 -7
  25. data/lib/kobako/codec/factory.rb +21 -18
  26. data/lib/kobako/codec/utils.rb +118 -29
  27. data/lib/kobako/codec.rb +6 -3
  28. data/lib/kobako/errors.rb +45 -28
  29. data/lib/kobako/fault.rb +40 -0
  30. data/lib/kobako/handle.rb +60 -0
  31. data/lib/kobako/namespace.rb +67 -0
  32. data/lib/kobako/outcome.rb +55 -29
  33. data/lib/kobako/runtime.rb +30 -0
  34. data/lib/kobako/sandbox.rb +131 -67
  35. data/lib/kobako/sandbox_options.rb +6 -9
  36. data/lib/kobako/snapshot.rb +40 -0
  37. data/lib/kobako/snippet/binary.rb +6 -7
  38. data/lib/kobako/snippet/source.rb +8 -8
  39. data/lib/kobako/snippet.rb +7 -9
  40. data/lib/kobako/transport/dispatcher.rb +195 -0
  41. data/lib/kobako/transport/error.rb +24 -0
  42. data/lib/kobako/transport/request.rb +78 -0
  43. data/lib/kobako/transport/response.rb +69 -0
  44. data/lib/kobako/transport/run.rb +141 -0
  45. data/lib/kobako/transport/yield.rb +91 -0
  46. data/lib/kobako/transport/yielder.rb +89 -0
  47. data/lib/kobako/transport.rb +24 -0
  48. data/lib/kobako/usage.rb +41 -0
  49. data/lib/kobako/version.rb +1 -1
  50. data/lib/kobako.rb +4 -3
  51. data/release-please-config.json +24 -0
  52. data/sig/kobako/capture.rbs +0 -2
  53. data/sig/kobako/{rpc/handle_table.rbs → catalog/handles.rbs} +3 -9
  54. data/sig/kobako/catalog/namespaces.rbs +17 -0
  55. data/sig/kobako/{snippet/table.rbs → catalog/snippets.rbs} +2 -11
  56. data/sig/kobako/{rpc.rbs → catalog.rbs} +1 -1
  57. data/sig/kobako/codec/decoder.rbs +2 -1
  58. data/sig/kobako/codec/factory.rbs +3 -3
  59. data/sig/kobako/codec/utils.rbs +11 -1
  60. data/sig/kobako/errors.rbs +7 -7
  61. data/sig/kobako/fault.rbs +19 -0
  62. data/sig/kobako/handle.rbs +18 -0
  63. data/sig/kobako/namespace.rbs +19 -0
  64. data/sig/kobako/outcome.rbs +2 -2
  65. data/sig/kobako/runtime.rbs +23 -0
  66. data/sig/kobako/sandbox.rbs +10 -7
  67. data/sig/kobako/snapshot.rbs +15 -0
  68. data/sig/kobako/transport/dispatcher.rbs +34 -0
  69. data/sig/kobako/transport/error.rbs +6 -0
  70. data/sig/kobako/transport/request.rbs +32 -0
  71. data/sig/kobako/transport/response.rbs +30 -0
  72. data/sig/kobako/transport/run.rbs +27 -0
  73. data/sig/kobako/transport/yield.rbs +34 -0
  74. data/sig/kobako/transport/yielder.rbs +21 -0
  75. data/sig/kobako/transport.rbs +4 -0
  76. data/sig/kobako/usage.rbs +11 -0
  77. metadata +52 -30
  78. data/ext/kobako/src/wasm/dispatch.rs +0 -161
  79. data/ext/kobako/src/wasm/instance.rs +0 -771
  80. data/ext/kobako/src/wasm.rs +0 -125
  81. data/lib/kobako/invocation.rb +0 -112
  82. data/lib/kobako/rpc/dispatcher.rb +0 -169
  83. data/lib/kobako/rpc/envelope.rb +0 -118
  84. data/lib/kobako/rpc/fault.rb +0 -41
  85. data/lib/kobako/rpc/handle.rb +0 -39
  86. data/lib/kobako/rpc/handle_table.rb +0 -107
  87. data/lib/kobako/rpc/namespace.rb +0 -74
  88. data/lib/kobako/rpc/server.rb +0 -158
  89. data/lib/kobako/rpc.rb +0 -11
  90. data/lib/kobako/wasm.rb +0 -25
  91. data/sig/kobako/invocation.rbs +0 -23
  92. data/sig/kobako/rpc/dispatcher.rbs +0 -33
  93. data/sig/kobako/rpc/envelope.rbs +0 -51
  94. data/sig/kobako/rpc/fault.rbs +0 -20
  95. data/sig/kobako/rpc/handle.rbs +0 -19
  96. data/sig/kobako/rpc/namespace.rbs +0 -24
  97. data/sig/kobako/rpc/server.rbs +0 -37
  98. data/sig/kobako/wasm.rbs +0 -39
@@ -1,771 +0,0 @@
1
- //! `Kobako::Wasm::Instance` — the only Ruby-visible wasmtime wrapper.
2
- //!
3
- //! Constructed via [`Instance::from_path`]; the wasmtime [`Engine`] and
4
- //! compiled [`Module`] are owned by the [`super::cache`] singletons and
5
- //! never surface to Ruby. The instance wraps a [`StoreCell`] (interior-
6
- //! mutability around `wasmtime::Store<HostState>`) plus three cached
7
- //! [`TypedFunc`] handles for the docs/wire-codec.md ABI exports used by
8
- //! the host-driven run path.
9
- //!
10
- //! The Ruby surface intentionally exposes intent, not the underlying ABI
11
- //! (SPEC.md "Code Organization"). The length-prefixed stdin frame
12
- //! protocol (three frames for `#eval`: preamble + source + snippets;
13
- //! two for `#run`: preamble + snippets), packed-u64 outcome encoding,
14
- //! and the `__kobako_eval` / `__kobako_run` / `__kobako_alloc` /
15
- //! `__kobako_take_outcome` exports are all wrapped inside
16
- //! [`Instance::eval`], [`Instance::run`], and [`Instance::outcome`];
17
- //! Ruby callers see only `#eval(preamble, source, snippets)`,
18
- //! `#run(preamble, snippets, envelope)`, `#stdout`, `#stderr`,
19
- //! `#outcome!`, and `#server=`.
20
- //!
21
- //! WASI stdout/stderr capture (docs/behavior.md B-04): wasmtime-wasi p1
22
- //! bindings route guest fd 1 and fd 2 into per-run [`MemoryOutputPipe`]
23
- //! instances rebuilt at the start of every [`Instance::eval`] /
24
- //! [`Instance::run`]. The per-channel cap is enforced directly on the
25
- //! pipe — the pipe is sized at `cap + 1` so a guest that writes exactly
26
- //! `cap` bytes is distinguishable from one that exceeded the cap, and
27
- //! `#stdout` / `#stderr` slice the captured bytes back to `cap` before
28
- //! returning them paired with a truncation flag. Uncapped channels
29
- //! (`None`) build the pipe at `usize::MAX`; `memory_limit` provides
30
- //! the real upper bound in that case.
31
- //!
32
- //! Per-run cap enforcement (docs/behavior.md B-01, E-19, E-20): every
33
- //! Store installs an epoch-deadline callback for wall-clock timeout and
34
- //! a [`ResourceLimiter`] for the linear-memory cap. Wasmtime turns
35
- //! limiter / callback errors into traps; the run-path methods downcast
36
- //! the trap source to surface as `Kobako::Wasm::TimeoutError` or
37
- //! `Kobako::Wasm::MemoryLimitError` so the `Sandbox` layer can map them
38
- //! to the named `Kobako::TrapError` subclasses.
39
- //!
40
- //! [`Engine`]: wasmtime::Engine
41
- //! [`Module`]: wasmtime::Module
42
- //! [`TypedFunc`]: wasmtime::TypedFunc
43
- //! [`MemoryOutputPipe`]: wasmtime_wasi::p2::pipe::MemoryOutputPipe
44
- //! [`ResourceLimiter`]: wasmtime::ResourceLimiter
45
-
46
- use std::path::Path;
47
- use std::time::{Duration, Instant};
48
-
49
- use magnus::{value::Opaque, Error as MagnusError, RArray, RString, Ruby, Value};
50
- use wasmtime::{
51
- AsContextMut, Caller, Extern, Instance as WtInstance, Linker, Memory, Module as WtModule,
52
- ResourceLimiter, Store as WtStore, StoreContextMut, TypedFunc, UpdateDeadline,
53
- };
54
- use wasmtime_wasi::p1;
55
- use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
56
- use wasmtime_wasi::WasiCtxBuilder;
57
-
58
- use super::cache::{cached_module, shared_engine};
59
- use super::dispatch;
60
- use super::host_state::{HostState, MemoryLimitTrap, StoreCell, TimeoutTrap};
61
- use super::{memory_limit_err, rstring_to_vec, timeout_err, wasm_err};
62
-
63
- #[magnus::wrap(class = "Kobako::Wasm::Instance", free_immediately, size)]
64
- pub(crate) struct Instance {
65
- inner: WtInstance,
66
- store: StoreCell,
67
- // Cached TypedFunc handles for the two host-driven ABI exports.
68
- // Optional because test fixtures (a minimal "ping" module) need not
69
- // provide them; real kobako.wasm always does, and the run-path methods
70
- // raise a Ruby `Kobako::Wasm::Error` when an export is missing.
71
- //
72
- // `__kobako_alloc` is NOT cached here — only `dispatch.rs` calls it,
73
- // and it does so through `Caller::get_export` on the wasmtime side.
74
- eval: Option<TypedFunc<(), ()>>,
75
- run: Option<TypedFunc<(i32, i32), ()>>,
76
- take_outcome: Option<TypedFunc<(), u64>>,
77
- // Wall-clock cap for one guest `#run` (docs/behavior.md B-01); `None` disables
78
- // the cap. Translated into an `Instant`-based deadline stamped into
79
- // [`HostState`] at the top of every `Instance::eval`.
80
- timeout: Option<Duration>,
81
- // Per-channel byte caps for guest stdout / stderr capture
82
- // (docs/behavior.md B-01 / B-04). `None` disables the cap on that
83
- // channel. Read by
84
- // [`Instance::refresh_wasi`] to size the MemoryOutputPipe and by
85
- // [`Instance::stdout`] / [`Instance::stderr`] to compute the
86
- // truncation flag. See the module-level note above for the `cap + 1`
87
- // sizing rationale. Unlike `memory_limit` (which lives on
88
- // [`HostState`] because the wasmtime [`ResourceLimiter`] callback
89
- // consumes it from within the wasm engine), these caps are read only
90
- // by Instance methods, so they live on Instance itself.
91
- stdout_limit_bytes: Option<usize>,
92
- stderr_limit_bytes: Option<usize>,
93
- }
94
-
95
- impl Instance {
96
- /// Construct an Instance from a wasm file path, using the process-wide
97
- /// shared Engine and per-path Module cache. The single Ruby-facing
98
- /// constructor for `Kobako::Wasm::Instance` — Engine and Module are
99
- /// never visible to Ruby.
100
- ///
101
- /// `timeout_seconds` is the docs/behavior.md B-01 wall-clock cap in seconds
102
- /// (`None` disables); `memory_limit` is the linear-memory cap in
103
- /// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
104
- /// are the per-channel output caps (docs/behavior.md B-01 / B-04; `None`
105
- /// disables). All four are validated by the caller
106
- /// (`Kobako::Sandbox`); this method only refuses non-finite or
107
- /// non-positive timeouts as a defence in depth.
108
- pub(crate) fn from_path(
109
- path: String,
110
- timeout_seconds: Option<f64>,
111
- memory_limit: Option<usize>,
112
- stdout_limit_bytes: Option<usize>,
113
- stderr_limit_bytes: Option<usize>,
114
- ) -> Result<Self, MagnusError> {
115
- let ruby = Ruby::get().expect("Ruby thread");
116
- let timeout = match timeout_seconds {
117
- None => None,
118
- Some(secs) if secs.is_finite() && secs > 0.0 => Some(Duration::from_secs_f64(secs)),
119
- Some(secs) => {
120
- return Err(wasm_err(
121
- &ruby,
122
- format!("timeout_seconds must be > 0 and finite, got {secs}"),
123
- ));
124
- }
125
- };
126
-
127
- let engine = shared_engine()?;
128
- let module = cached_module(Path::new(&path))?;
129
-
130
- let mut store = WtStore::new(engine, HostState::new(memory_limit));
131
- store.limiter(|state: &mut HostState| -> &mut dyn ResourceLimiter { state.limiter_mut() });
132
- store.epoch_deadline_callback(epoch_deadline_callback);
133
-
134
- let store_cell = StoreCell::new(store);
135
- Self::build(
136
- engine,
137
- &module,
138
- store_cell,
139
- timeout,
140
- stdout_limit_bytes,
141
- stderr_limit_bytes,
142
- )
143
- }
144
-
145
- /// Build an `Instance` from an engine, module, and store cell. The
146
- /// store cell is moved in and ends up owned by the returned Instance.
147
- /// Wires the WASI p1 imports plus the `__kobako_dispatch` host import.
148
- fn build(
149
- engine: &wasmtime::Engine,
150
- module: &WtModule,
151
- store_cell: StoreCell,
152
- timeout: Option<Duration>,
153
- stdout_limit_bytes: Option<usize>,
154
- stderr_limit_bytes: Option<usize>,
155
- ) -> Result<Self, MagnusError> {
156
- let ruby = Ruby::get().expect("Ruby thread");
157
- let mut linker: Linker<HostState> = Linker::new(engine);
158
-
159
- // Wire the wasmtime-wasi preview1 WASI imports. Routes guest fd 1/2
160
- // to the MemoryOutputPipes set up before each run via
161
- // `Instance::eval`. The closure pulls a `&mut WasiP1Ctx` out of
162
- // HostState; the panic semantics live inside `HostState::wasi_mut`
163
- // so the wiring stays honest about its precondition.
164
- p1::add_to_linker_sync(&mut linker, |state: &mut HostState| state.wasi_mut())
165
- .map_err(|e| wasm_err(&ruby, format!("add WASI p1 to linker: {}", e)))?;
166
-
167
- // `__kobako_dispatch` host import. Signature per SPEC Wire ABI:
168
- // (req_ptr: i32, req_len: i32) -> i64
169
- // Decodes the Request bytes, dispatches via the Ruby-side
170
- // `Kobako::RPC::Server` (set per-run via `set_server`), allocates a
171
- // guest buffer through `__kobako_alloc`, writes the Response bytes
172
- // there, and returns the packed `(ptr<<32)|len`. The dispatcher
173
- // returns 0 on any wire-layer fault (including a missing
174
- // Server); see `dispatch::handle`.
175
- linker
176
- .func_wrap(
177
- "env",
178
- "__kobako_dispatch",
179
- |mut caller: Caller<'_, HostState>, req_ptr: i32, req_len: i32| -> i64 {
180
- dispatch::handle(&mut caller, req_ptr, req_len)
181
- },
182
- )
183
- .map_err(|e| wasm_err(&ruby, format!("define __kobako_dispatch: {}", e)))?;
184
-
185
- let instance = {
186
- let mut store_ref = store_cell.borrow_mut();
187
- linker
188
- .instantiate(store_ref.as_context_mut(), module)
189
- .map_err(|e| instantiate_err(&ruby, e))?
190
- };
191
-
192
- // Best-effort export lookup. Missing exports are not an error here
193
- // (test fixture is a bare module); the host enforces presence at
194
- // invocation time by raising a Ruby `Kobako::Wasm::Error` when the
195
- // cached Option is None. Only the SPEC ABI `() -> ()` shape is
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) = {
200
- let mut store_ref = store_cell.borrow_mut();
201
- let mut ctx = store_ref.as_context_mut();
202
- let eval = instance
203
- .get_typed_func::<(), ()>(&mut ctx, "__kobako_eval")
204
- .ok();
205
- let run = instance
206
- .get_typed_func::<(i32, i32), ()>(&mut ctx, "__kobako_run")
207
- .ok();
208
- let take_outcome = instance
209
- .get_typed_func::<(), u64>(&mut ctx, "__kobako_take_outcome")
210
- .ok();
211
- (eval, run, take_outcome)
212
- };
213
-
214
- Ok(Self {
215
- inner: instance,
216
- store: store_cell,
217
- eval,
218
- run,
219
- take_outcome,
220
- timeout,
221
- stdout_limit_bytes,
222
- stderr_limit_bytes,
223
- })
224
- }
225
-
226
- /// Install the Ruby-side `Kobako::RPC::Server` into HostState. Bound to
227
- /// Ruby as `Instance#server=`. From this point on, every
228
- /// `__kobako_dispatch` import invocation routes through
229
- /// `server.dispatch(req_bytes)`.
230
- pub(crate) fn set_server(&self, server: Value) -> Result<(), MagnusError> {
231
- let mut store_ref = self.store.borrow_mut();
232
- store_ref.data_mut().bind_server(Opaque::from(server));
233
- Ok(())
234
- }
235
-
236
- // -----------------------------------------------------------------
237
- // Run-path methods. Each method is best-effort — it raises a Ruby
238
- // `Kobako::Wasm::Error` when the corresponding export is missing or
239
- // fails so the Sandbox layer can map errors to the three-class
240
- // taxonomy.
241
- // -----------------------------------------------------------------
242
-
243
- /// Execute one guest invocation (`__kobako_eval` — one-shot source).
244
- ///
245
- /// Rebuilds the WASI context with fresh stdin / stdout / stderr pipes
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> {
289
- let ruby = Ruby::get().expect("Ruby thread");
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)?;
293
- self.prime_caps();
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
- };
298
- self.disarm_caps();
299
- result.map_err(|e| call_err(&ruby, "__kobako_run", e))
300
- }
301
-
302
- /// Return the stdout capture from the most recent run as a Ruby
303
- /// `[bytes, truncated]` Array — `bytes` is a binary String containing
304
- /// the captured prefix (clipped to `stdout_limit_bytes` when set),
305
- /// and `truncated` is a boolean that is `true` only when the guest
306
- /// wrote strictly more than the cap. The pair is recomputed from the
307
- /// underlying pipe contents on every call; the pipe itself is not
308
- /// drained until the next `#run` rebuilds it.
309
- pub(crate) fn stdout(&self) -> Result<RArray, MagnusError> {
310
- let ruby = Ruby::get().expect("Ruby thread");
311
- let raw = self.store.borrow().data().stdout_bytes();
312
- capture_pair(&ruby, &raw, self.stdout_limit_bytes)
313
- }
314
-
315
- /// Return the stderr capture from the most recent run. Same shape
316
- /// and semantics as [`Instance::stdout`].
317
- pub(crate) fn stderr(&self) -> Result<RArray, MagnusError> {
318
- let ruby = Ruby::get().expect("Ruby thread");
319
- let raw = self.store.borrow().data().stderr_bytes();
320
- capture_pair(&ruby, &raw, self.stderr_limit_bytes)
321
- }
322
-
323
- /// Read OUTCOME_BUFFER bytes captured during the most recent run.
324
- /// Bound to Ruby as `Instance#outcome!`. The bang signals that the
325
- /// underlying `__kobako_take_outcome` export is guest-side destructive
326
- /// — the buffer pointer is invalidated after this call, so a second
327
- /// invocation within the same run is undefined — and that any failure
328
- /// (missing export, length overflow, OOB read) raises
329
- /// `Kobako::Wasm::Error`.
330
- pub(crate) fn outcome(&self) -> Result<RString, MagnusError> {
331
- let ruby = Ruby::get().expect("Ruby thread");
332
- let bytes = self.fetch_outcome_bytes(&ruby)?;
333
- Ok(ruby.str_from_slice(&bytes))
334
- }
335
-
336
- // -----------------------------------------------------------------
337
- // Private helpers.
338
- // -----------------------------------------------------------------
339
-
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`.
352
- fn prime_caps(&self) {
353
- let mut store_ref = self.store.borrow_mut();
354
- match self.timeout {
355
- Some(timeout) => {
356
- let deadline = Instant::now() + timeout;
357
- store_ref.data_mut().set_deadline(Some(deadline));
358
- store_ref.set_epoch_deadline(1);
359
- }
360
- None => {
361
- store_ref.data_mut().set_deadline(None);
362
- store_ref.set_epoch_deadline(u64::MAX);
363
- }
364
- }
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);
370
- }
371
-
372
- /// Drop the memory cap as soon as the guest call returns so that
373
- /// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
374
- /// which can grow guest memory transiently) is not attributed to
375
- /// the user script. Paired with [`Instance::prime_caps`].
376
- fn disarm_caps(&self) {
377
- self.store.borrow_mut().data_mut().disarm_memory_cap();
378
- }
379
-
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
-
389
- let mut store_ref = self.store.borrow_mut();
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
- }
431
-
432
- let stdin_pipe = MemoryInputPipe::new(stdin_content);
433
- let stdout_pipe = MemoryOutputPipe::new(pipe_capacity(self.stdout_limit_bytes));
434
- let stderr_pipe = MemoryOutputPipe::new(pipe_capacity(self.stderr_limit_bytes));
435
-
436
- let mut builder = WasiCtxBuilder::new();
437
- builder.stdin(stdin_pipe);
438
- builder.stdout(stdout_pipe.clone());
439
- builder.stderr(stderr_pipe.clone());
440
- let wasi = builder.build_p1();
441
-
442
- self.store
443
- .borrow_mut()
444
- .data_mut()
445
- .install_wasi(wasi, stdout_pipe, stderr_pipe);
446
- }
447
-
448
- /// Invoke `__kobako_take_outcome`, decode the packed +(ptr<<32)|len+
449
- /// u64, and copy the OUTCOME_BUFFER slice out of guest memory. Raises
450
- /// `Kobako::Wasm::Error` when the export is missing, the +ptr+/+len+
451
- /// arithmetic overflows, the slice falls outside live memory, or the
452
- /// `memory` export itself is absent.
453
- fn fetch_outcome_bytes(&self, ruby: &Ruby) -> Result<Vec<u8>, MagnusError> {
454
- let take = require_export(ruby, self.take_outcome.as_ref(), "__kobako_take_outcome")?;
455
-
456
- let mut store_ref = self.store.borrow_mut();
457
- let packed = take
458
- .call(store_ref.as_context_mut(), ())
459
- .map_err(|e| wasm_err(ruby, format!("__kobako_take_outcome(): {}", e)))?;
460
- let (ptr, len) = unpack_outcome_packed(packed);
461
-
462
- let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
463
- Some(Extern::Memory(m)) => m,
464
- _ => return Err(wasm_err(ruby, "guest does not export 'memory'")),
465
- };
466
- let data = mem.data(store_ref.as_context_mut());
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");
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)
520
- }
521
-
522
- /// Translate a per-channel byte cap into the MemoryOutputPipe capacity:
523
- /// `cap + 1` (saturated against `usize::MAX`) when a cap is set so the
524
- /// "wrote exactly cap" and "exceeded cap" cases stay distinguishable;
525
- /// `usize::MAX` when the channel is uncapped.
526
- fn pipe_capacity(cap: Option<usize>) -> usize {
527
- match cap {
528
- Some(c) => c.saturating_add(1),
529
- None => usize::MAX,
530
- }
531
- }
532
-
533
- /// Pure slicing core shared by [`Instance::stdout`] / [`Instance::stderr`]:
534
- /// given the unclipped pipe snapshot and the configured cap, return the
535
- /// bytes Ruby should observe (clipped to `cap`) plus the truncation flag.
536
- /// `truncated` is `true` only when the snapshot strictly exceeded the cap
537
- /// — this is the "wrote `cap + 1` bytes into a `cap + 1`-sized pipe" case;
538
- /// "wrote exactly `cap` bytes" stays `false`.
539
- fn clip_capture(raw: &[u8], cap: Option<usize>) -> (&[u8], bool) {
540
- match cap {
541
- Some(c) if raw.len() > c => (&raw[..c], true),
542
- _ => (raw, false),
543
- }
544
- }
545
-
546
- /// Build the `[bytes, truncated]` Ruby Array surfaced by
547
- /// [`Instance::stdout`] / [`Instance::stderr`]. Delegates the slicing
548
- /// to [`clip_capture`] so the channel-agnostic logic stays unit-
549
- /// testable from `cargo test`.
550
- fn capture_pair(ruby: &Ruby, raw: &[u8], cap: Option<usize>) -> Result<RArray, MagnusError> {
551
- let (visible, truncated) = clip_capture(raw, cap);
552
- let arr = ruby.ary_new_capa(2);
553
- arr.push(ruby.str_from_slice(visible))?;
554
- arr.push(truncated)?;
555
- Ok(arr)
556
- }
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
-
635
- #[cfg(test)]
636
- mod tests {
637
- //! Host-side unit tests for the pure capture helpers. The Ruby-
638
- //! facing E2E suite exercises stdout only (the kobako mrbgem
639
- //! allowlist excludes guest fd 2 writes); these tests pin the
640
- //! channel-agnostic slicing so a regression that only breaks one
641
- //! channel cannot sneak through.
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};
647
-
648
- #[test]
649
- fn pipe_capacity_adds_one_when_cap_is_set() {
650
- assert_eq!(pipe_capacity(Some(5)), 6);
651
- assert_eq!(pipe_capacity(Some(0)), 1);
652
- }
653
-
654
- #[test]
655
- fn pipe_capacity_falls_back_to_usize_max_when_uncapped() {
656
- assert_eq!(pipe_capacity(None), usize::MAX);
657
- }
658
-
659
- #[test]
660
- fn pipe_capacity_saturates_at_usize_max() {
661
- assert_eq!(pipe_capacity(Some(usize::MAX)), usize::MAX);
662
- }
663
-
664
- #[test]
665
- fn clip_capture_returns_full_bytes_when_under_cap() {
666
- let (bytes, truncated) = clip_capture(b"abc", Some(5));
667
- assert_eq!(bytes, b"abc");
668
- assert!(!truncated);
669
- }
670
-
671
- #[test]
672
- fn clip_capture_does_not_flag_truncation_at_exactly_cap_bytes() {
673
- let (bytes, truncated) = clip_capture(b"abcde", Some(5));
674
- assert_eq!(bytes, b"abcde");
675
- assert!(!truncated);
676
- }
677
-
678
- #[test]
679
- fn clip_capture_clips_to_cap_and_flags_truncation_on_overflow() {
680
- // The pipe is sized `cap + 1`, so the snapshot can be at most
681
- // 6 bytes when `cap == 5`; that surface is what triggers the
682
- // truncation flag.
683
- let (bytes, truncated) = clip_capture(b"abcdef", Some(5));
684
- assert_eq!(bytes, b"abcde");
685
- assert!(truncated);
686
- }
687
-
688
- #[test]
689
- fn clip_capture_treats_none_as_uncapped() {
690
- let (bytes, truncated) = clip_capture(b"abcdef", None);
691
- assert_eq!(bytes, b"abcdef");
692
- assert!(!truncated);
693
- }
694
-
695
- #[test]
696
- fn clip_capture_handles_empty_input() {
697
- let (bytes, truncated) = clip_capture(b"", Some(5));
698
- assert_eq!(bytes, b"");
699
- assert!(!truncated);
700
- }
701
-
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));
706
- }
707
-
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());
712
- }
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));
718
- }
719
-
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);
770
- }
771
- }