kobako 0.23.0 → 0.26.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +89 -0
  4. data/Cargo.lock +156 -110
  5. data/README.md +19 -18
  6. data/ROADMAP.md +12 -12
  7. data/crates/kobako-runtime/CHANGELOG.md +14 -0
  8. data/crates/kobako-runtime/Cargo.toml +2 -2
  9. data/crates/kobako-runtime/README.md +1 -1
  10. data/crates/kobako-runtime/src/profile.rs +2 -2
  11. data/crates/kobako-transport/CHANGELOG.md +14 -0
  12. data/crates/kobako-transport/Cargo.toml +1 -1
  13. data/crates/kobako-transport/README.md +1 -1
  14. data/crates/kobako-transport/src/abi.rs +2 -0
  15. data/crates/kobako-transport/src/envelope/bytes.rs +6 -0
  16. data/crates/kobako-transport/src/envelope/call.rs +9 -0
  17. data/crates/kobako-transport/src/envelope/error_record.rs +3 -0
  18. data/crates/kobako-transport/src/envelope/fault.rs +5 -0
  19. data/crates/kobako-transport/src/envelope/invocation_frames.rs +9 -0
  20. data/crates/kobako-transport/src/envelope/outcome.rs +11 -0
  21. data/crates/kobako-transport/src/envelope/reply.rs +9 -0
  22. data/crates/kobako-transport/src/envelope/run.rs +3 -0
  23. data/crates/kobako-wasmtime/CHANGELOG.md +16 -0
  24. data/crates/kobako-wasmtime/Cargo.toml +5 -5
  25. data/crates/kobako-wasmtime/README.md +1 -1
  26. data/crates/kobako-wasmtime/src/ambient.rs +2 -0
  27. data/crates/kobako-wasmtime/src/capture.rs +8 -0
  28. data/crates/kobako-wasmtime/src/frames.rs +3 -1
  29. data/crates/kobako-wasmtime/src/guest_mem.rs +8 -0
  30. data/crates/kobako-wasmtime/src/invocation.rs +10 -0
  31. data/crates/kobako-wasmtime/src/trap.rs +6 -0
  32. data/data/kobako.wasm +0 -0
  33. data/ext/kobako/Cargo.toml +1 -1
  34. data/lib/kobako/capture.rb +8 -22
  35. data/lib/kobako/catalog/handles.rb +33 -9
  36. data/lib/kobako/catalog/services.rb +9 -7
  37. data/lib/kobako/codec/encoder.rb +4 -13
  38. data/lib/kobako/codec/handle_walk.rb +1 -1
  39. data/lib/kobako/codec/nesting.rb +43 -0
  40. data/lib/kobako/codec.rb +4 -2
  41. data/lib/kobako/context.rb +8 -8
  42. data/lib/kobako/handle.rb +10 -40
  43. data/lib/kobako/sandbox.rb +5 -0
  44. data/lib/kobako/transport/dispatcher.rb +29 -26
  45. data/lib/kobako/transport/exposure.rb +100 -0
  46. data/lib/kobako/transport/reflection.rb +19 -20
  47. data/lib/kobako/transport/run.rb +5 -2
  48. data/lib/kobako/transport/yielder.rb +6 -4
  49. data/lib/kobako/transport.rb +4 -2
  50. data/lib/kobako/usage.rb +11 -22
  51. data/lib/kobako/version.rb +1 -1
  52. data/release-please-config.json +1 -1
  53. data/sig/kobako/catalog/handles.rbs +2 -0
  54. data/sig/kobako/catalog/services.rbs +1 -1
  55. data/sig/kobako/codec/nesting.rbs +9 -0
  56. data/sig/kobako/context.rbs +1 -1
  57. data/sig/kobako/transport/dispatcher.rbs +7 -7
  58. data/sig/kobako/transport/exposure.rbs +43 -0
  59. data/sig/kobako/transport/reflection.rbs +1 -3
  60. data/sig/kobako/transport.rbs +11 -1
  61. metadata +5 -1
@@ -31,23 +31,14 @@ module Kobako
31
31
  # same error: an integer outside i64..u64 (+RangeError+) and any
32
32
  # packer-internal +NoMethodError+.
33
33
  #
