kobako 0.27.0 → 0.28.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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +71 -0
  4. data/Cargo.lock +117 -140
  5. data/README.md +100 -125
  6. data/ROADMAP.md +23 -22
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +31 -0
  9. data/crates/kobako-runtime/Cargo.toml +2 -2
  10. data/crates/kobako-runtime/README.md +4 -3
  11. data/crates/kobako-runtime/src/error.rs +8 -9
  12. data/crates/kobako-runtime/src/lib.rs +1 -1
  13. data/crates/kobako-runtime/src/profile.rs +1 -1
  14. data/crates/kobako-runtime/src/runtime.rs +31 -17
  15. data/crates/kobako-runtime/src/snapshot.rs +13 -10
  16. data/crates/kobako-runtime/src/yielder.rs +9 -6
  17. data/crates/kobako-transport/CHANGELOG.md +28 -0
  18. data/crates/kobako-transport/Cargo.toml +1 -1
  19. data/crates/kobako-transport/README.md +21 -12
  20. data/crates/kobako-transport/src/abi.rs +11 -14
  21. data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
  22. data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
  23. data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
  24. data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
  25. data/crates/kobako-transport/src/envelope/reply.rs +77 -53
  26. data/crates/kobako-transport/src/envelope/request.rs +295 -0
  27. data/crates/kobako-transport/src/envelope/run.rs +1 -2
  28. data/crates/kobako-transport/src/envelope/tag.rs +49 -0
  29. data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
  30. data/crates/kobako-transport/src/envelope.rs +19 -12
  31. data/crates/kobako-transport/src/lib.rs +0 -2
  32. data/crates/kobako-wasmtime/CHANGELOG.md +44 -0
  33. data/crates/kobako-wasmtime/Cargo.toml +7 -5
  34. data/crates/kobako-wasmtime/README.md +2 -2
  35. data/crates/kobako-wasmtime/src/abi.rs +5 -8
  36. data/crates/kobako-wasmtime/src/ambient.rs +2 -2
  37. data/crates/kobako-wasmtime/src/cache.rs +25 -59
  38. data/crates/kobako-wasmtime/src/capture.rs +4 -11
  39. data/crates/kobako-wasmtime/src/config.rs +11 -14
  40. data/crates/kobako-wasmtime/src/dispatch.rs +70 -80
  41. data/crates/kobako-wasmtime/src/driver.rs +133 -86
  42. data/crates/kobako-wasmtime/src/exports.rs +88 -34
  43. data/crates/kobako-wasmtime/src/guest_mem.rs +145 -138
  44. data/crates/kobako-wasmtime/src/instance_pre.rs +11 -22
  45. data/crates/kobako-wasmtime/src/invocation.rs +55 -372
  46. data/crates/kobako-wasmtime/src/io.rs +286 -0
  47. data/crates/kobako-wasmtime/src/lib.rs +3 -24
  48. data/crates/kobako-wasmtime/src/limiter.rs +253 -0
  49. data/crates/kobako-wasmtime/src/trap.rs +32 -45
  50. data/crates/kobako-wasmtime/tests/preamble_flags.rs +69 -0
  51. data/crates/kobako-wasmtime/tests/snippet_compile.rs +94 -0
  52. data/crates/kobako-wasmtime/tests/support/mod.rs +34 -0
  53. data/crates/kobako-wasmtime/tests/unsupported_call.rs +50 -0
  54. data/data/kobako.wasm +0 -0
  55. data/ext/kobako/Cargo.toml +2 -2
  56. data/ext/kobako/src/runtime/bridge.rs +38 -47
  57. data/ext/kobako/src/runtime/errors.rs +26 -32
  58. data/ext/kobako/src/runtime/gvl.rs +4 -12
  59. data/ext/kobako/src/runtime.rs +169 -212
  60. data/lib/kobako/capture.rb +1 -4
  61. data/lib/kobako/catalog/extensions.rb +21 -59
  62. data/lib/kobako/catalog/flags.rb +41 -0
  63. data/lib/kobako/catalog/handles.rb +7 -54
  64. data/lib/kobako/catalog/services.rb +7 -61
  65. data/lib/kobako/catalog/snippets.rb +42 -66
  66. data/lib/kobako/catalog.rb +2 -10
  67. data/lib/kobako/codec/decoder.rb +9 -28
  68. data/lib/kobako/codec/encoder.rb +3 -22
  69. data/lib/kobako/codec/error.rb +6 -27
  70. data/lib/kobako/codec/ext_types.rb +72 -94
  71. data/lib/kobako/codec/handle_walk.rb +65 -130
  72. data/lib/kobako/codec/nesting.rb +3 -8
  73. data/lib/kobako/codec/state.rb +4 -10
  74. data/lib/kobako/codec/utils.rb +6 -34
  75. data/lib/kobako/codec.rb +3 -22
  76. data/lib/kobako/constant_name.rb +8 -0
  77. data/lib/kobako/context.rb +51 -112
  78. data/lib/kobako/errors.rb +51 -127
  79. data/lib/kobako/execution.rb +16 -21
  80. data/lib/kobako/extension.rb +23 -40
  81. data/lib/kobako/handle.rb +7 -12
  82. data/lib/kobako/outcome.rb +51 -80
  83. data/lib/kobako/payload/arguments.rb +1 -16
  84. data/lib/kobako/payload.rb +4 -12
  85. data/lib/kobako/pool.rb +23 -53
  86. data/lib/kobako/runtime.rb +7 -21
  87. data/lib/kobako/sandbox.rb +96 -133
  88. data/lib/kobako/sandbox_options.rb +38 -84
  89. data/lib/kobako/snippet/binary.rb +1 -16
  90. data/lib/kobako/snippet/source.rb +1 -16
  91. data/lib/kobako/snippet.rb +1 -9
  92. data/lib/kobako/transport/call.rb +1 -14
  93. data/lib/kobako/transport/dispatcher.rb +112 -195
  94. data/lib/kobako/transport/error.rb +6 -20
  95. data/lib/kobako/transport/exposure.rb +5 -10
  96. data/lib/kobako/transport/reflection.rb +39 -50
  97. data/lib/kobako/transport/run.rb +17 -71
  98. data/lib/kobako/transport/yielder.rb +21 -73
  99. data/lib/kobako/transport.rb +3 -15
  100. data/lib/kobako/unresolved.rb +4 -11
  101. data/lib/kobako/usage.rb +2 -6
  102. data/lib/kobako/version.rb +1 -1
  103. data/sig/kobako/catalog/extensions.rbs +2 -2
  104. data/sig/kobako/catalog/flags.rbs +17 -0
  105. data/sig/kobako/catalog/services.rbs +0 -6
  106. data/sig/kobako/catalog/snippets.rbs +4 -4
  107. data/sig/kobako/codec/ext_types.rbs +10 -8
  108. data/sig/kobako/codec/handle_walk.rbs +9 -7
  109. data/sig/kobako/codec/utils.rbs +2 -2
  110. data/sig/kobako/codec.rbs +2 -2
  111. data/sig/kobako/constant_name.rbs +3 -0
  112. data/sig/kobako/context.rbs +2 -3
  113. data/sig/kobako/errors.rbs +6 -0
  114. data/sig/kobako/outcome.rbs +6 -8
  115. data/sig/kobako/runtime.rbs +12 -8
  116. data/sig/kobako/sandbox.rbs +4 -0
  117. data/sig/kobako/sandbox_options.rbs +2 -6
  118. data/sig/kobako/transport/dispatcher.rbs +14 -18
  119. data/sig/kobako/transport/reflection.rbs +5 -3
  120. data/sig/kobako/transport/run.rbs +1 -3
  121. data/sig/kobako/transport/yielder.rbs +3 -3
  122. data/sig/kobako/transport.rbs +7 -6
  123. metadata +16 -4
  124. data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
  125. data/crates/kobako-wasmtime/src/frames.rs +0 -273
  126. data/rust-toolchain.toml +0 -15
