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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +89 -0
  4. data/Cargo.lock +156 -110
  5. data/README.md +19 -18
  6. data/ROADMAP.md +12 -12
  7. data/crates/kobako-runtime/CHANGELOG.md +14 -0
  8. data/crates/kobako-runtime/Cargo.toml +2 -2
  9. data/crates/kobako-runtime/README.md +1 -1
  10. data/crates/kobako-runtime/src/profile.rs +2 -2
  11. data/crates/kobako-transport/CHANGELOG.md +14 -0
  12. data/crates/kobako-transport/Cargo.toml +1 -1
  13. data/crates/kobako-transport/README.md +1 -1
  14. data/crates/kobako-transport/src/abi.rs +2 -0
  15. data/crates/kobako-transport/src/envelope/bytes.rs +6 -0
  16. data/crates/kobako-transport/src/envelope/call.rs +9 -0
  17. data/crates/kobako-transport/src/envelope/error_record.rs +3 -0
  18. data/crates/kobako-transport/src/envelope/fault.rs +5 -0
  19. data/crates/kobako-transport/src/envelope/invocation_frames.rs +9 -0
  20. data/crates/kobako-transport/src/envelope/outcome.rs +11 -0
  21. data/crates/kobako-transport/src/envelope/reply.rs +9 -0
  22. data/crates/kobako-transport/src/envelope/run.rs +3 -0
  23. data/crates/kobako-wasmtime/CHANGELOG.md +16 -0
  24. data/crates/kobako-wasmtime/Cargo.toml +5 -5
  25. data/crates/kobako-wasmtime/README.md +1 -1
  26. data/crates/kobako-wasmtime/src/ambient.rs +2 -0
  27. data/crates/kobako-wasmtime/src/capture.rs +8 -0
  28. data/crates/kobako-wasmtime/src/frames.rs +3 -1
  29. data/crates/kobako-wasmtime/src/guest_mem.rs +8 -0
  30. data/crates/kobako-wasmtime/src/invocation.rs +10 -0
  31. data/crates/kobako-wasmtime/src/trap.rs +6 -0
  32. data/data/kobako.wasm +0 -0
  33. data/ext/kobako/Cargo.toml +1 -1
  34. data/lib/kobako/capture.rb +8 -22
  35. data/lib/kobako/catalog/handles.rb +33 -9
  36. data/lib/kobako/catalog/services.rb +9 -7
  37. data/lib/kobako/codec/encoder.rb +4 -13
  38. data/lib/kobako/codec/handle_walk.rb +1 -1
  39. data/lib/kobako/codec/nesting.rb +43 -0
  40. data/lib/kobako/codec.rb +4 -2
  41. data/lib/kobako/context.rb +8 -8
  42. data/lib/kobako/handle.rb +10 -40
  43. data/lib/kobako/sandbox.rb +5 -0
  44. data/lib/kobako/transport/dispatcher.rb +29 -26
  45. data/lib/kobako/transport/exposure.rb +100 -0
  46. data/lib/kobako/transport/reflection.rb +19 -20
  47. data/lib/kobako/transport/run.rb +5 -2
  48. data/lib/kobako/transport/yielder.rb +6 -4
  49. data/lib/kobako/transport.rb +4 -2
  50. data/lib/kobako/usage.rb +11 -22
  51. data/lib/kobako/version.rb +1 -1
  52. data/release-please-config.json +1 -1
  53. data/sig/kobako/catalog/handles.rbs +2 -0
  54. data/sig/kobako/catalog/services.rbs +1 -1
  55. data/sig/kobako/codec/nesting.rbs +9 -0
  56. data/sig/kobako/context.rbs +1 -1
  57. data/sig/kobako/transport/dispatcher.rbs +7 -7
  58. data/sig/kobako/transport/exposure.rbs +43 -0
  59. data/sig/kobako/transport/reflection.rbs +1 -3
  60. data/sig/kobako/transport.rbs +11 -1
  61. 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.14.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.14.0", path = "../kobako-runtime" }
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.14.0", path = "../kobako-transport" }
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 = "47.0.2", default-features = false, features = [
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 = "47.0.2", default-features = false, features = ["p1"] }
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.
@@ -24,7 +24,7 @@ SDK consumes the same surface.
24
24
 
25
25
  ```toml
26
26
  [dependencies]
27
- kobako-wasmtime = "0.14.0" # x-release-please-version
27
+ kobako-wasmtime = "0.16.0" # x-release-please-version
28
28
  ```
29
29
 
30
30
  ## License
@@ -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 B-54 rung split at the WASI boundary: one probe
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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.23.0"
3
+ version = "0.26.0"
4
4
  edition = "2021"
5
5
  authors = ["Aotokitsuruya <contact@aotoki.me>"]
6
6
  license = "Apache-2.0"
@@ -1,26 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- # Host-side captured prefix of guest stdout / stderr produced during a
5
- # single +Kobako::Sandbox+ invocation, paired with the truncation flag
6
- # the WASI pipe sets when the guest wrote past the configured per-channel
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
- # Build a Capture wrapping +bytes+ (the captured prefix as a String) and
19
- # +truncated+ (whether the originating WASI pipe reported the cap was
20
- # hit). Coerces +bytes+ to UTF-8 when they are valid UTF-8, otherwise
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
- # Returns +true+ iff the underlying capture channel exceeded its
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
- # Pre-invocation sentinel. Empty UTF-8 bytes and +truncated? == false+;
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, untyped]
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. +id+ is a Handle ID previously
58
- # returned by +#alloc+. Returns the bound object. Raises
59
- # +Kobako::SandboxError+ if +id+ is not currently bound.
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. Raising
83
- # here keeps the rule at the single mint point, so it holds on both the
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
- raise SandboxError, "a #{object.class} cannot cross as a Capability Handle"
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→object table and the declared path set every invocation
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") # => kv_object
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, untyped]
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. Raises
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)