34
- # A value that nests without bound — a reference cycle necessarily
35
- # does — exhausts the packer's own recursion instead, which Ruby
36
- # reports outside +StandardError+. Mapping it keeps an unwritable
37
- # value a wire violation the dispatch boundary can answer, rather than
38
- # one that escapes every caller's rescue and traps the invocation.
39
- #
40
- # The refusal is spent once per thread: a thread that has absorbed one
41
- # such overflow aborts on the next instead of raising, and a Hash cycle
42
- # never reaches Ruby at all — the packer walks a Hash through C frames
43
- # that carry no stack guard. Bounding the walk before the packer is
44
- # handed the value is what would make the refusal repeatable.
34
+ # The caller bounds +value+'s nesting first (Nesting): the packer takes
35
+ # no depth limit and walks a list or map in frames that carry no stack
36
+ # guard, so a value nesting without end — a reference cycle necessarily
37
+ # does — exhausts the machine stack instead of raising.
45
38
  def self.encode(value)
46
39
  FACTORY.dump(value)
47
40
  rescue ::RangeError, ::NoMethodError => e
48
41
  raise UnsupportedTypeError, e.message
49
- rescue ::SystemStackError
50
- raise InvalidTypeError, "value nests deeper than this host can write (a reference cycle necessarily does)"
51
42
  end
52
43
  end
53
44
  end
@@ -95,7 +95,7 @@ module Kobako
95
95
  return unless depth > MAX_NESTING_DEPTH
96
96
 
97
97
  raise Kobako::SandboxError,
98
- "a #run argument nests deeper than #{MAX_NESTING_DEPTH} levels and " \
98
+ "a #run argument nests deeper than #{MAX_NESTING_DEPTH} levels within the Run payload and " \
99
99
  "cannot cross the sandbox boundary (possible reference cycle)"
100
100
  end
101
101
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Kobako
6
+ module Codec
7
+ # The structural nesting bound
8
+ # ({docs/wire/payload-msgpack.md}[link:../../../docs/wire/payload-msgpack.md]
9
+ # § Structural Nesting Depth), checked before a value reaches the
10
+ # packer. The packer takes no depth limit and walks a list or map in
11
+ # frames that carry no stack guard, so a value it cannot finish — a
12
+ # reference cycle necessarily is one — must be refused before it is
13
+ # handed over.
14
+ module Nesting
15
+ # Raise InvalidTypeError when +value+ nests past MAX_NESTING_DEPTH; a
16
+ # value nested exactly to the bound passes.
17
+ def self.assert_within_bound!(value, depth = 0)
18
+ case value
19
+ when ::Array then assert_members_within_bound!(value, depth)
20
+ when ::Hash
21
+ assert_members_within_bound!(value.keys, depth)
22
+ assert_members_within_bound!(value.values, depth)
23
+ end
24
+ end
25
+
26
+ # Measure the members of one container at +depth+, each sitting one
27
+ # level deeper. A member is matched by class rather than asked about
28
+ # itself — it may be a BasicObject — and only one that could be a
29
+ # container is walked into.
30
+ def self.assert_members_within_bound!(members, depth)
31
+ return if members.empty?
32
+ if depth >= MAX_NESTING_DEPTH
33
+ raise InvalidTypeError,
34
+ "value nests deeper than #{MAX_NESTING_DEPTH} levels (a reference cycle necessarily does)"
35
+ end
36
+ return unless members.any?(::Enumerable)
37
+
38
+ members.grep(::Enumerable) { |member| assert_within_bound!(member, depth + 1) }
39
+ end
40
+ private_class_method :assert_members_within_bound!
41
+ end
42
+ end
43
+ end
data/lib/kobako/codec.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "codec/error"
4
4
  require_relative "codec/utils"
5
5
  require_relative "codec/handle_walk"
6
+ require_relative "codec/nesting"
6
7
  require_relative "codec/state"
7
8
  require_relative "codec/ext_types"
8
9
  require_relative "codec/encoder"
@@ -29,8 +30,9 @@ module Kobako
29
30
  module Codec
30
31
  # The maximum structural nesting depth the wire represents (the
31
32
  # MessagePack ecosystem's bound), shared with the guest +kobako_codec+