@@ -8,39 +8,20 @@ require_relative "reflection"
8
8
  require_relative "yielder"
9
9
 
10
10
  module Kobako
11
- # See lib/kobako/transport.rb for the umbrella module doc; this file
12
- # owns the pure-function dispatcher that answers a routed Call.
13
11
  module Transport
14
- # Pure-function dispatcher for guest-initiated Calls. The native side
15
- # has already decoded the core envelope, so this resolves the target
16
- # through the per-invocation path +resolver+ (the +Context+, whose
17
- # +#lookup+ layers per-invocation providers over the static bindings)
18
- # or Catalog::Handles, decodes only the payload, invokes the method,
19
- # and answers +[ok, bytes]+ — which the native side puts on the
20
- # Reply's ok or fault arm. It never raises.
21
- #
22
- # The module is stateless — all mutable state is threaded through
23
- # arguments so Dispatcher has no instance variables and no side
24
- # effects beyond mutating the Catalog::Handles via +alloc+ when a
25
- # non-wire-representable return value must be wrapped.
26
- module Dispatcher
12
+ # Answers a routed Call with +[true, body, nil]+ or
13
+ # +[false, message, fault_type]+ and never raises, so every failure
14
+ # reaches the guest as a fault.
15
+ module Dispatcher # :nodoc:
27
16
  # Throw tag for the Yielder's break unwind back to the
