kobako 0.26.0-aarch64-linux → 0.28.0-aarch64-linux

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +103 -0
  4. data/README.md +156 -118
  5. data/ROADMAP.md +23 -22
  6. data/SECURITY.md +1 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +38 -0
  8. data/crates/kobako-runtime/README.md +10 -12
  9. data/crates/kobako-transport/CHANGELOG.md +35 -0
  10. data/crates/kobako-transport/README.md +43 -27
  11. data/crates/kobako-wasmtime/CHANGELOG.md +51 -0
  12. data/crates/kobako-wasmtime/README.md +8 -10
  13. data/data/kobako.wasm +0 -0
  14. data/lib/kobako/3.3/kobako.so +0 -0
  15. data/lib/kobako/3.4/kobako.so +0 -0
  16. data/lib/kobako/4.0/kobako.so +0 -0
  17. data/lib/kobako/capture.rb +1 -4
  18. data/lib/kobako/catalog/extensions.rb +21 -59
  19. data/lib/kobako/catalog/flags.rb +41 -0
  20. data/lib/kobako/catalog/handles.rb +7 -54
  21. data/lib/kobako/catalog/services.rb +7 -61
  22. data/lib/kobako/catalog/snippets.rb +42 -66
  23. data/lib/kobako/catalog.rb +2 -10
  24. data/lib/kobako/codec/decoder.rb +9 -28
  25. data/lib/kobako/codec/encoder.rb +3 -22
  26. data/lib/kobako/codec/error.rb +6 -27
  27. data/lib/kobako/codec/ext_types.rb +72 -94
  28. data/lib/kobako/codec/handle_walk.rb +65 -130
  29. data/lib/kobako/codec/nesting.rb +3 -8
  30. data/lib/kobako/codec/state.rb +4 -10
  31. data/lib/kobako/codec/utils.rb +6 -34
  32. data/lib/kobako/codec.rb +3 -22
  33. data/lib/kobako/constant_name.rb +8 -0
  34. data/lib/kobako/context.rb +51 -112
  35. data/lib/kobako/errors.rb +51 -127
  36. data/lib/kobako/execution.rb +16 -21
  37. data/lib/kobako/extension.rb +23 -40
  38. data/lib/kobako/handle.rb +7 -12
  39. data/lib/kobako/outcome.rb +51 -80
  40. data/lib/kobako/payload/arguments.rb +1 -16
  41. data/lib/kobako/payload.rb +4 -12
  42. data/lib/kobako/pool.rb +23 -53
  43. data/lib/kobako/runtime.rb +7 -21
  44. data/lib/kobako/sandbox.rb +96 -133
  45. data/lib/kobako/sandbox_options.rb +38 -84
  46. data/lib/kobako/snippet/binary.rb +1 -16
  47. data/lib/kobako/snippet/source.rb +1 -16
  48. data/lib/kobako/snippet.rb +1 -9
  49. data/lib/kobako/transport/call.rb +1 -14
  50. data/lib/kobako/transport/dispatcher.rb +112 -195
  51. data/lib/kobako/transport/error.rb +6 -20
  52. data/lib/kobako/transport/exposure.rb +5 -10
  53. data/lib/kobako/transport/reflection.rb +39 -50
  54. data/lib/kobako/transport/run.rb +17 -71
  55. data/lib/kobako/transport/yielder.rb +21 -73
  56. data/lib/kobako/transport.rb +3 -15
  57. data/lib/kobako/unresolved.rb +4 -11
  58. data/lib/kobako/usage.rb +2 -6
  59. data/lib/kobako/version.rb +1 -1
  60. data/sig/kobako/catalog/extensions.rbs +2 -2
  61. data/sig/kobako/catalog/flags.rbs +17 -0
  62. data/sig/kobako/catalog/services.rbs +0 -6
  63. data/sig/kobako/catalog/snippets.rbs +4 -4
  64. data/sig/kobako/codec/ext_types.rbs +10 -8
  65. data/sig/kobako/codec/handle_walk.rbs +9 -7
  66. data/sig/kobako/codec/utils.rbs +2 -2
  67. data/sig/kobako/codec.rbs +2 -2
  68. data/sig/kobako/constant_name.rbs +3 -0
  69. data/sig/kobako/context.rbs +2 -3
  70. data/sig/kobako/errors.rbs +6 -0
  71. data/sig/kobako/outcome.rbs +6 -8
  72. data/sig/kobako/runtime.rbs +12 -8
  73. data/sig/kobako/sandbox.rbs +4 -0
  74. data/sig/kobako/sandbox_options.rbs +2 -6
  75. data/sig/kobako/transport/dispatcher.rbs +14 -18
  76. data/sig/kobako/transport/reflection.rbs +5 -3
  77. data/sig/kobako/transport/run.rbs +1 -3
  78. data/sig/kobako/transport/yielder.rbs +3 -3
  79. data/sig/kobako/transport.rbs +7 -6
  80. metadata +6 -3
  81. data/rust-toolchain.toml +0 -11