32
- # so both sides cap identically. The host→guest wrap walk refuses a
33
- # +#run+ argument nesting past it — a reference cycle necessarily does.
33
+ # so both sides cap identically. The host refuses a value nesting past
34
+ # it — a reference cycle necessarily does — before handing it to the
35
+ # packer.
34
36
  MAX_NESTING_DEPTH = 128
35
37
 
36
38
  # Bracket a decode and return the block's result together with whether
@@ -30,8 +30,8 @@ module Kobako
30
30
  @services = services
31
31
  @snippets = snippets
32
32
  @extensions = extensions
33
- @resolved = {} # : Hash[String, untyped]
34
- @overrides = {} # : Hash[String, untyped]
33
+ @resolved = {} # : Hash[String, Transport::Exposure]
34
+ @overrides = {} # : Hash[String, Transport::Exposure]
35
35
  @spent = false
36
36
  @handler = Catalog::Handles.new
37
37
  @stdout_capture = @stderr_capture = Capture::EMPTY
@@ -52,11 +52,11 @@ module Kobako
52
52
  key = path.to_s
53
53
  raise ArgumentError, "cannot override undeclared path #{key.inspect}" unless @services.bound?(key)
54
54
 
55
- @overrides[key] = object
55
+ @overrides[key] = Transport::Exposure.of(object)
56
56
  self
57
57
  end
58
58
 
59
- # Resolve a Service +path+ to the object backing it this invocation,
59
+ # Resolve a Service +path+ to the Exposure backing it this invocation,
60
60
  # layering the per-eval +ctx.bind+ overrides over this Context's
61
61
  # per-invocation provider results over the Sandbox's static base bindings.
62
62
  # An unbound path raises +KeyError+; a fillable left unfilled resolves to
@@ -67,10 +67,10 @@ module Kobako
67
67
  # handler is the sole caller.
68
68
  def lookup(path)
69
69
  key = path.to_s
70
- object = @overrides.fetch(key) { @resolved.fetch(key) { @services.lookup(path) } }
71
- raise KeyError, "service #{path} is declared but unresolved this invocation" if Unresolved.equal?(object)
70
+ exposure = @overrides.fetch(key) { @resolved.fetch(key) { @services.lookup(path) } }
71
+ raise KeyError, "service #{path} is declared but unresolved this invocation" if Unresolved.equal?(exposure.object)
72
72
 
73
- object
73
+ exposure
74
74
  end
75
75
 
76
76
  # Execute a guest mruby source string in a fresh +mrb_state+ and return the
@@ -193,7 +193,7 @@ module Kobako
193
193
  # return value. A could-not-start fault ran no invocation at all, so it
194
194
  # carries no Execution and gains only the verb prefix.
195
195
  def invoke!(verb, entrypoint: nil)
196
- @resolved = @extensions.resolve
196
+ @resolved = @extensions.resolve.transform_values { |object| Transport::Exposure.of(object) }
197
197
  begin
198
198
  snapshot = yield
199
199
  rescue Kobako::TrapError => e
data/lib/kobako/handle.rb CHANGED
@@ -1,38 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- # Wire-level value object for an ext-0x01 Capability Handle, used in both
5
- # directions across the Sandbox boundary: as a Service method's return
6
- # value (guest→host return path) and as a +#run+ argument auto-wrapped
7
- # by the host.
8
- #
9
- # SPEC pins the binary layout to fixext 4 with a 4-byte big-endian u32
10
- # payload ({docs/wire-codec.md}[link:../../docs/wire-codec.md]
11
- # § Ext Types → ext 0x01). ID 0 is reserved as the invalid sentinel;
12
- # the maximum valid ID is 0x7fff_ffff (2^31 - 1).
13
- #
14
- # The constructor is internal to the Host Gem. +Kobako::Handle.new+ is
15
- # privatised so Host App code cannot fabricate a Handle from a bare
16
- # integer; legitimate Handle instances enter Host App code only as
17
- # fields on raised error objects. +#with+ — Data's copy-with-changes
18
- # constructor — is removed for the same reason: a legitimate Handle
19
- # must not derive a sibling with a caller-chosen id. The Host Gem itself constructs
20
- # Handles through +.restore+, which exists at exactly two call
21
- # sites: +Kobako::Codec::ExtTypes#unpack_handle+ (wire decode) and
22
- # +Kobako::Codec::HandleWalk.deep_wrap+ / +Kobako::Transport::Dispatcher#wrap_as_handle+
23
- # (allocator paths). Both live inside +lib/kobako/+ and are not part
24
- # of any public surface.
25
- #
26
- # The mruby counterpart +Kobako::Handle+ lives inside the Wasm guest
27
- # under the same canonical name and shares neither code nor instances
28
- # with this host-side class.
4
+ # A Capability Handle as it crosses the wire (ext 0x01, laid out in
5
+ # {docs/wire-codec.md}[link:../../docs/wire-codec.md]), in either
6
+ # direction. Host App code cannot construct one: +.new+ is private and
7
+ # +#with+ removed, so no Handle is ever made from a caller-chosen id.
8
+ # The guest's +Kobako::Handle+ shares only the name.
29
9
  class Handle < Data.define(:id)