28
17
  # dispatcher's +catch+ frame. +private_constant+ is a
29
18
  # convention boundary — not a defence.
30
19
  BREAK_THROW = :__kobako_break__
31
20
  private_constant :BREAK_THROW
32
21
 
33
- module_function
22
+ class UndefinedTargetError < StandardError; end # :nodoc:
34
23
 
35
- # Internal sentinel raised when target resolution fails. Becomes a
36
- # Fault with type="undefined". Contained at the wire boundary —
37
- # not part of the public Kobako error taxonomy.
38
- class UndefinedTargetError < StandardError; end
39
-
40
- # The codec fault of a request that never became a call, restated in
41
- # the vocabulary the guest can act on while staying inside the codec
42
- # taxonomy the fault boundary sorts on.
43
- class UnreadableRequestError < Kobako::Codec::Error; end
24
+ class UnreadableRequestError < Kobako::Codec::Error; end # :nodoc:
44
25
 
45
26
  # The category kobako's own refusals answer under, keyed by the class
46
27
  # each is raised as and ordered most specific first. A class absent
@@ -55,30 +36,18 @@ module Kobako
55
36
  UndefinedTargetError => "undefined",
56
37
  ArgumentError => "argument",
57
38
  YieldValueError => "runtime",
39
+ UnsupportedError => "runtime",
58
40
  Kobako::Codec::Error => "internal",
59
41
  Kobako::SandboxError => "runtime"
60
42
  }.freeze
61
43
  private_constant :OWN_FAULTS
62
44
 
63
- # Answer a single routed Call with +[ok, bytes]+, which the native
64
- # side puts on the Reply's ok or fault arm. Invoked from the
65
- # per-invocation dispatch Proc that
66
- # +Kobako::Context+ hands to +Runtime#eval+ / +#run+; +resolver+,
67
- # +handler+, and +yield_to_guest+ are captured in that Proc's
68
- # closure so the Dispatcher stays stateless and neither the resolver
69
- # nor the Context needs to publish accessors for the per-invocation
70
- # +Catalog::Handles+ or +Runtime+. +yield_to_guest+ is a +String → String+ callable
71
- # (the ext's per-dispatch +Kobako::Runtime::GuestYielder+) used only
72
- # when the Call carries +block_given: true+. Never raises — every
73
- # failure path takes the fault arm instead, so the guest sees a
74
- # transport error rather than a wasm trap.
75
- #
76
- # The decode runs inside +Codec.track_handles+ so #resolve_call_args
77
- # can skip the argument walk when no Capability Handle crossed the
78
- # wire.
79
- def dispatch(call, resolver, handler, yield_to_guest)
80
- yielder = Yielder.new(yield_to_guest, BREAK_THROW, handler) if call.block_given
81
- [true, encode_ok(run(call, resolver, handler, yielder), handler), nil] # : [bool, String, String?]
45
+ # The per-invocation state arrives as arguments so the Dispatcher
46
+ # stays stateless and neither the resolver nor the Context publishes
47
+ # accessors for it.
48
+ def self.dispatch(call, resolver, handles, guest_yielder)
49
+ yielder = Yielder.new(guest_yielder, BREAK_THROW, handles) if call.block_given
50
+ [true, encode_ok(run(call, resolver, handles, yielder), handles), nil] # : [bool, String, String?]
82
51
  # StandardError is the boundary by intent: a Service method's
83
52
  # application fault folds into a guest-rescuable fault, while a
84
53
  # host-process failure (NoMemoryError, SignalException, a bare Exception)
@@ -90,172 +59,120 @@ module Kobako
90
59
  yielder&.invalidate!
91
60
  end
92
61
 
