kobako 0.3.0 → 0.4.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +85 -5
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +1 -1
  6. data/ext/kobako/src/wasm/cache.rs +12 -4
  7. data/ext/kobako/src/wasm/dispatch.rs +15 -14
  8. data/ext/kobako/src/wasm/host_state.rs +111 -5
  9. data/ext/kobako/src/wasm/instance.rs +135 -33
  10. data/ext/kobako/src/wasm.rs +1 -0
  11. data/lib/kobako/codec/decoder.rb +0 -2
  12. data/lib/kobako/codec/factory.rb +13 -10
  13. data/lib/kobako/codec/utils.rb +105 -13
  14. data/lib/kobako/handle.rb +62 -0
  15. data/lib/kobako/handle_table.rb +119 -0
  16. data/lib/kobako/invocation.rb +56 -25
  17. data/lib/kobako/outcome.rb +42 -12
  18. data/lib/kobako/rpc/dispatcher.rb +22 -20
  19. data/lib/kobako/rpc/envelope.rb +7 -7
  20. data/lib/kobako/rpc/fault.rb +1 -1
  21. data/lib/kobako/rpc/server.rb +12 -24
  22. data/lib/kobako/rpc/wire_error.rb +23 -0
  23. data/lib/kobako/sandbox.rb +77 -24
  24. data/lib/kobako/usage.rb +41 -0
  25. data/lib/kobako/version.rb +1 -1
  26. data/lib/kobako.rb +1 -0
  27. data/sig/kobako/codec/factory.rbs +1 -1
  28. data/sig/kobako/codec/utils.rbs +10 -0
  29. data/sig/kobako/handle.rbs +19 -0
  30. data/sig/kobako/handle_table.rbs +23 -0
  31. data/sig/kobako/invocation.rbs +3 -1
  32. data/sig/kobako/outcome.rbs +1 -1
  33. data/sig/kobako/rpc/dispatcher.rbs +7 -7
  34. data/sig/kobako/rpc/envelope.rbs +3 -3
  35. data/sig/kobako/rpc/server.rbs +1 -7
  36. data/sig/kobako/rpc/wire_error.rbs +6 -0
  37. data/sig/kobako/sandbox.rbs +7 -1
  38. data/sig/kobako/usage.rbs +11 -0
  39. data/sig/kobako/wasm.rbs +2 -0
  40. metadata +9 -5
  41. data/lib/kobako/rpc/handle.rb +0 -39
  42. data/lib/kobako/rpc/handle_table.rb +0 -107
  43. data/sig/kobako/rpc/handle.rbs +0 -19
  44. data/sig/kobako/rpc/handle_table.rbs +0 -25
@@ -119,7 +119,7 @@ impl Instance {
119
119
  Some(secs) => {
120
120
  return Err(wasm_err(
121
121
  &ruby,
122
- format!("timeout_seconds must be > 0 and finite, got {secs}"),
122
+ format!("timeout must be > 0 and finite, got {secs} seconds"),
123
123
  ));
124
124
  }
125
125
  };
@@ -161,10 +161,17 @@ impl Instance {
161
161
  // `Instance::eval`. The closure pulls a `&mut WasiP1Ctx` out of
162
162
  // HostState; the panic semantics live inside `HostState::wasi_mut`
163
163
  // so the wiring stays honest about its precondition.
164
- p1::add_to_linker_sync(&mut linker, |state: &mut HostState| state.wasi_mut())
165
- .map_err(|e| wasm_err(&ruby, format!("add WASI p1 to linker: {}", e)))?;
164
+ p1::add_to_linker_sync(&mut linker, |state: &mut HostState| state.wasi_mut()).map_err(
165
+ |e| {
166
+ wasm_err(
167
+ &ruby,
168
+ format!("failed to wire WASI runtime into Sandbox: {}", e),
169
+ )
170
+ },
171
+ )?;
166
172
 
167
- // `__kobako_dispatch` host import. Signature per SPEC Wire ABI:
173
+ // `__kobako_dispatch` host import. Signature per docs/wire-codec.md
174
+ // § ABI Signatures:
168
175
  // (req_ptr: i32, req_len: i32) -> i64
169
176
  // Decodes the Request bytes, dispatches via the Ruby-side
170
177
  // `Kobako::RPC::Server` (set per-run via `set_server`), allocates a
@@ -180,7 +187,12 @@ impl Instance {
180
187
  dispatch::handle(&mut caller, req_ptr, req_len)
181
188
  },
182
189
  )