30
- # Inclusive lower bound on the wire Handle ID. ID 0 is reserved as
31
- # the invalid sentinel and is never allocated.
10
+ # ID 0 is the invalid sentinel and is never allocated.
32
11
  MIN_ID = 1
33
- # Inclusive upper bound on the wire Handle ID. The cap matches the
34
- # u32 signed-positive range so Handle IDs fit in a signed integer
35
- # on either side of the wire without re-encoding.
12
+ # The positive half of a signed 32-bit integer, so an id fits either
13
+ # side of the wire unchanged.
36
14
  MAX_ID = 0x7fff_ffff
37
15
 
38
16
  def initialize(id:)
@@ -45,16 +23,8 @@ module Kobako
45
23
  private_class_method :new
46
24
  undef_method :with
47
25
 
48
- # Host Gem–internal factory. Allocates the Data instance through
49
- # +Class#allocate+ and dispatches +#initialize+ explicitly so the
50
- # invariant checks still run, while keeping the public +.new+
51
- # privatised against Host App callers.
52
- #
53
- # Two collaborators call this: the codec when an ext 0x01 frame is
54
- # decoded off the wire, and the allocator paths when a host-side
55
- # Ruby object is registered into the Sandbox's +Catalog::Handles+. Both
56
- # paths live inside +lib/kobako/+ and treat this method as a
57
- # package-private constructor.
26
+ # The Host Gem's own constructor, for the wire decoder and the Handle
27
+ # allocator; it runs the same id checks +.new+ would.
58
28
  def self.restore(id)
59
29
  allocate.tap { |handle| handle.send(:initialize, id: id) }
60
30
  end
@@ -66,6 +66,11 @@ module Kobako
66
66
  # (+"MyService::KV"+ or a top-level +"File"+). Returns +self+ for
67
67
  # chaining.
68
68
  #
69
+ # The guest reaches the public methods +object+'s own class and +object+
70
+ # itself define in source, fixed at this call — nothing inherited, mixed
71
+ # in, or built into the platform. An +object+ defining a private
72
+ # +respond_to_guest?(name)+ decides instead, asked on every call.
73
+ #
69
74
  # Called with only a +path+, it declares a fillable Service:
70
75
  # +bind(path)+ reserves the path for +Kobako::Unresolved+, so the guest
71
76
  # sees the constant while the host defers the object it stands for. A
@@ -95,9 +95,9 @@ module Kobako
95
95
  # so the reply-shaping and the failure boundary stay one glance wide.
96
96
  def run(call, resolver, handler, yielder)
97
97
  arguments, carried_handle = decode_arguments(call.payload)
98
- receiver = resolve_target(call.target, resolver, handler)
98
+ exposure = resolve_target(call.target, resolver, handler)
99
99
  args, kwargs = resolve_call_args(arguments, handler, carried_handle)
100
- catch(BREAK_THROW) { invoke(receiver, call.method_name, args, kwargs, yielder) }
100
+ catch(BREAK_THROW) { invoke(exposure, call.method_name, args, kwargs, yielder) }
101
101
  end
102
102
 
103
103
  # Decode the Call's payload into its arguments, reporting whether any
@@ -150,20 +150,22 @@ module Kobako
150
150
  fault("runtime", "#{error.class}: #{error.message}")