93
- # Decode the payload, resolve the receiver, and run the method inside
94
- # the +catch+ frame a guest +break+ unwinds to. Split from #dispatch
95
- # so the reply-shaping and the failure boundary stay one glance wide.
96
- def run(call, resolver, handler, yielder)
97
- arguments, carried_handle = decode_arguments(call.payload)
98
- exposure = resolve_target(call.target, resolver, handler)
99
- args, kwargs = resolve_call_args(arguments, handler, carried_handle)
100
- catch(BREAK_THROW) { invoke(exposure, call.method_name, args, kwargs, yielder) }
101
- end
102
-
103
- # Decode the Call's payload into its arguments, reporting whether any
104
- # Capability Handle crossed. A codec fault here is a request that
105
- # never became a call, restated so it cannot read as an unwritable
106
- # reply — the same restatement #encode_ok makes in the other
107
- # direction.
108
- def decode_arguments(payload)
109
- Kobako::Codec.track_handles { Payload::Arguments.decode(payload) }
110
- rescue Kobako::Codec::Error => e
111
- raise UnreadableRequestError, "Sandbox could not read the request: #{e.message}"
112
- end
113
-
114
- # Resolve positional and keyword arguments off the decoded payload in
115
- # one step. +carried_handle+ reports whether the decode carried any
116
- # Capability Handle; when it did not, every argument resolves to
117
- # itself, so the decoded values pass straight through and the walk is
118
- # skipped entirely. Otherwise both go through #resolve_arg so Handles
119
- # round-trip back to the host-side Ruby object before the call reaches
120
- # +public_send+.
121
- def resolve_call_args(arguments, handler, carried_handle)
122
- return [arguments.args, arguments.kwargs] unless carried_handle
62
+ class << self
63
+ private
123
64
 
124
- [arguments.args.map { |v| resolve_arg(v, handler) },
125
- arguments.kwargs.transform_values { |v| resolve_arg(v, handler) }]
126
- end
65
+ def run(call, resolver, handles, yielder)
66
+ arguments, carried_handle = decode_arguments(call.payload)
67
+ exposure = resolve_target(call.target, resolver, handles)
68
+ args, kwargs = resolve_call_args(arguments, handles, carried_handle)
69
+ catch(BREAK_THROW) { invoke(exposure, call.method_name, args, kwargs, yielder) }
70
+ end
127
71
 
128
- # Map an error caught at the dispatch boundary to the message and the
129
- # category the native side frames into the Reply's fault arm. +error+
130
- # is the +StandardError+ caught by #dispatch's rescue; the category
131
- # tells the guest which kind of failure it was so it can raise the
132
- # matching proxy-side error.
133
- #
134
- # The class prefix marks a Service's own exception and nothing else:
135
- # it is the +<class>: <message>+ shape a Host App is told to keep
136
- # secrets out of, so wearing it says the Service raised. kobako's own
137
- # refusals answer under their own wording instead of borrowing that
138
- # shape.
139
- #
140
- # The guest's own block failing is not the Service's to report at
141
- # all, so the Yielder that raised it is asked first — it recognises
142
- # its own by identity and words the failure the guest's way.
143
- def caught_fault(error, yielder)
144
- block_failure = yielder&.fault_text(error)
145
- return fault("block", block_failure) if block_failure
72
+ # A codec fault here is a request that never became a call, restated
73
+ # so it cannot read as an unwritable reply.
74
+ def decode_arguments(payload)
75
+ Kobako::Codec.track_handles { Payload::Arguments.decode(payload) }
76
+ rescue Kobako::Codec::Error => e
77
+ raise UnreadableRequestError, "Sandbox could not read the request: #{e.message}"
78
+ end
146
79
 
147
- own = OWN_FAULTS.find { |klass, _| error.is_a?(klass) }
148
- return fault(own.last, error.message) if own
80
+ def resolve_call_args(arguments, handles, carried_handle)
81
+ return [arguments.args, arguments.kwargs] unless carried_handle
149
82
 
150
- fault("runtime", "#{error.class}: #{error.message}")
151
- end
83
+ [arguments.args.map { |v| resolve_arg(v, handles) },
84
+ arguments.kwargs.transform_values { |v| resolve_arg(v, handles) }]
85
+ end
152
86
 