@@ -3,52 +3,42 @@
3
3
  require_relative "errors"
4
4
 
5
5
  module Kobako
6
- # Kobako::SandboxOptions — immutable Value Object holding the four
7
- # per-Sandbox configuration caps and the requested isolation profile.
8
- # Built on the +class X < Data.define(...)+ subclass form (the
9
- # Steep-friendly shape — see +.rubocop.yml+ for the rationale).
6
+ # The options a Sandbox was built with, read through Sandbox#options or
7
+ # directly on the Sandbox, as in Sandbox#timeout. A cap left out takes its
8
+ # default, and +nil+ switches it off.
10
9
  #
11
- # The +initialize+ normalises every option before delegating to Data's
12
- # +super+: +timeout+ to Float seconds, +memory_limit+ / +stdout_limit+ /
13
- # +stderr_limit+ to positive Integer bytes. Each cap is +nil+-disablable
14
- # (an absent argument takes its DEFAULT; an explicit +nil+ leaves the
15
- # bound off), so all four behave uniformly. +profile+ is the one
16
- # non-cap: a Symbol on the PROFILES ladder naming the posture the
17
- # runtime builds, which is also the weakest the Host App accepts —
18
- # +nil+ is rejected because the weakest posture is requested as an
19
- # explicit +:permissive+. Anything that survives +SandboxOptions.new+
20
- # is a wire-ready bundle the +Kobako::Runtime+ constructor consumes
21
- # as-is. The options also own the ladder comparison
22
- # (+#enforce_floor!+) that +Kobako::Sandbox+ delegates its
23
- # construction floor check to.
10
+ # [+timeout+] Float seconds one invocation may run.
11
+ # [+memory_limit+] Integer bytes one invocation may grow guest memory by.
12
+ # [+stdout_limit+, +stderr_limit+]
13
+ # Integer bytes kept from each output channel.
14
+ # [+profile+] The isolation the runtime must provide, one of PROFILES.
15
+ # [+gvl+] How an invocation holds Ruby's GVL, one of GVL_MODES.
16
+ #
17
+ # Raises ArgumentError for a value outside these shapes, and SetupError
18
+ # when the runtime provides less isolation than +profile+ asks for.
24
19
  class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile, :gvl)
25
- # Default wall-clock timeout for a single invocation: 60 seconds.
20
+ # The default +timeout+: 60 seconds.
26
21
  DEFAULT_TIMEOUT_SECONDS = 60.0
27
22
 
28
- # Default cap on the per-invocation guest linear-memory delta:
29
- # 1 MiB. The mruby image's initial allocation and prior invocations'
30
- # watermark sit outside this budget.
23
+ # The default +memory_limit+: 1 MiB of growth past the guest's starting
24
+ # memory.
31
25
  DEFAULT_MEMORY_LIMIT = 1 << 20