151
151
  end
152
152
 
153
- # Dispatch +method+ on +target+. +kwargs+ is already Symbol-keyed
154
- # (the +Payload::Arguments+ invariant pins it). The empty-kwargs branch omits
155
- # the +**+ splat so Ruby 3.x's strict kwargs separation does not
156
- # reject calls to no-kwarg methods when the wire carries the
157
- # uniform empty-map shape.
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.
158
159
  #
159
160
  # +yielder+ is the host-side Yielder materialised when the guest
160
161
  # call site supplied a block; its Yielder#to_proc
161
162
  # rides the +&block+ slot. +&nil+ is a no-op block argument in Ruby,
162
163
  # so the same call site handles both cases without an explicit
163
164
  # conditional.
164
- def invoke(target, method, args, kwargs, yielder = nil)
165
+ def invoke(exposure, method, args, kwargs, yielder = nil)
165
166
  name = method.to_sym
166
- reject_unreachable!(target, name)
167
+ reject_unreachable!(exposure, name)
168
+ target = exposure.object
167
169
  block = yielder&.to_proc
168
170
  if kwargs.empty?
169
171
  target.public_send(name, *args, &block)
@@ -173,12 +175,12 @@ module Kobako
173
175
  end
174
176
 
175
177
  # Guard the +public_send+ below: Reflection decides what counts as
176
- # Service behaviour on this target, and its refusal reason becomes
177
- # the guest's +undefined+ fault. Both the ambient-surface floor and
178
- # the target's own narrowing predicate answer through it, so a
179
- # rejected name discloses nothing about which of the two refused.
180
- def reject_unreachable!(target, name)
181
- reason = Reflection.refusal(target, name)
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)
182
184
  raise UndefinedTargetError, reason if reason
183
185
  end
184
186
 
@@ -192,18 +194,18 @@ module Kobako
192
194
  raise UndefinedTargetError, e.message
193
195
  end
194
196
 
195
- # Resolve a Call target to the Ruby object the path +resolver+ (or
196
- # Catalog::Handles) holds. The native side already discriminated the
197
- # two forms off the core envelope's +kind+ tag: a String is a bound
198
- # constant's path, an Integer is a Capability Handle id. No
199
- # else-branch is needed — the envelope layer is the system boundary
200
- # that enforces the invariant.
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.
201
203
  def resolve_target(target, resolver, handler)
202
204
  case target
203
205
  when String
204
206
  resolve_path(target, resolver)
205
207
  when Integer
206
- require_live_object!(target, handler)
208
+ resolve_handle(target, handler)
207
209
  end
208
210
  end
209
211
 
@@ -213,10 +215,10 @@ module Kobako
213
215
  raise UndefinedTargetError, e.message
214
216
  end
215
217
 
216
- # Resolve +id+ through the Catalog::Handles. An unknown id
217
- # surfaces as UndefinedTargetError.
218
- def require_live_object!(id, handler)
219
- handler.fetch(id)
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)
220
222
  rescue Kobako::SandboxError => e
221
223
  raise UndefinedTargetError, e.message
222
224
  end
@@ -234,6 +236,7 @@ module Kobako
234
236
  # known, instead of falling to the boundary's codec floor and reporting
235
237
  # as an exchange that produced no Service outcome.
236
238
  def encode_ok(value, handler)
239
+ Kobako::Codec::Nesting.assert_within_bound!(value)
237
240
  Kobako::Codec::Encoder.encode(value)
238
241
  rescue Kobako::Codec::UnsupportedTypeError
239
242
  encode_ok(wrap_as_handle(value, handler), handler)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ require_relative "reflection"