183
- .map_err(|e| wasm_err(&ruby, format!("define __kobako_dispatch: {}", e)))?;
190
+ .map_err(|e| {
191
+ wasm_err(
192
+ &ruby,
193
+ format!("failed to register host RPC dispatch import: {}", e),
194
+ )
195
+ })?;
184
196
 
185
197
  let instance = {
186
198
  let mut store_ref = store_cell.borrow_mut();
@@ -268,7 +280,7 @@ impl Instance {
268
280
  eval.call(store_ref.as_context_mut(), ())
269
281
  };
270
282
  self.disarm_caps();
271
- result.map_err(|e| call_err(&ruby, "__kobako_eval", e))
283
+ result.map_err(|e| call_err(&ruby, e))
272
284
  }
273
285
 
274
286
  /// Execute one entrypoint dispatch (`__kobako_run`).
@@ -296,7 +308,7 @@ impl Instance {
296
308
  run.call(store_ref.as_context_mut(), (env_ptr, env_len))
297
309
  };
298
310
  self.disarm_caps();
299
- result.map_err(|e| call_err(&ruby, "__kobako_run", e))
311
+ result.map_err(|e| call_err(&ruby, e))
300
312
  }
301
313
 
302
314
  /// Return the stdout capture from the most recent run as a Ruby
@@ -333,6 +345,39 @@ impl Instance {
333
345
  Ok(ruby.str_from_slice(&bytes))
334
346
  }
335
347
 
348
+ /// Return the docs/behavior.md B-35 per-last-invocation usage as a
349
+ /// Ruby 2-tuple `[wall_time, memory_peak]`. The element order
350
+ /// matches the `Kobako::Usage` field order declared in
351
+ /// `lib/kobako/usage.rb`; reorder both sides together if the field
352
+ /// list ever grows.
353
+ ///
354
+ /// * `wall_time` (Float seconds) — the wall-clock duration the
355
+ /// most recent invocation spent inside the guest export call.
356
+ /// Bracket opens in [`Instance::prime_caps`] and closes in
357
+ /// [`Instance::disarm_caps`], so the value mirrors the
358
+ /// `timeout` deadline accounting and excludes everything that
359
+ /// runs after the guest export returns — the post-export
360
+ /// `OUTCOME_BUFFER` fetch and decode, plus stdout / stderr
361
+ /// capture readout. `0.0` before the first invocation.
362
+ /// * `memory_peak` (Integer bytes) — the high-water mark of the
363
+ /// per-invocation `memory.grow` delta past the linear-memory
364
+ /// size captured at invocation entry. `0` before the first
365
+ /// invocation.
366
+ ///
367
+ /// Packing both readers into one ext call mirrors the
368
+ /// [`Instance::stdout`] / [`Instance::stderr`] pattern: one
369
+ /// `store.borrow()` per readout and a single magnus binding to
370
+ /// extend when B-35's field list grows past two.
371
+ pub(crate) fn usage(&self) -> Result<RArray, MagnusError> {
372
+ let ruby = Ruby::get().expect("Ruby thread");
373
+ let state = self.store.borrow();
374
+ let data = state.data();
375
+ let arr = ruby.ary_new_capa(2);
376
+ arr.push(data.wall_time().as_secs_f64())?;
377
+ arr.push(data.memory_peak())?;
378
+ Ok(arr)
379
+ }
380
+
336
381
  // -----------------------------------------------------------------
337
382
  // Private helpers.
338
383
  // -----------------------------------------------------------------
