kobako 0.1.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 +7 -0
- data/Cargo.lock +2347 -0
- data/Cargo.toml +11 -0
- data/LICENSE +201 -0
- data/README.md +228 -0
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +36 -0
- data/ext/kobako/extconf.rb +6 -0
- data/ext/kobako/src/lib.rs +10 -0
- data/ext/kobako/src/wasm/cache.rs +92 -0
- data/ext/kobako/src/wasm/dispatch.rs +110 -0
- data/ext/kobako/src/wasm/host_state.rs +59 -0
- data/ext/kobako/src/wasm/instance.rs +361 -0
- data/ext/kobako/src/wasm.rs +80 -0
- data/lib/kobako/errors.rb +88 -0
- data/lib/kobako/registry/dispatcher.rb +168 -0
- data/lib/kobako/registry/handle_table.rb +107 -0
- data/lib/kobako/registry/service_group.rb +65 -0
- data/lib/kobako/registry.rb +160 -0
- data/lib/kobako/sandbox/outcome_decoder.rb +100 -0
- data/lib/kobako/sandbox/output_buffer.rb +79 -0
- data/lib/kobako/sandbox.rb +148 -0
- data/lib/kobako/version.rb +5 -0
- data/lib/kobako/wasm.rb +35 -0
- data/lib/kobako/wire/codec/decoder.rb +87 -0
- data/lib/kobako/wire/codec/encoder.rb +41 -0
- data/lib/kobako/wire/codec/error.rb +35 -0
- data/lib/kobako/wire/codec/factory.rb +136 -0
- data/lib/kobako/wire/codec.rb +44 -0
- data/lib/kobako/wire/envelope/payloads.rb +145 -0
- data/lib/kobako/wire/envelope.rb +147 -0
- data/lib/kobako/wire/exception.rb +38 -0
- data/lib/kobako/wire/handle.rb +36 -0
- data/lib/kobako/wire.rb +40 -0
- data/lib/kobako.rb +7 -0
- data/sig/kobako.rbs +4 -0
- metadata +112 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Top-level Kobako namespace.
|
|
4
|
+
module Kobako
|
|
5
|
+
# Three-class error taxonomy (SPEC.md → Error Scenarios).
|
|
6
|
+
#
|
|
7
|
+
# Every `Kobako::Sandbox#run` invocation either returns a value or raises
|
|
8
|
+
# exactly one of these three classes. Attribution is decided after the
|
|
9
|
+
# guest binary returns control to the host (SPEC "Step 1 — Wasm trap"
|
|
10
|
+
# then "Step 2 — Outcome envelope tag").
|
|
11
|
+
#
|
|
12
|
+
# Three top-level branches:
|
|
13
|
+
#
|
|
14
|
+
# * {TrapError} — Wasm engine layer (trap, OOM, unreachable, or a
|
|
15
|
+
# wire-violation fallback signalling a corrupted
|
|
16
|
+
# guest runtime).
|
|
17
|
+
# * {SandboxError} — sandbox / wire layer (mruby script error,
|
|
18
|
+
# wire-decode failure on an otherwise valid tag,
|
|
19
|
+
# HandleTable exhaustion, output buffer overrun).
|
|
20
|
+
# * {ServiceError} — service / capability layer (a Service RPC that
|
|
21
|
+
# failed and was not rescued inside the script).
|
|
22
|
+
#
|
|
23
|
+
# Subclasses pinned by SPEC "Error Classes":
|
|
24
|
+
#
|
|
25
|
+
# * {HandleTableExhausted} < {SandboxError} — id cap hit (B-21).
|
|
26
|
+
# * {ServiceError::Disconnected} < {ServiceError} — `:disconnected`
|
|
27
|
+
# sentinel hit on the HandleTable (E-14).
|
|
28
|
+
|
|
29
|
+
# Base for all kobako-raised errors so callers that want to ignore the
|
|
30
|
+
# taxonomy can rescue a single class.
|
|
31
|
+
class Error < StandardError; end
|
|
32
|
+
|
|
33
|
+
# Wasm engine layer. Raised when the Wasm execution engine crashed
|
|
34
|
+
# (trap, OOM, unreachable) or when the wire layer detected a structural
|
|
35
|
+
# violation that signals a corrupted guest execution environment
|
|
36
|
+
# (zero-length OUTCOME_BUFFER, unknown outcome tag — SPEC E-02 / E-03).
|
|
37
|
+
class TrapError < Error; end
|
|
38
|
+
|
|
39
|
+
# Sandbox / wire layer. Raised when the guest ran to completion but
|
|
40
|
+
# execution failed due to a mruby script error, a protocol fault, or a
|
|
41
|
+
# host-side wire decode failure on an otherwise valid outcome tag.
|
|
42
|
+
class SandboxError < Error
|
|
43
|
+
attr_reader :origin, :klass, :backtrace_lines, :details
|
|
44
|
+
|
|
45
|
+
def initialize(message, origin: nil, klass: nil, backtrace_lines: nil, details: nil)
|
|
46
|
+
super(message)
|
|
47
|
+
@origin = origin
|
|
48
|
+
@klass = klass
|
|
49
|
+
@backtrace_lines = backtrace_lines
|
|
50
|
+
@details = details
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Service layer. Raised when a Service capability call inside a mruby
|
|
55
|
+
# script reported an application-level failure that the script did not
|
|
56
|
+
# rescue.
|
|
57
|
+
class ServiceError < Error
|
|
58
|
+
attr_reader :origin, :klass, :backtrace_lines, :details
|
|
59
|
+
|
|
60
|
+
def initialize(message, origin: nil, klass: nil, backtrace_lines: nil, details: nil)
|
|
61
|
+
super(message)
|
|
62
|
+
@origin = origin
|
|
63
|
+
@klass = klass
|
|
64
|
+
@backtrace_lines = backtrace_lines
|
|
65
|
+
@details = details
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# SPEC "Error Classes": ServiceError::Disconnected is raised
|
|
69
|
+
# when the RPC target Handle resolves to the `:disconnected` sentinel
|
|
70
|
+
# in the HandleTable (ABA protection rule — id exists but entry was
|
|
71
|
+
# invalidated). E-14.
|
|
72
|
+
class Disconnected < ServiceError; end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# HandleTable lookup-failure error (unknown id passed to #fetch /
|
|
76
|
+
# #release). A SandboxError subclass: per the wire-layer rule, an
|
|
77
|
+
# unknown Handle id surfaces as a `type="undefined"` Response.err
|
|
78
|
+
# envelope inside RpcDispatcher and never reaches the Host App
|
|
79
|
+
# directly; outside that path (e.g. tests poking the HandleTable
|
|
80
|
+
# directly), it surfaces as a SandboxError.
|
|
81
|
+
class HandleTableError < SandboxError; end
|
|
82
|
+
|
|
83
|
+
# SPEC "Error Classes": HandleTableExhausted is the canonical
|
|
84
|
+
# SandboxError subclass for the id-cap-hit path (B-21). Inherits from
|
|
85
|
+
# HandleTableError so a single `rescue Kobako::HandleTableError` covers
|
|
86
|
+
# both lookup-failure and cap-exhaustion paths.
|
|
87
|
+
class HandleTableExhausted < HandleTableError; end
|
|
88
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
class Registry
|
|
5
|
+
# Pure-function dispatcher for guest-initiated RPC calls. Decodes a
|
|
6
|
+
# msgpack-encoded Request envelope, resolves the target object through the
|
|
7
|
+
# Registry (path lookup or HandleTable lookup), invokes the method, and
|
|
8
|
+
# returns a msgpack-encoded Response envelope.
|
|
9
|
+
#
|
|
10
|
+
# The module is stateless — all mutable state is threaded through the
|
|
11
|
+
# +registry+ argument so Dispatcher has no instance variables and no side
|
|
12
|
+
# effects beyond mutating the HandleTable via +alloc+ when a non-wire-
|
|
13
|
+
# representable return value must be wrapped ({SPEC.md B-14}[link:../../../SPEC.md]).
|
|
14
|
+
#
|
|
15
|
+
# Entry point:
|
|
16
|
+
#
|
|
17
|
+
# Kobako::Registry::Dispatcher.dispatch(request_bytes, registry)
|
|
18
|
+
# # => msgpack-encoded Response bytes (never raises)
|
|
19
|
+
module Dispatcher
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Internal sentinel raised when target resolution fails. Mapped to
|
|
23
|
+
# Response.err with type="undefined". Contained at the wire boundary —
|
|
24
|
+
# not part of the public Kobako error taxonomy
|
|
25
|
+
# ({SPEC.md E-xx}[link:../../../SPEC.md]).
|
|
26
|
+
class UndefinedTargetError < StandardError; end
|
|
27
|
+
|
|
28
|
+
# Internal sentinel raised when a Handle target resolves to the
|
|
29
|
+
# +:disconnected+ sentinel in the HandleTable (ABA protection,
|
|
30
|
+
# {SPEC.md E-14}[link:../../../SPEC.md]). Mapped to Response.err with
|
|
31
|
+
# type="disconnected". Contained at the wire boundary.
|
|
32
|
+
class DisconnectedTargetError < StandardError; end
|
|
33
|
+
|
|
34
|
+
# Dispatch a single RPC request and return the encoded response bytes.
|
|
35
|
+
# Called by +Kobako::Registry#dispatch+ which is invoked from the Rust
|
|
36
|
+
# ext inside +__kobako_rpc_call+. +request_bytes+ is the msgpack-encoded
|
|
37
|
+
# Request envelope. +registry+ is the live registry for this run, used
|
|
38
|
+
# to resolve path-based targets via +#lookup+ and to access the
|
|
39
|
+
# +#handle_table+ for Handle-based targets and return-value wrapping.
|
|
40
|
+
# Always returns a binary String — never raises. Any failure during
|
|
41
|
+
# decode, lookup, or method invocation is reified as a Response.err
|
|
42
|
+
# envelope so the guest sees the failure as a normal RPC error rather
|
|
43
|
+
# than a wasm trap
|
|
44
|
+
# ({SPEC.md B-12}[link:../../../SPEC.md]).
|
|
45
|
+
def dispatch(request_bytes, registry)
|
|
46
|
+
value = perform_dispatch(request_bytes, registry)
|
|
47
|
+
encode_ok_or_wrap(value, registry)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
encode_dispatch_error(e)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Map an error raised during dispatch to a Response.err envelope.
|
|
53
|
+
# +error+ is the +StandardError+ caught at the dispatch boundary. Returns
|
|
54
|
+
# a msgpack-encoded Response envelope (binary). Four error buckets
|
|
55
|
+
# ({SPEC.md B-12}[link:../../../SPEC.md]): +Wire::Codec::Error+ →
|
|
56
|
+
# type="runtime" (wire decode failed); +DisconnectedTargetError+ →
|
|
57
|
+
# type="disconnected" (E-14); +UndefinedTargetError+ → type="undefined"
|
|
58
|
+
# (E-13); +ArgumentError+ → type="argument" (B-12 arity mismatch);
|
|
59
|
+
# everything else → type="runtime".
|
|
60
|
+
def encode_dispatch_error(error)
|
|
61
|
+
case error
|
|
62
|
+
when Kobako::Wire::Codec::Error then encode_err("runtime", "wire decode failed: #{error.message}")
|
|
63
|
+
when DisconnectedTargetError then encode_err("disconnected", error.message)
|
|
64
|
+
when UndefinedTargetError then encode_err("undefined", error.message)
|
|
65
|
+
when ArgumentError then encode_err("argument", error.message)
|
|
66
|
+
else encode_err("runtime", "#{error.class}: #{error.message}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def perform_dispatch(request_bytes, registry)
|
|
71
|
+
request = Kobako::Wire::Envelope.decode_request(request_bytes)
|
|
72
|
+
handle_table = registry.handle_table
|
|
73
|
+
target_object = resolve_target(request.target, registry, handle_table)
|
|
74
|
+
args = request.args.map { |v| resolve_arg(v, handle_table) }
|
|
75
|
+
kwargs = request.kwargs.transform_values { |v| resolve_arg(v, handle_table) }
|
|
76
|
+
invoke(target_object, request.method_name, args, kwargs)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Dispatch +method+ on +target+. +kwargs+ is already Symbol-keyed
|
|
80
|
+
# (the +Envelope::Request+ invariant pins it). The empty-kwargs
|
|
81
|
+
# branch omits the +**+ splat so Ruby 3.x's strict kwargs
|
|
82
|
+
# separation does not reject calls to no-kwarg methods when the
|
|
83
|
+
# wire carries the uniform empty-map shape.
|
|
84
|
+
def invoke(target, method, args, kwargs)
|
|
85
|
+
if kwargs.empty?
|
|
86
|
+
target.public_send(method.to_sym, *args)
|
|
87
|
+
else
|
|
88
|
+
target.public_send(method.to_sym, *args, **kwargs)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# {SPEC.md B-16}[link:../../../SPEC.md] — A Wire::Handle arriving as a positional or keyword
|
|
93
|
+
# argument identifies a host-side object previously allocated by a prior
|
|
94
|
+
# RPC's Handle wrap (B-14). Resolve it back to the Ruby object before
|
|
95
|
+
# the dispatch reaches +public_send+. A Handle whose entry is the
|
|
96
|
+
# +:disconnected+ sentinel (E-14) raises DisconnectedTargetError so
|
|
97
|
+
# the dispatcher emits a Response.err with type="disconnected".
|
|
98
|
+
def resolve_arg(value, handle_table)
|
|
99
|
+
case value
|
|
100
|
+
when Kobako::Wire::Handle
|
|
101
|
+
fetch_live_object(value.id, handle_table)
|
|
102
|
+
else
|
|
103
|
+
value
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Resolve a Request target to the Ruby object the Registry (or
|
|
108
|
+
# HandleTable) holds. String targets go through the Registry;
|
|
109
|
+
# Handle targets (ext 0x01) go through the HandleTable.
|
|
110
|
+
#
|
|
111
|
+
# Target type is already validated by +Wire::Envelope.decode_request+
|
|
112
|
+
# before this method is reached, so no else-branch is needed here —
|
|
113
|
+
# the wire layer is the system boundary that enforces the invariant.
|
|
114
|
+
def resolve_target(target, registry, handle_table)
|
|
115
|
+
case target
|
|
116
|
+
when String
|
|
117
|
+
resolve_path(target, registry)
|
|
118
|
+
when Kobako::Wire::Handle
|
|
119
|
+
resolve_handle(target, handle_table)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resolve_path(path, registry)
|
|
124
|
+
registry.lookup(path)
|
|
125
|
+
rescue KeyError => e
|
|
126
|
+
raise UndefinedTargetError, e.message
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def resolve_handle(handle, handle_table)
|
|
130
|
+
fetch_live_object(handle.id, handle_table)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Resolve +id+ through the HandleTable, distinguishing the
|
|
134
|
+
# +:disconnected+ sentinel (E-14) from an unknown id (E-13).
|
|
135
|
+
def fetch_live_object(id, handle_table)
|
|
136
|
+
object = handle_table.fetch(id)
|
|
137
|
+
raise DisconnectedTargetError, "Handle id #{id} is disconnected" if object == :disconnected
|
|
138
|
+
|
|
139
|
+
object
|
|
140
|
+
rescue Kobako::HandleTableError => e
|
|
141
|
+
raise UndefinedTargetError, e.message
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Encode +value+ as a +Response.ok+ envelope. When the value is not
|
|
145
|
+
# wire-representable per {SPEC.md B-13}[link:../../../SPEC.md]'s type
|
|
146
|
+
# mapping, the +UnsupportedType+ rescue routes it through the
|
|
147
|
+
# HandleTable and re-encodes with the Capability Handle in place
|
|
148
|
+
# ({SPEC.md B-14}[link:../../../SPEC.md]). The happy path encodes
|
|
149
|
+
# exactly once.
|
|
150
|
+
def encode_ok_or_wrap(value, registry)
|
|
151
|
+
encode_ok(value)
|
|
152
|
+
rescue Kobako::Wire::Codec::UnsupportedType
|
|
153
|
+
encode_ok(Kobako::Wire::Handle.new(registry.handle_table.alloc(value)))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def encode_ok(value)
|
|
157
|
+
response = Kobako::Wire::Envelope::Response.ok(value)
|
|
158
|
+
Kobako::Wire::Envelope.encode_response(response)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def encode_err(type, message)
|
|
162
|
+
exception = Kobako::Wire::Exception.new(type: type, message: message)
|
|
163
|
+
response = Kobako::Wire::Envelope::Response.err(exception)
|
|
164
|
+
Kobako::Wire::Envelope.encode_response(response)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../wire/handle"
|
|
4
|
+
|
|
5
|
+
module Kobako
|
|
6
|
+
class Registry
|
|
7
|
+
# Host-side mapping from opaque integer Handle IDs to Ruby objects
|
|
8
|
+
# (capability proxies). One table is owned per Kobako::Registry instance
|
|
9
|
+
# (and therefore per Kobako::Sandbox instance). See
|
|
10
|
+
# {SPEC.md B-15}[link:../../../SPEC.md].
|
|
11
|
+
#
|
|
12
|
+
# Lifecycle invariants ({SPEC.md}[link:../../../SPEC.md]):
|
|
13
|
+
#
|
|
14
|
+
# - {SPEC.md B-15}[link:../../../SPEC.md] — Handle IDs are allocated by
|
|
15
|
+
# a monotonically increasing counter scoped to a single `#run`. The
|
|
16
|
+
# first ID issued in a run is 1; ID 0 is reserved as the invalid
|
|
17
|
+
# sentinel and is never returned by #alloc.
|
|
18
|
+
#
|
|
19
|
+
# - {SPEC.md B-19}[link:../../../SPEC.md] — When between `#run`
|
|
20
|
+
# invocations (via `#reset!`), every Handle issued under the old state
|
|
21
|
+
# becomes invalid.
|
|
22
|
+
#
|
|
23
|
+
# - {SPEC.md B-21}[link:../../../SPEC.md] — The cap is `0x7fff_ffff`
|
|
24
|
+
# (2³¹ − 1). Allocation beyond the cap raises immediately — no silent
|
|
25
|
+
# truncation, no wrap, no ID reuse.
|
|
26
|
+
class HandleTable
|
|
27
|
+
# Build a fresh, empty HandleTable. +next_id+ is an internal seam that
|
|
28
|
+
# sets the starting value of the monotonic counter (defaults to 1 per
|
|
29
|
+
# B-15); tests pass a value near +Wire::Handle::MAX_ID+ to exercise
|
|
30
|
+
# the cap-exhaustion path without 2³¹ allocations.
|
|
31
|
+
def initialize(next_id: 1)
|
|
32
|
+
@entries = {}
|
|
33
|
+
@next_id = next_id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Bind +object+ in the table and return its newly-allocated Handle ID.
|
|
37
|
+
# +object+ is any host-side Ruby object to bind. Returns a freshly-
|
|
38
|
+
# allocated Handle ID in +[1, Wire::Handle::MAX_ID]+. Raises
|
|
39
|
+
# +Kobako::HandleTableExhausted+ if the next ID would exceed the cap.
|
|
40
|
+
# The cap is anchored on +Wire::Handle+ — the wire codec and the
|
|
41
|
+
# allocator share the same invariant ({SPEC.md B-21}[link:../../../SPEC.md]).
|
|
42
|
+
def alloc(object)
|
|
43
|
+
id = @next_id
|
|
44
|
+
cap = Wire::Handle::MAX_ID
|
|
45
|
+
raise HandleTableExhausted, "HandleTable exhausted: id #{id} exceeds MAX_ID #{cap}" if id > cap
|
|
46
|
+
|
|
47
|
+
@entries[id] = object
|
|
48
|
+
@next_id = id + 1
|
|
49
|
+
id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Resolve a Handle ID to its bound object. +id+ is a Handle ID previously
|
|
53
|
+
# returned by +#alloc+. Returns the bound object. Raises
|
|
54
|
+
# +Kobako::HandleTableError+ if +id+ is not currently bound.
|
|
55
|
+
def fetch(id)
|
|
56
|
+
require_bound!(id)
|
|
57
|
+
@entries[id]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Remove and return the binding for +id+. +id+ is the Handle ID to
|
|
61
|
+
# release. Returns the previously-bound object. Raises
|
|
62
|
+
# +Kobako::HandleTableError+ if +id+ is not currently bound.
|
|
63
|
+
def release(id)
|
|
64
|
+
require_bound!(id)
|
|
65
|
+
@entries.delete(id)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Clear all entries AND reset the counter to 1. Called at the per-run
|
|
69
|
+
# boundary — see {SPEC.md B-19}[link:../../../SPEC.md].
|
|
70
|
+
# Returns +self+.
|
|
71
|
+
def reset!
|
|
72
|
+
@entries.clear
|
|
73
|
+
@next_id = 1
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Mark the entry at +id+ as disconnected (ABA protection). +id+ is the
|
|
78
|
+
# Handle ID to poison; silently ignored if +id+ is not currently bound.
|
|
79
|
+
# Returns +self+ for chainability, matching the convention of +#reset!+.
|
|
80
|
+
def mark_disconnected(id)
|
|
81
|
+
@entries[id] = :disconnected if @entries.key?(id)
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns the number of currently-bound entries.
|
|
86
|
+
def size
|
|
87
|
+
@entries.size
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns +true+ when +id+ is currently bound, +false+ otherwise.
|
|
91
|
+
def include?(id)
|
|
92
|
+
@entries.key?(id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Single source of truth for the "unknown Handle id" raise shared by
|
|
98
|
+
# {#fetch} and {#release}. Returns +nil+ on success; raises
|
|
99
|
+
# +Kobako::HandleTableError+ when +id+ is not currently bound.
|
|
100
|
+
def require_bound!(id)
|
|
101
|
+
return if @entries.key?(id)
|
|
102
|
+
|
|
103
|
+
raise HandleTableError, "unknown Handle id: #{id.inspect}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
class Registry
|
|
5
|
+
# A named namespace of Service Members for one Sandbox ({SPEC.md B-07..B-11}[link:../../../SPEC.md]).
|
|
6
|
+
class ServiceGroup
|
|
7
|
+
attr_reader :name, :members
|
|
8
|
+
|
|
9
|
+
# Build a new ServiceGroup. +name+ is an already-validated Group name
|
|
10
|
+
# (must satisfy +NAME_PATTERN+; validation is the caller's responsibility).
|
|
11
|
+
def initialize(name)
|
|
12
|
+
@name = name
|
|
13
|
+
@members = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Bind +object+ under +member+ inside this group. +member+ is a
|
|
17
|
+
# constant-form name as a +Symbol+ or +String+. +object+ is any Ruby
|
|
18
|
+
# object that responds to the methods guest code will invoke. Returns
|
|
19
|
+
# +self+ for chaining. Raises +ArgumentError+ when +member+ does not
|
|
20
|
+
# match the constant pattern, or a member of the same name is already
|
|
21
|
+
# bound ({SPEC.md B-11}[link:../../../SPEC.md]).
|
|
22
|
+
def bind(member, object)
|
|
23
|
+
member_str = validate_member_name!(member)
|
|
24
|
+
raise ArgumentError, "Member #{@name}::#{member_str} is already bound" if @members.key?(member_str)
|
|
25
|
+
|
|
26
|
+
@members[member_str] = object
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Member lookup. Returns the bound object or +nil+ when missing.
|
|
31
|
+
def [](member)
|
|
32
|
+
@members[member.to_s]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Strict variant of {#[]}; raises +KeyError+ when no member is
|
|
36
|
+
# registered under +member+.
|
|
37
|
+
def fetch(member)
|
|
38
|
+
member_str = member.to_s
|
|
39
|
+
unless @members.key?(member_str)
|
|
40
|
+
raise KeyError, "no member named #{member_str.inspect} in group #{@name.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@members[member_str]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Structured description for the guest preamble (Frame 1). Returns a
|
|
47
|
+
# two-element array +[name, member_keys]+ suitable for msgpack encoding.
|
|
48
|
+
def to_preamble
|
|
49
|
+
[@name, @members.keys]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def validate_member_name!(member)
|
|
55
|
+
member_str = member.to_s
|
|
56
|
+
unless Registry::NAME_PATTERN.match?(member_str)
|
|
57
|
+
raise ArgumentError,
|
|
58
|
+
"MemberName must match #{Registry::NAME_PATTERN.inspect} (got #{member.inspect})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
member_str
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "msgpack"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "wire"
|
|
6
|
+
require_relative "registry/service_group"
|
|
7
|
+
require_relative "registry/handle_table"
|
|
8
|
+
require_relative "registry/dispatcher"
|
|
9
|
+
|
|
10
|
+
module Kobako
|
|
11
|
+
# Kobako::Registry — per-Sandbox container of Service Groups and Handle
|
|
12
|
+
# state. Manages capability injection and guest-initiated RPC dispatch
|
|
13
|
+
# ({SPEC.md B-07..B-21}[link:../../SPEC.md]).
|
|
14
|
+
#
|
|
15
|
+
# Public API:
|
|
16
|
+
#
|
|
17
|
+
# registry = Kobako::Registry.new
|
|
18
|
+
# group = registry.define(:MyService) # => ServiceGroup
|
|
19
|
+
# group.bind(:KV, kv_object) # => group (chainable)
|
|
20
|
+
# registry.to_preamble # => array for Frame 1
|
|
21
|
+
# registry.dispatch(request_bytes) # => msgpack bytes (delegated to Dispatcher)
|
|
22
|
+
#
|
|
23
|
+
# Service Groups are defined in +Kobako::Registry::ServiceGroup+
|
|
24
|
+
# (lib/kobako/registry/service_group.rb). The opaque Handle allocator lives
|
|
25
|
+
# in +Kobako::Registry::HandleTable+ (lib/kobako/registry/handle_table.rb).
|
|
26
|
+
# Dispatch helpers live in +Kobako::Registry::Dispatcher+
|
|
27
|
+
# (lib/kobako/registry/dispatcher.rb).
|
|
28
|
+
class Registry
|
|
29
|
+
# Ruby constant-name pattern shared by Group and Member names
|
|
30
|
+
# ({SPEC.md B-07/B-08 Notes}[link:../../SPEC.md]). Referenced by both
|
|
31
|
+
# +#define+ here and +ServiceGroup#bind+ — single source of truth so
|
|
32
|
+
# the validation rule cannot drift between the two boundaries.
|
|
33
|
+
NAME_PATTERN = /\A[A-Z]\w*\z/
|
|
34
|
+
|
|
35
|
+
# Build a fresh Registry. +handle_table+ is an internal seam that
|
|
36
|
+
# injects a pre-configured +HandleTable+; tests pass one whose +next_id+
|
|
37
|
+
# is pinned near +MAX_ID+ to exercise the B-21 cap-exhaustion path
|
|
38
|
+
# without 2³¹ allocations. Production callers leave it at the default.
|
|
39
|
+
def initialize(handle_table: HandleTable.new)
|
|
40
|
+
@groups = {}
|
|
41
|
+
@handle_table = handle_table
|
|
42
|
+
@sealed = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Declare or retrieve the Group named +name+ (idempotent — SPEC.md B-10).
|
|
46
|
+
# +name+ is a constant-form name as a +Symbol+ or +String+ (must satisfy
|
|
47
|
+
# +NAME_PATTERN+). Returns the +Kobako::Registry::ServiceGroup+ for that
|
|
48
|
+
# name, creating it if it does not exist. Raises +ArgumentError+ when
|
|
49
|
+
# +name+ is malformed, or when called after the owning Sandbox has been
|
|
50
|
+
# sealed by +#run+.
|
|
51
|
+
def define(name)
|
|
52
|
+
raise ArgumentError, "cannot define after Sandbox#run has been invoked" if @sealed
|
|
53
|
+
|
|
54
|
+
name_str = name.to_s
|
|
55
|
+
unless NAME_PATTERN.match?(name_str)
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"GroupName must match #{NAME_PATTERN.inspect} (got #{name.inspect})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@groups[name_str] ||= ServiceGroup.new(name_str)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Resolve a +target+ path of the form +"GroupName::MemberName"+ to the
|
|
64
|
+
# bound Host object. +target+ is a two-level path using the +::+
|
|
65
|
+
# separator. Returns the bound Host object. Raises +KeyError+ when the
|
|
66
|
+
# group or the member is not bound.
|
|
67
|
+
def lookup(target)
|
|
68
|
+
group, member_name, group_name = resolve_pair(target)
|
|
69
|
+
raise KeyError, "no service group named #{group_name.inspect}" if group.nil?
|
|
70
|
+
raise KeyError, "no member #{target.inspect} bound in registry" unless member_name
|
|
71
|
+
|
|
72
|
+
group.fetch(member_name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns +true+ when +target+ (a +"GroupName::MemberName"+ path) resolves
|
|
76
|
+
# to a bound member, +false+ otherwise.
|
|
77
|
+
def bound?(target)
|
|
78
|
+
group, member_name, = resolve_pair(target)
|
|
79
|
+
!group.nil? && !member_name.nil? && !group[member_name].nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns all declared +Kobako::Registry::ServiceGroup+ instances as an
|
|
83
|
+
# +Array+.
|
|
84
|
+
def groups
|
|
85
|
+
@groups.values
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns the number of declared groups as an +Integer+.
|
|
89
|
+
def size
|
|
90
|
+
@groups.size
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns +true+ when no groups have been declared, +false+ otherwise.
|
|
94
|
+
def empty?
|
|
95
|
+
@groups.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Structured Frame 1 description. Called by +Sandbox#run+ when assembling
|
|
99
|
+
# stdin Frame 1 ({SPEC.md B-02}[link:../../SPEC.md]). Returns an
|
|
100
|
+
# unencoded preamble array — an +Array+ of two-element +[name, members]+
|
|
101
|
+
# arrays, one per declared group.
|
|
102
|
+
def to_preamble
|
|
103
|
+
@groups.values.map(&:to_preamble)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Encode the preamble as msgpack bytes for stdin Frame 1 delivery
|
|
107
|
+
# ({SPEC.md B-02}[link:../../SPEC.md]). Uses plain MessagePack (no
|
|
108
|
+
# kobako ext types) because the preamble contains only strings — no
|
|
109
|
+
# Handles or Exception envelopes. Structure:
|
|
110
|
+
# +[["GroupName", ["MemberA", "MemberB"]], ...]+. Returns a binary
|
|
111
|
+
# +String+ of msgpack bytes.
|
|
112
|
+
def guest_preamble
|
|
113
|
+
MessagePack.pack(to_preamble)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Mark the Registry as sealed. Called by `Sandbox#run` on first run.
|
|
117
|
+
# After sealing, #define raises ArgumentError. Idempotent.
|
|
118
|
+
def seal!
|
|
119
|
+
@sealed = true
|
|
120
|
+
self
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns +true+ when {#seal!} has been called, +false+ otherwise.
|
|
124
|
+
def sealed?
|
|
125
|
+
@sealed
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Reset the HandleTable for a new +#run+ boundary. Called by +Sandbox#run+
|
|
129
|
+
# before each invocation ({SPEC.md B-19}[link:../../SPEC.md]).
|
|
130
|
+
def reset_handles!
|
|
131
|
+
@handle_table.reset!
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Dispatch a single RPC request and return the encoded response bytes
|
|
135
|
+
# ({SPEC.md B-12}[link:../../SPEC.md]). +request_bytes+ is a
|
|
136
|
+
# msgpack-encoded Request envelope. Called by the Rust ext from inside
|
|
137
|
+
# +__kobako_rpc_call+. Always returns a binary +String+ — never raises.
|
|
138
|
+
# Delegates to +Dispatcher.dispatch+ which reifies any failure as a
|
|
139
|
+
# +Response.err+ envelope so the guest sees the failure as a normal RPC
|
|
140
|
+
# error rather than a wasm trap.
|
|
141
|
+
def dispatch(request_bytes)
|
|
142
|
+
Dispatcher.dispatch(request_bytes, self)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Expose the +Kobako::Registry::HandleTable+ for tests and wire-layer
|
|
146
|
+
# Handle wrapping.
|
|
147
|
+
attr_reader :handle_table
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Split +target+ on the +::+ separator and resolve the group half.
|
|
152
|
+
# Returns +[group_or_nil, member_str_or_nil, group_name_str]+ so each
|
|
153
|
+
# public method ({#lookup} / {#bound?}) only owns its boundary
|
|
154
|
+
# semantics (raise vs predicate).
|
|
155
|
+
def resolve_pair(target)
|
|
156
|
+
group_name, member_name = target.to_s.split("::", 2)
|
|
157
|
+
[@groups[group_name], member_name, group_name]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
class Sandbox
|
|
5
|
+
# Pure-function decoder for the OUTCOME_BUFFER bytes returned by
|
|
6
|
+
# +__kobako_run+. Maps a tagged msgpack envelope to either the unwrapped
|
|
7
|
+
# mruby return value or a raised three-layer
|
|
8
|
+
# ({SPEC.md "Error Scenarios"}[link:../../../SPEC.md]) exception.
|
|
9
|
+
#
|
|
10
|
+
# * tag 0x01, decode OK → return Result.value
|
|
11
|
+
# * tag 0x01, decode fails → SandboxError (E-09)
|
|
12
|
+
# * tag 0x02, origin="service" → ServiceError (E-13)
|
|
13
|
+
# * tag 0x02, origin="sandbox"/missing → SandboxError (E-04..E-07)
|
|
14
|
+
# * tag 0x02, decode fails → SandboxError (E-08)
|
|
15
|
+
# * unknown tag → TrapError (E-03)
|
|
16
|
+
module OutcomeDecoder
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def decode(bytes)
|
|
20
|
+
tag, body = split_outcome_tag(bytes)
|
|
21
|
+
case tag
|
|
22
|
+
when Kobako::Wire::Envelope::OUTCOME_TAG_RESULT
|
|
23
|
+
decode_result(body)
|
|
24
|
+
when Kobako::Wire::Envelope::OUTCOME_TAG_PANIC
|
|
25
|
+
decode_panic(body)
|
|
26
|
+
else
|
|
27
|
+
raise trap_for_tag(tag)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# TrapError for unknown or absent tag
|
|
32
|
+
# ({SPEC.md ABI Signatures}[link:../../../SPEC.md]: len=0 and unknown-tag
|
|
33
|
+
# both walk the trap path).
|
|
34
|
+
def trap_for_tag(tag)
|
|
35
|
+
return TrapError.new("guest exited without writing an outcome (len=0)") if tag.nil?
|
|
36
|
+
|
|
37
|
+
TrapError.new(format("unknown outcome tag 0x%<tag>02x", tag: tag))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def split_outcome_tag(bytes)
|
|
41
|
+
bytes = bytes.b
|
|
42
|
+
[bytes.getbyte(0), bytes.byteslice(1, bytes.bytesize - 1)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Decode failure on a known Result tag is a SandboxError (E-09): the
|
|
46
|
+
# framing was fine, but the wrapped value is unrepresentable.
|
|
47
|
+
def decode_result(body)
|
|
48
|
+
Kobako::Wire::Envelope.decode_result(body).value
|
|
49
|
+
rescue Kobako::Wire::Codec::Error => e
|
|
50
|
+
raise wire_violation_error("result envelope decode failed: #{e.message}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Decode failure on a known Panic tag is a SandboxError (E-08). Either
|
|
54
|
+
# path raises — on success the decoded Panic is mapped to its three-
|
|
55
|
+
# layer exception via +build_panic_error+ and raised; on wire-decode
|
|
56
|
+
# failure the rescue path raises the wire-violation +SandboxError+.
|
|
57
|
+
# Symmetric with +decode_result+ — both have signature
|
|
58
|
+
# "decode body and return value, or raise".
|
|
59
|
+
def decode_panic(body)
|
|
60
|
+
raise build_panic_error(Kobako::Wire::Envelope.decode_panic(body))
|
|
61
|
+
rescue Kobako::Wire::Codec::Error => e
|
|
62
|
+
raise wire_violation_error("panic envelope decode failed: #{e.message}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Map a decoded Panic envelope into the corresponding three-layer
|
|
66
|
+
# Ruby exception. +origin == "service"+ → ServiceError (with the
|
|
67
|
+
# +::Disconnected+ subclass selected when the panic carries the
|
|
68
|
+
# disconnected sentinel —
|
|
69
|
+
# {SPEC "Error Classes"}[link:../../../SPEC.md]); everything else
|
|
70
|
+
# → SandboxError.
|
|
71
|
+
def build_panic_error(panic)
|
|
72
|
+
panic_target_class(panic).new(
|
|
73
|
+
panic.message,
|
|
74
|
+
origin: panic.origin,
|
|
75
|
+
klass: panic.klass,
|
|
76
|
+
backtrace_lines: panic.backtrace,
|
|
77
|
+
details: panic.details
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# {SPEC "Error Classes"}[link:../../../SPEC.md]: when
|
|
82
|
+
# +origin="service"+ and the panic +class+ field names
|
|
83
|
+
# +ServiceError::Disconnected+, surface that subclass so callers can
|
|
84
|
+
# rescue the disconnected path specifically (E-14).
|
|
85
|
+
def panic_target_class(panic)
|
|
86
|
+
return SandboxError unless panic.origin == Kobako::Wire::Envelope::Panic::ORIGIN_SERVICE
|
|
87
|
+
|
|
88
|
+
panic.klass == "Kobako::ServiceError::Disconnected" ? ServiceError::Disconnected : ServiceError
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def wire_violation_error(message)
|
|
92
|
+
SandboxError.new(
|
|
93
|
+
message,
|
|
94
|
+
origin: Kobako::Wire::Envelope::Panic::ORIGIN_SANDBOX,
|
|
95
|
+
klass: "Kobako::WireError"
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|