32
26
 
33
- # Default per-channel capture ceiling: 1 MiB.
27
+ # The default +stdout_limit+ and +stderr_limit+: 1 MiB each.
34
28
  DEFAULT_OUTPUT_LIMIT = 1 << 20
35
29
 
36
- # The isolation ladder, weakest first — index order is rank order,
37
- # so a floor check is an index comparison.
30
+ # The isolation profiles, weakest first. +:hermetic+ denies the guest the
31
+ # clock and entropy; +:permissive+ leaves them live.
38
32
  PROFILES = %i[permissive hermetic].freeze
39
33
 
40
- # Default isolation profile: the strictest rung — opting down to
41
- # +:permissive+ is the Host App's explicit trade.
34
+ # The default +profile+, the strictest.
42
35
  DEFAULT_PROFILE = :hermetic
43
36
 
44
- # The GVL scheduling modes: +:hold+ keeps Ruby's GVL for the whole
45
- # invocation, +:release+ drops it for the guest span so distinct
46
- # Sandboxes on distinct Threads run their guest code in parallel.
37
+ # +:hold+ keeps the GVL through the invocation; +:release+ drops it while
38
+ # the guest runs, so Sandboxes on different Threads run in parallel.
47
39
  GVL_MODES = %i[hold release].freeze
48
40
 
49
- # Default GVL mode: +:hold+ — holding the GVL matches single-threaded
50
- # execution, so +:release+ is the Host App's explicit opt-in for
51
- # host-parallel guest execution.
41
+ # The default +gvl+, +:hold+.
52
42
  DEFAULT_GVL = :hold
53
43
 
54
44
  def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
@@ -58,22 +48,17 @@ module Kobako
58
48
  profile: DEFAULT_PROFILE,
59
49
  gvl: DEFAULT_GVL)
60
50
  timeout = normalize_timeout(timeout)
61
- memory_limit = normalize_memory_limit(memory_limit)
62
- stdout_limit = normalize_output_limit(stdout_limit, "stdout_limit")
63
- stderr_limit = normalize_output_limit(stderr_limit, "stderr_limit")
64
- profile = normalize_profile(profile)
65
- gvl = normalize_gvl(gvl)
51
+ memory_limit = normalize_byte_limit(memory_limit, "memory_limit")
52
+ stdout_limit = normalize_byte_limit(stdout_limit, "stdout_limit")
53
+ stderr_limit = normalize_byte_limit(stderr_limit, "stderr_limit")
54
+ profile = normalize_choice(profile, PROFILES, "profile")
55
+ gvl = normalize_choice(gvl, GVL_MODES, "gvl")
66
56
  super
67
57
  end
68
58
 
69
- # Enforce the requested +profile+ as the floor against +declared+ —
70
- # the posture a runtime reports having built — so a runtime that
71
- # cannot honor the request fails construction with
72
- # +Kobako::SetupError+ instead of weakening the posture silently.
73
- # Both fallbacks fail closed: a declaration off the PROFILES ladder
74
- # ranks below every request, and a request off the ladder
75
- # (unreachable past +initialize+) refuses every declaration.
76
- def enforce_floor!(declared)
59
+ # Both fallbacks fail closed: an unknown declaration ranks below every
60
+ # request, and an unknown request refuses every declaration.
61
+ def enforce_floor!(declared) # :nodoc:
77
62
  return if (PROFILES.index(declared) || -1) >= (PROFILES.index(profile) || PROFILES.size)
78
63
 
79
64
  raise Kobako::SetupError, "runtime declares isolation profile #{declared.inspect}, " \
@@ -82,11 +67,8 @@ module Kobako
82
67
 
83
68
  private
84
69
 