@@ -349,6 +394,12 @@ impl Instance {
349
394
  /// mark left by prior invocations on the same Sandbox are folded
350
395
  /// into the baseline rather than the budget — only `memory.grow`
351
396
  /// past +baseline+ counts against `memory_limit`.
397
+ ///
398
+ /// Also stamps the wall-clock entry instant for the
399
+ /// docs/behavior.md B-35 `wall_time` measurement. The bracket
400
+ /// closes in [`Instance::disarm_caps`] so it matches the
401
+ /// `timeout` deadline window and excludes `OUTCOME_BUFFER`
402
+ /// decoding and stdout / stderr capture readout.
352
403
  fn prime_caps(&self) {
353
404
  let mut store_ref = self.store.borrow_mut();
354
405
  match self.timeout {
@@ -367,14 +418,19 @@ impl Instance {
367
418
  _ => 0,
368
419
  };
369
420
  store_ref.data_mut().arm_memory_cap(baseline);
421
+ store_ref.data_mut().start_wall_clock();
370
422
  }
371
423
 
372
424
  /// Drop the memory cap as soon as the guest call returns so that
373
425
  /// any post-run host bookkeeping (e.g. fetching the OUTCOME_BUFFER,
374
426
  /// which can grow guest memory transiently) is not attributed to
375
- /// the user script. Paired with [`Instance::prime_caps`].
427
+ /// the user script. Also closes the docs/behavior.md B-35
428
+ /// `wall_time` bracket opened by [`Instance::prime_caps`]. Paired
429
+ /// with [`Instance::prime_caps`].
376
430
  fn disarm_caps(&self) {
377
- self.store.borrow_mut().data_mut().disarm_memory_cap();
431
+ let mut store_ref = self.store.borrow_mut();
432
+ store_ref.data_mut().stop_wall_clock();
433
+ store_ref.data_mut().disarm_memory_cap();
378
434
  }
379
435
 
380
436
  /// Allocate a +len+-byte buffer in guest linear memory via
@@ -390,17 +446,20 @@ impl Instance {
390
446
  let alloc: TypedFunc<u32, u32> = self
391
447
  .inner
392
448
  .get_typed_func(store_ref.as_context_mut(), "__kobako_alloc")
393
- .map_err(|_| wasm_err(ruby, "guest does not export __kobako_alloc"))?;
449
+ .map_err(|_| wasm_err(ruby, SANDBOX_RUNTIME_MISSING_HOOKS))?;
394
450
  let ptr = alloc
395
451
  .call(store_ref.as_context_mut(), bytes.len() as u32)
396
- .map_err(|e| wasm_err(ruby, format!("__kobako_alloc(): {}", e)))?;
452
+ .map_err(|e| wasm_err(ruby, format!("failed to allocate input buffer: {}", e)))?;
397
453
  if ptr == 0 {
398
- return Err(wasm_err(ruby, "__kobako_alloc returned 0 (out of memory)"));
454
+ return Err(wasm_err(
455
+ ruby,
456
+ "could not allocate input buffer (out of memory)",
457
+ ));
399
458
  }
400
459
 
401
460
  let memory: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
402
461
  Some(Extern::Memory(m)) => m,
403
- _ => return Err(wasm_err(ruby, "guest does not export 'memory'")),
462
+ _ => return Err(wasm_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
404
463
  };
405
464
  let data = memory.data_mut(store_ref.as_context_mut());
406
465
  let range = guest_buffer_range(ptr as usize, bytes.len(), data.len())
@@ -456,42 +515,64 @@ impl Instance {
456
515
  let mut store_ref = self.store.borrow_mut();
457
516
  let packed = take
458
517
  .call(store_ref.as_context_mut(), ())
459
- .map_err(|e| wasm_err(ruby, format!("__kobako_take_outcome(): {}", e)))?;
518
+ .map_err(|e| wasm_err(ruby, format!("failed to read invocation result: {}", e)))?;
460
519
  let (ptr, len) = unpack_outcome_packed(packed);
461
520
 
462
521
  let mem: Memory = match self.inner.get_export(store_ref.as_context_mut(), "memory") {
463
522
  Some(Extern::Memory(m)) => m,
464
- _ => return Err(wasm_err(ruby, "guest does not export 'memory'")),
523
+ _ => return Err(wasm_err(ruby, SANDBOX_RUNTIME_NOT_KOBAKO)),
465
524
  };
466
525
  let data = mem.data(store_ref.as_context_mut());
467
- let range = guest_buffer_range(ptr, len, data.len())
468
- .map_err(|msg| wasm_err(ruby, format!("outcome: {}", msg)))?;
526
+ let range = guest_buffer_range(ptr, len, data.len()).map_err(|msg| {
527
+ wasm_err(ruby, format!("invocation result is out of bounds: {}", msg))
528
+ })?;
469
529
  Ok(data[range].to_vec())
470
530
  }
471
531
  }
472
532
 
533
+ /// User-facing message for the "Sandbox runtime is missing one of the
534
+ /// internal Kobako hooks" failure mode. Phrased in caller vocabulary —
535
+ /// the underlying ABI symbol names (`__kobako_alloc`, `__kobako_eval`,
536
+ /// `__kobako_take_outcome`) are not actionable to callers, and the
537
+ /// gem itself raises this error so a self-reference like "matches the
538
+ /// kobako gem version" reads as third-person. The actionable
539
+ /// diagnosis is "your data/kobako.wasm is out of sync; rebuild it".
540
+ const SANDBOX_RUNTIME_MISSING_HOOKS: &str = "Sandbox runtime is missing required hooks; \
541
+ rebuild data/kobako.wasm against the installed version";
542
+
543
+ /// User-facing message for the "the loaded Wasm module is not a
544
+ /// Kobako-shaped runtime at all" failure mode (no linear memory
545
+ /// export). Same phrasing philosophy as
546
+ /// [`SANDBOX_RUNTIME_MISSING_HOOKS`].
547
+ const SANDBOX_RUNTIME_NOT_KOBAKO: &str = "Sandbox runtime does not export linear memory; \
548
+ this is not a Kobako-compatible Wasm module";
549
+
473
550
  /// Return the cached +TypedFunc+ for an ABI export, or raise
474
551
  /// +Kobako::Wasm::Error+ when the option is +None+. The run-path
475
552
  /// methods (+#eval+, +#run+, +#outcome!+) all share the same
476
553
  /// "missing export → Ruby error" boilerplate; this helper collapses
477
- /// the three sites onto one safe entry.
554
+ /// the three sites onto one safe entry. The +_name+ argument is
555
+ /// retained for future operator-side logging but is deliberately not
556
+ /// spliced into the user-facing message (see
557
+ /// [`SANDBOX_RUNTIME_MISSING_HOOKS`]).
478
558
  fn require_export<'a, Params, Results>(
479
559
  ruby: &Ruby,
480
560
  export: Option<&'a TypedFunc<Params, Results>>,
481
- name: &str,
561
+ _name: &str,
482
562
  ) -> Result<&'a TypedFunc<Params, Results>, MagnusError>
483
563
  where
484
564
  Params: wasmtime::WasmParams,
485
565
  Results: wasmtime::WasmResults,
486
566
  {
487
- export.ok_or_else(|| wasm_err(ruby, format!("guest does not export {}", name)))
567
+ export.ok_or_else(|| wasm_err(ruby, SANDBOX_RUNTIME_MISSING_HOOKS))
488
568
  }
489
569
 
490
570
  /// Validate the invocation envelope length and return it as +i32+ — the
491
- /// signed wasm wire-ABI parameter type for `__kobako_run`. Rejects sizes
492
- /// above +i32::MAX+ so the downstream cast cannot silently wrap.
571
+ /// signed wasm ABI parameter type for the guest-run entrypoint.
572
+ /// Rejects sizes above +i32::MAX+ (2 GiB) so the downstream cast cannot
573
+ /// silently wrap.
493
574
  fn envelope_len_to_i32(len: usize) -> Result<i32, &'static str> {
494
- i32::try_from(len).map_err(|_| "invocation envelope exceeds i32::MAX bytes")
575
+ i32::try_from(len).map_err(|_| "invocation payload exceeds 2 GiB")
495
576
  }
