kobako 0.14.0 → 0.15.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 +51 -0
- data/Cargo.lock +3 -3
- data/README.md +104 -29
- data/ROADMAP.md +2 -3
- data/SECURITY.md +1 -1
- data/crates/kobako-runtime/CHANGELOG.md +7 -0
- data/crates/kobako-runtime/Cargo.toml +1 -1
- data/crates/kobako-runtime/README.md +1 -1
- data/crates/kobako-runtime/src/snapshot.rs +7 -3
- data/crates/kobako-wasmtime/CHANGELOG.md +7 -0
- data/crates/kobako-wasmtime/Cargo.toml +2 -2
- data/crates/kobako-wasmtime/README.md +1 -1
- data/crates/kobako-wasmtime/src/capture.rs +17 -13
- data/crates/kobako-wasmtime/src/driver.rs +6 -7
- data/crates/kobako-wasmtime/src/frames.rs +2 -9
- data/crates/kobako-wasmtime/src/guest_mem.rs +8 -1
- data/crates/kobako-wasmtime/src/trap.rs +30 -57
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/ext/kobako/src/runtime/errors.rs +9 -39
- data/ext/kobako/src/runtime.rs +5 -20
- data/lib/kobako/catalog/handles.rb +1 -1
- data/lib/kobako/catalog/services.rb +123 -0
- data/lib/kobako/catalog/snippets.rb +1 -1
- data/lib/kobako/catalog.rb +2 -2
- data/lib/kobako/codec/decoder.rb +3 -3
- data/lib/kobako/codec/encoder.rb +4 -4
- data/lib/kobako/codec/ext_types.rb +169 -0
- data/lib/kobako/codec/state.rb +98 -0
- data/lib/kobako/codec/utils.rb +2 -2
- data/lib/kobako/codec.rb +23 -6
- data/lib/kobako/fault.rb +1 -1
- data/lib/kobako/handle.rb +1 -1
- data/lib/kobako/outcome.rb +13 -7
- data/lib/kobako/pool.rb +1 -1
- data/lib/kobako/sandbox.rb +20 -13
- data/lib/kobako/transport/dispatcher.rb +25 -18
- data/lib/kobako/transport/request.rb +14 -9
- data/lib/kobako/transport/response.rb +9 -2
- data/lib/kobako/transport/yield.rb +4 -1
- data/lib/kobako/transport/yielder.rb +14 -6
- data/lib/kobako/version.rb +1 -1
- data/sig/kobako/catalog/services.rbs +25 -0
- data/sig/kobako/codec/ext_types.rbs +31 -0
- data/sig/kobako/codec/state.rbs +20 -0
- data/sig/kobako/codec.rbs +3 -0
- data/sig/kobako/sandbox.rbs +1 -1
- data/sig/kobako/transport/dispatcher.rbs +4 -4
- data/sig/kobako/transport/yielder.rbs +1 -1
- data/sig/kobako/transport.rbs +2 -2
- metadata +7 -7
- data/lib/kobako/catalog/namespaces.rb +0 -115
- data/lib/kobako/codec/factory.rb +0 -187
- data/lib/kobako/namespace.rb +0 -78
- data/sig/kobako/catalog/namespaces.rbs +0 -17
- data/sig/kobako/codec/factory.rbs +0 -34
- data/sig/kobako/namespace.rbs +0 -21
|
@@ -44,6 +44,13 @@ impl Yielder for CallerYielder<'_, '_> {
|
|
|
44
44
|
const RUNTIME_INCOMPATIBLE: &str =
|
|
45
45
|
"the Sandbox runtime is incompatible; rebuild data/kobako.wasm against the installed version";
|
|
46
46
|
|
|
47
|
+
/// User-facing message for the "the loaded Wasm module is not a
|
|
48
|
+
/// Kobako-shaped runtime at all" failure mode — no linear memory export
|
|
49
|
+
/// here, no `memory` module export on the instantiation path in
|
|
50
|
+
/// `frames`. One constant so the two detection sites cannot drift.
|
|
51
|
+
pub(crate) const SANDBOX_RUNTIME_NOT_KOBAKO: &str =
|
|
52
|
+
"the loaded Wasm module is not a Kobako-compatible runtime";
|
|
53
|
+
|
|
47
54
|
/// Resolve the guest's exported linear `memory`. The lookup shape (and its
|
|
48
55
|
/// diagnostic) is shared by every Caller-based path here — the write side
|
|
49
56
|
/// (`alloc_and_write`), the read side (`read`), and the yield round-trip
|
|
@@ -52,7 +59,7 @@ const RUNTIME_INCOMPATIBLE: &str =
|
|
|
52
59
|
fn memory_export(caller: &mut Caller<'_, Invocation>) -> Result<Memory, &'static str> {
|
|
53
60
|
match caller.get_export("memory") {
|
|
54
61
|
Some(Extern::Memory(m)) => Ok(m),
|
|
55
|
-
_ => Err(
|
|
62
|
+
_ => Err(SANDBOX_RUNTIME_NOT_KOBAKO),
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
|
|
@@ -42,60 +42,29 @@ pub(crate) fn epoch_deadline_callback(
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
///
|
|
46
|
-
///
|
|
47
|
-
///
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
/// Wall-clock cap path.
|
|
51
|
-
Timeout,
|
|
52
|
-
/// Linear-memory cap path.
|
|
53
|
-
MemoryLimit,
|
|
54
|
-
/// Any other wasmtime error — surfaces as the base `Trap::Other`.
|
|
55
|
-
Other,
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/// Inspect a wasmtime error to decide which neutral trap kind it
|
|
59
|
-
/// belongs to. Pure function — operates on the error's downcast chain
|
|
60
|
-
/// only, no frontend state required.
|
|
61
|
-
fn classify_trap(err: &wasmtime::Error) -> TrapClass {
|
|
62
|
-
if err.downcast_ref::<TimeoutTrap>().is_some() {
|
|
63
|
-
TrapClass::Timeout
|
|
64
|
-
} else if err.downcast_ref::<MemoryLimitTrap>().is_some() {
|
|
65
|
-
TrapClass::MemoryLimit
|
|
66
|
-
} else {
|
|
67
|
-
TrapClass::Other
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/// Classify a wasmtime call error into a neutral `Trap`. The ABI export
|
|
72
|
-
/// symbol (`__kobako_eval` / `__kobako_run`) is deliberately omitted from
|
|
73
|
-
/// the message — the Sandbox layer attaches the user-facing verb
|
|
45
|
+
/// Classify a wasmtime call error into a neutral `Trap`. Pure function
|
|
46
|
+
/// over the error's downcast chain, so the kind routing is exercisable
|
|
47
|
+
/// from `cargo test` without any frontend. The ABI export symbol
|
|
48
|
+
/// (`__kobako_eval` / `__kobako_run`) is deliberately omitted from the
|
|
49
|
+
/// message — the Sandbox layer attaches the user-facing verb
|
|
74
50
|
/// (`Sandbox#eval` / `Sandbox#run`) so the message reads in caller
|
|
75
51
|
/// vocabulary rather than ABI vocabulary.
|
|
76
52
|
///
|
|
77
|
-
/// For the configured-cap paths
|
|
78
|
-
/// `TrapClass::MemoryLimit`) the trap's own `std::fmt::Display`
|
|
53
|
+
/// For the configured-cap paths the trap's own `std::fmt::Display`
|
|
79
54
|
/// carries the user-facing reason (`"wall-clock deadline exceeded"`,
|
|
80
|
-
/// `"linear memory growth exceeded memory_limit: ..."`)
|
|
81
|
-
/// outer wrapper
|
|
82
|
-
///
|
|
83
|
-
///
|
|
84
|
-
///
|
|
85
|
-
///
|
|
55
|
+
/// `"linear memory growth exceeded memory_limit: ..."`); the wasmtime
|
|
56
|
+
/// outer wrapper would otherwise surface only the `"error while
|
|
57
|
+
/// executing at wasm backtrace: ..."` framing, which is operator noise
|
|
58
|
+
/// on a cap trap. For any other error the framing is kept but the
|
|
59
|
+
/// chain's root cause is appended (see `other_trap_message`) so the
|
|
60
|
+
/// real trap reason survives.
|
|
86
61
|
pub(crate) fn trap_from(err: wasmtime::Error) -> Trap {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)
|
|
93
|
-
TrapClass::MemoryLimit => Trap::MemoryLimit(
|
|
94
|
-
err.downcast_ref::<MemoryLimitTrap>()
|
|
95
|
-
.map(|t| t.to_string())
|
|
96
|
-
.unwrap_or_else(|| format!("{err}")),
|
|
97
|
-
),
|
|
98
|
-
TrapClass::Other => Trap::Other(other_trap_message(&err)),
|
|
62
|
+
if let Some(t) = err.downcast_ref::<TimeoutTrap>() {
|
|
63
|
+
Trap::Timeout(t.to_string())
|
|
64
|
+
} else if let Some(t) = err.downcast_ref::<MemoryLimitTrap>() {
|
|
65
|
+
Trap::MemoryLimit(t.to_string())
|
|
66
|
+
} else {
|
|
67
|
+
Trap::Other(other_trap_message(&err))
|
|
99
68
|
}
|
|
100
69
|
}
|
|
101
70
|
|
|
@@ -129,8 +98,9 @@ pub(crate) fn instantiate_err(err: wasmtime::Error) -> SetupError {
|
|
|
129
98
|
|
|
130
99
|
#[cfg(test)]
|
|
131
100
|
mod tests {
|
|
132
|
-
use super::{
|
|
101
|
+
use super::{other_trap_message, trap_from, NO_TIMEOUT_EPOCH_DELTA};
|
|
133
102
|
use crate::invocation::{Invocation, MemoryLimitTrap, TimeoutTrap};
|
|
103
|
+
use kobako_runtime::error::Trap;
|
|
134
104
|
|
|
135
105
|
// The no-timeout priming delta is added to the engine's current
|
|
136
106
|
// epoch inside wasmtime, and the process-wide ticker advances that
|
|
@@ -148,21 +118,24 @@ mod tests {
|
|
|
148
118
|
}
|
|
149
119
|
|
|
150
120
|
#[test]
|
|
151
|
-
fn
|
|
121
|
+
fn trap_from_routes_timeout_trap_to_timeout() {
|
|
152
122
|
let err = wasmtime::Error::new(TimeoutTrap);
|
|
153
|
-
|
|
123
|
+
let expected = TimeoutTrap.to_string();
|
|
124
|
+
assert!(matches!(trap_from(err), Trap::Timeout(msg) if msg == expected));
|
|
154
125
|
}
|
|
155
126
|
|
|
156
127
|
#[test]
|
|
157
|
-
fn
|
|
158
|
-
let
|
|
159
|
-
|
|
128
|
+
fn trap_from_routes_memory_limit_trap_to_memory_limit() {
|
|
129
|
+
let trap = MemoryLimitTrap::new(1 << 20, 1 << 19);
|
|
130
|
+
let expected = trap.to_string();
|
|
131
|
+
let err = wasmtime::Error::new(trap);
|
|
132
|
+
assert!(matches!(trap_from(err), Trap::MemoryLimit(msg) if msg == expected));
|
|
160
133
|
}
|
|
161
134
|
|
|
162
135
|
#[test]
|
|
163
|
-
fn
|
|
136
|
+
fn trap_from_falls_back_to_other_for_unknown_errors() {
|
|
164
137
|
let err = wasmtime::Error::msg("some other wasmtime fault");
|
|
165
|
-
|
|
138
|
+
assert!(matches!(trap_from(err), Trap::Other(_)));
|
|
166
139
|
}
|
|
167
140
|
|
|
168
141
|
// A guest hard trap reaches the host as a wasmtime error whose Display is
|
data/data/kobako.wasm
CHANGED
|
Binary file
|
data/ext/kobako/Cargo.toml
CHANGED
|
@@ -55,48 +55,15 @@ pub(super) fn trap_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
|
55
55
|
error_in(ruby, &TRAP_ERROR, msg)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/// Construct a `Kobako::SetupError` magnus error. Used for every
|
|
59
|
-
/// construction-time failure on the `Runtime.from_path` path before any
|
|
60
|
-
/// invocation runs — unreadable artifact, bytes that are not a valid Wasm
|
|
61
|
-
/// module, or engine / linker / instantiation setup failure. The
|
|
62
|
-
/// `ModuleNotBuiltError` subclass (artifact absent) is
|
|
63
|
-
/// raised through `MODULE_NOT_BUILT_ERROR` directly.
|
|
64
|
-
pub(super) fn setup_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
65
|
-
error_in(ruby, &SETUP_ERROR, msg)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/// Construct a `Kobako::TimeoutError` magnus error. Surfaces the
|
|
69
|
-
/// wall-clock cap path with the verb prefix added
|
|
70
|
-
/// by `Kobako::Sandbox#invoke!`.
|
|
71
|
-
fn timeout_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
72
|
-
error_in(ruby, &TIMEOUT_ERROR, msg)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/// Construct a `Kobako::MemoryLimitError` magnus error. Surfaces the
|
|
76
|
-
/// linear-memory cap path with the verb prefix
|
|
77
|
-
/// added by `Kobako::Sandbox#invoke!`.
|
|
78
|
-
fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
79
|
-
error_in(ruby, &MEMORY_LIMIT_ERROR, msg)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/// Construct a `Kobako::SandboxError` magnus error. Used for the
|
|
83
|
-
/// host-side pre-call faults the SPEC attributes to the sandbox / wire
|
|
84
|
-
/// layer rather than the Wasm engine — currently the `#run` invocation
|
|
85
|
-
/// envelope reservation failure (`__kobako_alloc` returns 0).
|
|
86
|
-
/// The runtime is intact, so this must not be a
|
|
87
|
-
/// `TrapError`: no discard-and-recreate recovery is owed to the caller.
|
|
88
|
-
fn sandbox_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
89
|
-
error_in(ruby, &SANDBOX_ERROR, msg)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
58
|
/// Map a neutral `Trap` onto its `Kobako::TrapError`-family Ruby exception.
|
|
93
59
|
/// The boundary between the magnus-free run mechanics and the Ruby surface:
|
|
94
60
|
/// the run path classifies a fault into a `Trap`, and this is where it
|
|
95
|
-
/// becomes a raised exception.
|
|
61
|
+
/// becomes a raised exception. The verb prefix (`Sandbox#eval` / `#run`)
|
|
62
|
+
/// is added by `Kobako::Sandbox#invoke!`.
|
|
96
63
|
pub(super) fn trap_to_magnus(ruby: &Ruby, trap: Trap) -> MagnusError {
|
|
97
64
|
match trap {
|
|
98
|
-
Trap::Timeout(msg) =>
|
|
99
|
-
Trap::MemoryLimit(msg) =>
|
|
65
|
+
Trap::Timeout(msg) => error_in(ruby, &TIMEOUT_ERROR, msg),
|
|
66
|
+
Trap::MemoryLimit(msg) => error_in(ruby, &MEMORY_LIMIT_ERROR, msg),
|
|
100
67
|
Trap::Other(msg) => trap_err(ruby, msg),
|
|
101
68
|
}
|
|
102
69
|
}
|
|
@@ -106,8 +73,11 @@ pub(super) fn trap_to_magnus(ruby: &Ruby, trap: Trap) -> MagnusError {
|
|
|
106
73
|
pub(super) fn setup_to_magnus(ruby: &Ruby, err: SetupError) -> MagnusError {
|
|
107
74
|
match err {
|
|
108
75
|
SetupError::ModuleNotBuilt(msg) => error_in(ruby, &MODULE_NOT_BUILT_ERROR, msg),
|
|
109
|
-
SetupError::Dead(msg) =>
|
|
110
|
-
|
|
76
|
+
SetupError::Dead(msg) => error_in(ruby, &SETUP_ERROR, msg),
|
|
77
|
+
// Runtime intact means a host-side pre-call fault the SPEC
|
|
78
|
+
// attributes to the sandbox / wire layer, not the engine — no
|
|
79
|
+
// discard-and-recreate recovery is owed, so never a TrapError.
|
|
80
|
+
SetupError::Intact(msg) => error_in(ruby, &SANDBOX_ERROR, msg),
|
|
111
81
|
}
|
|
112
82
|
}
|
|
113
83
|
|
data/ext/kobako/src/runtime.rs
CHANGED
|
@@ -53,26 +53,14 @@ fn rstring_to_vec(s: RString) -> Vec<u8> {
|
|
|
53
53
|
unsafe { s.as_slice() }.to_vec()
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/// The pre-invocation sentinel for one capture channel: no bytes, cap
|
|
57
|
-
/// not reached. Fresh `Vec`s per call because `Capture` owns its buffer.
|
|
58
|
-
fn empty_capture() -> Capture {
|
|
59
|
-
Capture {
|
|
60
|
-
bytes: Vec::new(),
|
|
61
|
-
truncated: false,
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
56
|
// ---------------------------------------------------------------------------
|
|
66
57
|
// Ruby init
|
|
67
58
|
// ---------------------------------------------------------------------------
|
|
68
59
|
|
|
69
60
|
pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
|
|
70
|
-
// Error hierarchy lives in `lib/kobako/errors.rb
|
|
71
|
-
//
|
|
72
|
-
// `
|
|
73
|
-
// those classes through `trap_err` / `timeout_err` / `memory_limit_err`
|
|
74
|
-
// / `sandbox_err` / `setup_err` / `MODULE_NOT_BUILT_ERROR`; no
|
|
75
|
-
// intermediate hierarchy is registered.
|
|
61
|
+
// Error hierarchy lives in `lib/kobako/errors.rb`; the ext raises
|
|
62
|
+
// directly into those classes through the constructors and mappers
|
|
63
|
+
// in `runtime/errors.rs` — no intermediate hierarchy is registered.
|
|
76
64
|
|
|
77
65
|
let runtime = kobako.define_class("Runtime", ruby.class_object())?;
|
|
78
66
|
runtime.define_singleton_method("from_path", function!(Runtime::from_path, 6))?;
|
|
@@ -210,11 +198,8 @@ impl Runtime {
|
|
|
210
198
|
Ok(Self {
|
|
211
199
|
driver,
|
|
212
200
|
on_dispatch: Cell::new(None),
|
|
213
|
-
last_usage: Cell::new(Usage
|
|
214
|
-
|
|
215
|
-
memory_peak: 0,
|
|
216
|
-
}),
|
|
217
|
-
last_captures: RefCell::new((empty_capture(), empty_capture())),
|
|
201
|
+
last_usage: Cell::new(Usage::default()),
|
|
202
|
+
last_captures: RefCell::new((Capture::default(), Capture::default())),
|
|
218
203
|
})
|
|
219
204
|
}
|
|
220
205
|
|
|
@@ -6,7 +6,7 @@ module Kobako
|
|
|
6
6
|
module Catalog
|
|
7
7
|
# Host-side mapping from opaque integer Handle IDs to Ruby objects.
|
|
8
8
|
# The table is owned by +Kobako::Sandbox+ and injected
|
|
9
|
-
# into the per-Sandbox +Kobako::Catalog::
|
|
9
|
+
# into the per-Sandbox +Kobako::Catalog::Services+ so guest→host dispatch
|
|
10
10
|
# resolves Handle targets and arguments against the same table that
|
|
11
11
|
# host→guest wire encoding allocates into.
|
|
12
12
|
#
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "handles"
|
|
4
|
+
require_relative "../codec"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module Kobako
|
|
8
|
+
module Catalog
|
|
9
|
+
# Kobako::Catalog::Services — per-Sandbox registry of Service
|
|
10
|
+
# bindings keyed by their constant-path name. Holds the flat
|
|
11
|
+
# path→object table and the preamble emitted on Frame 1.
|
|
12
|
+
#
|
|
13
|
+
# Public API:
|
|
14
|
+
#
|
|
15
|
+
# services = Kobako::Catalog::Services.new
|
|
16
|
+
# services.bind("MyService::KV", kv_object) # => services (chainable)
|
|
17
|
+
# services.encode # => msgpack bytes for Frame 1
|
|
18
|
+
# services.lookup("MyService::KV") # => kv_object
|
|
19
|
+
#
|
|
20
|
+
# Per-dispatch routing is +Kobako::Transport::Dispatcher+'s
|
|
21
|
+
# responsibility — the Dispatcher receives this registry and the
|
|
22
|
+
# +Catalog::Handles+ as arguments from the +Runtime#on_dispatch+ Proc
|
|
23
|
+
# that +Kobako::Sandbox#initialize+ installs. The registry holds an
|
|
24
|
+
# injected +Catalog::Handles+ reference so dispatch target resolution
|
|
25
|
+
# and host→guest auto-wrap share the same Sandbox-owned allocator.
|
|
26
|
+
class Services
|
|
27
|
+
# Ruby constant-name pattern each +::+-separated bind-path segment
|
|
28
|
+
# must match.
|
|
29
|
+
NAME_PATTERN = /\A[A-Z]\w*\z/
|
|
30
|
+
|
|
31
|
+
# Build a fresh registry. +handler+ is an internal seam that injects
|
|
32
|
+
# a pre-configured +Catalog::Handles+; tests pass one whose +next_id+
|
|
33
|
+
# is pinned near +MAX_ID+ to exercise the cap-exhaustion path
|
|
34
|
+
# without 2³¹ allocations. Production callers leave it at the default.
|
|
35
|
+
def initialize(handler: Catalog::Handles.new)
|
|
36
|
+
@bindings = {} # : Hash[String, untyped]
|
|
37
|
+
@handler = handler
|
|
38
|
+
@sealed = false
|
|
39
|
+
@encoded = nil # : String?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Bind +object+ as the Service reachable at +path+ — a +Symbol+ or
|
|
43
|
+
# +String+ of one or more +::+-separated constant-form segments
|
|
44
|
+
# (+"MyService::KV"+ or a top-level +"File"+). Returns +self+ for
|
|
45
|
+
# chaining. Raises +ArgumentError+ when a segment is malformed, when
|
|
46
|
+
# +path+ collides with an existing binding (a name is a bound Service
|
|
47
|
+
# or a grouping prefix, never both), or when the owning Sandbox has
|
|
48
|
+
# been sealed by its first invocation.
|
|
49
|
+
def bind(path, object)
|
|
50
|
+
raise ArgumentError, "cannot bind after first Sandbox invocation" if @sealed
|
|
51
|
+
|
|
52
|
+
path_str = validate_path!(path)
|
|
53
|
+
raise ArgumentError, "Service path #{path_str} conflicts with an existing binding" if collision?(path_str)
|
|
54
|
+
|
|
55
|
+
@bindings[path_str] = object
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resolve a +target+ constant path to the bound Service. Raises
|
|
60
|
+
# +KeyError+ when no Service is bound at +target+.
|
|
61
|
+
def lookup(target)
|
|
62
|
+
target_str = target.to_s
|
|
63
|
+
raise KeyError, "no service bound at #{target_str.inspect}" unless @bindings.key?(target_str)
|
|
64
|
+
|
|
65
|
+
@bindings[target_str]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Encode the preamble as msgpack bytes for stdin Frame 1 delivery —
|
|
69
|
+
# a flat array of the bound constant paths, in bind order:
|
|
70
|
+
# +["MyService::KV", "File"]+. Routes through Kobako::Codec::Encoder
|
|
71
|
+
# like every other host-side wire encode; the preamble carries only
|
|
72
|
+
# Strings, so none of the kobako ext types fire. Returns a binary
|
|
73
|
+
# +String+ of msgpack bytes.
|
|
74
|
+
#
|
|
75
|
+
# Once sealed, the bytes are computed once and reused for every
|
|
76
|
+
# subsequent invocation: sealing freezes Service registration at the
|
|
77
|
+
# first invocation, so a bind reaching the registry after the seal
|
|
78
|
+
# raises +ArgumentError+ and never alters Frame 1.
|
|
79
|
+
def encode
|
|
80
|
+
return @encoded if @encoded
|
|
81
|
+
|
|
82
|
+
bytes = Codec::Encoder.encode(@bindings.keys).freeze
|
|
83
|
+
@encoded = bytes if @sealed
|
|
84
|
+
bytes
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Mark the registry as sealed. Called by +Sandbox+ on the first
|
|
88
|
+
# invocation; afterwards #bind raises ArgumentError. Idempotent;
|
|
89
|
+
# returns +self+.
|
|
90
|
+
def seal!
|
|
91
|
+
@sealed = true
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns +true+ when #seal! has been called, +false+ otherwise.
|
|
96
|
+
def sealed?
|
|
97
|
+
@sealed
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def validate_path!(path)
|
|
103
|
+
path_str = path.to_s
|
|
104
|
+
segments = path_str.split("::", -1)
|
|
105
|
+
return path_str if !segments.empty? && segments.all? { |seg| NAME_PATTERN.match?(seg) }
|
|
106
|
+
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"bind path must be constant-form segments joined by '::' (got #{path.inspect})"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A path collides when it equals, is a prefix of, or extends an
|
|
112
|
+
# existing binding on the +::+ segment boundary — the guardrail that
|
|
113
|
+
# keeps a name from being both a bound Service and a grouping prefix.
|
|
114
|
+
def collision?(path)
|
|
115
|
+
@bindings.each_key.any? do |existing|
|
|
116
|
+
existing == path ||
|
|
117
|
+
existing.start_with?("#{path}::") ||
|
|
118
|
+
path.start_with?("#{existing}::")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -44,7 +44,7 @@ module Kobako
|
|
|
44
44
|
# The bytes are memoized — the table is replayed verbatim on every
|
|
45
45
|
# invocation after sealing, so Frame 3 never changes between
|
|
46
46
|
# encodes; #register drops the memo while the table is still open.
|
|
47
|
-
# Unlike +Catalog::
|
|
47
|
+
# Unlike +Catalog::Services#encode+, which gates its memo on the
|
|
48
48
|
# seal, this one can fill eagerly and invalidate in +#register+
|
|
49
49
|
# because every mutation funnels through that single method — there is
|
|
50
50
|
# no out-of-sight child object to change the result behind its back.
|
data/lib/kobako/catalog.rb
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "catalog/handles"
|
|
4
|
-
require_relative "catalog/
|
|
4
|
+
require_relative "catalog/services"
|
|
5
5
|
require_relative "catalog/snippets"
|
|
6
6
|
|
|
7
7
|
module Kobako
|
|
8
8
|
# Kobako::Catalog — Sandbox-level configuration and per-invocation
|
|
9
9
|
# allocation tables. Houses the three host-side registries the Sandbox
|
|
10
|
-
# owns: +Catalog::
|
|
10
|
+
# owns: +Catalog::Services+ (path→Service binding registry),
|
|
11
11
|
# +Catalog::Snippets+ (preloaded source / bytecode entries), and
|
|
12
12
|
# +Catalog::Handles+ (per-invocation Handle ID allocator).
|
|
13
13
|
#
|
data/lib/kobako/codec/decoder.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "msgpack"
|
|
4
4
|
|
|
5
5
|
require_relative "error"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "ext_types"
|
|
7
7
|
require_relative "utils"
|
|
8
8
|
|
|
9
9
|
module Kobako
|
|
@@ -31,7 +31,7 @@ module Kobako
|
|
|
31
31
|
# surfaces as InvalidType without a separate Utils.with_boundary
|
|
32
32
|
# wrapper at the call site.
|
|
33
33
|
def self.decode(bytes)
|
|
34
|
-
value =
|
|
34
|
+
value = FACTORY.load(bytes.b)
|
|
35
35
|
validate_utf8!(value)
|
|
36
36
|
block_given? ? yield(value) : value
|
|
37
37
|
# msgpack gem raises the format/type errors below; +ArgumentError+
|
|
@@ -54,7 +54,7 @@ module Kobako
|
|
|
54
54
|
# str family but does not validate the bytes; +bin+ family decodes
|
|
55
55
|
# to ASCII-8BIT. Walk the tree once and reject invalid UTF-8 in any
|
|
56
56
|
# str-typed leaf via Utils.assert_utf8!. Kobako::Fault
|
|
57
|
-
# payloads are validated transitively: +
|
|
57
|
+
# payloads are validated transitively: +ExtTypes#unpack_fault+
|
|
58
58
|
# feeds the inner ext-0x02 bytes back through this Decoder, so their
|
|
59
59
|
# +str+ fields are already covered by the time control returns here.
|
|
60
60
|
class << self
|
data/lib/kobako/codec/encoder.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "msgpack"
|
|
4
4
|
|
|
5
5
|
require_relative "error"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "ext_types"
|
|
7
7
|
|
|
8
8
|
module Kobako
|
|
9
9
|
module Codec
|
|
@@ -13,8 +13,8 @@ module Kobako
|
|
|
13
13
|
# The codec backbone is the official +msgpack+ gem: integers, floats,
|
|
14
14
|
# strings, arrays, and maps go through the gem's narrowest-encoding
|
|
15
15
|
# logic; the three kobako-specific ext types (0x00 Symbol, 0x01
|
|
16
|
-
# Capability Handle, 0x02 Exception envelope) are registered
|
|
17
|
-
# the
|
|
16
|
+
# Capability Handle, 0x02 Exception envelope) are registered by
|
|
17
|
+
# ExtTypes on the process-wide factory.
|
|
18
18
|
#
|
|
19
19
|
# Public API is a single function — +.encode+. The codec is stateless;
|
|
20
20
|
# there is no buffer accumulator and no streaming write API. Callers
|
|
@@ -32,7 +32,7 @@ module Kobako
|
|
|
32
32
|
# +NoMethodError+ is likewise reported as +UnsupportedType+ rather than
|
|
33
33
|
# propagating.
|
|
34
34
|
def self.encode(value)
|
|
35
|
-
|
|
35
|
+
FACTORY.dump(value)
|
|
36
36
|
rescue ::RangeError, ::NoMethodError => e
|
|
37
37
|
raise UnsupportedType, e.message
|
|
38
38
|
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "msgpack"
|
|
4
|
+
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "utils"
|
|
7
|
+
require_relative "state"
|
|
8
|
+
require_relative "../handle"
|
|
9
|
+
require_relative "../fault"
|
|
10
|
+
|
|
11
|
+
module Kobako
|
|
12
|
+
module Codec
|
|
13
|
+
# The kobako wire ext-type conversions
|
|
14
|
+
# ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types)
|
|
15
|
+
# as pure functions: per-operation decode state is threaded in as an
|
|
16
|
+
# argument, so the module itself holds nothing. #build_factory assembles
|
|
17
|
+
# the one +MessagePack::Factory+ these conversions are registered on.
|
|
18
|
+
module ExtTypes
|
|
19
|
+
# MessagePack ext type code reserved for Symbol
|
|
20
|
+
# ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
|
|
21
|
+
# → ext 0x00). Module-private — mirrors +codec::EXT_SYMBOL+ on the
|
|
22
|
+
# Rust side.
|
|
23
|
+
EXT_SYMBOL = 0x00
|
|
24
|
+
# MessagePack ext type code reserved for Capability Handle
|
|
25
|
+
# ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
|
|
26
|
+
# → ext 0x01). Module-private — mirrors +codec::EXT_HANDLE+ on the
|
|
27
|
+
# Rust side.
|
|
28
|
+
EXT_HANDLE = 0x01
|
|
29
|
+
# MessagePack ext type code reserved for Exception envelope
|
|
30
|
+
# ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
|
|
31
|
+
# → ext 0x02). Module-private — mirrors +codec::EXT_ERRENV+ on the
|
|
32
|
+
# Rust side.
|
|
33
|
+
EXT_ERRENV = 0x02
|
|
34
|
+
private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Assemble a +MessagePack::Factory+ with the three kobako ext types
|
|
39
|
+
# registered, frozen because registration is its only mutation and
|
|
40
|
+
# happens exactly once. The stateful conversions resolve their
|
|
41
|
+
# per-operation state at call time, so one registered factory serves
|
|
42
|
+
# every thread.
|
|
43
|
+
def build_factory
|
|
44
|
+
factory = MessagePack::Factory.new
|
|
45
|
+
register_symbol(factory)
|
|
46
|
+
register_handle(factory)
|
|
47
|
+
register_fault(factory)
|
|
48
|
+
factory.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Symbol-to-name packer for the ext-0x00 registration.
|
|
52
|
+
def pack_symbol(symbol)
|
|
53
|
+
symbol.name
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Validate the ext-0x00 payload as UTF-8 and intern. Raises
|
|
57
|
+
# InvalidEncoding on invalid bytes — SPEC forbids the
|
|
58
|
+
# binary-encoding fallback that msgpack-gem's default unpacker
|
|
59
|
+
# would otherwise apply. The re-tag step lives here because the
|
|
60
|
+
# msgpack ext-type unpacker hands us binary bytes; the assertion
|
|
61
|
+
# itself is shared with Decoder via Utils.assert_utf8!. The
|
|
62
|
+
# +"Symbol"+ label keeps the error message in Ruby vocabulary
|
|
63
|
+
# rather than wire-ext-code vocabulary.
|
|
64
|
+
def unpack_symbol(payload)
|
|
65
|
+
name = payload.b.force_encoding(Encoding::UTF_8)
|
|
66
|
+
Utils.assert_utf8!(name, "Symbol payload")
|
|
67
|
+
name.to_sym
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Handle-id packer for the ext-0x01 registration: the fixext-4
|
|
71
|
+
# big-endian id frame.
|
|
72
|
+
def pack_handle(handle)
|
|
73
|
+
[handle.id].pack("N")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Peel off the fixext-4 frame, hand the bytes to the
|
|
77
|
+
# Host-Gem-internal +Kobako::Handle.restore+ factory, and
|
|
78
|
+
# translate the +ArgumentError+ raised by Handle's invariants
|
|
79
|
+
# into a wire-layer +InvalidType+ via Codec::Utils.with_boundary.
|
|
80
|
+
# The Value Object owns the id-range contract; this method only
|
|
81
|
+
# owns the frame shape. Records the Handle sighting on +state+ so a
|
|
82
|
+
# Handle-free decode can skip the downstream resolution walk.
|
|
83
|
+
def unpack_handle(payload, state)
|
|
84
|
+
state.record_handle!
|
|
85
|
+
bytes = payload.b
|
|
86
|
+
raise InvalidType, "Handle payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
|
|
87
|
+
|
|
88
|
+
id = bytes.unpack1("N") # : Integer
|
|
89
|
+
Codec::Utils.with_boundary { Kobako::Handle.restore(id) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Encode the inner ext-0x02 map via Encoder (not the raw factory) so
|
|
93
|
+
# the embedded payload flows through the same boundary as a top-level
|
|
94
|
+
# encode — nested kobako values (Handle, nested Fault) reach the
|
|
95
|
+
# registered ext-type packers. A +details+ chain nested past the
|
|
96
|
+
# +state+ depth cap has no wire representation and surfaces as
|
|
97
|
+
# +UnsupportedType+. In a payload position (+state+ inside a
|
|
98
|
+
# forbid_faults bracket) the envelope has no wire representation at
|
|
99
|
+
# all, so the refusal routes the value into the position's
|
|
100
|
+
# non-representable handling — the Dispatcher's auto-wrap rescue,
|
|
101
|
+
# or a raise at the yield site.
|
|
102
|
+
def pack_fault(fault, state)
|
|
103
|
+
if state.faults_forbidden?
|
|
104
|
+
raise UnsupportedType, "Kobako::Fault has no wire representation in a payload position"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
state.within_ext_frame(UnsupportedType) do
|
|
108
|
+
Encoder.encode("type" => fault.type, "message" => fault.message, "details" => fault.details)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Peel the embedded msgpack map and hand it to +Kobako::Fault.new+
|
|
113
|
+
# inside Decoder.decode's block form, so the value-object's
|
|
114
|
+
# +ArgumentError+ invariants surface as +InvalidType+ through the
|
|
115
|
+
# decoder boundary. Inner decode goes through Decoder (not the raw
|
|
116
|
+
# factory) so the embedded +str+ payloads flow through the same
|
|
117
|
+
# UTF-8 validation as a top-level decode. A nested ext 0x02 in
|
|
118
|
+
# +details+ re-enters this method, so the +state+ ext-frame guard
|
|
119
|
+
# bounds the chain depth to keep it from exhausting the native stack.
|
|
120
|
+
# In a payload position (+state+ inside a forbid_faults bracket) the
|
|
121
|
+
# envelope is a wire violation outright — its sole legal position is
|
|
122
|
+
# the Response fault field.
|
|
123
|
+
def unpack_fault(payload, state)
|
|
124
|
+
if state.faults_forbidden?
|
|
125
|
+
raise InvalidType, "Fault envelope (ext 0x02) is not a legal value in a payload position"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
state.within_ext_frame(InvalidType) do
|
|
129
|
+
Decoder.decode(payload) do |map|
|
|
130
|
+
raise InvalidType, "Fault payload must be a map" unless map.is_a?(Hash)
|
|
131
|
+
|
|
132
|
+
Kobako::Fault.new(type: map["type"], message: map["message"], details: map["details"])
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def register_symbol(factory)
|
|
138
|
+
factory.register_type(
|
|
139
|
+
EXT_SYMBOL, Symbol,
|
|
140
|
+
packer: ->(symbol) { ExtTypes.pack_symbol(symbol) },
|
|
141
|
+
unpacker: ->(payload) { ExtTypes.unpack_symbol(payload) }
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def register_handle(factory)
|
|
146
|
+
factory.register_type(
|
|
147
|
+
EXT_HANDLE, Kobako::Handle,
|
|
148
|
+
packer: ->(handle) { ExtTypes.pack_handle(handle) },
|
|
149
|
+
unpacker: ->(payload) { ExtTypes.unpack_handle(payload, State.current) }
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def register_fault(factory)
|
|
154
|
+
factory.register_type(
|
|
155
|
+
EXT_ERRENV, Kobako::Fault,
|
|
156
|
+
packer: ->(fault) { ExtTypes.pack_fault(fault, State.current) },
|
|
157
|
+
unpacker: ->(payload) { ExtTypes.unpack_fault(payload, State.current) }
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The process-wide registered factory: ext registration is paid once at
|
|
163
|
+
# load, and a registered +MessagePack::Factory+ only reads its type
|
|
164
|
+
# registry afterwards, so every thread shares this instance for byte
|
|
165
|
+
# work.
|
|
166
|
+
FACTORY = ExtTypes.build_factory
|
|
167
|
+
private_constant :FACTORY
|
|
168
|
+
end
|
|
169
|
+
end
|