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.
@@ -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