496
577
 
497
578
  /// Compute the half-open range `[ptr, ptr + len)` for a guest linear-memory
@@ -505,7 +586,7 @@ fn guest_buffer_range(
505
586
  ) -> Result<core::ops::Range<usize>, &'static str> {
506
587
  let end = ptr.checked_add(len).ok_or("ptr + len overflow")?;
507
588
  if end > mem_size {
508
- return Err("range exceeds guest memory size");
589
+ return Err("range exceeds Sandbox memory size");
509
590
  }
510
591
  Ok(ptr..end)
511
592
  }
@@ -603,16 +684,37 @@ fn classify_trap(err: &wasmtime::Error) -> TrapClass {
603
684
  }
604
685
 
605
686
  /// Map a wasmtime call error to the right `Kobako::Wasm::*` Ruby
606
- /// exception class. `__kobako_eval` / `__kobako_run` traps are routed
607
- /// through [`classify_trap`]; +export+ is the failing export name and
608
- /// appears in the trap message so the Sandbox layer can attribute the
609
- /// fault to the right verb.
610
- fn call_err(ruby: &Ruby, export: &str, err: wasmtime::Error) -> MagnusError {
611
- let msg = format!("{}(): {}", export, err);
687
+ /// exception class. The ABI export symbol (`__kobako_eval` /
688
+ /// `__kobako_run`) is deliberately omitted from the message the
689
+ /// Sandbox layer attaches the user-facing verb (`Sandbox#eval` /
690
+ /// `Sandbox#run`) so the message reads in caller vocabulary rather
691
+ /// than ABI vocabulary.
692
+ ///
693
+ /// For the configured-cap paths ([`TrapClass::Timeout`] /
694
+ /// [`TrapClass::MemoryLimit`]) the trap's own [`std::fmt::Display`]
695
+ /// carries the user-facing reason (`"wall-clock deadline exceeded"`,
696
+ /// `"linear memory growth exceeded memory_limit: ..."`). The wasmtime
697
+ /// outer wrapper at `format!("{}", err)` would otherwise surface only
698
+ /// the `"error while executing at wasm backtrace: ..."` framing, which
699
+ /// is operator noise on a cap trap. For [`TrapClass::Other`] the
700
+ /// wasmtime wrapper IS the diagnostic (real script trap) so it stays.
701
+ fn call_err(ruby: &Ruby, err: wasmtime::Error) -> MagnusError {
612
702
  match classify_trap(&err) {
613
- TrapClass::Timeout => timeout_err(ruby, msg),
614
- TrapClass::MemoryLimit => memory_limit_err(ruby, msg),
615
- TrapClass::Other => wasm_err(ruby, msg),
703
+ TrapClass::Timeout => {
704
+ let msg = err
705
+ .downcast_ref::<TimeoutTrap>()
706
+ .map(|t| t.to_string())
707
+ .unwrap_or_else(|| format!("{}", err));
708
+ timeout_err(ruby, msg)
709
+ }
710
+ TrapClass::MemoryLimit => {
711
+ let msg = err
712
+ .downcast_ref::<MemoryLimitTrap>()
713
+ .map(|t| t.to_string())
714
+ .unwrap_or_else(|| format!("{}", err));
715
+ memory_limit_err(ruby, msg)
716
+ }
717
+ TrapClass::Other => wasm_err(ruby, format!("{}", err)),
616
718
  }
617
719
  }
618
720
 
@@ -120,6 +120,7 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
120
120
  instance.define_method("stdout", method!(Instance::stdout, 0))?;
121
121
  instance.define_method("stderr", method!(Instance::stderr, 0))?;
122
122
  instance.define_method("outcome!", method!(Instance::outcome, 0))?;
123
+ instance.define_method("usage", method!(Instance::usage, 0))?;
123
124
 
124
125
  Ok(())
125
126
  }
