kobako 0.26.0 → 0.28.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +103 -0
  4. data/Cargo.lock +117 -140
  5. data/README.md +156 -118
  6. data/ROADMAP.md +23 -22
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +38 -0
  9. data/crates/kobako-runtime/Cargo.toml +2 -2
  10. data/crates/kobako-runtime/README.md +10 -12
  11. data/crates/kobako-runtime/src/error.rs +8 -9
  12. data/crates/kobako-runtime/src/lib.rs +1 -1
  13. data/crates/kobako-runtime/src/profile.rs +1 -1
  14. data/crates/kobako-runtime/src/runtime.rs +31 -17
  15. data/crates/kobako-runtime/src/snapshot.rs +13 -10
  16. data/crates/kobako-runtime/src/yielder.rs +9 -6
  17. data/crates/kobako-transport/CHANGELOG.md +35 -0
  18. data/crates/kobako-transport/Cargo.toml +1 -1
  19. data/crates/kobako-transport/README.md +43 -27
  20. data/crates/kobako-transport/src/abi.rs +11 -14
  21. data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
  22. data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
  23. data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
  24. data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
  25. data/crates/kobako-transport/src/envelope/reply.rs +77 -53
  26. data/crates/kobako-transport/src/envelope/request.rs +295 -0
  27. data/crates/kobako-transport/src/envelope/run.rs +1 -2
  28. data/crates/kobako-transport/src/envelope/tag.rs +49 -0
  29. data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
  30. data/crates/kobako-transport/src/envelope.rs +19 -12
  31. data/crates/kobako-transport/src/lib.rs +0 -2
  32. data/crates/kobako-wasmtime/CHANGELOG.md +51 -0
  33. data/crates/kobako-wasmtime/Cargo.toml +7 -5
  34. data/crates/kobako-wasmtime/README.md +8 -10
  35. data/crates/kobako-wasmtime/src/abi.rs +5 -8
  36. data/crates/kobako-wasmtime/src/ambient.rs +2 -2
  37. data/crates/kobako-wasmtime/src/cache.rs +25 -59
  38. data/crates/kobako-wasmtime/src/capture.rs +4 -11
  39. data/crates/kobako-wasmtime/src/config.rs +11 -14
  40. data/crates/kobako-wasmtime/src/dispatch.rs +70 -80
  41. data/crates/kobako-wasmtime/src/driver.rs +133 -86
  42. data/crates/kobako-wasmtime/src/exports.rs +88 -34
  43. data/crates/kobako-wasmtime/src/guest_mem.rs +145 -138
  44. data/crates/kobako-wasmtime/src/instance_pre.rs +11 -22
  45. data/crates/kobako-wasmtime/src/invocation.rs +55 -372
  46. data/crates/kobako-wasmtime/src/io.rs +286 -0
  47. data/crates/kobako-wasmtime/src/lib.rs +3 -24
  48. data/crates/kobako-wasmtime/src/limiter.rs +253 -0
  49. data/crates/kobako-wasmtime/src/trap.rs +32 -45
  50. data/crates/kobako-wasmtime/tests/preamble_flags.rs +69 -0
  51. data/crates/kobako-wasmtime/tests/snippet_compile.rs +94 -0
  52. data/crates/kobako-wasmtime/tests/support/mod.rs +34 -0
  53. data/crates/kobako-wasmtime/tests/unsupported_call.rs +50 -0
  54. data/data/kobako.wasm +0 -0
  55. data/ext/kobako/Cargo.toml +2 -2
  56. data/ext/kobako/src/runtime/bridge.rs +38 -47
  57. data/ext/kobako/src/runtime/errors.rs +26 -32
  58. data/ext/kobako/src/runtime/gvl.rs +4 -12
  59. data/ext/kobako/src/runtime.rs +169 -212
  60. data/lib/kobako/capture.rb +1 -4
  61. data/lib/kobako/catalog/extensions.rb +21 -59
  62. data/lib/kobako/catalog/flags.rb +41 -0
  63. data/lib/kobako/catalog/handles.rb +7 -54
  64. data/lib/kobako/catalog/services.rb +7 -61
  65. data/lib/kobako/catalog/snippets.rb +42 -66
  66. data/lib/kobako/catalog.rb +2 -10
  67. data/lib/kobako/codec/decoder.rb +9 -28
  68. data/lib/kobako/codec/encoder.rb +3 -22
  69. data/lib/kobako/codec/error.rb +6 -27
  70. data/lib/kobako/codec/ext_types.rb +72 -94
  71. data/lib/kobako/codec/handle_walk.rb +65 -130
  72. data/lib/kobako/codec/nesting.rb +3 -8
  73. data/lib/kobako/codec/state.rb +4 -10
  74. data/lib/kobako/codec/utils.rb +6 -34
  75. data/lib/kobako/codec.rb +3 -22
  76. data/lib/kobako/constant_name.rb +8 -0
  77. data/lib/kobako/context.rb +51 -112
  78. data/lib/kobako/errors.rb +51 -127
  79. data/lib/kobako/execution.rb +16 -21
  80. data/lib/kobako/extension.rb +23 -40
  81. data/lib/kobako/handle.rb +7 -12
  82. data/lib/kobako/outcome.rb +51 -80
  83. data/lib/kobako/payload/arguments.rb +1 -16
  84. data/lib/kobako/payload.rb +4 -12
  85. data/lib/kobako/pool.rb +23 -53
  86. data/lib/kobako/runtime.rb +7 -21
  87. data/lib/kobako/sandbox.rb +96 -133
  88. data/lib/kobako/sandbox_options.rb +38 -84
  89. data/lib/kobako/snippet/binary.rb +1 -16
  90. data/lib/kobako/snippet/source.rb +1 -16
  91. data/lib/kobako/snippet.rb +1 -9
  92. data/lib/kobako/transport/call.rb +1 -14
  93. data/lib/kobako/transport/dispatcher.rb +112 -195
  94. data/lib/kobako/transport/error.rb +6 -20
  95. data/lib/kobako/transport/exposure.rb +5 -10
  96. data/lib/kobako/transport/reflection.rb +39 -50
  97. data/lib/kobako/transport/run.rb +17 -71
  98. data/lib/kobako/transport/yielder.rb +21 -73
  99. data/lib/kobako/transport.rb +3 -15
  100. data/lib/kobako/unresolved.rb +4 -11
  101. data/lib/kobako/usage.rb +2 -6
  102. data/lib/kobako/version.rb +1 -1
  103. data/sig/kobako/catalog/extensions.rbs +2 -2
  104. data/sig/kobako/catalog/flags.rbs +17 -0
  105. data/sig/kobako/catalog/services.rbs +0 -6
  106. data/sig/kobako/catalog/snippets.rbs +4 -4
  107. data/sig/kobako/codec/ext_types.rbs +10 -8
  108. data/sig/kobako/codec/handle_walk.rbs +9 -7
  109. data/sig/kobako/codec/utils.rbs +2 -2
  110. data/sig/kobako/codec.rbs +2 -2
  111. data/sig/kobako/constant_name.rbs +3 -0
  112. data/sig/kobako/context.rbs +2 -3
  113. data/sig/kobako/errors.rbs +6 -0
  114. data/sig/kobako/outcome.rbs +6 -8
  115. data/sig/kobako/runtime.rbs +12 -8
  116. data/sig/kobako/sandbox.rbs +4 -0
  117. data/sig/kobako/sandbox_options.rbs +2 -6
  118. data/sig/kobako/transport/dispatcher.rbs +14 -18
  119. data/sig/kobako/transport/reflection.rbs +5 -3
  120. data/sig/kobako/transport/run.rbs +1 -3
  121. data/sig/kobako/transport/yielder.rbs +3 -3
  122. data/sig/kobako/transport.rbs +7 -6
  123. metadata +16 -4
  124. data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
  125. data/crates/kobako-wasmtime/src/frames.rs +0 -273
  126. data/rust-toolchain.toml +0 -11
