kobako 0.4.0 → 0.5.0

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