@@ -38,8 +38,6 @@ module Kobako
38
38
  # buffer edge.
39
39
  rescue ::MessagePack::UnpackError, ::EOFError => e
40
40
  raise Truncated, e.message
41
- rescue ::EncodingError => e
42
- raise InvalidEncoding, e.message
43
41
  end
44
42
 
45
43
  # SPEC pins +str+ family payloads to UTF-8
@@ -6,7 +6,7 @@ require "msgpack"
6
6
 
7
7
  require_relative "error"
8
8
  require_relative "utils"
9
- require_relative "../rpc/handle"
9
+ require_relative "../handle"
10
10
  require_relative "../rpc/fault"
11
11
 
12
12
  module Kobako
@@ -89,16 +89,18 @@ module Kobako
89
89
  # binary-encoding fallback that msgpack-gem's default unpacker
90
90
  # would otherwise apply. The re-tag step lives here because the
91
91
  # msgpack ext-type unpacker hands us binary bytes; the assertion
92
- # itself is shared with {Decoder} via {Utils.assert_utf8!}.
92
+ # itself is shared with {Decoder} via {Utils.assert_utf8!}. The
93
+ # +"Symbol"+ label keeps the error message in Ruby vocabulary
94
+ # rather than wire-ext-code vocabulary.
93
95
  def unpack_symbol(payload)
94
96
  name = payload.b.force_encoding(Encoding::UTF_8)
95
- Utils.assert_utf8!(name, "ext 0x00 payload")
97
+ Utils.assert_utf8!(name, "Symbol payload")
96
98
  name.to_sym
97
99
  end
98
100
 
99
101
  def register_handle