@@ -0,0 +1,286 @@
1
+ //! Per-invocation I/O between the host and the guest: it writes the
2
+ //! entry's downcall into a freshly allocated guest buffer, puts the
3
+ //! Preamble on stdin beside stdout / stderr capture pipes for the WASI
4
+ //! context, and reads the Outcome the downcall answered with.
5
+
6
+ use wasmtime::Store as WtStore;
7
+ use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
8
+ use wasmtime_wasi::WasiCtxBuilder;
9
+
10
+ use crate::config::Config;
11
+ use crate::exports::Exports;
12
+ use crate::guest_mem::{self, ReadError, WriteError};
13
+ use crate::invocation::Invocation;
14
+ use crate::{ambient, capture};
15
+ use kobako_runtime::error::{InvokeError, SetupError, Trap};
16
+ use kobako_runtime::profile::Profile;
17
+ use kobako_transport::abi::FRAME_LEN_SIZE;
18
+ use kobako_transport::envelope::{CompileReply, Outcome};
19
+
20
+ /// A missing or trapping allocator is an engine fault, a `Trap`; one that
21
+ /// runs but returns 0 leaves the runtime intact, so it is a `SetupError`.
22
+ pub(crate) fn write_request(
23
+ store: &mut WtStore<Invocation>,
24
+ exports: &Exports,
25
+ envelope: &[u8],
26
+ ) -> Result<(i32, i32), InvokeError> {
27
+ let trap = |message: String| -> InvokeError { Trap::Other(message).into() };
28
+ let ptr =
29
+ guest_mem::alloc_and_write(&mut *store, exports, envelope).map_err(|err| match err {
30
+ WriteError::OutOfMemory => {
31
+ SetupError::Intact("could not allocate input buffer (out of memory)".to_string())
32
+ .into()
33
+ }
34
+ WriteError::TooLarge => trap("payload exceeds the 16 MiB limit".to_string()),
35
+ WriteError::Incompatible(message) => trap(message.to_string()),
36
+ WriteError::Trapped(e) => trap(format!("failed to allocate input buffer: {e}")),
37
+ WriteError::OutOfBounds => {
38
+ trap("the input buffer lies outside the Sandbox's memory".to_string())
39
+ }
40
+ })?;
41
+ // The write has already held the length to the payload cap.
42
+ Ok((ptr as i32, envelope.len() as i32))
43
+ }
44
+
45
+ /// An uncapped output channel relies on `memory_limit` for its real
46
+ /// ceiling. The 16 MiB cap keeps the Preamble's `u32` length prefix from
47
+ /// wrapping.
48
+ pub(crate) fn install_wasi(
49
+ store: &mut WtStore<Invocation>,
50
+ config: &Config,
51
+ preamble: &[u8],
52
+ ) -> Result<(), Trap> {
53
+ // The Preamble carries the same 16 MiB cap as a downcall request
54
+ // (`write_request`): past it, the length prefix would silently wrap.
55
+ guest_mem::checked_payload_len(preamble.len()).map_err(|msg| Trap::Other(msg.to_string()))?;
56
+
57
+ let mut stdin_content: Vec<u8> = Vec::with_capacity(FRAME_LEN_SIZE + preamble.len());
58
+ stdin_content.extend_from_slice(&(preamble.len() as u32).to_be_bytes());
59
+ stdin_content.extend_from_slice(preamble);
60
+
61
+ let stdin_pipe = MemoryInputPipe::new(stdin_content);
62
+ let stdout_pipe = MemoryOutputPipe::new(capture::pipe_capacity(config.stdout_limit));
63
+ let stderr_pipe = MemoryOutputPipe::new(capture::pipe_capacity(config.stderr_limit));
64
+
65
+ let mut builder = WasiCtxBuilder::new();
66
+ builder.stdin(stdin_pipe);
67
+ builder.stdout(stdout_pipe.clone());
68
+ builder.stderr(stderr_pipe.clone());
69
+ // The requested profile decides the ambient-authority grant: the
70
+ // hermetic rung denies the preview1 time and entropy imports (see
71
+ // `ambient`), the permissive rung leaves the live WASI sources.
72
+ // Filesystem, environment, and network stay absent on both rungs —
73
+ // the builder grants none unless asked. The exhaustive match makes
74
+ // a future ladder rung a compile error here, not a silent grant.
75
+ match config.profile {
76
+ Profile::Hermetic => {
77
+ builder.wall_clock(ambient::FrozenWallClock);
78
+ builder.monotonic_clock(ambient::FrozenMonotonicClock);
79
+ builder.secure_random(ambient::deterministic_rng());
80
+ }
81
+ Profile::Permissive => {}
82
+ }
83
+ let wasi = builder.build_p1();
84
+
85
+ store
86
+ .data_mut()
87
+ .install_wasi(wasi, stdout_pipe, stderr_pipe);
88
+ Ok(())
89
+ }
90
+
91
+ /// A guest that answered without an Outcome the envelope can frame — an
92
+ /// empty answer included — has nothing left to attribute, so it is a
93
+ /// `Trap` like every other way of failing to read the result.
94
+ pub(crate) fn read_outcome(
95
+ store: &WtStore<Invocation>,
96
+ exports: &Exports,
97
+ packed: u64,
98
+ ) -> Result<Outcome, Trap> {
99
+ let bytes = read_answer(store, exports, packed)?;
100
+ Outcome::decode(&bytes).map_err(|_| {
101
+ Trap::Other(
102
+ if bytes.is_empty() {
103
+ "Sandbox exited without producing a result"
104
+ } else {
105
+ "Sandbox produced an unrecognised result"
106
+ }
107
+ .to_string(),
108
+ )
109
+ })
110
+ }
111
+
112
+ /// Held to the same 16 MiB bound as an Outcome; an answer that does not
113
+ /// frame is a `Trap` the caller treats like any other failed compile.
114
+ pub(crate) fn read_compile_reply(
115
+ store: &WtStore<Invocation>,
116
+ exports: &Exports,
117
+ packed: u64,
118
+ ) -> Result<CompileReply, Trap> {
119
+ let bytes = read_answer(store, exports, packed)?;
120
+ CompileReply::decode(&bytes)
121
+ .map_err(|_| Trap::Other("Sandbox produced an unrecognised compile answer".to_string()))
122
+ }
123
+
124
+ fn read_answer(
125
+ store: &WtStore<Invocation>,
126
+ exports: &Exports,
127
+ packed: u64,
128
+ ) -> Result<Vec<u8>, Trap> {
129
+ let mem = exports.require_memory()?;
130
+ let (ptr, len) = guest_mem::unpack_guest_buffer(packed);
131
+ guest_mem::read_buffer(store, mem, ptr, len).map_err(|err| {
132
+ Trap::Other(
133
+ match err {
134
+ ReadError::TooLarge => "result payload exceeds the 16 MiB limit",
135
+ ReadError::OutOfBounds => "the Sandbox result is out of bounds",
136
+ }
137
+ .to_string(),
138
+ )
139
+ })
140
+ }
141
+
142
+ #[cfg(test)]
143
+ mod tests {
144
+ //! Witness what the context `install_wasi` builds grants at
145
+ //! each profile rung: `wasi:clocks` / `wasi:random` frozen under
146
+ //! `Hermetic` and live under `Permissive`, and no filesystem,
147
+ //! environment, or socket under either.
148
+ use wasmtime::{AsContextMut, Linker, Module};
149
+ use wasmtime_wasi::p1;
150
+
151
+ use super::*;
152
+ use crate::cache::shared_engine;
153
+
154
+ /// Preview1 `errno` for a descriptor the context does not hold.
155
+ const ERRNO_BADF: i64 = 8;
156
+
157
+ /// Preview1 probe with one export per ambient source: `clock_ns`
158
+ /// reads the realtime clock, `random_word` reads eight entropy bytes,
159
+ /// `environ_count` counts environment variables, and `prestat_errno`
160
+ /// / `accept_errno` ask descriptor 3 for a directory and a
161
+ /// connection. Preview1 numbers preopens from 3, so nothing there
162
+ /// means no directory and no socket was granted.
163
+ const PROBE_WAT: &str = r#"
164
+ (module
165
+ (import "wasi_snapshot_preview1" "clock_time_get"
166
+ (func $clock (param i32 i64 i32) (result i32)))
167
+ (import "wasi_snapshot_preview1" "random_get"
168
+ (func $random (param i32 i32) (result i32)))
169
+ (import "wasi_snapshot_preview1" "environ_sizes_get"
170
+ (func $environ (param i32 i32) (result i32)))
171
+ (import "wasi_snapshot_preview1" "fd_prestat_get"
172
+ (func $prestat (param i32 i32) (result i32)))
173
+ (import "wasi_snapshot_preview1" "sock_accept"
174
+ (func $accept (param i32 i32 i32) (result i32)))
175
+ (memory (export "memory") 1)
176
+ (func (export "clock_ns") (result i64)
177
+ (drop (call $clock (i32.const 0) (i64.const 1) (i32.const 0)))
178
+ (i64.load (i32.const 0)))
179
+ (func (export "random_word") (result i64)
180
+ (drop (call $random (i32.const 8) (i32.const 8)))
181
+ (i64.load (i32.const 8)))
182
+ (func (export "environ_count") (result i64)
183
+ (drop (call $environ (i32.const 16) (i32.const 20)))
184
+ (i64.extend_i32_u (i32.load (i32.const 16))))
185
+ (func (export "prestat_errno") (result i64)
186
+ (i64.extend_i32_u (call $prestat (i32.const 3) (i32.const 24))))
187
+ (func (export "accept_errno") (result i64)
188
+ (i64.extend_i32_u (call $accept (i32.const 3) (i32.const 0) (i32.const 24)))))
189
+ "#;
190
+
191
+ /// Instantiate the probe over a WASI context built at `profile` and
192
+ /// return a reader for its exports.
193
+ fn probe(profile: Profile) -> impl FnMut(&str) -> i64 {
194
+ let engine = shared_engine().expect("shared engine must be constructible");
195
+ let config = Config {
196
+ timeout: None,
197
+ memory_limit: None,
198
+ stdout_limit: None,
199
+ stderr_limit: None,
200
+ profile,
201
+ };
202
+ let mut store = WtStore::new(engine, Invocation::new(None));
203
+ store.set_epoch_deadline(crate::trap::NO_TIMEOUT_EPOCH_DELTA);
204
+ install_wasi(&mut store, &config, &[]).expect("WASI context must install");
205
+
206
+ let mut linker: Linker<Invocation> = Linker::new(engine);
207
+ p1::add_to_linker_sync(&mut linker, |state: &mut Invocation| state.wasi_mut())
208
+ .expect("WASI imports must link");
209
+ let module = Module::new(engine, PROBE_WAT).expect("probe module must compile");
210
+ let instance = linker
211
+ .instantiate(&mut store, &module)
212
+ .expect("probe module must instantiate");
213
+
214
+ move |name| {
215
+ instance
216
+ .get_typed_func::<(), i64>(store.as_context_mut(), name)
217
+ .expect("probe export must resolve")
218
+ .call(store.as_context_mut(), ())
219
+ .expect("probe export must run")
220
+ }
221
+ }
222
+
223
+ /// Assert that the context built at `profile` grants no directory,
224
+ /// environment variable, or socket.
225
+ fn assert_grants_no_resource(profile: Profile) {
226
+ let mut read = probe(profile);
227
+ assert_eq!(
228
+ read("prestat_errno"),
229
+ ERRNO_BADF,
230
+ "a {profile:?} guest's WASI context must preopen no directory"
231
+ );
232
+ assert_eq!(
233
+ read("environ_count"),
234
+ 0,
235
+ "a {profile:?} guest's WASI context must carry no environment variable"
236
+ );
237
+ assert_eq!(
238
+ read("accept_errno"),
239
+ ERRNO_BADF,
240
+ "a {profile:?} guest's WASI context must hold no socket"
241
+ );
242
+ }
243
+
244
+ // @behavior RT-047 RT-048
245
+ #[test]
246
+ fn hermetic_denies_ambient_time_and_entropy() {
247
+ let mut read = probe(Profile::Hermetic);
248
+ assert_eq!(
249
+ read("clock_ns"),
250
+ 0,
251
+ "a hermetic guest's wasi:clocks must read the Unix epoch, not host time"
252
+ );
253
+ assert_eq!(
254
+ read("random_word"),
255
+ 0,
256
+ "a hermetic guest's wasi:random must yield the constant stream, not host entropy"
257
+ );
258
+ }
259
+
260
+ // @behavior RT-049 RT-050
261
+ #[test]
262
+ fn permissive_grants_live_ambient_time_and_entropy() {
263
+ let mut read = probe(Profile::Permissive);
264
+ assert!(
265
+ read("clock_ns") > 0,
266
+ "a permissive guest's wasi:clocks must read live host time"
267
+ );
268
+ assert_ne!(
269
+ read("random_word"),
270
+ 0,
271
+ "a permissive guest's wasi:random must yield host entropy"
272
+ );
273
+ }
274
+
275
+ // @behavior RT-067
276
+ #[test]
277
+ fn hermetic_grants_no_filesystem_environment_or_socket() {
278
+ assert_grants_no_resource(Profile::Hermetic);
279
+ }
280
+
281
+ // @behavior RT-067
282
+ #[test]
283
+ fn permissive_grants_no_filesystem_environment_or_socket() {
284
+ assert_grants_no_resource(Profile::Permissive);
285
+ }
286
+ }
@@ -8,29 +8,7 @@
8
8
  //! lives behind the contract surface, so a frontend shell (the Ruby