6
+
7
+ module Kobako
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)
19
+ PREDICATE = :respond_to_guest?
20
+ private_constant :PREDICATE
21
+
22
+ # Kernel's own reflection, bound onto each object so a forwarder or a
23
+ # BasicObject answers from its own method table rather than through
24
+ # +method_missing+ / +respond_to_missing?+.
25
+ CLASS_OF = Kernel.instance_method(:class)
26
+ SINGLETON_CLASS_OF = Kernel.instance_method(:singleton_class)
27
+ SINGLETON_METHODS = Kernel.instance_method(:singleton_methods)
28
+ PRIVATE_METHODS = Kernel.instance_method(:private_methods)
29
+ private_constant :CLASS_OF, :SINGLETON_CLASS_OF, :SINGLETON_METHODS, :PRIVATE_METHODS
30
+
31
+ CALLABLE = Set.new(Reflection::CALLABLE_ALLOW).freeze
32
+ NOTHING = Set.new.freeze
33
+ private_constant :CALLABLE, :NOTHING
34
+
35
+ # The Exposure +object+ carries from this moment on. +surfaces+ caches
36
+ # each class's own surface for a caller that makes many references at
37
+ # once (a Handle table), so two objects of one class enumerate it once.
38
+ def self.of(object, surfaces = {})
39
+ klass = CLASS_OF.bind_call(object)
40
+ names = narrows_itself?(object, klass) ? nil : default_names(object, klass, surfaces)
41
+ new(object: object, names: names)
42
+ end
43
+
44
+ # Whether the guest may call +name+ through this reference. The
45
+ # object's own predicate is consulted with the private surface
46
+ # included, so the guest's +public_send+ can never reach it.
47
+ def exposes?(name)
48
+ return names.include?(name) if names
49
+
50
+ object.__send__(PREDICATE, name) ? true : false
51
+ end
52
+
53
+ class << self
54
+ private
55
+
56
+ # Whether +object+ defines the narrowing predicate itself — read off
57
+ # the method tables, so a forwarder's target or a catch-all
58
+ # +respond_to_missing?+ cannot answer for it.
59
+ def narrows_itself?(object, klass)
60
+ klass.method_defined?(PREDICATE) || klass.private_method_defined?(PREDICATE) ||
61
+ PRIVATE_METHODS.bind_call(object, false).include?(PREDICATE) ||
62
+ SINGLETON_METHODS.bind_call(object, false).include?(PREDICATE)
63
+ end
64
+
65
+ # A callable keeps its callable names; a class-level surface or a
66
+ # forwarder has no surface of its own to expose.
67
+ def default_names(object, klass, surfaces)
68
+ case object
69
+ when Proc, Method then CALLABLE
70
+ when Module, Delegator then NOTHING
71
+ else with_singleton(object, surfaces[klass] ||= class_names(object, klass))
72
+ end
73
+ end
74
+
75
+ def class_names(object, klass)
76
+ names = authored(klass)
77
+ case object
78
+ when Struct, Data then names.concat(object.members)
79
+ end
80
+ Set.new(names).freeze
81
+ end
82
+
83
+ def with_singleton(object, names)
84
+ return names if SINGLETON_METHODS.bind_call(object, false).empty?
85
+
86
+ names.union(authored(SINGLETON_CLASS_OF.bind_call(object))).freeze
87
+ end
88
+
89
+ # The public methods +owner+ itself defines in source. A method with
90
+ # no source, or whose source is the platform's own, is built in.
91
+ def authored(owner)
92
+ owner.public_instance_methods(false).select do |name|
93
+ file, = owner.instance_method(name).source_location
94
+ file && !file.start_with?("<internal:")
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -32,18 +32,19 @@ module Kobako
32
32
 
33
33
  # The sole methods reachable on a GADGET_OWNERS target: invoking it
34
34
  # (+call+ / +[]+ / +yield+) and the harmless +arity+ / +lambda?+
35
- # describers that aid guest-side debugging.
35
+ # describers that aid guest-side debugging. Also the whole of what a
36
+ # callable exposes by default, so the floor and the Exposure agree.
36
37
  CALLABLE_ALLOW = %i[call [] yield arity lambda?].freeze
37
- private_constant :CALLABLE_ALLOW
38
38
 
39
39
  module_function
40
40
 
41
- # The reason +name+ is unreachable on +target+, or +nil+ when the
42
- # dispatch may proceed. Composes the ambient-surface floor with the
43
- # target's own opt-in narrowing, in that order: the predicate only
44
- # narrows and can never re-open what the floor rejects.
45
- def refusal(target, name)
46
- ambient_refusal(target, name) || narrowing_refusal(target, name)
41
+ # The reason +name+ is unreachable through +exposure+, or +nil+ when
42
+ # the dispatch may proceed. Composes the ambient-surface floor with the
43
+ # reference's Exposure, in that order: the Exposure only narrows and
44
+ # can never re-open what the floor rejects.
45
+ def refusal(exposure, name)
46
+ ambient_refusal(exposure.object, name) ||
47
+ (exposure.exposes?(name) ? nil : "method #{name.inspect} is not exposed to the guest")
47
48
  end