100
102
  @factory.register_type(
101
- EXT_HANDLE, RPC::Handle,
103
+ EXT_HANDLE, Kobako::Handle,
102
104
  packer: ->(handle) { [handle.id].pack("N") },
103
105
  unpacker: ->(payload) { unpack_handle(payload) }
104
106
  )
@@ -112,17 +114,18 @@ module Kobako
112
114
  )
113
115
  end
114
116
 
115
- # Peel off the fixext-4 frame, hand the bytes to +RPC::Handle.new+, and
116
- # translate the +ArgumentError+ raised by Handle's invariants into
117
- # a wire-layer +InvalidType+ via {Codec::Utils.wire_boundary}.
117
+ # Peel off the fixext-4 frame, hand the bytes to the
118
+ # Host-Gem-internal +Kobako::Handle.from_wire+ factory, and
119
+ # translate the +ArgumentError+ raised by Handle's invariants
120
+ # into a wire-layer +InvalidType+ via {Codec::Utils.wire_boundary}.
118
121
  # The Value Object owns the id-range contract; this method only
119
122
  # owns the frame shape.
120
123
  def unpack_handle(payload)
121
124
  bytes = payload.b
122
- raise InvalidType, "ext 0x01 payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
125
+ raise InvalidType, "Handle payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
123
126
 
124
127
  id = bytes.unpack1("N") # : Integer
125
- Codec::Utils.wire_boundary { RPC::Handle.new(id) }
128
+ Codec::Utils.wire_boundary { Kobako::Handle.from_wire(id) }
126
129
  end
127
130
 
128
131
  # Encode the inner ext-0x02 map via {Encoder} (not +factory.dump+) so
@@ -148,7 +151,7 @@ module Kobako
148
151
  # and re-opens the Decoder's special case for Fault (removed in M5).
149
152
  def unpack_fault(payload)
150
153
  map = Decoder.decode(payload)
151
- raise InvalidType, "ext 0x02 payload must be a map" unless map.is_a?(Hash)
154
+ raise InvalidType, "Fault payload must be a map" unless map.is_a?(Hash)
152
155
 
153
156
  Codec::Utils.wire_boundary do
154
157
  RPC::Fault.new(type: map["type"], message: map["message"], details: map["details"])
@@ -1,30 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "error"
4
+ require_relative "../handle"
4
5
 
5
6
  module Kobako
6
7
  module Codec
7
8
  # Wire-codec helpers shared by the host-side encoders and decoders.
8
- # The single concern today is UTF-8 assertion at the wire boundary
9
- # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § str/bin
10
- # Encoding Rules and § Ext Types → ext 0x00). Two call sites lean on
11
- # this:
9
+ # Three concerns live here today:
12
10
  #
13
- # - {Decoder} validates +str+ family payloads as it walks the
14
- # decoded value tree.
15
- # - {Factory} validates the +ext 0x00+ Symbol payload after
16
- # re-tagging the binary bytes as UTF-8.
11
+ # - UTF-8 assertion at the wire boundary
12
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md]
13
+ # § str/bin Encoding Rules and § Ext Types → ext 0x00). Used by
14
+ # {Decoder} when walking +str+ family payloads and by {Factory}
15
+ # when validating the +ext 0x00+ Symbol payload.
16
+ # - Wire-boundary +ArgumentError+ translation
17
+ # ({wire_boundary}) so the public taxonomy stays
18
+ # {Kobako::Codec::Error}.
19
+ # - Wire-representability predicate ({wire_representable?}) and
20
+ # the symmetric host→guest +#run+ argument walk
21
+ # ({deep_wrap}) used by +Kobako::Invocation#encode+ to route
22
+ # non-wire-representable leaves through the Sandbox's
23
+ # +Kobako::HandleTable+
24
+ # ({docs/behavior.md B-34}[link:../../../docs/behavior.md]).
17
25
  #
18
- # Encoding setup (re-tagging binary as UTF-8 when needed) stays at
19
- # the caller only the assertion shape is shared. The helper does
20
- # not mutate +string+; it only inspects +String#valid_encoding?+
21
- # against +string+'s current encoding tag.
26
+ # All helpers are pure they only inspect inputs, never mutate
27
+ # themexcept {deep_wrap}, whose only side effect is allocating
28
+ # new Handle ids into the supplied table.
22
29
  module Utils
23
30
  module_function
24
31
 
25
32
  # Raise {InvalidEncoding} unless +string+'s bytes are valid under