9
9
  //! ext's `Kobako::Runtime`) sees no wasmtime type.
10
10
  //!
11
- //! Module layout (one responsibility per file):
12
- //!
13
- //! * `driver` — `Driver` + `impl kobako_runtime::runtime::Runtime`
14
- //! (the run mechanics).
15
- //! * `cache` — process-wide Engine + per-path Module cache and the
16
- //! process-singleton epoch ticker thread.
17
- //! * `config` — per-Driver caps (timeout / stdout / stderr limits).
18
- //! * `exports` — per-invocation `__kobako_eval` / `_run` /
19
- //! `_take_outcome` / `_alloc` / `memory` handles.
20
- //! * `instance_pre` — host-import Linker wiring + per-path
21
- //! `InstancePre` cache.
22
- //! * `abi` — the Guest Binary's declared ABI version check, verified
23
- //! with the cached `InstancePre` it guards.
24
- //! * `invocation` — Invocation (per-Store context), the
25
- //! `MemoryLimiter` memory cap, and the trap marker types
26
- //! (`TimeoutTrap` / `MemoryLimitTrap`).
27
- //! * `dispatch` — `__kobako_dispatch` host-import dispatch helpers.
28
- //! * `frames` — stdin frame stream + WASI context assembly, `#run`
29
- //! envelope write, OUTCOME_BUFFER readout.
30
- //! * `guest_mem` — Caller-based guest linear-memory alloc / write / read.
31
- //! * `capture` — stdout / stderr pipe sizing + clip helpers.
32
- //! * `ambient` — frozen WASI clocks + constant RNG (ambient denial).
33
- //! * `trap` — wasmtime-error → neutral `Trap` classification.
11
+ //! Each module holds one responsibility and opens with its own doc.
34
12
 