153
- # Dispatch +method+ on the object behind +exposure+, once the Exposure
154
- # the guest's reference carries permits it. +kwargs+ is already
155
- # Symbol-keyed (the +Payload::Arguments+ invariant pins it). The
156
- # empty-kwargs branch omits the +**+ splat so Ruby 3.x's strict kwargs
157
- # separation does not reject calls to no-kwarg methods when the wire
158
- # carries the uniform empty-map shape.
159
- #
160
- # +yielder+ is the host-side Yielder materialised when the guest
161
- # call site supplied a block; its Yielder#to_proc
162
- # rides the +&block+ slot. +&nil+ is a no-op block argument in Ruby,
163
- # so the same call site handles both cases without an explicit
164
- # conditional.
165
- def invoke(exposure, method, args, kwargs, yielder = nil)
166
- name = method.to_sym
167
- reject_unreachable!(exposure, name)
168
- target = exposure.object
169
- block = yielder&.to_proc
170
- if kwargs.empty?
171
- target.public_send(name, *args, &block)
172
- else
173
- target.public_send(name, *args, **kwargs, &block)
87
+ # The class prefix marks a Service's own exception and nothing else:
88
+ # it is the +<class>: <message>+ shape a Host App is told to keep
89
+ # secrets out of, so wearing it says the Service raised. kobako's own
90
+ # refusals answer under their own wording instead of borrowing that
91
+ # shape.
92
+ #
93
+ # The guest's own block failing is not the Service's to report at
94
+ # all, so the Yielder that raised it is asked first — it recognises
95
+ # its own by identity and words the failure the guest's way.
96
+ def caught_fault(error, yielder)
97
+ block_failure = yielder&.fault_text(error)
98
+ return fault("block", block_failure) if block_failure
99
+
100
+ own = OWN_FAULTS.find { |klass, _| error.is_a?(klass) }
101
+ return fault(own.last, error.message) if own
102
+
103
+ fault("runtime", "#{error.class}: #{error.message}")
174
104
  end
175
- end
176
105
 
177
- # Guard the +public_send+ below: Reflection decides what counts as
178
- # Service behaviour through this reference, and its refusal reason
179
- # becomes the guest's +undefined+ fault. Both the ambient-surface
180
- # floor and the reference's Exposure answer through it, so a rejected
181
- # name discloses nothing about which of the two refused.
182
- def reject_unreachable!(exposure, name)
183
- reason = Reflection.refusal(exposure, name)
184
- raise UndefinedTargetError, reason if reason
185
- end
106
+ # The wire always carries a keyword map, so an empty one is left
107
+ # unsplatted for methods that take no keywords.
108
+ def invoke(exposure, method, args, kwargs, yielder)
109
+ name = method.to_sym
110
+ reject_unreachable!(exposure, name)
111
+ target = exposure.object
112
+ block = yielder&.to_proc
113
+ if kwargs.empty?
114
+ target.public_send(name, *args, &block)
115
+ else
116
+ target.public_send(name, *args, **kwargs, &block)
117
+ end
118
+ end
186
119
 
187
- # Resolve every Kobako::Handle in an argument — bare or nested in an
188
- # Array / Hash — back to its host object before the dispatch reaches
189
- # +public_send+, symmetric with the guest→host return path. A Handle id
190
- # with no live entry surfaces as an unrecognized target.
191
- def resolve_arg(value, handler)
192
- Kobako::Codec::HandleWalk.deep_restore(value, handler)
193
- rescue Kobako::SandboxError => e
194
- raise UndefinedTargetError, e.message
195
- end
120
+ # Both the ambient-surface floor and the reference's Exposure answer
121
+ # through Reflection, so a rejected name discloses nothing about
122
+ # which of the two refused.
123
+ def reject_unreachable!(exposure, name)
124
+ reason = Reflection.refusal(exposure, name)
125
+ raise UndefinedTargetError, reason if reason
126
+ end
196
127
 
197
- # Resolve a Call target to the Exposure the path +resolver+ (or
198
- # Catalog::Handles) holds for it. The native side already
199
- # discriminated the two forms off the core envelope's +kind+ tag: a
200
- # String is a bound constant's path, an Integer is a Capability Handle
201
- # id. No else-branch is needed — the envelope layer is the system
202
- # boundary that enforces the invariant.
203
- def resolve_target(target, resolver, handler)
204
- case target
205
- when String
206
- resolve_path(target, resolver)
207
- when Integer
208
- resolve_handle(target, handler)
128
+ def resolve_arg(value, handles)
129
+ Kobako::Codec::HandleWalk.deep_restore(value, handles)
130
+ rescue Kobako::SandboxError => e
131
+ raise UndefinedTargetError, e.message
209
132
  end
210
- end
211
133
 