26
33
  # its current encoding tag. +label+ is the caller-supplied prefix
27
- # for the error message (e.g. +"str payload"+, +"ext 0x00 payload"+).
34
+ # for the error message (e.g. +"str payload"+, +"Symbol payload"+).
28
35
  def assert_utf8!(string, label)
29
36
  return if string.valid_encoding?
30
37
 
@@ -51,6 +58,91 @@ module Kobako
51
58
  rescue ::ArgumentError => e
52
59
  raise InvalidType, e.message
53
60
  end
61
+
62
+ # Inclusive Integer range the msgpack gem encodes without raising
63
+ # +RangeError+ at encode time — signed +int 64+ minimum through
64
+ # unsigned +uint 64+ maximum
65
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type
66
+ # Mapping #3, the +fixint+ / +int 8..64+ / +uint 8..64+ union).
67
+ # Anchored as a +Range+ so {primitive_wire_type?} stays a single
68
+ # dispatch line. This is the codec's wire-encode domain — not to
69
+ # be confused with the Handle id range, which lives on
70
+ # +Kobako::Handle+ as +MIN_ID+ / +MAX_ID+ (1..2^31 − 1) and
71
+ # represents a different concept entirely.
72
+ MSGPACK_INT_RANGE = (-(2**63)..((2**64) - 1))
73
+
74
+ # Wire-type predicate
75
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type
76
+ # Mapping). Returns +true+ when +value+ belongs to the closed
77
+ # 12-entry wire set — +nil+, +TrueClass+, +FalseClass+, +Integer+
78
+ # (in the +i64..u64+ value domain), +Float+, +String+, +Symbol+,
79
+ # +Kobako::Handle+, +Array+ whose every element is itself
80
+ # wire-representable, or +Hash+ whose every key and value are
81
+ # wire-representable. Integers outside the codec's signed-64 /
82
+ # unsigned-64 union are rejected so the predicate agrees with the
83
+ # msgpack gem's encode-time +RangeError+ behaviour the codec
84
+ # already surfaces as {UnsupportedType}.
85
+ def wire_representable?(value)
86
+ primitive_wire_type?(value) || container_wire_representable?(value)
87
+ end
88
+
89
+ # Deep-walk Array / Hash containers in +value+ and replace every
90
+ # leaf that fails {wire_representable?} with a +Kobako::Handle+
91
+ # allocated from +handle_table+
92
+ # ({docs/behavior.md B-34}[link:../../../docs/behavior.md]). The
93
+ # walk only descends through wire-representable container shapes
94
+ # (Array, Hash) one structural level at a time; a non-
95
+ # wire-representable leaf is wrapped as-is without inspecting its
96
+ # internal structure. An existing +Kobako::Handle+ is wire-
97
+ # representable and passes through unchanged — auto-wrap never
98
+ # re-wraps a Handle.
99
+ #
100
+ # +value+ may be any Ruby value; +handle_table+ must respond to
101
+ # +#alloc(object) -> Kobako::Handle+ (a host-side
102
+ # +Kobako::HandleTable+). Returns a structurally equivalent value
103
+ # whose leaves are either wire-representable or +Kobako::Handle+
104
+ # tokens.
105
+ #
106
+ # The block bodies spell +Utils.deep_wrap+ explicitly rather than
107
+ # the unqualified +deep_wrap+ because +module_function+ makes the
108
+ # instance copy of these helpers private; an implicit receiver
109
+ # inside a block would resolve against the enclosing +self+
110
+ # (still +Utils+ at definition time, but the qualified form keeps
111
+ # the dispatch readable when the recursive call sits inside a
112
+ # Proc captured from elsewhere).
113
+ def deep_wrap(value, handle_table)
114
+ case value
115
+ when ::Array then value.map { |element| Utils.deep_wrap(element, handle_table) }
116
+ when ::Hash then value.transform_values { |val| Utils.deep_wrap(val, handle_table) }
117
+ else
118
+ wire_representable?(value) ? value : handle_table.alloc(value)
119
+ end
120
+ end
121
+
122
+ # Predicate split out of {wire_representable?} for cyclomatic
123
+ # budget — the closed-set non-container branch. Returns +true+ for
124
+ # the wire scalar leaves and an existing Handle. Not part of the
125
+ # public surface; reach for {wire_representable?} instead.
126
+ def primitive_wire_type?(value)
127
+ case value
128
+ when ::NilClass, ::TrueClass, ::FalseClass, ::Float, ::String, ::Symbol, Kobako::Handle then true
129
+ when ::Integer then MSGPACK_INT_RANGE.cover?(value)
130
+ else false
131
+ end
132
+ end
133
+
134
+ # Predicate split out of {wire_representable?} for cyclomatic
135
+ # budget — the container branch. Recurses into Array elements and
136
+ # Hash key+value pairs through the public {wire_representable?}.
137
+ # Not part of the public surface; reach for {wire_representable?}
138
+ # instead.
139
+ def container_wire_representable?(value)
140
+ case value
141
+ when ::Array then value.all? { |element| Utils.wire_representable?(element) }
142
+ when ::Hash then value.all? { |key, val| Utils.wire_representable?(key) && Utils.wire_representable?(val) }
143
+ else false
144
+ end
145
+ end
54
146
  end
