kobako 0.14.0 → 0.16.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 +67 -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 +14 -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 +14 -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/extensions.rb +114 -0
- data/lib/kobako/catalog/handles.rb +1 -1
- data/lib/kobako/catalog/services.rb +135 -0
- data/lib/kobako/catalog/snippets.rb +1 -1
- data/lib/kobako/catalog.rb +7 -5
- 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/extension.rb +47 -0
- 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 +47 -27
- 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/lib/kobako.rb +1 -0
- data/sig/kobako/catalog/extensions.rbs +25 -0
- data/sig/kobako/catalog/services.rbs +27 -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/extension.rbs +20 -0
- data/sig/kobako/sandbox.rbs +3 -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 +11 -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
|
@@ -18,11 +18,11 @@ use kobako_runtime::profile::Profile;
|
|
|
18
18
|
|
|
19
19
|
/// Return the resolved `memory` export handle, or a `Trap` when the loaded
|
|
20
20
|
/// module exports no linear memory — the "not a Kobako-shaped runtime"
|
|
21
|
-
/// failure mode (`SANDBOX_RUNTIME_NOT_KOBAKO`).
|
|
21
|
+
/// failure mode (`guest_mem::SANDBOX_RUNTIME_NOT_KOBAKO`).
|
|
22
22
|
fn require_memory(exports: &Exports) -> Result<Memory, Trap> {
|
|
23
23
|
exports
|
|
24
24
|
.memory
|
|
25
|
-
.ok_or_else(|| Trap::Other(SANDBOX_RUNTIME_NOT_KOBAKO.to_string()))
|
|
25
|
+
.ok_or_else(|| Trap::Other(guest_mem::SANDBOX_RUNTIME_NOT_KOBAKO.to_string()))
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/// Allocate a `len`-byte buffer in guest linear memory via
|
|
@@ -160,13 +160,6 @@ pub(crate) fn fetch_outcome_bytes(
|
|
|
160
160
|
const SANDBOX_RUNTIME_MISSING_HOOKS: &str = "Sandbox runtime is missing required hooks; \
|
|
161
161
|
rebuild data/kobako.wasm against the installed version";
|
|
162
162
|
|
|
163
|
-
/// User-facing message for the "the loaded Wasm module is not a
|
|
164
|
-
/// Kobako-shaped runtime at all" failure mode (no linear memory
|
|
165
|
-
/// export). Same phrasing philosophy as
|
|
166
|
-
/// `SANDBOX_RUNTIME_MISSING_HOOKS`.
|
|
167
|
-
const SANDBOX_RUNTIME_NOT_KOBAKO: &str =
|
|
168
|
-
"the loaded Wasm module is not a Kobako-compatible runtime";
|
|
169
|
-
|
|
170
163
|
/// Return the resolved `TypedFunc` for an ABI export, or a `Trap`
|
|
171
164
|
/// (boundary → `Kobako::TrapError`) when the option is `None`. Both
|
|
172
165
|
/// run-path methods (`#eval`, `#run`) plus the `build_snapshot` readout
|
|
@@ -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
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
module Catalog
|
|
5
|
+
# Kobako::Catalog::Extensions — per-Sandbox registry of installed
|
|
6
|
+
# Extensions. Composes each Extension onto the sibling registries at
|
|
7
|
+
# install time (its +source+ into +Catalog::Snippets+, its +backend+
|
|
8
|
+
# path into +Catalog::Services+), asserts declared dependencies are
|
|
9
|
+
# present when the Sandbox seals, and resolves each callable-backed
|
|
10
|
+
# path to a fresh object at the start of every invocation.
|
|
11
|
+
#
|
|
12
|
+
# Sealing and the reject-after-install guard are governed by the owning
|
|
13
|
+
# Sandbox through +Catalog::Services#sealed?+, the shared seal signal;
|
|
14
|
+
# this registry only records entries and enforces the Extension-shape
|
|
15
|
+
# and dependency rules.
|
|
16
|
+
class Extensions
|
|
17
|
+
def initialize
|
|
18
|
+
@entries = [] # : Array[untyped]
|
|
19
|
+
@asserted = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Install +extension+: validate its shape, register its +source+ as a
|
|
23
|
+
# preloaded snippet, and — when it carries a +backend+ — reserve the
|
|
24
|
+
# backend path in +services+. A callable provider reserves the path
|
|
25
|
+
# with a placeholder the per-invocation refresh replaces; a fixed
|
|
26
|
+
# provider binds its object directly. Raises +ArgumentError+ on a
|
|
27
|
+
# malformed Extension; a malformed +name+ or +backend.path+ surfaces
|
|
28
|
+
# through the +snippets+ / +services+ registration it routes to.
|
|
29
|
+
def install(extension, snippets:, services:)
|
|
30
|
+
validate!(extension)
|
|
31
|
+
snippets.register(code: extension.source, name: extension.name)
|
|
32
|
+
backend = extension.backend
|
|
33
|
+
services.bind(backend.path, initial_object(backend.provider)) if backend
|
|
34
|
+
@entries << extension
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Assert every installed Extension's +depends_on+ names a fellow
|
|
39
|
+
# installed Extension. Runs once, when the Sandbox first seals; an
|
|
40
|
+
# unmet dependency raises +ArgumentError+ naming the gap, before the
|
|
41
|
+
# guest runs. Idempotent across later invocations.
|
|
42
|
+
def seal!
|
|
43
|
+
return self if @asserted
|
|
44
|
+
|
|
45
|
+
@asserted = true
|
|
46
|
+
assert_dependencies!
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolve each callable-backed path to this invocation's object and
|
|
51
|
+
# refresh it behind its already-sealed path in +services+. Distinct
|
|
52
|
+
# providers yield distinct objects; one provider shared by several
|
|
53
|
+
# Extensions is invoked once and its result shared, so provider
|
|
54
|
+
# identity is resource identity. Fixed providers are left untouched —
|
|
55
|
+
# they stay the object bound at install.
|
|
56
|
+
def refresh_backends!(services)
|
|
57
|
+
resolved = {} # : Hash[untyped, untyped]
|
|
58
|
+
resolved.compare_by_identity
|
|
59
|
+
@entries.each { |extension| refresh_backend(extension, services, resolved) }
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Resolve one Extension's callable-backed path against the shared
|
|
66
|
+
# per-invocation +resolved+ cache (keyed by provider identity) and
|
|
67
|
+
# refresh it in +services+. A fixed provider is skipped — its object
|
|
68
|
+
# stays as bound at install.
|
|
69
|
+
def refresh_backend(extension, services, resolved)
|
|
70
|
+
backend = extension.backend
|
|
71
|
+
return unless backend
|
|
72
|
+
|
|
73
|
+
provider = backend.provider
|
|
74
|
+
return unless callable?(provider)
|
|
75
|
+
|
|
76
|
+
object = resolved.fetch(provider) { resolved[provider] = provider.call }
|
|
77
|
+
services.refresh(backend.path, object)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Enforce the Extension-shape checks +#preload+ / +#bind+ do not: a
|
|
81
|
+
# mandatory String +source+ (the install/bind boundary) and a
|
|
82
|
+
# +backend+ that, when present, exposes +path+ and +provider+.
|
|
83
|
+
def validate!(extension)
|
|
84
|
+
source = extension.source
|
|
85
|
+
raise ArgumentError, "Extension #source must be a String, got #{source.class}" unless source.is_a?(String)
|
|
86
|
+
|
|
87
|
+
backend = extension.backend
|
|
88
|
+
return if backend.nil?
|
|
89
|
+
return if backend.respond_to?(:path) && backend.respond_to?(:provider)
|
|
90
|
+
|
|
91
|
+
raise ArgumentError, "Extension #backend must expose #path and #provider"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def assert_dependencies!
|
|
95
|
+
names = @entries.map(&:name)
|
|
96
|
+
@entries.each do |extension|
|
|
97
|
+
(extension.depends_on || []).each do |dependency|
|
|
98
|
+
next if names.include?(dependency)
|
|
99
|
+
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"Extension #{extension.name.inspect} depends on #{dependency.inspect}, which is not installed"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def callable?(provider) = provider.respond_to?(:call)
|
|
107
|
+
|
|
108
|
+
# A callable provider reserves its path with a placeholder that the
|
|
109
|
+
# per-invocation refresh replaces before any dispatch; a fixed
|
|
110
|
+
# provider is the bound object itself.
|
|
111
|
+
def initial_object(provider) = callable?(provider) ? nil : provider
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -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,135 @@
|
|
|
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
|
+
# Replace the object bound at an already-registered +path+ without the
|
|
69
|
+
# collision or seal checks +#bind+ applies. +Catalog::Extensions+ calls
|
|
70
|
+
# this during +begin_invocation!+ to swap a callable backend provider's
|
|
71
|
+
# per-invocation result behind a path whose Frame 1 membership was
|
|
72
|
+
# fixed at the seal. A path that was never registered is left untouched,
|
|
73
|
+
# so the sealed key set can never grow here.
|
|
74
|
+
def refresh(path, object)
|
|
75
|
+
key = path.to_s
|
|
76
|
+
@bindings[key] = object if @bindings.key?(key)
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Encode the preamble as msgpack bytes for stdin Frame 1 delivery —
|
|
81
|
+
# a flat array of the bound constant paths, in bind order:
|
|
82
|
+
# +["MyService::KV", "File"]+. Routes through Kobako::Codec::Encoder
|
|
83
|
+
# like every other host-side wire encode; the preamble carries only
|
|
84
|
+
# Strings, so none of the kobako ext types fire. Returns a binary
|
|
85
|
+
# +String+ of msgpack bytes.
|
|
86
|
+
#
|
|
87
|
+
# Once sealed, the bytes are computed once and reused for every
|
|
88
|
+
# subsequent invocation: sealing freezes Service registration at the
|
|
89
|
+
# first invocation, so a bind reaching the registry after the seal
|
|
90
|
+
# raises +ArgumentError+ and never alters Frame 1.
|
|
91
|
+
def encode
|
|
92
|
+
return @encoded if @encoded
|
|
93
|
+
|
|
94
|
+
bytes = Codec::Encoder.encode(@bindings.keys).freeze
|
|
95
|
+
@encoded = bytes if @sealed
|
|
96
|
+
bytes
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Mark the registry as sealed. Called by +Sandbox+ on the first
|
|
100
|
+
# invocation; afterwards #bind raises ArgumentError. Idempotent;
|
|
101
|
+
# returns +self+.
|
|
102
|
+
def seal!
|
|
103
|
+
@sealed = true
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns +true+ when #seal! has been called, +false+ otherwise.
|
|
108
|
+
def sealed?
|
|
109
|
+
@sealed
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def validate_path!(path)
|
|
115
|
+
path_str = path.to_s
|
|
116
|
+
segments = path_str.split("::", -1)
|
|
117
|
+
return path_str if !segments.empty? && segments.all? { |seg| NAME_PATTERN.match?(seg) }
|
|
118
|
+
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"bind path must be constant-form segments joined by '::' (got #{path.inspect})"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A path collides when it equals, is a prefix of, or extends an
|
|
124
|
+
# existing binding on the +::+ segment boundary — the guardrail that
|
|
125
|
+
# keeps a name from being both a bound Service and a grouping prefix.
|
|
126
|
+
def collision?(path)
|
|
127
|
+
@bindings.each_key.any? do |existing|
|
|
128
|
+
existing == path ||
|
|
129
|
+
existing.start_with?("#{path}::") ||
|
|
130
|
+
path.start_with?("#{existing}::")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
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,15 +1,17 @@
|
|
|
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
|
+
require_relative "catalog/extensions"
|
|
6
7
|
|
|
7
8
|
module Kobako
|
|
8
9
|
# Kobako::Catalog — Sandbox-level configuration and per-invocation
|
|
9
|
-
# allocation tables. Houses the
|
|
10
|
-
#
|
|
11
|
-
# +Catalog::Snippets+ (preloaded source / bytecode entries),
|
|
12
|
-
# +Catalog::
|
|
10
|
+
# allocation tables. Houses the host-side registries the Sandbox owns:
|
|
11
|
+
# +Catalog::Services+ (path→Service binding registry),
|
|
12
|
+
# +Catalog::Snippets+ (preloaded source / bytecode entries),
|
|
13
|
+
# +Catalog::Extensions+ (installed Extensions, composed onto Services /
|
|
14
|
+
# Snippets), and +Catalog::Handles+ (per-invocation Handle ID allocator).
|
|
13
15
|
#
|
|
14
16
|
# See {SPEC.md Refinement → Internal Concepts}[link:../../SPEC.md] for
|
|
15
17
|
# how Catalog fits alongside Transport and Runtime.
|
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
|