48
49
 
49
50
  # Guard against ambient reflection methods. A public method whose
@@ -55,7 +56,17 @@ module Kobako
55
56
  # only when the target opts into it via +respond_to?+ (dynamic
56
57
  # +method_missing+ Services), since the dangerous methods are all
57
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+.
58
67
  def ambient_refusal(target, name)
68
+ return "method #{name.inspect} is not a Service method" if name == :method_missing
69
+
59
70
  owner = target.public_method(name).owner
60
71
  return nil unless ambient_owner?(owner, target)
61
72
  return nil if GADGET_OWNERS.include?(owner) && CALLABLE_ALLOW.include?(name)
@@ -79,18 +90,6 @@ module Kobako
79
90
  GADGET_OWNERS.include?(owner) ||
80
91
  (target.is_a?(Module) && owner.singleton_class?)
81
92
  end
82
-
83
- # Consult the target's opt-in narrowing predicate. A bound object may
84
- # define a private +respond_to_guest?(name)+ to restrict which of its
85
- # methods the guest reaches; a falsy answer refuses the dispatch. It
86
- # is consulted with the private surface included so the guest's
87
- # +public_send+ dispatch can never reach +respond_to_guest?+ itself.
88
- def narrowing_refusal(target, name)
89
- return nil unless target.respond_to?(:respond_to_guest?, true)
90
- return nil if target.__send__(:respond_to_guest?, name)
91
-
92
- "method #{name.inspect} is not exposed to the guest"
93
- end
94
93
  end
95
94
  end
96
95
  end
@@ -54,10 +54,13 @@ module Kobako
54
54
  # invocation's table, sharing the same allocator the guest→host
55
55
  # return path uses. A wrapped leaf rides as ext 0x01 in its
56
56
  # original position (docs/wire/payload-msgpack.md § ext 0x01).
57
+ # The walk starts one level down, since +args+ and +kwargs+ ride
58
+ # inside the payload document the wire's nesting bound is counted
59
+ # from.
57
60
  def payload(handler)
58
61
  Payload::Arguments.new(
59
- args: Codec::HandleWalk.deep_wrap(args, handler),
60
- kwargs: Codec::HandleWalk.deep_wrap(kwargs, handler)
62
+ args: Codec::HandleWalk.deep_wrap(args, handler, 1),
63
+ kwargs: Codec::HandleWalk.deep_wrap(kwargs, handler, 1)
61
64
  ).encode
62
65
  end
63
66
 
@@ -94,11 +94,13 @@ module Kobako
94
94
 
95
95
  private
96
96
 
97
- # Encode what the Service is yielding. A value outside the wire type
98
- # set fails here, before the guest is re-entered, and is restated so
99
- # the Service reads a refusal of its own argument rather than a codec
100
- # class it never named.
97
+ # Encode what the Service is yielding. Arguments outside the wire
98
+ # type set, or nesting past the wire's bound as one list, fail here,
99
+ # before the guest is re-entered, and are restated so the Service
100
+ # reads a refusal of its own argument rather than a codec class it
101
+ # never named.
101
102
  def encode_args(args)
103
+ Kobako::Codec::Nesting.assert_within_bound!(args)
102
104
  Kobako::Codec::Encoder.encode(args)
103
105
  rescue Kobako::Codec::Error => e
104
106
  raise YieldValueError, "Service yielded a value the block cannot receive: #{e.message}"
@@ -4,6 +4,7 @@ require_relative "transport/call"
4
4
  require_relative "transport/run"
5
5
  require_relative "transport/yielder"
6
6
  require_relative "transport/error"
7
+ require_relative "transport/exposure"
7
8
  require_relative "transport/reflection"
8
9
  require_relative "transport/dispatcher"
9
10
 
@@ -12,8 +13,9 @@ module Kobako
12
13
  # host side of one Call/Reply exchange: the call value objects +Call+
