kobako 0.23.0 → 0.26.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 +89 -0
- data/Cargo.lock +156 -110
- data/README.md +19 -18
- data/ROADMAP.md +12 -12
- data/crates/kobako-runtime/CHANGELOG.md +14 -0
- data/crates/kobako-runtime/Cargo.toml +2 -2
- data/crates/kobako-runtime/README.md +1 -1
- data/crates/kobako-runtime/src/profile.rs +2 -2
- data/crates/kobako-transport/CHANGELOG.md +14 -0
- data/crates/kobako-transport/Cargo.toml +1 -1
- data/crates/kobako-transport/README.md +1 -1
- data/crates/kobako-transport/src/abi.rs +2 -0
- data/crates/kobako-transport/src/envelope/bytes.rs +6 -0
- data/crates/kobako-transport/src/envelope/call.rs +9 -0
- data/crates/kobako-transport/src/envelope/error_record.rs +3 -0
- data/crates/kobako-transport/src/envelope/fault.rs +5 -0
- data/crates/kobako-transport/src/envelope/invocation_frames.rs +9 -0
- data/crates/kobako-transport/src/envelope/outcome.rs +11 -0
- data/crates/kobako-transport/src/envelope/reply.rs +9 -0
- data/crates/kobako-transport/src/envelope/run.rs +3 -0
- data/crates/kobako-wasmtime/CHANGELOG.md +16 -0
- data/crates/kobako-wasmtime/Cargo.toml +5 -5
- data/crates/kobako-wasmtime/README.md +1 -1
- data/crates/kobako-wasmtime/src/ambient.rs +2 -0
- data/crates/kobako-wasmtime/src/capture.rs +8 -0
- data/crates/kobako-wasmtime/src/frames.rs +3 -1
- data/crates/kobako-wasmtime/src/guest_mem.rs +8 -0
- data/crates/kobako-wasmtime/src/invocation.rs +10 -0
- data/crates/kobako-wasmtime/src/trap.rs +6 -0
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/lib/kobako/capture.rb +8 -22
- data/lib/kobako/catalog/handles.rb +33 -9
- data/lib/kobako/catalog/services.rb +9 -7
- data/lib/kobako/codec/encoder.rb +4 -13
- data/lib/kobako/codec/handle_walk.rb +1 -1
- data/lib/kobako/codec/nesting.rb +43 -0
- data/lib/kobako/codec.rb +4 -2
- data/lib/kobako/context.rb +8 -8
- data/lib/kobako/handle.rb +10 -40
- data/lib/kobako/sandbox.rb +5 -0
- data/lib/kobako/transport/dispatcher.rb +29 -26
- data/lib/kobako/transport/exposure.rb +100 -0
- data/lib/kobako/transport/reflection.rb +19 -20
- data/lib/kobako/transport/run.rb +5 -2
- data/lib/kobako/transport/yielder.rb +6 -4
- data/lib/kobako/transport.rb +4 -2
- data/lib/kobako/usage.rb +11 -22
- data/lib/kobako/version.rb +1 -1
- data/release-please-config.json +1 -1
- data/sig/kobako/catalog/handles.rbs +2 -0
- data/sig/kobako/catalog/services.rbs +1 -1
- data/sig/kobako/codec/nesting.rbs +9 -0
- data/sig/kobako/context.rbs +1 -1
- data/sig/kobako/transport/dispatcher.rbs +7 -7
- data/sig/kobako/transport/exposure.rbs +43 -0
- data/sig/kobako/transport/reflection.rbs +1 -3
- data/sig/kobako/transport.rbs +11 -1
- metadata +5 -1
|
@@ -107,6 +107,7 @@ impl YieldReply {
|
|
|
107
107
|
mod tests {
|
|
108
108
|
use super::*;
|
|
109
109
|
|
|
110
|
+
// @behavior WE-018
|
|
110
111
|
#[test]
|
|
111
112
|
fn both_reply_arms_round_trip() {
|
|
112
113
|
for reply in [
|
|
@@ -125,6 +126,7 @@ mod tests {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// @behavior WE-019
|
|
128
130
|
#[test]
|
|
129
131
|
fn an_empty_ok_body_round_trips() {
|
|
130
132
|
let encoded = Reply::Ok(Vec::new()).encode();
|
|
@@ -135,6 +137,7 @@ mod tests {
|
|
|
135
137
|
);
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
// @behavior WE-020
|
|
138
141
|
#[test]
|
|
139
142
|
fn an_unknown_reply_tag_is_refused() {
|
|
140
143
|
assert!(
|
|
@@ -143,6 +146,7 @@ mod tests {
|
|
|
143
146
|
);
|
|
144
147
|
}
|
|
145
148
|
|
|
149
|
+
// @behavior WE-021
|
|
146
150
|
#[test]
|
|
147
151
|
fn a_zero_length_reply_is_refused() {
|
|
148
152
|
assert!(
|
|
@@ -151,6 +155,7 @@ mod tests {
|
|
|
151
155
|
);
|
|
152
156
|
}
|
|
153
157
|
|
|
158
|
+
// @behavior WE-022
|
|
154
159
|
#[test]
|
|
155
160
|
fn every_live_yield_arm_round_trips() {
|
|
156
161
|
let arms = [
|
|
@@ -172,6 +177,7 @@ mod tests {
|
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
// @behavior WE-023
|
|
175
181
|
#[test]
|
|
176
182
|
fn the_reserved_yield_tag_is_refused() {
|
|
177
183
|
assert!(
|
|
@@ -180,6 +186,7 @@ mod tests {
|
|
|
180
186
|
);
|
|
181
187
|
}
|
|
182
188
|
|
|
189
|
+
// @behavior WE-024
|
|
183
190
|
#[test]
|
|
184
191
|
fn an_unknown_yield_tag_is_refused() {
|
|
185
192
|
assert!(
|
|
@@ -188,6 +195,7 @@ mod tests {
|
|
|
188
195
|
);
|
|
189
196
|
}
|
|
190
197
|
|
|
198
|
+
// @behavior WE-025
|
|
191
199
|
#[test]
|
|
192
200
|
fn golden_layout_pins_the_reply_tags() {
|
|
193
201
|
// The same body on both arms: what separates "the Service returned
|
|
@@ -206,6 +214,7 @@ mod tests {
|
|
|
206
214
|
);
|
|
207
215
|
}
|
|
208
216
|
|
|
217
|
+
// @behavior WE-026
|
|
209
218
|
#[test]
|
|
210
219
|
fn golden_layout_pins_the_yield_tags() {
|
|
211
220
|
assert_eq!(
|
|
@@ -39,6 +39,7 @@ impl Run {
|
|
|
39
39
|
mod tests {
|
|
40
40
|
use super::*;
|
|
41
41
|
|
|
42
|
+
// @behavior WE-044
|
|
42
43
|
#[test]
|
|
43
44
|
fn a_run_round_trips() {
|
|
44
45
|
let run = Run {
|
|
@@ -53,6 +54,7 @@ mod tests {
|
|
|
53
54
|
);
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// @behavior WE-045
|
|
56
58
|
#[test]
|
|
57
59
|
fn a_run_with_no_arguments_round_trips() {
|
|
58
60
|
let run = Run {
|
|
@@ -67,6 +69,7 @@ mod tests {
|
|
|
67
69
|
);
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
// @behavior WE-046
|
|
70
73
|
#[test]
|
|
71
74
|
fn golden_layout_pins_the_run_field_order() {
|
|
72
75
|
let run = Run {
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.16.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.15.0...kobako-wasmtime-v0.16.0) (2026-09-15)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Miscellaneous Chores
|
|
7
|
+
|
|
8
|
+
* **kobako-wasmtime:** Synchronize kobako crates versions
|
|
9
|
+
|
|
10
|
+
## [0.15.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.14.0...kobako-wasmtime-v0.15.0) (2026-09-13)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **spec:** bring the Rust frontend under the behaviors it answers ([b62243f](https://github.com/elct9620/kobako/commit/b62243f68b338fd219136d60c9cf760d0be03fc0))
|
|
16
|
+
* **spec:** declare the bytes both sides must already agree on ([3875755](https://github.com/elct9620/kobako/commit/3875755999550511837808dcd24a25086bedc926))
|
|
17
|
+
* **spec:** declare what the driver holds a run to ([4539cc1](https://github.com/elct9620/kobako/commit/4539cc16a7ddb6c1706c36282e64182bb480b684))
|
|
18
|
+
|
|
3
19
|
## [0.14.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.13.1...kobako-wasmtime-v0.14.0) (2026-08-06)
|
|
4
20
|
|
|
5
21
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
[package]
|
|
17
17
|
name = "kobako-wasmtime"
|
|
18
|
-
version = "0.
|
|
18
|
+
version = "0.16.0"
|
|
19
19
|
edition = "2021"
|
|
20
20
|
description = "wasmtime implementation of the kobako host runtime contract."
|
|
21
21
|
license = "Apache-2.0"
|
|
@@ -28,17 +28,17 @@ categories = ["wasm", "virtualization"]
|
|
|
28
28
|
# The engine-neutral contract this crate implements. The version pin
|
|
29
29
|
# rides the linked release group; the path keeps in-tree builds (and
|
|
30
30
|
# the Ruby gem, which ships both crates) resolving locally.
|
|
31
|
-
kobako-runtime = { version = "0.
|
|
31
|
+
kobako-runtime = { version = "0.16.0", path = "../kobako-runtime" }
|
|
32
32
|
# The fixed tier: the core envelope this driver shuttles and the ABI
|
|
33
33
|
# values it drives the exports against.
|
|
34
|
-
kobako-transport = { version = "0.
|
|
34
|
+
kobako-transport = { version = "0.16.0", path = "../kobako-transport" }
|
|
35
35
|
# wasmtime — host-side embedder for kobako.wasm. We disable default-features
|
|
36
36
|
# and opt back in only what kobako needs: a Cranelift-backed runtime that can
|
|
37
37
|
# compile a pre-built wasm32-wasip1 module on the host triple, plus the `wat`
|
|
38
38
|
# feature so test fixtures can be expressed as text.
|
|
39
39
|
# `cache` / `parallel-compilation` / `pooling` / `component-model` / `async`
|
|
40
40
|
# are intentionally off — kobako runs short-lived synchronous sandboxes.
|
|
41
|
-
wasmtime = { version = "
|
|
41
|
+
wasmtime = { version = "48.0.1", default-features = false, features = [
|
|
42
42
|
"cranelift",
|
|
43
43
|
"runtime",
|
|
44
44
|
"gc",
|
|
@@ -52,7 +52,7 @@ wasmtime = { version = "47.0.2", default-features = false, features = [
|
|
|
52
52
|
# WasiCtxBuilder + preview1 adapter which wires fd 1/2 to pipes. We omit
|
|
53
53
|
# `p2` (component-model) and `p0`/`p3` (async) because kobako runs
|
|
54
54
|
# synchronous sandboxes only.
|
|
55
|
-
wasmtime-wasi = { version = "
|
|
55
|
+
wasmtime-wasi = { version = "48.0.1", default-features = false, features = ["p1"] }
|
|
56
56
|
# sha2 keys the on-disk compiled-module cache by Guest Binary content
|
|
57
57
|
# (see cache.rs); a collision would load the wrong artifact, so the
|
|
58
58
|
# hash must be cryptographic.
|
|
@@ -61,6 +61,7 @@ mod tests {
|
|
|
61
61
|
//! default would re-expose ambient time to a future libc-backed gem.
|
|
62
62
|
use super::*;
|
|
63
63
|
|
|
64
|
+
// @behavior RT-047
|
|
64
65
|
#[test]
|
|
65
66
|
fn the_guest_wall_clock_reads_the_unix_epoch() {
|
|
66
67
|
assert_eq!(
|
|
@@ -70,6 +71,7 @@ mod tests {
|
|
|
70
71
|
);
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// @behavior RT-051
|
|
73
75
|
#[test]
|
|
74
76
|
fn the_guest_monotonic_clock_never_advances() {
|
|
75
77
|
assert_eq!(
|
|
@@ -39,22 +39,26 @@ pub(crate) fn clip_capture(mut raw: Vec<u8>, cap: Option<usize>) -> (Vec<u8>, bo
|
|
|
39
39
|
mod tests {
|
|
40
40
|
use super::{clip_capture, pipe_capacity};
|
|
41
41
|
|
|
42
|
+
// @behavior S-108
|
|
42
43
|
#[test]
|
|
43
44
|
fn pipe_capacity_adds_one_when_cap_is_set() {
|
|
44
45
|
assert_eq!(pipe_capacity(Some(5)), 6);
|
|
45
46
|
assert_eq!(pipe_capacity(Some(0)), 1);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// @behavior S-109
|
|
48
50
|
#[test]
|
|
49
51
|
fn pipe_capacity_falls_back_to_usize_max_when_uncapped() {
|
|
50
52
|
assert_eq!(pipe_capacity(None), usize::MAX);
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
// @behavior S-110
|
|
53
56
|
#[test]
|
|
54
57
|
fn pipe_capacity_saturates_at_usize_max() {
|
|
55
58
|
assert_eq!(pipe_capacity(Some(usize::MAX)), usize::MAX);
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
// @behavior S-111
|
|
58
62
|
#[test]
|
|
59
63
|
fn clip_capture_returns_full_bytes_when_under_cap() {
|
|
60
64
|
let (bytes, truncated) = clip_capture(b"abc".to_vec(), Some(5));
|
|
@@ -62,6 +66,7 @@ mod tests {
|
|
|
62
66
|
assert!(!truncated);
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
// @behavior S-112
|
|
65
70
|
#[test]
|
|
66
71
|
fn clip_capture_does_not_flag_truncation_at_exactly_cap_bytes() {
|
|
67
72
|
let (bytes, truncated) = clip_capture(b"abcde".to_vec(), Some(5));
|
|
@@ -69,6 +74,7 @@ mod tests {
|
|
|
69
74
|
assert!(!truncated);
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
// @behavior S-024
|
|
72
78
|
#[test]
|
|
73
79
|
fn clip_capture_clips_to_cap_and_flags_truncation_on_overflow() {
|
|
74
80
|
// The pipe is sized `cap + 1`, so the snapshot can be at most
|
|
@@ -79,6 +85,7 @@ mod tests {
|
|
|
79
85
|
assert!(truncated);
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
// @behavior S-033
|
|
82
89
|
#[test]
|
|
83
90
|
fn clip_capture_treats_none_as_uncapped() {
|
|
84
91
|
let (bytes, truncated) = clip_capture(b"abcdef".to_vec(), None);
|
|
@@ -86,6 +93,7 @@ mod tests {
|
|
|
86
93
|
assert!(!truncated);
|
|
87
94
|
}
|
|
88
95
|
|
|
96
|
+
// @behavior S-100 S-101
|
|
89
97
|
#[test]
|
|
90
98
|
fn clip_capture_handles_empty_input() {
|
|
91
99
|
let (bytes, truncated) = clip_capture(Vec::new(), Some(5));
|
|
@@ -181,7 +181,7 @@ where
|
|
|
181
181
|
|
|
182
182
|
#[cfg(test)]
|
|
183
183
|
mod tests {
|
|
184
|
-
//! Witness the
|
|
184
|
+
//! Witness the profile rung split at the WASI boundary: one probe
|
|
185
185
|
//! module reads `wasi:clocks` / `wasi:random` through the context
|
|
186
186
|
//! `install_wasi_frames` builds — frozen under `Hermetic`, live
|
|
187
187
|
//! under `Permissive`.
|
|
@@ -243,6 +243,7 @@ mod tests {
|
|
|
243
243
|
(clock, random)
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// @behavior RT-047 RT-048
|
|
246
247
|
#[test]
|
|
247
248
|
fn hermetic_denies_ambient_time_and_entropy() {
|
|
248
249
|
let (clock, random) = probe_ambient(Profile::Hermetic);
|
|
@@ -256,6 +257,7 @@ mod tests {
|
|
|
256
257
|
);
|
|
257
258
|
}
|
|
258
259
|
|
|
260
|
+
// @behavior RT-049 RT-050
|
|
259
261
|
#[test]
|
|
260
262
|
fn permissive_grants_live_ambient_time_and_entropy() {
|
|
261
263
|
let (clock, random) = probe_ambient(Profile::Permissive);
|
|
@@ -198,6 +198,7 @@ mod tests {
|
|
|
198
198
|
checked_payload_len, guest_buffer_range, unpack_outcome_packed, MAX_DISPATCH_PAYLOAD,
|
|
199
199
|
};
|
|
200
200
|
|
|
201
|
+
// @behavior WE-059
|
|
201
202
|
#[test]
|
|
202
203
|
fn checked_payload_len_accepts_zero_and_the_cap() {
|
|
203
204
|
assert_eq!(checked_payload_len(0), Ok(0));
|
|
@@ -207,34 +208,40 @@ mod tests {
|
|
|
207
208
|
);
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
// @behavior WE-060
|
|
210
212
|
#[test]
|
|
211
213
|
fn checked_payload_len_rejects_past_the_cap() {
|
|
212
214
|
assert!(checked_payload_len(MAX_DISPATCH_PAYLOAD + 1).is_err());
|
|
213
215
|
assert!(checked_payload_len(usize::MAX).is_err());
|
|
214
216
|
}
|
|
215
217
|
|
|
218
|
+
// @behavior WE-061
|
|
216
219
|
#[test]
|
|
217
220
|
fn guest_buffer_range_returns_half_open_range() {
|
|
218
221
|
assert_eq!(guest_buffer_range(10, 5, 100), Ok(10..15));
|
|
219
222
|
}
|
|
220
223
|
|
|
224
|
+
// @behavior WE-062
|
|
221
225
|
#[test]
|
|
222
226
|
fn guest_buffer_range_accepts_zero_length_at_any_in_bounds_ptr() {
|
|
223
227
|
assert_eq!(guest_buffer_range(0, 0, 0), Ok(0..0));
|
|
224
228
|
assert_eq!(guest_buffer_range(42, 0, 100), Ok(42..42));
|
|
225
229
|
}
|
|
226
230
|
|
|
231
|
+
// @behavior WE-063
|
|
227
232
|
#[test]
|
|
228
233
|
fn guest_buffer_range_rejects_ptr_plus_len_overflow() {
|
|
229
234
|
assert!(guest_buffer_range(usize::MAX, 1, usize::MAX).is_err());
|
|
230
235
|
}
|
|
231
236
|
|
|
237
|
+
// @behavior WE-064
|
|
232
238
|
#[test]
|
|
233
239
|
fn guest_buffer_range_rejects_end_past_memory() {
|
|
234
240
|
assert!(guest_buffer_range(10, 100, 50).is_err());
|
|
235
241
|
assert_eq!(guest_buffer_range(0, 50, 50), Ok(0..50));
|
|
236
242
|
}
|
|
237
243
|
|
|
244
|
+
// @behavior WE-001
|
|
238
245
|
#[test]
|
|
239
246
|
fn unpack_outcome_packed_extracts_high_ptr_low_len() {
|
|
240
247
|
assert_eq!(
|
|
@@ -243,6 +250,7 @@ mod tests {
|
|
|
243
250
|
);
|
|
244
251
|
}
|
|
245
252
|
|
|
253
|
+
// @behavior WE-065
|
|
246
254
|
#[test]
|
|
247
255
|
fn unpack_outcome_packed_zero_decodes_to_zero_pair() {
|
|
248
256
|
assert_eq!(unpack_outcome_packed(0), (0, 0));
|
|
@@ -394,6 +394,7 @@ mod tests {
|
|
|
394
394
|
);
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
// @behavior S-113
|
|
397
398
|
#[test]
|
|
398
399
|
fn dormant_limiter_allows_any_growth() {
|
|
399
400
|
let mut limiter = MemoryLimiter::new(Some(1 << 20));
|
|
@@ -402,6 +403,7 @@ mod tests {
|
|
|
402
403
|
assert_growing(&mut limiter, 100 << 20);
|
|
403
404
|
}
|
|
404
405
|
|
|
406
|
+
// @behavior S-114
|
|
405
407
|
#[test]
|
|
406
408
|
fn delta_below_cap_passes_after_activate() {
|
|
407
409
|
let mut limiter = MemoryLimiter::new(Some(1 << 20));
|
|
@@ -410,6 +412,7 @@ mod tests {
|
|
|
410
412
|
assert_growing(&mut limiter, (2 << 20) + (1 << 19));
|
|
411
413
|
}
|
|
412
414
|
|
|
415
|
+
// @behavior S-121
|
|
413
416
|
#[test]
|
|
414
417
|
fn delta_past_cap_traps_with_memory_limit_trap() {
|
|
415
418
|
let mut limiter = MemoryLimiter::new(Some(1 << 20));
|
|
@@ -418,6 +421,7 @@ mod tests {
|
|
|
418
421
|
assert_trapping(&mut limiter, 4 << 20);
|
|
419
422
|
}
|
|
420
423
|
|
|
424
|
+
// @behavior S-008
|
|
421
425
|
#[test]
|
|
422
426
|
fn activate_resets_baseline_on_each_invocation() {
|
|
423
427
|
let mut limiter = MemoryLimiter::new(Some(1 << 20));
|
|
@@ -431,6 +435,7 @@ mod tests {
|
|
|
431
435
|
assert_growing(&mut limiter, (3 << 20) + (1 << 20));
|
|
432
436
|
}
|
|
433
437
|
|
|
438
|
+
// @behavior S-009
|
|
434
439
|
#[test]
|
|
435
440
|
fn disabled_cap_ignores_delta_size() {
|
|
436
441
|
let mut limiter = MemoryLimiter::new(None);
|
|
@@ -438,12 +443,14 @@ mod tests {
|
|
|
438
443
|
assert_growing(&mut limiter, 100 << 20);
|
|
439
444
|
}
|
|
440
445
|
|
|
446
|
+
// @behavior S-116
|
|
441
447
|
#[test]
|
|
442
448
|
fn peak_starts_at_zero_before_any_grow() {
|
|
443
449
|
let limiter = MemoryLimiter::new(Some(1 << 20));
|
|
444
450
|
assert_eq!(limiter.peak(), 0);
|
|
445
451
|
}
|
|
446
452
|
|
|
453
|
+
// @behavior S-115
|
|
447
454
|
#[test]
|
|
448
455
|
fn peak_tracks_high_water_of_delta_past_baseline() {
|
|
449
456
|
let mut limiter = MemoryLimiter::new(Some(1 << 20));
|
|
@@ -454,6 +461,7 @@ mod tests {
|
|
|
454
461
|
assert_eq!(limiter.peak(), 1 << 19);
|
|
455
462
|
}
|
|
456
463
|
|
|
464
|
+
// @behavior S-062
|
|
457
465
|
#[test]
|
|
458
466
|
fn trap_does_not_update_peak() {
|
|
459
467
|
let mut limiter = MemoryLimiter::new(Some(1 << 20));
|
|
@@ -464,6 +472,7 @@ mod tests {
|
|
|
464
472
|
assert_eq!(limiter.peak(), 1 << 19);
|
|
465
473
|
}
|
|
466
474
|
|
|
475
|
+
// @behavior S-117
|
|
467
476
|
#[test]
|
|
468
477
|
fn activate_resets_peak_for_new_invocation() {
|
|
469
478
|
let mut limiter = MemoryLimiter::new(Some(1 << 20));
|
|
@@ -474,6 +483,7 @@ mod tests {
|
|
|
474
483
|
assert_eq!(limiter.peak(), 0);
|
|
475
484
|
}
|
|
476
485
|
|
|
486
|
+
// @behavior S-118
|
|
477
487
|
#[test]
|
|
478
488
|
fn disabled_cap_still_tracks_peak() {
|
|
479
489
|
let mut limiter = MemoryLimiter::new(None);
|
|
@@ -110,6 +110,7 @@ mod tests {
|
|
|
110
110
|
// `increment_epoch` stands in for the ticker to make the ticked
|
|
111
111
|
// state deterministic; under debug overflow checks an overflowing
|
|
112
112
|
// delta panics right here.
|
|
113
|
+
// @behavior S-119
|
|
113
114
|
#[test]
|
|
114
115
|
fn no_timeout_delta_survives_a_ticked_engine_epoch() {
|
|
115
116
|
let engine = crate::cache::shared_engine().expect("shared engine must be constructible");
|
|
@@ -118,6 +119,7 @@ mod tests {
|
|
|
118
119
|
store.set_epoch_deadline(NO_TIMEOUT_EPOCH_DELTA);
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// @behavior OC-032
|
|
121
123
|
#[test]
|
|
122
124
|
fn trap_from_routes_timeout_trap_to_timeout() {
|
|
123
125
|
let err = wasmtime::Error::new(TimeoutTrap);
|
|
@@ -125,6 +127,7 @@ mod tests {
|
|
|
125
127
|
assert!(matches!(trap_from(err), Trap::Timeout(msg) if msg == expected));
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
// @behavior OC-033
|
|
128
131
|
#[test]
|
|
129
132
|
fn trap_from_routes_memory_limit_trap_to_memory_limit() {
|
|
130
133
|
let trap = MemoryLimitTrap::new(1 << 20, 1 << 19);
|
|
@@ -133,6 +136,7 @@ mod tests {
|
|
|
133
136
|
assert!(matches!(trap_from(err), Trap::MemoryLimit(msg) if msg == expected));
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
// @behavior OC-028
|
|
136
140
|
#[test]
|
|
137
141
|
fn trap_from_falls_back_to_other_for_unknown_errors() {
|
|
138
142
|
let err = wasmtime::Error::msg("some other wasmtime fault");
|
|
@@ -142,6 +146,7 @@ mod tests {
|
|
|
142
146
|
// A guest hard trap reaches the host as a wasmtime error whose Display is
|
|
143
147
|
// only the backtrace framing, with the trap reason buried as the chain's
|
|
144
148
|
// root cause. The named-capture regex bug surfaced as exactly this shape.
|
|
149
|
+
// @behavior OC-029
|
|
145
150
|
#[test]
|
|
146
151
|
fn other_trap_message_surfaces_buried_trap_reason() {
|
|
147
152
|
let err = wasmtime::Error::msg("wasm trap: indirect call type mismatch")
|
|
@@ -159,6 +164,7 @@ mod tests {
|
|
|
159
164
|
|
|
160
165
|
// A flat error (no cause chain) is its own root_cause; appending it would
|
|
161
166
|
// duplicate the whole message.
|
|
167
|
+
// @behavior OC-030
|
|
162
168
|
#[test]
|
|
163
169
|
fn other_trap_message_does_not_duplicate_a_flat_error() {
|
|
164
170
|
let err = wasmtime::Error::msg("plain fault");
|
data/data/kobako.wasm
CHANGED
|
Binary file
|
data/ext/kobako/Cargo.toml
CHANGED
data/lib/kobako/capture.rb
CHANGED
|
@@ -1,26 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Kobako
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# cap.
|
|
8
|
-
#
|
|
9
|
-
# Immutable value object: the captured bytes and the truncation flag
|
|
10
|
-
# always travel together and the instance is frozen on construction.
|
|
11
|
-
# Construct via +Capture.new(bytes:, truncated:)+ for the ext-provided
|
|
12
|
-
# binary bytes (the constructor handles the UTF-8 / ASCII-8BIT fallback)
|
|
13
|
-
# or reach +Capture::EMPTY+ for the pre-invocation sentinel that
|
|
14
|
-
# +Sandbox+ uses before any invocation has executed.
|
|
4
|
+
# What one invocation wrote to stdout or stderr, up to that channel's
|
|
5
|
+
# cap, together with whether it wrote past the cap. Frozen, so the bytes
|
|
6
|
+
# and the flag always travel together.
|
|
15
7
|
class Capture
|
|
16
8
|
attr_reader :bytes
|
|
17
9
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# falls back to ASCII-8BIT so invalid sequences remain inspectable
|
|
22
|
-
# without raising; +bytes+ is duplicated, never mutated. Freezes the
|
|
23
|
-
# instance so callers cannot mutate the pair.
|
|
10
|
+
# The bytes read as UTF-8 when they are valid UTF-8, and as binary
|
|
11
|
+
# otherwise, so output that is not text stays inspectable instead of
|
|
12
|
+
# raising. The caller's String is copied, never changed.
|
|
24
13
|
def initialize(bytes:, truncated:)
|
|
25
14
|
copy = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
26
15
|
copy.force_encoding(Encoding::ASCII_8BIT) unless copy.valid_encoding?
|
|
@@ -29,13 +18,10 @@ module Kobako
|
|
|
29
18
|
freeze
|
|
30
19
|
end
|
|
31
20
|
|
|
32
|
-
#
|
|
33
|
-
# configured cap during the originating +Sandbox+ invocation.
|
|
21
|
+
# Whether the invocation wrote past this channel's cap.
|
|
34
22
|
def truncated? = @truncated
|
|
35
23
|
|
|
36
|
-
#
|
|
37
|
-
# reused by every fresh +Sandbox+ and by +Sandbox+ between invocations
|
|
38
|
-
# to denote "no capture yet".
|
|
24
|
+
# The capture before any invocation has written anything.
|
|
39
25
|
EMPTY = new(bytes: "", truncated: false)
|
|
40
26
|
end
|
|
41
27
|
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
3
5
|
require_relative "../handle"
|
|
6
|
+
require_relative "../transport/exposure"
|
|
4
7
|
|
|
5
8
|
module Kobako
|
|
6
9
|
module Catalog
|
|
@@ -29,7 +32,9 @@ module Kobako
|
|
|
29
32
|
# tests pass a value near +Kobako::Handle::MAX_ID+ to exercise
|
|
30
33
|
# the cap-exhaustion path without 2³¹ allocations.
|
|
31
34
|
def initialize(next_id: 1)
|
|
32
|
-
@entries = {} # : Hash[Integer,
|
|
35
|
+
@entries = {} # : Hash[Integer, Kobako::Transport::Exposure]
|
|
36
|
+
surfaces = {} # : Hash[Module, Set[Symbol]]
|
|
37
|
+
@surfaces = surfaces.compare_by_identity
|
|
33
38
|
@next_id = next_id
|
|
34
39
|
end
|
|
35
40
|
|
|
@@ -45,19 +50,31 @@ module Kobako
|
|
|
45
50
|
# allocator's output a domain entity. An id is the Handle's only
|
|
46
51
|
# content, so the same internal +Kobako::Handle.restore+ constructor
|
|
47
52
|
# serves both this allocator and the codec's wire-decode path.
|
|
53
|
+
#
|
|
54
|
+
# The entry records the object's Exposure as it stands at mint, so a
|
|
55
|
+
# call made through this Handle is authorized against the reference
|
|
56
|
+
# the guest was given.
|
|
48
57
|
def alloc(object)
|
|
49
58
|
reject_unwrappable!(object)
|
|
50
59
|
ensure_capacity!
|
|
51
60
|
id = @next_id
|
|
52
|
-
@entries[id] = object
|
|
61
|
+
@entries[id] = Kobako::Transport::Exposure.of(object, @surfaces)
|
|
53
62
|
@next_id = id + 1
|
|
54
63
|
Kobako::Handle.restore(id)
|
|
55
64
|
end
|
|
56
65
|
|
|
57
|
-
# Resolve a Handle ID to its bound object
|
|
58
|
-
#
|
|
59
|
-
# +Kobako::SandboxError+ if
|
|
66
|
+
# Resolve a Handle ID to its bound object — the very object +#alloc+
|
|
67
|
+
# received, so a Handle crossing back restores to it. +id+ is a Handle
|
|
68
|
+
# ID previously returned by +#alloc+. Raises +Kobako::SandboxError+ if
|
|
69
|
+
# +id+ is not currently bound.
|
|
60
70
|
def fetch(id)
|
|
71
|
+
exposure(id).object
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The Exposure +id+ was minted with, which authorizes a call the guest
|
|
75
|
+
# makes through that Handle. Raises +Kobako::SandboxError+ if +id+ is
|
|
76
|
+
# not currently bound.
|
|
77
|
+
def exposure(id)
|
|
61
78
|
require_bound!(id)
|
|
62
79
|
@entries[id]
|
|
63
80
|
end
|
|
@@ -79,13 +96,20 @@ module Kobako
|
|
|
79
96
|
# reflection (a returned +Binding+ reaches +Binding#eval+); a +Class+
|
|
80
97
|
# or +Module+ hands over its class-level API (+File.popen+ / +read+,
|
|
81
98
|
# +Kernel.system+), which the owner-based dispatch floor cannot see
|
|
82
|
-
# because a singleton-class owner matches no core-module list
|
|
83
|
-
#
|
|
99
|
+
# because a singleton-class owner matches no core-module list; a
|
|
100
|
+
# +Delegator+ (+SimpleDelegator+, +DelegateClass+, +WeakRef+,
|
|
101
|
+
# +Tempfile+) is a transparent forwarder whose public +method_missing+
|
|
102
|
+
# binds and calls the private method the guest names (+Kernel#system+),
|
|
103
|
+
# a surface the floor reads as ordinary Service behaviour. Raising here
|
|
104
|
+
# keeps the rule at the single mint point, so it holds on both the
|
|
84
105
|
# Service-return and the +#run+ host→guest auto-wrap paths.
|
|
85
106
|
def reject_unwrappable!(object)
|
|
86
107
|
case object
|
|
87
|
-
when Binding, Method, UnboundMethod, Module
|
|
88
|
-
|
|
108
|
+
when Binding, Method, UnboundMethod, Module, Delegator
|
|
109
|
+
# Delegator < BasicObject exposes no static +#class+, so name the
|
|
110
|
+
# rejected object's class through Object's own.
|
|
111
|
+
kind = Object.instance_method(:class).bind_call(object)
|
|
112
|
+
raise SandboxError, "a #{kind} cannot cross as a Capability Handle"
|
|
89
113
|
end
|
|
90
114
|
end
|
|
91
115
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../errors"
|
|
4
|
+
require_relative "../transport/exposure"
|
|
4
5
|
|
|
5
6
|
module Kobako
|
|
6
7
|
module Catalog
|
|
7
8
|
# Kobako::Catalog::Services — per-Sandbox registry of Service
|
|
8
9
|
# bindings keyed by their constant-path name. Holds the flat
|
|
9
|
-
# path→
|
|
10
|
+
# path→Exposure table and the declared path set every invocation
|
|
10
11
|
# announces on Frame 1.
|
|
11
12
|
#
|
|
12
13
|
# Public API:
|
|
@@ -14,7 +15,7 @@ module Kobako
|
|
|
14
15
|
# services = Kobako::Catalog::Services.new
|
|
15
16
|
# services.bind("MyService::KV", kv_object) # => services (chainable)
|
|
16
17
|
# services.paths # => ["MyService::KV"]
|
|
17
|
-
# services.lookup("MyService::KV")
|
|
18
|
+
# services.lookup("MyService::KV").object # => kv_object
|
|
18
19
|
#
|
|
19
20
|
# Per-dispatch routing is +Kobako::Transport::Dispatcher+'s
|
|
20
21
|
# responsibility — the Dispatcher resolves a path against the invocation's
|
|
@@ -29,7 +30,7 @@ module Kobako
|
|
|
29
30
|
|
|
30
31
|
# Build a fresh registry.
|
|
31
32
|
def initialize
|
|
32
|
-
@bindings = {} # : Hash[String,
|
|
33
|
+
@bindings = {} # : Hash[String, Kobako::Transport::Exposure]
|
|
33
34
|
@sealed = false
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -39,14 +40,15 @@ module Kobako
|
|
|
39
40
|
# chaining. Raises +ArgumentError+ when a segment is malformed, when
|
|
40
41
|
# +path+ collides with an existing binding (a name is a bound Service
|
|
41
42
|
# or a grouping prefix, never both), or when the owning Sandbox has
|
|
42
|
-
# been sealed by its first invocation.
|
|
43
|
+
# been sealed by its first invocation. The binding records +object+'s
|
|
44
|
+
# Exposure as it stands now.
|
|
43
45
|
def bind(path, object)
|
|
44
46
|
raise ArgumentError, "cannot bind after first Sandbox invocation" if @sealed
|
|
45
47
|
|
|
46
48
|
path_str = validate_path!(path)
|
|
47
49
|
raise ArgumentError, "Service path #{path_str} conflicts with an existing binding" if collision?(path_str)
|
|
48
50
|
|
|
49
|
-
@bindings[path_str] = object
|
|
51
|
+
@bindings[path_str] = Kobako::Transport::Exposure.of(object)
|
|
50
52
|
self
|
|
51
53
|
end
|
|
52
54
|
|
|
@@ -56,8 +58,8 @@ module Kobako
|
|
|
56
58
|
@bindings.key?(path.to_s)
|
|
57
59
|
end
|
|
58
60
|
|
|
59
|
-
# Resolve a +target+ constant path to the bound Service.
|
|
60
|
-
# +KeyError+ when no Service is bound at +target+.
|
|
61
|
+
# Resolve a +target+ constant path to the bound Service's Exposure.
|
|
62
|
+
# Raises +KeyError+ when no Service is bound at +target+.
|
|
61
63
|
def lookup(target)
|
|
62
64
|
target_str = target.to_s
|
|
63
65
|
raise KeyError, "no service bound at #{target_str.inspect}" unless @bindings.key?(target_str)
|