212
- def resolve_path(path, resolver)
213
- resolver.lookup(path)
214
- rescue KeyError => e
215
- raise UndefinedTargetError, e.message
216
- end
134
+ # The envelope already discriminated the two target forms, so no
135
+ # else-branch is needed.
136
+ def resolve_target(target, resolver, handles)
137
+ case target
138
+ when String
139
+ resolve_path(target, resolver)
140
+ when Integer
141
+ resolve_handle(target, handles)
142
+ end
143
+ end
217
144
 
218
- # Resolve +id+ to the Exposure its Handle was minted with. An unknown
219
- # id surfaces as UndefinedTargetError.
220
- def resolve_handle(id, handler)
221
- handler.exposure(id)
222
- rescue Kobako::SandboxError => e
223
- raise UndefinedTargetError, e.message
224
- end
145
+ def resolve_path(path, resolver)
146
+ resolver.lookup(path)
147
+ rescue KeyError => e
148
+ raise UndefinedTargetError, e.message
149
+ end
225
150
 
226
- # Encode +value+ as the body of a Reply's ok arm — the value alone,
227
- # since the envelope's tag already carries the success. A value that
228
- # is not wire-representable per the codec's type mapping raises
229
- # +UnsupportedTypeError+; the rescue routes it through the
230
- # Catalog::Handles via #wrap_as_handle and re-encodes with the
231
- # Capability Handle in place. The happy path encodes exactly once.
232
- #
233
- # Any other codec fault is the answer failing to encode rather than the
234
- # request failing to decode, and a Service is the only side that can
235
- # change what it returns — so it is named here, where the direction is
236
- # known, instead of falling to the boundary's codec floor and reporting
237
- # as an exchange that produced no Service outcome.
238
- def encode_ok(value, handler)
239
- Kobako::Codec::Nesting.assert_within_bound!(value)
240
- Kobako::Codec::Encoder.encode(value)
241
- rescue Kobako::Codec::UnsupportedTypeError
242
- encode_ok(wrap_as_handle(value, handler), handler)
243
- rescue Kobako::Codec::Error => e
244
- raise Kobako::SandboxError, "Sandbox could not write the Service's answer: #{e.message}"
245
- end
151
+ def resolve_handle(id, handles)
152
+ handles.exposure(id)
153
+ rescue Kobako::SandboxError => e
154
+ raise UndefinedTargetError, e.message
155
+ end
246
156
 
247
- # Allocate +value+ in the Sandbox's Catalog::Handles and return a +Handle+
248
- # that the wire codec can carry. Used as the fallback path of
249
- # #encode_ok when +value+ has no wire representation.
250
- def wrap_as_handle(value, handler)
251
- handler.alloc(value)
252
- end
157
+ # A value with no wire form goes back as a Capability Handle. Any
158
+ # other codec fault is the answer failing to encode, which only the
159
+ # Service can change — so it is named here, where the direction is
160
+ # known, instead of falling to the boundary's codec floor.
161
+ def encode_ok(value, handles)
162
+ Kobako::Codec::Nesting.assert_within_bound!(value)
163
+ Kobako::Codec::Encoder.encode(value)
164
+ rescue Kobako::Codec::UnsupportedTypeError
165
+ encode_ok(handles.alloc(value), handles)
166
+ rescue Kobako::Codec::Error => e
167
+ raise Kobako::SandboxError, "Sandbox could not write the Service's answer: #{e.message}"
168
+ end
253
169
 
254
- # +message+ folds to UTF-8 first: Ruby core builds some exception
255
- # messages as ASCII-8BIT (the arity ArgumentError, for one), and
256
- # the envelope requires UTF-8 of the text fields it frames.
257
- def fault(type, message)
258
- [message.encode(Encoding::UTF_8, invalid: :replace, undef: :replace), type] # : [String, String]
170
+ # Ruby core builds some exception messages as ASCII-8BIT (the arity
171
+ # ArgumentError, for one), and the envelope requires UTF-8 of the
172
+ # text fields it frames.
173
+ def fault(type, message)
174
+ [message.encode(Encoding::UTF_8, invalid: :replace, undef: :replace), type] # : [String, String]
175
+ end
259
176
  end
260
177
  end
261
178
  end
@@ -4,31 +4,17 @@ require_relative "../errors"
4
4
 
5
5
  module Kobako
6
6
  module Transport