55
147
  end
56
148
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # Wire-level value object for an ext-0x01 Capability Handle, used in both
5
+ # directions across the Sandbox boundary: as a Service method's return
6
+ # value (guest→host return path; {docs/behavior.md B-14}[link:../../docs/behavior.md])
7
+ # and as a +#run+ argument auto-wrapped by the host
8
+ # ({docs/behavior.md B-34}[link:../../docs/behavior.md]).
9
+ #
10
+ # SPEC pins the binary layout to fixext 4 with a 4-byte big-endian u32
11
+ # payload ({docs/wire-codec.md}[link:../../docs/wire-codec.md]
12
+ # § Ext Types → ext 0x01). ID 0 is reserved as the invalid sentinel;
13
+ # the maximum valid ID is 0x7fff_ffff (2^31 - 1).
14
+ #
15
+ # The constructor is internal to the Host Gem. +Kobako::Handle.new+ is
16
+ # privatised so Host App code cannot fabricate a Handle from a bare
17
+ # integer; legitimate Handle instances enter Host App code only as
18
+ # fields on raised error objects. The Host Gem itself constructs
19
+ # Handles through {.from_wire}, which exists at exactly two call
20
+ # sites: +Kobako::Codec::Factory#unpack_handle+ (wire decode) and
21
+ # +Kobako::Codec::Utils.deep_wrap+ / +Kobako::RPC::Dispatcher#wrap_as_handle+
22
+ # (allocator paths). Both live inside +lib/kobako/+ and are not part
23
+ # of any public surface.
24
+ #
25
+ # The mruby counterpart +Kobako::Handle+ lives inside the Wasm guest
26
+ # under the same canonical name and shares neither code nor instances
27
+ # with this host-side class.
28
+ class Handle < Data.define(:id)
29
+ # Inclusive lower bound on the wire Handle ID. ID 0 is reserved as
30
+ # the invalid sentinel and is never allocated.
31
+ MIN_ID = 1
32
+ # Inclusive upper bound on the wire Handle ID. The cap matches the
33
+ # u32 signed-positive range so Handle IDs fit in a signed integer
34
+ # on either side of the wire without re-encoding.
35
+ MAX_ID = 0x7fff_ffff
36
+
37
+ # steep:ignore:start
38
+ def initialize(id:)
39
+ raise ArgumentError, "Handle id must be Integer" unless id.is_a?(Integer)
40
+ raise ArgumentError, "Handle id #{id} out of range [#{MIN_ID}, #{MAX_ID}]" unless id.between?(MIN_ID, MAX_ID)
41
+
42
+ super
43
+ end
44
+ # steep:ignore:end
45
+
46
+ private_class_method :new
47
+
48
+ # Host Gem–internal factory. Allocates the Data instance through
49
+ # +Class#allocate+ and dispatches +#initialize+ explicitly so the
50
+ # invariant checks still run, while keeping the public +.new+
51
+ # privatised against Host App callers.
52
+ #
53
+ # Two collaborators call this: the codec when an ext 0x01 frame is
54
+ # decoded off the wire, and the allocator paths when a host-side
55
+ # Ruby object is registered into the Sandbox's +HandleTable+. Both
56
+ # paths live inside +lib/kobako/+ and treat this method as a
57
+ # package-private constructor.
58
+ def self.from_wire(id)
59
+ allocate.tap { |handle| handle.send(:initialize, id: id) }
60
+ end
61
+ end
62
+ end