85
- # Coerce +timeout+ into the Float seconds the ext expects, or +nil+
86
- # to mean the cap is disabled. Any finite non-positive value is
87
- # rejected — a zero or negative timeout would either fire instantly
88
- # or never, both of which would surprise callers more than an early
89
- # +ArgumentError+.
70
+ # A zero or negative timeout would either fire instantly or never, both
71
+ # more surprising than an early ArgumentError.
90
72
  def normalize_timeout(timeout)
91
73
  return nil if timeout.nil?
92
74
  raise ArgumentError, "timeout must be Numeric or nil, got #{timeout.class}" unless timeout.is_a?(Numeric)
@@ -97,24 +79,7 @@ module Kobako
97
79
  seconds
98
80
  end
99
81
 
100
- # Coerce +memory_limit+ into the byte cap the ext expects, or +nil+
101
- # to mean unbounded. Must be a positive Integer when set; Float or
102
- # zero/negative values are rejected.
103
- def normalize_memory_limit(memory_limit)
104
- return nil if memory_limit.nil?
105
- unless memory_limit.is_a?(Integer) && memory_limit.positive?
106
- raise ArgumentError, "memory_limit must be a positive Integer or nil, got #{memory_limit.inspect}"
107
- end
108
-
109
- memory_limit
110
- end
111
-
112
- # Coerce a per-channel output cap (+stdout_limit+ / +stderr_limit+)
113
- # into the byte cap the ext expects, or +nil+ to leave the channel
114
- # uncapped. Same shape as +normalize_memory_limit+: a positive Integer
115
- # when set, Float / zero / negative rejected. +name+ tags the
116
- # +ArgumentError+ with the offending keyword.
117
- def normalize_output_limit(limit, name)
82
+ def normalize_byte_limit(limit, name)
118
83
  return nil if limit.nil?
119
84
  unless limit.is_a?(Integer) && limit.positive?
120
85
  raise ArgumentError, "#{name} must be a positive Integer or nil, got #{limit.inspect}"
@@ -123,23 +88,12 @@ module Kobako
123
88
  limit
124
89
  end
125
90
 
126
- # Validate +profile+ against the PROFILES ladder. Unlike the caps
127
- # there is no +nil+ form: the weakest posture is requested as an
128
- # explicit +:permissive+, so anything off the ladder — +nil+
129
- # included — is rejected.
130
- def normalize_profile(profile)
131
- return profile if PROFILES.include?(profile)
132
-
133
- raise ArgumentError, "profile must be one of #{PROFILES.map(&:inspect).join(", ")}, got #{profile.inspect}"
134
- end
135
-
136
- # Validate +gvl+ against GVL_MODES. Like +profile+ it has no +nil+
137
- # form: the scheduling mode is requested as an explicit Symbol, so
138
- # anything off the set — +nil+ included — is rejected.
139
- def normalize_gvl(gvl)
140
- return gvl if GVL_MODES.include?(gvl)
91
+ # Unlike the caps there is no +nil+ form: every choice, the weakest
92
+ # included, is requested by name, as in +:permissive+.
93
+ def normalize_choice(value, choices, name)
94
+ return value if choices.include?(value)
141
95
 
142
- raise ArgumentError, "gvl must be one of #{GVL_MODES.map(&:inspect).join(", ")}, got #{gvl.inspect}"
96
+ raise ArgumentError, "#{name} must be one of #{choices.map(&:inspect).join(", ")}, got #{value.inspect}"
143
97
  end
144
98
  end
145
99
  end
@@ -2,22 +2,7 @@
2
2
 
3
3
  module Kobako
4
4
  module Snippet