7
- # +Kobako::SandboxError+ subclass raised when the host detects a
8
- # structural violation of the wire contract while reading what the
9
- # guest produced — an invocation value the payload codec cannot
10
- # decode. Distinct from a Wasm trap (engine signalled the guest
11
- # runtime is unrecoverable) and from a normal sandbox-layer failure
12
- # (the script raised but the protocol was respected): a
13
- # +Transport::Error+ always indicates the guest runtime is corrupted —
14
- # the only safe recovery is to discard the Sandbox and start a new
15
- # invocation.
16
- #
17
- # Inherits from +Kobako::SandboxError+ so a single
18
- # +rescue Kobako::SandboxError+ still catches it; callers that want
19
- # to distinguish wire-violation paths from script failures can
20
- # +rescue Kobako::Transport::Error+ directly.
7
+ # The guest broke the exchange with the host: it produced a value that
8
+ # could not be decoded, or a dispatch it made could not complete. Being a
9
+ # SandboxError, it is caught by rescuing that too.
21
10
  class Error < Kobako::SandboxError
22
- def initialize(message, diagnostic: nil, **)
11
+ def initialize(message, diagnostic: nil, **) # :nodoc:
23
12
  super(message, **)
24
13
  @diagnostic = diagnostic
25
14
  end
26
15
 
27
- # The codec fault behind this violation, appended to Ruby's own
28
- # rendering. A caller cannot act on the inner "Symbol payload must
29
- # be …" wording, so it stays out of #message; an operator
30
- # triaging a corrupted runtime still needs it, and
31
- # +detailed_message+ is where Ruby puts text of exactly that kind.
16
+ # Appends the codec's own reason, which an operator needs and a caller
17
+ # cannot act on, so #message leaves it out.
32
18
  def detailed_message(...)
33
19
  rendered = super
34
20
  return rendered if @diagnostic.nil?
@@ -6,16 +6,11 @@ require_relative "reflection"
6
6
 
7
7
  module Kobako
8
8
  module Transport
9
- # The methods a host object lets the guest call through one reference
10
- # to it — a bound path or a Capability Handle — fixed when the reference
11
- # is made. An object defining its own private +respond_to_guest?(name)+
12
- # is asked on every dispatch; any other exposes only what its own class
13
- # and the object itself define in source, since the methods a Host App
14
- # cannot foresee handing over are the ones it never wrote.
15
- #
16
- # Built on the +class X < Data.define(...)+ subclass form so the class
17
- # body is fully Steep-visible; see +.rubocop.yml+ for the rationale.
18
- class Exposure < Data.define(:object, :names)
9
+ # The methods one reference lets the guest call, fixed when the reference
10
+ # is made. Only what the object's own class and the object define in
11
+ # source is exposed, since a Host App cannot foresee handing over what it
12
+ # never wrote.
13
+ class Exposure < Data.define(:object, :names) # :nodoc:
19
14
  PREDICATE = :respond_to_guest?
20
15
  private_constant :PREDICATE
21
16
 
@@ -2,15 +2,7 @@
2
2
 
3
3
  module Kobako
4
4
  module Transport
5
- # The reflection floor a guest→host dispatch must clear before the
6
- # Dispatcher reaches +public_send+: which method names on a resolved
7
- # target count as Service behaviour, and which are Ruby's ambient
8
- # metaprogramming surface.
9
- #
10
- # Answers with a refusal reason rather than raising, so the error
11
- # taxonomy stays with the Dispatcher and this module holds only the
12
- # policy.
13
- module Reflection
5
+ module Reflection # :nodoc:
14
6
  # Modules whose instance methods are ambient Ruby reflection /
15
7
  # metaprogramming surface (+send+, +public_send+, +instance_eval+,
16
8
  # +method+, +tap+, +instance_variable_get+, ...) rather than Service
@@ -36,59 +28,56 @@ module Kobako
36
28
  # callable exposes by default, so the floor and the Exposure agree.
37
29
  CALLABLE_ALLOW = %i[call [] yield arity lambda?].freeze
38
30
 
39
- module_function
40
-
41
31
  # The reason +name+ is unreachable through +exposure+, or +nil+ when
42
32
  # the dispatch may proceed. Composes the ambient-surface floor with the
43
33
  # reference's Exposure, in that order: the Exposure only narrows and
44
34
  # can never re-open what the floor rejects.
45
- def refusal(exposure, name)
35
+ def self.refusal(exposure, name)
46
36
  ambient_refusal(exposure.object, name) ||
47
37
  (exposure.exposes?(name) ? nil : "method #{name.inspect} is not exposed to the guest")
48
38
  end
49
39
 