13
14
  # (guest→host, as the native side decoded it) and +Run+ (host→guest), the
14
15
  # +Dispatcher+ that answers a routed Call, the +Yielder+ that re-enters
15
- # the guest for a block, the +Reflection+ floor a dispatch must clear, and
16
- # +Error+ for a wire violation the host detects. Each invocation's
16
+ # the guest for a block, the +Reflection+ floor a dispatch must clear, the
17
+ # +Exposure+ each reference authorizes a call against, and +Error+ for a
18
+ # wire violation the host detects. Each invocation's
17
19
  # +Context+ composes them into the dispatch +Proc+ it passes +Runtime+ for
18
20
  # that run.
19
21
  #
data/lib/kobako/usage.rb CHANGED
@@ -1,30 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- # Per-last-invocation resource accounting for a +Kobako::Sandbox+.
5
- # Carries two readers populated by every +#eval+ / +#run+ invocation:
4
+ # What one invocation spent against its caps, measured the way the caps
5
+ # measure it:
6
6
  #
7
- # * +wall_time+ — the Float number of seconds the guest export call
8
- # spent inside wasmtime during the most recent invocation. The
9
- # measurement bracket aligns with the +timeout+ deadline; time spent
10
- # in host Service callbacks is included, but everything that runs
11
- # after the guest export returns — the post-export
12
- # +OUTCOME_BUFFER+ fetch and decode, plus stdout / stderr capture
13
- # readout — is excluded.
14
- # * +memory_peak+ — the Integer high-water mark, in bytes, of the
15
- # per-invocation +memory.grow+ delta past the linear-memory size
16
- # captured at invocation entry. Same baseline accounting as
17
- # +memory_limit+: the mruby image's initial allocation and any
18
- # prior-invocation watermark sit outside the measurement. On
19
- # +MemoryLimitError+ +memory_peak+ never exceeds the configured
20
- # cap because the rejected +desired+ value is not promoted into
21
- # the high-water.
7
+ # * +wall_time+ — Float seconds inside the guest, the span the
8
+ # +timeout+ deadline governs; Service callbacks count, reading the
9
+ # result and the captures afterwards does not.
10
+ # * +memory_peak+ — Integer bytes of memory the invocation grew,
11
+ # against the same baseline as +memory_limit+; it never exceeds the
12
+ # cap, even when the cap was hit.
22
13
  #
23
- # Both readers are populated on every outcome, including +TrapError+
24
- # branches, so the Host App can read +#usage+ off the run's
25
- # +Kobako::Execution+ — the one a raised error carries on +#execution+ —
26
- # after rescuing a trap to diagnose how much of the budget the failing
27
- # invocation consumed.
14
+ # Filled on every outcome, traps included, so a Host App that rescues a
15
+ # trap can read from the error's +#execution+ how much of the budget the
16
+ # invocation used.
28
17
  #
29
18
  # Built on the +class X < Data.define(...)+ subclass form (the
30
19
  # Steep-friendly shape — see +.rubocop.yml+ for the rationale).
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.23.0"
4
+ VERSION = "0.26.0"
5
5
  end
@@ -5,9 +5,9 @@
5
5
  "last-release-sha": "98509af508988f708bf0d7a76a718bb0428a177e",
6
6
  "packages": {
7
7
  ".": {
8
- "component": "kobako",
9
8
  "include-component-in-tag": false,
10
9
  "release-type": "ruby",
10
+ "version-file": "lib/kobako/version.rb",
11
11
  "exclude-paths": ["wasm", "crates"]
12
12
  },
13
13
  "wasm/kobako-core": {
@@ -7,6 +7,8 @@ module Kobako
7
7
 
8
8
  def fetch: (Integer id) -> untyped
9
9
 
10
+ def exposure: (Integer id) -> Kobako::Transport::Exposure
11
+
10
12
  def size: () -> Integer
11
13
 
12
14
  private
@@ -9,7 +9,7 @@ module Kobako
9
9
 
10
10
  def bound?: (Symbol | String path) -> bool
11
11
 
12
- def lookup: (Symbol | String target) -> untyped
12
+ def lookup: (Symbol | String target) -> Kobako::Transport::Exposure
13
13
 
14
14
  def paths: () -> Array[String]
15
15