35
13
  mod abi;
36
14
  mod ambient;
@@ -40,10 +18,11 @@ mod config;
40
18
  mod dispatch;
41
19
  mod driver;
42
20
  mod exports;
43
- mod frames;
44
21
  mod guest_mem;
45
22
  mod instance_pre;
46
23
  mod invocation;
24
+ mod io;
25
+ mod limiter;
47
26
  mod trap;
48
27
 
49
28
  pub use config::Config;
@@ -0,0 +1,253 @@
1
+ //! The per-invocation memory cap: only the `memory.grow` delta past the
2
+ //! linear-memory size captured at invocation entry is charged, so the
3
+ //! image's initial allocation is outside the budget.
4
+
5
+ use wasmtime::ResourceLimiter;
6
+
7
+ /// Resource limiter that enforces the per-invocation `memory_limit`
8
+ /// cap.
9
+ ///
10
+ /// `max_memory` is the byte cap on per-invocation growth (`None` disables
11
+ /// the cap). `baseline` is the linear-memory size captured at invocation
12
+ /// entry by `MemoryLimiter::activate`; the limiter charges only the
13
+ /// `memory.grow` delta past `baseline` against `max_memory`, so the
14
+ /// mruby image's initial allocation and any high-water mark left by
15
+ /// prior invocations on the same Sandbox do not consume the budget.
16
+ /// `cap_active` gates whether the cap is enforced — wasmtime's
17
+ /// `ResourceLimiter` also fires for the module's declared initial
18
+ /// allocation at instantiation time, but the cap stays dormant until
19
+ /// `MemoryLimiter::activate` flips the flag for one `Driver` invoke
20
+ /// call. When `cap_active` is `false`, the limiter always allows
21
+ /// growth.
22
+ ///
23
+ /// When `memory.grow` would push the per-invocation delta past
24
+ /// `max_memory`, the limiter returns `MemoryLimitTrap` from
25
+ /// `memory_growing`; wasmtime turns that into the trap surfaced to the
26
+ /// host as a guest invocation failure.
27
+ #[derive(Debug, Clone, Copy)]
28
+ pub(crate) struct MemoryLimiter {
29
+ max_memory: Option<usize>,
30
+ baseline: usize,
31
+ cap_active: bool,
32
+ peak: usize,
33
+ }
34
+
35
+ impl MemoryLimiter {
36
+ pub(crate) fn new(max_memory: Option<usize>) -> Self {
37
+ Self {
38
+ max_memory,
39
+ baseline: 0,
40
+ cap_active: false,
41
+ peak: 0,
42
+ }
43
+ }
44
+
45
+ /// The cap stays dormant until armed, because the module's declared
46
+ /// initial memory is allocated during instantiation and must pass.
47
+ pub(crate) fn activate(&mut self, baseline: usize) {
48
+ self.baseline = baseline;
49
+ self.cap_active = true;
50
+ self.peak = 0;
51
+ }
52
+
53
+ pub(crate) fn deactivate(&mut self) {
54
+ self.cap_active = false;
55
+ }
56
+
57
+ /// A grow the cap rejects never updates the peak, so the reported value
58
+ /// never exceeds `memory_limit`.
59
+ pub(crate) fn peak(&self) -> usize {
60
+ self.peak
61
+ }
62
+ }
63
+
64
+ impl ResourceLimiter for MemoryLimiter {
65
+ fn memory_growing(
66
+ &mut self,
67
+ _current: usize,
68
+ desired: usize,
69
+ _maximum: Option<usize>,
70
+ ) -> wasmtime::Result<bool> {
71
+ if !self.cap_active {
72
+ return Ok(true);
73
+ }
74
+ let delta = desired.saturating_sub(self.baseline);
75
+ if let Some(limit) = self.max_memory {
76
+ if delta > limit {
77
+ return Err(wasmtime::Error::new(MemoryLimitTrap { desired, limit }));
78
+ }
79
+ }
80
+ if delta > self.peak {
81
+ self.peak = delta;
82
+ }
83
+ Ok(true)
84
+ }
85
+
86
+ fn table_growing(
87
+ &mut self,
88
+ _current: usize,
89
+ _desired: usize,
90
+ _maximum: Option<usize>,
91
+ ) -> wasmtime::Result<bool> {
92
+ Ok(true)
93
+ }
94
+ }
95
+
96
+ /// Marker error returned from `MemoryLimiter::memory_growing` when the
97
+ /// per-invocation memory cap is exceeded. Downcast from the wasmtime
98
+ /// trap error to classify the failure as a memory-limit `Trap`. Callers
99
+ /// use the `Display` impl below — no field is read directly — so the
100
+ /// inner state stays private.
101
+ #[derive(Debug)]
102
+ pub(crate) struct MemoryLimitTrap {
103
+ desired: usize,
104
+ limit: usize,
105
+ }
106
+
107
+ impl MemoryLimitTrap {
108
+ #[cfg(test)]
109
+ pub(crate) fn new(desired: usize, limit: usize) -> Self {
110
+ Self { desired, limit }
111
+ }
112
+ }
113
+
114
+ impl std::fmt::Display for MemoryLimitTrap {
115
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116
+ write!(
117
+ f,
118
+ "memory usage exceeded memory_limit: \
119
+ requested={} bytes, limit={} bytes",
120
+ self.desired, self.limit
121
+ )
122
+ }
123
+ }
124
+
125
+ impl std::error::Error for MemoryLimitTrap {}
126
+
127
+ #[cfg(test)]
128
+ mod tests {
129
+ //! Unit tests for `MemoryLimiter` — the per-invocation memory
130
+ //! delta cap. The Ruby-facing E2E suite exercises the full path
131
+ //! through wasmtime; these tests pin the pure delta arithmetic so
132
+ //! a regression that breaks the baseline accounting (e.g. dropping
133
+ //! the `baseline` subtraction, or letting `activate` carry stale
134
+ //! state across invocations) is caught without spinning up a
135
+ //! Store.
136
+ use super::{MemoryLimitTrap, MemoryLimiter};
137
+ use wasmtime::ResourceLimiter;
138
+
139
+ fn assert_growing(limiter: &mut MemoryLimiter, desired: usize) {
140
+ assert!(
141
+ limiter.memory_growing(0, desired, None).unwrap(),
142
+ "expected memory_growing({desired}) to allow growth"
143
+ );
144
+ }
145
+
146
+ fn assert_trapping(limiter: &mut MemoryLimiter, desired: usize) {
147
+ let err = limiter
148
+ .memory_growing(0, desired, None)
149
+ .expect_err("expected memory_growing to trap");
150
+ assert!(
151
+ err.downcast_ref::<MemoryLimitTrap>().is_some(),
152
+ "expected MemoryLimitTrap, got {err:?}"
153
+ );
154
+ }
155
+
156
+ // @behavior S-113
157
+ #[test]
158
+ fn dormant_limiter_allows_any_growth() {
159
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
160
+ // Without `activate`, the cap is dormant — the module's
161
+ // declared initial allocation must pass through unconditionally.
162
+ assert_growing(&mut limiter, 100 << 20);
163
+ }
164
+
165
+ // @behavior S-114
166
+ #[test]
167
+ fn delta_below_cap_passes_after_activate() {
168
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
169
+ limiter.activate(2 << 20);
170
+ // baseline=2 MiB, desired=2.5 MiB → delta=0.5 MiB ≤ 1 MiB cap.
171
+ assert_growing(&mut limiter, (2 << 20) + (1 << 19));
172
+ }
173
+
174
+ // @behavior S-121
175
+ #[test]
176
+ fn delta_past_cap_traps_with_memory_limit_trap() {
177
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
178
+ limiter.activate(2 << 20);
179
+ // baseline=2 MiB, desired=4 MiB → delta=2 MiB > 1 MiB cap.
180
+ assert_trapping(&mut limiter, 4 << 20);
181
+ }
182
+
183
+ // @behavior S-008
184
+ #[test]
185
+ fn activate_resets_baseline_on_each_invocation() {
186
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
187
+ limiter.activate(2 << 20);
188
+ assert_growing(&mut limiter, (2 << 20) + (1 << 20));
189
+ // Second invocation: linear memory has grown to 3 MiB. Re-arming
190
+ // must re-anchor the baseline so the next 1 MiB of growth fits
191
+ // the per-invocation budget rather than being charged against
192
+ // the prior invocation's residue.
193
+ limiter.activate(3 << 20);
194
+ assert_growing(&mut limiter, (3 << 20) + (1 << 20));
195
+ }
196
+
197
+ // @behavior S-009
198
+ #[test]
199
+ fn disabled_cap_ignores_delta_size() {
200
+ let mut limiter = MemoryLimiter::new(None);
201
+ limiter.activate(0);
202
+ assert_growing(&mut limiter, 100 << 20);
203
+ }
204
+
205
+ // @behavior S-116
206
+ #[test]
207
+ fn peak_starts_at_zero_before_any_grow() {
208
+ let limiter = MemoryLimiter::new(Some(1 << 20));
209
+ assert_eq!(limiter.peak(), 0);
210
+ }
211
+
212
+ // @behavior S-115
213
+ #[test]
214
+ fn peak_tracks_high_water_of_delta_past_baseline() {
215
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
216
+ limiter.activate(2 << 20);
217
+ assert_growing(&mut limiter, (2 << 20) + (1 << 18)); // delta=256 KiB
218
+ assert_growing(&mut limiter, (2 << 20) + (1 << 19)); // delta=512 KiB (new peak)
219
+ assert_growing(&mut limiter, (2 << 20) + (1 << 17)); // delta=128 KiB (below peak)
220
+ assert_eq!(limiter.peak(), 1 << 19);
221
+ }
222
+
223
+ // @behavior S-062
224
+ #[test]
225
+ fn trap_does_not_update_peak() {
226
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
227
+ limiter.activate(2 << 20);
228
+ assert_growing(&mut limiter, (2 << 20) + (1 << 19)); // delta=512 KiB
229
+ assert_trapping(&mut limiter, (2 << 20) + (2 << 20)); // would be 2 MiB > 1 MiB cap
230
+ // Peak reflects the last accepted grow, not the rejected desired.
231
+ assert_eq!(limiter.peak(), 1 << 19);
232
+ }
233
+
234
+ // @behavior S-117
235
+ #[test]
236
+ fn activate_resets_peak_for_new_invocation() {
237
+ let mut limiter = MemoryLimiter::new(Some(1 << 20));
238
+ limiter.activate(2 << 20);
239
+ assert_growing(&mut limiter, (2 << 20) + (1 << 19));
240
+ assert_eq!(limiter.peak(), 1 << 19);
241
+ limiter.activate(3 << 20);
242
+ assert_eq!(limiter.peak(), 0);
243
+ }
244
+
245
+ // @behavior S-118
246
+ #[test]
247
+ fn disabled_cap_still_tracks_peak() {
248
+ let mut limiter = MemoryLimiter::new(None);
249
+ limiter.activate(1 << 20);
250
+ assert_growing(&mut limiter, (1 << 20) + (4 << 20));
251
+ assert_eq!(limiter.peak(), 4 << 20);
252
+ }
253
+ }