50
- # Guard against ambient reflection methods. A public method whose
51
- # owner is a META_OWNERS or GADGET_OWNERS module — or a singleton
52
- # class, the owner of every class-level method (+File.popen+ /
53
- # +Kernel.system+, unreachable via any fixed core-module list) — is
54
- # rejected, except CALLABLE_ALLOW on a gadget target (a bound lambda
55
- # stays invocable). A name with no concrete public method is allowed
56
- # only when the target opts into it via +respond_to?+ (dynamic
57
- # +method_missing+ Services), since the dangerous methods are all
58
- # concretely defined and therefore never reach that branch.
59
- #
60
- # +method_missing+ named explicitly is refused whatever its owner:
61
- # it is Ruby's dynamic-dispatch hook, and a public override (a
62
- # +Delegator+) binds and calls the private method the guest passes as
63
- # its first argument (+Kernel#system+). A dynamic +method_missing+
64
- # Service is untouched — the guest reaches it by the virtual name,
65
- # which resolves through the +NameError+ branch below, never by
66
- # naming +method_missing+.
67
- def ambient_refusal(target, name)
68
- return "method #{name.inspect} is not a Service method" if name == :method_missing
40
+ class << self
41
+ private
69
42
 
70
- owner = target.public_method(name).owner
71
- return nil unless ambient_owner?(owner, target)
72
- return nil if GADGET_OWNERS.include?(owner) && CALLABLE_ALLOW.include?(name)
43
+ # Guard against ambient reflection methods. A public method whose
44
+ # owner is a META_OWNERS or GADGET_OWNERS module — or a singleton
45
+ # class, the owner of every class-level method (+File.popen+ /
46
+ # +Kernel.system+, unreachable via any fixed core-module list) — is
47
+ # rejected, except CALLABLE_ALLOW on a gadget target (a bound lambda
48
+ # stays invocable). A name with no concrete public method is allowed
49
+ # only when the target opts into it via +respond_to?+ (dynamic
50
+ # +method_missing+ Services), since the dangerous methods are all
51
+ # concretely defined and therefore never reach that branch.
52
+ #
53
+ # +method_missing+ named explicitly is refused whatever its owner:
54
+ # it is Ruby's dynamic-dispatch hook, and a public override (a
55
+ # +Delegator+) binds and calls the private method the guest passes as
56
+ # its first argument (+Kernel#system+). A dynamic +method_missing+
57
+ # Service is untouched — the guest reaches it by the virtual name,
58
+ # which resolves through the +NameError+ branch below, never by
59
+ # naming +method_missing+.
60
+ def ambient_refusal(target, name)
61
+ return "method #{name.inspect} is not a Service method" if name == :method_missing
73
62
 
74
- "method #{name.inspect} is not a Service method"
75
- rescue NameError
76
- return nil if target.respond_to?(name)
63
+ owner = target.public_method(name).owner
64
+ return nil unless ambient_owner?(owner, target)
65
+ return nil if GADGET_OWNERS.include?(owner) && CALLABLE_ALLOW.include?(name)
77
66
 
78
- "no public method #{name.inspect} on target"
79
- end
67
+ "method #{name.inspect} is not a Service method"
68
+ rescue NameError
69
+ return nil if target.respond_to?(name)
70
+
71
+ "no public method #{name.inspect} on target"
72
+ end
80
73
 
81
- # Whether +name+'s +owner+ is ambient surface rather than Service
82
- # behaviour: a core meta module, a callable gadget type, or — when
83
- # +target+ is itself a Class or Module bound as a Service — the
84
- # singleton class that owns its class-level API (+File.popen+ /
85
- # +Kernel.system+, unreachable via any fixed core-module list). A plain
86
- # object's own singleton method (+def obj.x+) stays reachable, since
87
- # +target+ is not a Module there.
88
- def ambient_owner?(owner, target)
89
- META_OWNERS.include?(owner) ||
90
- GADGET_OWNERS.include?(owner) ||
91
- (target.is_a?(Module) && owner.singleton_class?)
74
+ # A plain object's own singleton method (+def obj.x+) stays reachable,
75
+ # since only a Module target counts its singleton class as ambient.
76
+ def ambient_owner?(owner, target)
77
+ META_OWNERS.include?(owner) ||
78
+ GADGET_OWNERS.include?(owner) ||
79
+ (target.is_a?(Module) && owner.singleton_class?)
80
+ end
92
81
  end
93
82
  end
94
83
  end