kobako 0.27.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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +71 -0
- data/Cargo.lock +117 -140
- data/README.md +100 -125
- data/ROADMAP.md +23 -22
- data/SECURITY.md +1 -1
- data/crates/kobako-runtime/CHANGELOG.md +31 -0
- data/crates/kobako-runtime/Cargo.toml +2 -2
- data/crates/kobako-runtime/README.md +4 -3
- data/crates/kobako-runtime/src/error.rs +8 -9
- data/crates/kobako-runtime/src/lib.rs +1 -1
- data/crates/kobako-runtime/src/profile.rs +1 -1
- data/crates/kobako-runtime/src/runtime.rs +31 -17
- data/crates/kobako-runtime/src/snapshot.rs +13 -10
- data/crates/kobako-runtime/src/yielder.rs +9 -6
- data/crates/kobako-transport/CHANGELOG.md +28 -0
- data/crates/kobako-transport/Cargo.toml +1 -1
- data/crates/kobako-transport/README.md +21 -12
- data/crates/kobako-transport/src/abi.rs +11 -14
- data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
- data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
- data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
- data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
- data/crates/kobako-transport/src/envelope/reply.rs +77 -53
- data/crates/kobako-transport/src/envelope/request.rs +295 -0
- data/crates/kobako-transport/src/envelope/run.rs +1 -2
- data/crates/kobako-transport/src/envelope/tag.rs +49 -0
- data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
- data/crates/kobako-transport/src/envelope.rs +19 -12
- data/crates/kobako-transport/src/lib.rs +0 -2
- data/crates/kobako-wasmtime/CHANGELOG.md +44 -0
- data/crates/kobako-wasmtime/Cargo.toml +7 -5
- data/crates/kobako-wasmtime/README.md +2 -2
- data/crates/kobako-wasmtime/src/abi.rs +5 -8
- data/crates/kobako-wasmtime/src/ambient.rs +2 -2
- data/crates/kobako-wasmtime/src/cache.rs +25 -59
- data/crates/kobako-wasmtime/src/capture.rs +4 -11
- data/crates/kobako-wasmtime/src/config.rs +11 -14
- data/crates/kobako-wasmtime/src/dispatch.rs +70 -80
- data/crates/kobako-wasmtime/src/driver.rs +133 -86
- data/crates/kobako-wasmtime/src/exports.rs +88 -34
- data/crates/kobako-wasmtime/src/guest_mem.rs +145 -138
- data/crates/kobako-wasmtime/src/instance_pre.rs +11 -22
- data/crates/kobako-wasmtime/src/invocation.rs +55 -372
- data/crates/kobako-wasmtime/src/io.rs +286 -0
- data/crates/kobako-wasmtime/src/lib.rs +3 -24
- data/crates/kobako-wasmtime/src/limiter.rs +253 -0
- data/crates/kobako-wasmtime/src/trap.rs +32 -45
- data/crates/kobako-wasmtime/tests/preamble_flags.rs +69 -0
- data/crates/kobako-wasmtime/tests/snippet_compile.rs +94 -0
- data/crates/kobako-wasmtime/tests/support/mod.rs +34 -0
- data/crates/kobako-wasmtime/tests/unsupported_call.rs +50 -0
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +2 -2
- data/ext/kobako/src/runtime/bridge.rs +38 -47
- data/ext/kobako/src/runtime/errors.rs +26 -32
- data/ext/kobako/src/runtime/gvl.rs +4 -12
- data/ext/kobako/src/runtime.rs +169 -212
- data/lib/kobako/capture.rb +1 -4
- data/lib/kobako/catalog/extensions.rb +21 -59
- data/lib/kobako/catalog/flags.rb +41 -0
- data/lib/kobako/catalog/handles.rb +7 -54
- data/lib/kobako/catalog/services.rb +7 -61
- data/lib/kobako/catalog/snippets.rb +42 -66
- data/lib/kobako/catalog.rb +2 -10
- data/lib/kobako/codec/decoder.rb +9 -28
- data/lib/kobako/codec/encoder.rb +3 -22
- data/lib/kobako/codec/error.rb +6 -27
- data/lib/kobako/codec/ext_types.rb +72 -94
- data/lib/kobako/codec/handle_walk.rb +65 -130
- data/lib/kobako/codec/nesting.rb +3 -8
- data/lib/kobako/codec/state.rb +4 -10
- data/lib/kobako/codec/utils.rb +6 -34
- data/lib/kobako/codec.rb +3 -22
- data/lib/kobako/constant_name.rb +8 -0
- data/lib/kobako/context.rb +51 -112
- data/lib/kobako/errors.rb +51 -127
- data/lib/kobako/execution.rb +16 -21
- data/lib/kobako/extension.rb +23 -40
- data/lib/kobako/handle.rb +7 -12
- data/lib/kobako/outcome.rb +51 -80
- data/lib/kobako/payload/arguments.rb +1 -16
- data/lib/kobako/payload.rb +4 -12
- data/lib/kobako/pool.rb +23 -53
- data/lib/kobako/runtime.rb +7 -21
- data/lib/kobako/sandbox.rb +96 -133
- data/lib/kobako/sandbox_options.rb +38 -84
- data/lib/kobako/snippet/binary.rb +1 -16
- data/lib/kobako/snippet/source.rb +1 -16
- data/lib/kobako/snippet.rb +1 -9
- data/lib/kobako/transport/call.rb +1 -14
- data/lib/kobako/transport/dispatcher.rb +112 -195
- data/lib/kobako/transport/error.rb +6 -20
- data/lib/kobako/transport/exposure.rb +5 -10
- data/lib/kobako/transport/reflection.rb +39 -50
- data/lib/kobako/transport/run.rb +17 -71
- data/lib/kobako/transport/yielder.rb +21 -73
- data/lib/kobako/transport.rb +3 -15
- data/lib/kobako/unresolved.rb +4 -11
- data/lib/kobako/usage.rb +2 -6
- data/lib/kobako/version.rb +1 -1
- data/sig/kobako/catalog/extensions.rbs +2 -2
- data/sig/kobako/catalog/flags.rbs +17 -0
- data/sig/kobako/catalog/services.rbs +0 -6
- data/sig/kobako/catalog/snippets.rbs +4 -4
- data/sig/kobako/codec/ext_types.rbs +10 -8
- data/sig/kobako/codec/handle_walk.rbs +9 -7
- data/sig/kobako/codec/utils.rbs +2 -2
- data/sig/kobako/codec.rbs +2 -2
- data/sig/kobako/constant_name.rbs +3 -0
- data/sig/kobako/context.rbs +2 -3
- data/sig/kobako/errors.rbs +6 -0
- data/sig/kobako/outcome.rbs +6 -8
- data/sig/kobako/runtime.rbs +12 -8
- data/sig/kobako/sandbox.rbs +4 -0
- data/sig/kobako/sandbox_options.rbs +2 -6
- data/sig/kobako/transport/dispatcher.rbs +14 -18
- data/sig/kobako/transport/reflection.rbs +5 -3
- data/sig/kobako/transport/run.rbs +1 -3
- data/sig/kobako/transport/yielder.rbs +3 -3
- data/sig/kobako/transport.rbs +7 -6
- metadata +16 -4
- data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
- data/crates/kobako-wasmtime/src/frames.rs +0 -273
- data/rust-toolchain.toml +0 -15
|
@@ -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
|
-
//!
|
|
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
|
+
}
|