5
- # Kobako::Snippet::Binary — value object representing a single
6
- # +#preload(binary:)+ entry held by +Kobako::Catalog::Snippets+.
7
- #
8
- # The +body+ is RITE bytecode (as emitted by +mrbc+) carried as an
9
- # +ASCII_8BIT+ String so msgpack-ruby encodes it as +bin+ family on
10
- # the wire ({docs/wire-codec.md Invocation channels}[link:../../../docs/wire-codec.md]).
11
- # The host treats the bytes as opaque — the snippet's canonical
12
- # name, when present, lives in the bytecode's embedded +debug_info+
13
- # and is resolved by the guest at load time; structural validation
14
- # is deferred to the first invocation's guest replay.
15
- #
16
- # The class is a +Data.define+ subclass — frozen and value-equal.
17
- # Callers (chiefly +Catalog::Snippets+) construct instances via keyword
18
- # form +Binary.new(body: ...)+. Wire-form construction is the
19
- # registry's responsibility.
20
- class Binary < Data.define(:body)
5
+ class Binary < Data.define(:body) # :nodoc:
21
6
  # Names the snippet form the guest replays this entry as. The wire's
22
7
  # discriminant byte is assigned by the core envelope, not here.
23
8
  KIND = :bytecode
@@ -2,22 +2,7 @@
2
2
 
3
3
  module Kobako
4
4
  module Snippet
5
- # Kobako::Snippet::Source — value object representing a single
6
- # +#preload(code:, name:)+ entry held by +Kobako::Catalog::Snippets+.
7
- #
8
- # +name+ is the canonical +Symbol+ identity baked into the loaded
9
- # IREP's +debug_info+; backtrace frames originating in this snippet
10
- # surface as +(snippet:Name):line+. +body+ is the UTF-8 mruby source
11
- # detached from the caller's reference at +Catalog::Snippets#register+
12
- # time so later mutation of the original String cannot bleed through.
13
- #
14
- # The class is a +Data.define+ subclass — frozen, value-equal, and
15
- # carries no mutation API. Callers (chiefly +Catalog::Snippets+)
16
- # construct instances via keyword form +Source.new(name: ..., body: ...)+.
17
- # Wire-form construction is the registry's responsibility: as a leaf
18
- # carrier this Source stays pure and +Catalog::Snippets#entries+ reads
19
- # its attributes off the outside rather than asking it to project itself.
20
- class Source < Data.define(:name, :body)
5
+ class Source < Data.define(:name, :body) # :nodoc:
21
6
  # Names the snippet form the guest replays this entry as. The wire's
22
7
  # discriminant byte is assigned by the core envelope, not here.
23
8
  KIND = :source
@@ -4,14 +4,6 @@ require_relative "snippet/binary"
4
4
  require_relative "snippet/source"
5
5
 
6
6
  module Kobako
7
- # Kobako::Snippet — value-object family for preloaded snippet entries
8
- # held by +Kobako::Catalog::Snippets+.
9
- #
10
- # +Source+ represents a single +#preload(code:, name:)+ entry; +Binary+
11
- # represents a single +#preload(binary:)+ entry. Both are plain value
12
- # objects with no dependency on the +Catalog::Snippets+ registry that
13
- # holds them — the registry reads their attributes externally into the
14
- # tuples the native side frames.
15
- module Snippet
7
+ module Snippet # :nodoc:
16
8
  end
17
9
  end
@@ -2,20 +2,7 @@
2
2
 
3
3
  module Kobako
4
4
  module Transport
5
- # One guest-initiated dispatch, as the native side decoded it off the
6
- # core envelope. The routing fields arrive already decoded — a String
7
- # for a bound constant's path, an Integer for a Capability Handle id —
8
- # so the host resolves a target without interpreting a payload byte.
9
- #
10
- # +payload+ stays bytes here on purpose: routing never interprets a
11
- # payload byte, and the one place that does — the Dispatcher — must
12
- # decode it inside the Codec bracket that records whether a Handle
13
- # crossed.
14
- #
15
- # Built on the +class X < Data.define(...)+ subclass form so the class
16
- # body is fully Steep-visible; see +.rubocop.yml+ for the
17
- # rationale.
18
- class Call < Data.define(:target, :method_name, :block_given, :payload)
5
+ class Call < Data.define(:target, :method_name, :block_given, :payload) # :nodoc:
19
6
  end
20
7
  end
21
8
  end
@@ -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