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.
- checksums.yaml +4 -4
- data/Cargo.lock +1 -1
- data/README.md +85 -5
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/ext/kobako/src/wasm/cache.rs +12 -4
- data/ext/kobako/src/wasm/dispatch.rs +15 -14
- data/ext/kobako/src/wasm/host_state.rs +111 -5
- data/ext/kobako/src/wasm/instance.rs +135 -33
- data/ext/kobako/src/wasm.rs +1 -0
- data/lib/kobako/codec/decoder.rb +0 -2
- data/lib/kobako/codec/factory.rb +13 -10
- data/lib/kobako/codec/utils.rb +105 -13
- data/lib/kobako/handle.rb +62 -0
- data/lib/kobako/handle_table.rb +119 -0
- data/lib/kobako/invocation.rb +56 -25
- data/lib/kobako/outcome.rb +42 -12
- data/lib/kobako/rpc/dispatcher.rb +22 -20
- data/lib/kobako/rpc/envelope.rb +7 -7
- data/lib/kobako/rpc/fault.rb +1 -1
- data/lib/kobako/rpc/server.rb +12 -24
- data/lib/kobako/rpc/wire_error.rb +23 -0
- data/lib/kobako/sandbox.rb +77 -24
- data/lib/kobako/usage.rb +41 -0
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako.rb +1 -0
- data/sig/kobako/codec/factory.rbs +1 -1
- data/sig/kobako/codec/utils.rbs +10 -0
- data/sig/kobako/handle.rbs +19 -0
- data/sig/kobako/handle_table.rbs +23 -0
- data/sig/kobako/invocation.rbs +3 -1
- data/sig/kobako/outcome.rbs +1 -1
- data/sig/kobako/rpc/dispatcher.rbs +7 -7
- data/sig/kobako/rpc/envelope.rbs +3 -3
- data/sig/kobako/rpc/server.rbs +1 -7
- data/sig/kobako/rpc/wire_error.rbs +6 -0
- data/sig/kobako/sandbox.rbs +7 -1
- data/sig/kobako/usage.rbs +11 -0
- data/sig/kobako/wasm.rbs +2 -0
- metadata +9 -5
- data/lib/kobako/rpc/handle.rb +0 -39
- data/lib/kobako/rpc/handle_table.rb +0 -107
- data/sig/kobako/rpc/handle.rbs +0 -19
- 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!("
|
|
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
|
-
|
|
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
|
|
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|
|
|
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,
|
|
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,
|
|
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.
|
|
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()
|
|
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,
|
|
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!("
|
|
452
|
+
.map_err(|e| wasm_err(ruby, format!("failed to allocate input buffer: {}", e)))?;
|
|
397
453
|
if ptr == 0 {
|
|
398
|
-
return Err(wasm_err(
|
|
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,
|
|
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!("
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
492
|
-
/// above +i32::MAX+ so the downstream cast cannot
|
|
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
|
|
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
|
|
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` /
|
|
607
|
-
///
|
|
608
|
-
///
|
|
609
|
-
///
|
|
610
|
-
|
|
611
|
-
|
|
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 =>
|
|
614
|
-
|
|
615
|
-
|
|
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
|
|
data/ext/kobako/src/wasm.rs
CHANGED
|
@@ -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
|
}
|
data/lib/kobako/codec/decoder.rb
CHANGED
data/lib/kobako/codec/factory.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "msgpack"
|
|
|
6
6
|
|
|
7
7
|
require_relative "error"
|
|
8
8
|
require_relative "utils"
|
|
9
|
-
require_relative "../
|
|
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, "
|
|
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,
|
|
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
|
|
116
|
-
#
|
|
117
|
-
#
|
|
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, "
|
|
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 {
|
|
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, "
|
|
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"])
|
data/lib/kobako/codec/utils.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
# -
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# against +string+'s current encoding tag.
|
|
26
|
+
# All helpers are pure — they only inspect inputs, never mutate
|
|
27
|
+
# them — except {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"+, +"
|
|
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
|