kobako 0.2.1 → 0.4.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +205 -59
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +2 -2
  6. data/ext/kobako/src/wasm/cache.rs +15 -7
  7. data/ext/kobako/src/wasm/dispatch.rs +88 -36
  8. data/ext/kobako/src/wasm/host_state.rs +298 -55
  9. data/ext/kobako/src/wasm/instance.rs +477 -160
  10. data/ext/kobako/src/wasm.rs +20 -5
  11. data/lib/kobako/capture.rb +12 -10
  12. data/lib/kobako/codec/decoder.rb +3 -4
  13. data/lib/kobako/codec/encoder.rb +1 -1
  14. data/lib/kobako/codec/error.rb +3 -2
  15. data/lib/kobako/codec/factory.rb +24 -17
  16. data/lib/kobako/codec/utils.rb +105 -12
  17. data/lib/kobako/codec.rb +2 -1
  18. data/lib/kobako/errors.rb +22 -10
  19. data/lib/kobako/handle.rb +62 -0
  20. data/lib/kobako/handle_table.rb +119 -0
  21. data/lib/kobako/invocation.rb +143 -0
  22. data/lib/kobako/outcome/panic.rb +2 -2
  23. data/lib/kobako/outcome.rb +61 -24
  24. data/lib/kobako/rpc/dispatcher.rb +30 -28
  25. data/lib/kobako/rpc/envelope.rb +10 -10
  26. data/lib/kobako/rpc/fault.rb +4 -3
  27. data/lib/kobako/rpc/namespace.rb +3 -3
  28. data/lib/kobako/rpc/server.rb +23 -33
  29. data/lib/kobako/rpc/wire_error.rb +23 -0
  30. data/lib/kobako/sandbox.rb +211 -136
  31. data/lib/kobako/sandbox_options.rb +73 -0
  32. data/lib/kobako/snippet/binary.rb +30 -0
  33. data/lib/kobako/snippet/source.rb +28 -0
  34. data/lib/kobako/snippet/table.rb +174 -0
  35. data/lib/kobako/snippet.rb +20 -0
  36. data/lib/kobako/usage.rb +41 -0
  37. data/lib/kobako/version.rb +1 -1
  38. data/lib/kobako.rb +1 -0
  39. data/sig/kobako/codec/factory.rbs +1 -1
  40. data/sig/kobako/codec/utils.rbs +10 -0
  41. data/sig/kobako/errors.rbs +3 -0
  42. data/sig/kobako/handle.rbs +19 -0
  43. data/sig/kobako/handle_table.rbs +23 -0
  44. data/sig/kobako/invocation.rbs +25 -0
  45. data/sig/kobako/outcome.rbs +1 -1
  46. data/sig/kobako/rpc/dispatcher.rbs +7 -7
  47. data/sig/kobako/rpc/envelope.rbs +3 -3
  48. data/sig/kobako/rpc/server.rbs +1 -7
  49. data/sig/kobako/rpc/wire_error.rbs +6 -0
  50. data/sig/kobako/sandbox.rbs +22 -17
  51. data/sig/kobako/sandbox_options.rbs +32 -0
  52. data/sig/kobako/snippet/binary.rbs +12 -0
  53. data/sig/kobako/snippet/source.rbs +13 -0
  54. data/sig/kobako/snippet/table.rbs +36 -0
  55. data/sig/kobako/snippet.rbs +4 -0
  56. data/sig/kobako/usage.rbs +11 -0
  57. data/sig/kobako/wasm.rbs +5 -1
  58. metadata +21 -5
  59. data/lib/kobako/rpc/handle.rb +0 -38
  60. data/lib/kobako/rpc/handle_table.rb +0 -107
  61. data/sig/kobako/rpc/handle.rbs +0 -19
  62. data/sig/kobako/rpc/handle_table.rbs +0 -25
@@ -32,10 +32,23 @@ mod host_state;
32
32
  mod instance;
33
33
 
34
34
  use magnus::value::Lazy;
35
- use magnus::{function, method, prelude::*, Error as MagnusError, ExceptionClass, RModule, Ruby};
35
+ use magnus::{
36
+ function, method, prelude::*, Error as MagnusError, ExceptionClass, RModule, RString, Ruby,
37
+ };
36
38
 
37
39
  use instance::Instance;
38
40
 
41
+ /// Copy the bytes of +s+ into a fresh +Vec<u8>+. Single safe entry to
42
+ /// what would otherwise be an inline +unsafe { rstring.as_slice() }
43
+ /// .to_vec()+ duplicated at every host-↔-guest boundary. The borrow
44
+ /// does not outlive this call, so no Ruby allocation can move the
45
+ /// underlying RString between the borrow and the copy — the safety
46
+ /// invariant the inline form relied on is established once here.
47
+ pub(crate) fn rstring_to_vec(s: RString) -> Vec<u8> {
48
+ // SAFETY: see item doc.
49
+ unsafe { s.as_slice() }.to_vec()
50
+ }
51
+
39
52
  // ---------------------------------------------------------------------------
40
53
  // Error classes (lazy-resolved from Ruby once Kobako::Wasm is defined).
41
54
  // ---------------------------------------------------------------------------
@@ -69,14 +82,14 @@ pub(crate) fn wasm_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
69
82
  }
70
83
 
71
84
  /// Construct a `Kobako::Wasm::TimeoutError` magnus error. Surfaces the
72
- /// SPEC.md E-19 wall-clock cap path so the Sandbox layer can rewrap it
85
+ /// docs/behavior.md E-19 wall-clock cap path so the Sandbox layer can rewrap it
73
86
  /// as `Kobako::TimeoutError`.
74
87
  pub(crate) fn timeout_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
75
88
  MagnusError::new(ruby.get_inner(&WASM_TIMEOUT_ERROR), msg.into())
76
89
  }
77
90
 
78
91
  /// Construct a `Kobako::Wasm::MemoryLimitError` magnus error. Surfaces
79
- /// the SPEC.md E-20 linear-memory cap path so the Sandbox layer can
92
+ /// the docs/behavior.md E-20 linear-memory cap path so the Sandbox layer can
80
93
  /// rewrap it as `Kobako::MemoryLimitError`.
81
94
  pub(crate) fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
82
95
  MagnusError::new(ruby.get_inner(&WASM_MEMORY_LIMIT_ERROR), msg.into())
@@ -92,7 +105,7 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
92
105
  // Error hierarchy. ModuleNotBuiltError is the headline error for the
93
106
  // common pre-build state where `data/kobako.wasm` has not yet been
94
107
  // produced (e.g. fresh clone before `rake compile`). TimeoutError and
95
- // MemoryLimitError carry the SPEC.md B-01 per-run cap paths up to the
108
+ // MemoryLimitError carry the docs/behavior.md B-01 per-run cap paths up to the
96
109
  // Sandbox layer.
97
110
  let base_err = wasm.define_error("Error", ruby.exception_standard_error())?;
98
111
  wasm.define_error("ModuleNotBuiltError", base_err)?;
@@ -102,10 +115,12 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
102
115
  let instance = wasm.define_class("Instance", ruby.class_object())?;
103
116
  instance.define_singleton_method("from_path", function!(Instance::from_path, 5))?;
104
117
  instance.define_method("server=", method!(Instance::set_server, 1))?;
105
- instance.define_method("run", method!(Instance::run, 2))?;
118
+ instance.define_method("eval", method!(Instance::eval, 3))?;
119
+ instance.define_method("run", method!(Instance::run, 3))?;
106
120
  instance.define_method("stdout", method!(Instance::stdout, 0))?;
107
121
  instance.define_method("stderr", method!(Instance::stderr, 0))?;
108
122
  instance.define_method("outcome!", method!(Instance::outcome, 0))?;
123
+ instance.define_method("usage", method!(Instance::usage, 0))?;
109
124
 
110
125
  Ok(())
111
126
  }
@@ -2,15 +2,16 @@
2
2
 
3
3
  module Kobako
4
4
  # Host-side captured prefix of guest stdout / stderr produced during a
5
- # single +Kobako::Sandbox#run+, paired with the truncation flag the WASI
6
- # pipe sets when the guest wrote past the configured per-channel cap
7
- # ({SPEC.md B-04}[link:../../SPEC.md]).
5
+ # single +Kobako::Sandbox+ invocation, paired with the truncation flag
6
+ # the WASI pipe sets when the guest wrote past the configured per-channel
7
+ # cap ({docs/behavior.md B-04}[link:../../docs/behavior.md]).
8
8
  #
9
9
  # Immutable value object: the captured bytes and the truncation flag
10
10
  # always travel together and the instance is frozen on construction.
11
11
  # Construct via +Capture.from_ext+ for ext-provided binary bytes (handles
12
- # UTF-8 / ASCII-8BIT fallback) or reach +Capture::EMPTY+ for the pre-run
13
- # sentinel that +Sandbox+ uses before any +#run+ has executed.
12
+ # UTF-8 / ASCII-8BIT fallback) or reach +Capture::EMPTY+ for the pre-
13
+ # invocation sentinel that +Sandbox+ uses before any invocation has
14
+ # executed.
14
15
  class Capture
15
16
  attr_reader :bytes
16
17
 
@@ -24,8 +25,8 @@ module Kobako
24
25
  end
25
26
 
26
27
  # Returns +true+ iff the underlying capture channel exceeded its
27
- # configured cap during the originating +Sandbox#run+
28
- # ({SPEC.md B-04}[link:../../SPEC.md]).
28
+ # configured cap during the originating +Sandbox+ invocation
29
+ # ({docs/behavior.md B-04}[link:../../docs/behavior.md]).
29
30
  def truncated? = @truncated
30
31
 
31
32
  # Construct a Capture from ext-provided binary bytes. Coerces +bytes+
@@ -38,9 +39,10 @@ module Kobako
38
39
  new(bytes: copy, truncated: truncated)
39
40
  end
40
41
 
41
- # Pre-run sentinel ({SPEC.md B-05}[link:../../SPEC.md]). Empty UTF-8
42
- # bytes and +truncated? == false+; reused by every fresh +Sandbox+ and
43
- # by +Sandbox#run+ between invocations to denote "no capture yet".
42
+ # Pre-invocation sentinel ({docs/behavior.md B-05}[link:../../docs/behavior.md]).
43
+ # Empty UTF-8 bytes and +truncated? == false+; reused by every fresh
44
+ # +Sandbox+ and by +Sandbox+ between invocations to denote "no capture
45
+ # yet".
44
46
  EMPTY = new(bytes: "", truncated: false)
45
47
  end
46
48
  end
@@ -9,7 +9,7 @@ require_relative "utils"
9
9
  module Kobako
10
10
  module Codec
11
11
  # Module-level entry point for the host side of the kobako wire
12
- # (SPEC.md Wire Codec → Type Mapping).
12
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type Mapping).
13
13
  #
14
14
  # Translates msgpack gem exceptions into the kobako error taxonomy
15
15
  # ({Truncated}, {InvalidType}, {InvalidEncoding}, {UnsupportedType}) so
@@ -38,11 +38,10 @@ module Kobako
38
38
  # buffer edge.
39
39
  rescue ::MessagePack::UnpackError, ::EOFError => e
40
40
  raise Truncated, e.message
41
- rescue ::EncodingError => e
42
- raise InvalidEncoding, e.message
43
41
  end
44
42
 
45
- # SPEC pins +str+ family payloads to UTF-8 (Wire Codec → str/bin
43
+ # SPEC pins +str+ family payloads to UTF-8
44
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § str/bin
46
45
  # Encoding Rules). The msgpack gem returns UTF-8-tagged Strings for
47
46
  # str family but does not validate the bytes; +bin+ family decodes
48
47
  # to ASCII-8BIT. Walk the tree once and reject invalid UTF-8 in any
@@ -8,7 +8,7 @@ require_relative "factory"
8
8
  module Kobako
9
9
  module Codec
10
10
  # Module-level entry point for the host side of the kobako wire
11
- # (SPEC.md Wire Codec → Type Mapping).
11
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type Mapping).
12
12
  #
13
13
  # The codec backbone is the official +msgpack+ gem: integers, floats,
14
14
  # strings, arrays, and maps go through the gem's narrowest-encoding
@@ -4,8 +4,9 @@ module Kobako
4
4
  module Codec
5
5
  # Base class for all wire-codec faults raised by the pure-Ruby host codec.
6
6
  #
7
- # The wire codec implements the binary contract pinned in SPEC.md
8
- # (Wire Codec Type Mapping). Every wire violation surfaces as a
7
+ # The wire codec implements the binary contract pinned in
8
+ # {docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type Mapping.
9
+ # Every wire violation surfaces as a
9
10
  # subclass of {Error} so callers can pattern-match on the specific
10
11
  # fault while still rescuing all codec faults via this base class.
11
12
  #
@@ -6,13 +6,14 @@ require "msgpack"
6
6
 
7
7
  require_relative "error"
8
8
  require_relative "utils"
9
- require_relative "../rpc/handle"
9
+ require_relative "../handle"
10
10
  require_relative "../rpc/fault"
11
11
 
12
12
  module Kobako
13
13
  module Codec
14
14
  # Cached +MessagePack::Factory+ that owns the kobako wire ext-type
15
- # registration (SPEC.md → Wire Codec → Ext Types).
15
+ # registration ({docs/wire-codec.md}[link:../../../docs/wire-codec.md]
16
+ # § Ext Types).
16
17
  #
17
18
  # The factory is the single place in the host gem that touches the
18
19
  # msgpack API — both {Encoder} and {Decoder} delegate through it, so
@@ -31,16 +32,19 @@ module Kobako
31
32
  extend SingleForwardable
32
33
 
33
34
  # MessagePack ext type code reserved for Symbol
34
- # (SPEC.md Wire Codec → Ext Types → ext 0x00). Class-private —
35
- # mirrors +codec::EXT_SYMBOL+ on the Rust side.
35
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
36
+ # → ext 0x00). Class-private — mirrors +codec::EXT_SYMBOL+ on the
37
+ # Rust side.
36
38
  EXT_SYMBOL = 0x00
37
39
  # MessagePack ext type code reserved for Capability Handle
38
- # (SPEC.md Wire Codec → Ext Types → ext 0x01). Class-private —
39
- # mirrors +codec::EXT_HANDLE+ on the Rust side.
40
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
41
+ # → ext 0x01). Class-private — mirrors +codec::EXT_HANDLE+ on the
42
+ # Rust side.
40
43
  EXT_HANDLE = 0x01
41
44
  # MessagePack ext type code reserved for Exception envelope
42
- # (SPEC.md Wire Codec → Ext Types → ext 0x02). Class-private —
43
- # mirrors +codec::EXT_ERRENV+ on the Rust side.
45
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Ext Types
46
+ # → ext 0x02). Class-private — mirrors +codec::EXT_ERRENV+ on the
47
+ # Rust side.
44
48
  EXT_ERRENV = 0x02
45
49
  private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
46
50
 
@@ -85,16 +89,18 @@ module Kobako
85
89
  # binary-encoding fallback that msgpack-gem's default unpacker
86
90
  # would otherwise apply. The re-tag step lives here because the
87
91
  # msgpack ext-type unpacker hands us binary bytes; the assertion
88
- # itself is shared with {Decoder} via {Utils.assert_utf8!}.
92
+ # itself is shared with {Decoder} via {Utils.assert_utf8!}. The
93
+ # +"Symbol"+ label keeps the error message in Ruby vocabulary
94
+ # rather than wire-ext-code vocabulary.
89
95
  def unpack_symbol(payload)
90
96
  name = payload.b.force_encoding(Encoding::UTF_8)
91
- Utils.assert_utf8!(name, "ext 0x00 payload")
97
+ Utils.assert_utf8!(name, "Symbol payload")
92
98
  name.to_sym
93
99
  end
94
100
 
95
101
  def register_handle
96
102
  @factory.register_type(
97
- EXT_HANDLE, RPC::Handle,
103
+ EXT_HANDLE, Kobako::Handle,
98
104
  packer: ->(handle) { [handle.id].pack("N") },
99
105
  unpacker: ->(payload) { unpack_handle(payload) }
100
106
  )
@@ -108,17 +114,18 @@ module Kobako
108
114
  )
109
115
  end
110
116
 
111
- # Peel off the fixext-4 frame, hand the bytes to +RPC::Handle.new+, and
112
- # translate the +ArgumentError+ raised by Handle's invariants into
113
- # a wire-layer +InvalidType+ via {Codec::Utils.wire_boundary}.
117
+ # Peel off the fixext-4 frame, hand the bytes to the
118
+ # Host-Gem-internal +Kobako::Handle.from_wire+ factory, and
119
+ # translate the +ArgumentError+ raised by Handle's invariants
120
+ # into a wire-layer +InvalidType+ via {Codec::Utils.wire_boundary}.
114
121
  # The Value Object owns the id-range contract; this method only
115
122
  # owns the frame shape.
116
123
  def unpack_handle(payload)
117
124
  bytes = payload.b
118
- raise InvalidType, "ext 0x01 payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
125
+ raise InvalidType, "Handle payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
119
126
 
120
127
  id = bytes.unpack1("N") # : Integer
121
- Codec::Utils.wire_boundary { RPC::Handle.new(id) }
128
+ Codec::Utils.wire_boundary { Kobako::Handle.from_wire(id) }
122
129
  end
123
130
 
124
131
  # Encode the inner ext-0x02 map via {Encoder} (not +factory.dump+) so
@@ -144,7 +151,7 @@ module Kobako
144
151
  # and re-opens the Decoder's special case for Fault (removed in M5).
145
152
  def unpack_fault(payload)
146
153
  map = Decoder.decode(payload)
147
- raise InvalidType, "ext 0x02 payload must be a map" unless map.is_a?(Hash)
154
+ raise InvalidType, "Fault payload must be a map" unless map.is_a?(Hash)
148
155
 
149
156
  Codec::Utils.wire_boundary do
150
157
  RPC::Fault.new(type: map["type"], message: map["message"], details: map["details"])
@@ -1,29 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "error"
4
+ require_relative "../handle"
4
5
 
5
6
  module Kobako
6
7
  module Codec
7
8
  # Wire-codec helpers shared by the host-side encoders and decoders.
8
- # The single concern today is UTF-8 assertion at the wire boundary
9
- # (SPEC.md → Wire Codec → str/bin Encoding Rules and Ext Types →
10
- # ext 0x00). Two call sites lean on this:
9
+ # Three concerns live here today:
11
10
  #
12
- # - {Decoder} validates +str+ family payloads as it walks the
13
- # decoded value tree.
14
- # - {Factory} validates the +ext 0x00+ Symbol payload after
15
- # re-tagging the binary bytes as UTF-8.
11
+ # - UTF-8 assertion at the wire boundary
12
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md]
13
+ # § str/bin Encoding Rules and § Ext Types → ext 0x00). Used by
14
+ # {Decoder} when walking +str+ family payloads and by {Factory}
15
+ # when validating the +ext 0x00+ Symbol payload.
16
+ # - Wire-boundary +ArgumentError+ translation
17
+ # ({wire_boundary}) so the public taxonomy stays
18
+ # {Kobako::Codec::Error}.
19
+ # - Wire-representability predicate ({wire_representable?}) and
20
+ # the symmetric host→guest +#run+ argument walk
21
+ # ({deep_wrap}) used by +Kobako::Invocation#encode+ to route
22
+ # non-wire-representable leaves through the Sandbox's
23
+ # +Kobako::HandleTable+
24
+ # ({docs/behavior.md B-34}[link:../../../docs/behavior.md]).
16
25
  #
17
- # Encoding setup (re-tagging binary as UTF-8 when needed) stays at
18
- # the caller only the assertion shape is shared. The helper does
19
- # not mutate +string+; it only inspects +String#valid_encoding?+
20
- # against +string+'s current encoding tag.
26
+ # All helpers are pure they only inspect inputs, never mutate
27
+ # themexcept {deep_wrap}, whose only side effect is allocating
28
+ # new Handle ids into the supplied table.
21
29
  module Utils
22
30
  module_function
23
31
 
24
32
  # Raise {InvalidEncoding} unless +string+'s bytes are valid under
25
33
  # its current encoding tag. +label+ is the caller-supplied prefix
26
- # for the error message (e.g. +"str payload"+, +"ext 0x00 payload"+).
34
+ # for the error message (e.g. +"str payload"+, +"Symbol payload"+).
27
35
  def assert_utf8!(string, label)
28
36
  return if string.valid_encoding?
29
37
 
@@ -50,6 +58,91 @@ module Kobako
50
58
  rescue ::ArgumentError => e
51
59
  raise InvalidType, e.message
52
60
  end
61
+
62
+ # Inclusive Integer range the msgpack gem encodes without raising
63
+ # +RangeError+ at encode time — signed +int 64+ minimum through
64
+ # unsigned +uint 64+ maximum
65
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type
66
+ # Mapping #3, the +fixint+ / +int 8..64+ / +uint 8..64+ union).
67
+ # Anchored as a +Range+ so {primitive_wire_type?} stays a single
68
+ # dispatch line. This is the codec's wire-encode domain — not to
69
+ # be confused with the Handle id range, which lives on
70
+ # +Kobako::Handle+ as +MIN_ID+ / +MAX_ID+ (1..2^31 − 1) and
71
+ # represents a different concept entirely.
72
+ MSGPACK_INT_RANGE = (-(2**63)..((2**64) - 1))
73
+
74
+ # Wire-type predicate
75
+ # ({docs/wire-codec.md}[link:../../../docs/wire-codec.md] § Type
76
+ # Mapping). Returns +true+ when +value+ belongs to the closed
77
+ # 12-entry wire set — +nil+, +TrueClass+, +FalseClass+, +Integer+
78
+ # (in the +i64..u64+ value domain), +Float+, +String+, +Symbol+,
79
+ # +Kobako::Handle+, +Array+ whose every element is itself
80
+ # wire-representable, or +Hash+ whose every key and value are
81
+ # wire-representable. Integers outside the codec's signed-64 /
82
+ # unsigned-64 union are rejected so the predicate agrees with the
83
+ # msgpack gem's encode-time +RangeError+ behaviour the codec
84
+ # already surfaces as {UnsupportedType}.
85
+ def wire_representable?(value)
86
+ primitive_wire_type?(value) || container_wire_representable?(value)
87
+ end
88
+
89
+ # Deep-walk Array / Hash containers in +value+ and replace every
90
+ # leaf that fails {wire_representable?} with a +Kobako::Handle+
91
+ # allocated from +handle_table+
92
+ # ({docs/behavior.md B-34}[link:../../../docs/behavior.md]). The
93
+ # walk only descends through wire-representable container shapes
94
+ # (Array, Hash) one structural level at a time; a non-
95
+ # wire-representable leaf is wrapped as-is without inspecting its
96
+ # internal structure. An existing +Kobako::Handle+ is wire-
97
+ # representable and passes through unchanged — auto-wrap never
98
+ # re-wraps a Handle.
99
+ #
100
+ # +value+ may be any Ruby value; +handle_table+ must respond to
101
+ # +#alloc(object) -> Kobako::Handle+ (a host-side
102
+ # +Kobako::HandleTable+). Returns a structurally equivalent value
103
+ # whose leaves are either wire-representable or +Kobako::Handle+
104
+ # tokens.
105
+ #
106
+ # The block bodies spell +Utils.deep_wrap+ explicitly rather than
107
+ # the unqualified +deep_wrap+ because +module_function+ makes the
108
+ # instance copy of these helpers private; an implicit receiver
109
+ # inside a block would resolve against the enclosing +self+
110
+ # (still +Utils+ at definition time, but the qualified form keeps
111
+ # the dispatch readable when the recursive call sits inside a
112
+ # Proc captured from elsewhere).
113
+ def deep_wrap(value, handle_table)
114
+ case value
115
+ when ::Array then value.map { |element| Utils.deep_wrap(element, handle_table) }
116
+ when ::Hash then value.transform_values { |val| Utils.deep_wrap(val, handle_table) }
117
+ else
118
+ wire_representable?(value) ? value : handle_table.alloc(value)
119
+ end
120
+ end
121
+
122
+ # Predicate split out of {wire_representable?} for cyclomatic
123
+ # budget — the closed-set non-container branch. Returns +true+ for
124
+ # the wire scalar leaves and an existing Handle. Not part of the
125
+ # public surface; reach for {wire_representable?} instead.
126
+ def primitive_wire_type?(value)
127
+ case value
128
+ when ::NilClass, ::TrueClass, ::FalseClass, ::Float, ::String, ::Symbol, Kobako::Handle then true
129
+ when ::Integer then MSGPACK_INT_RANGE.cover?(value)
130
+ else false
131
+ end
132
+ end
133
+
134
+ # Predicate split out of {wire_representable?} for cyclomatic
135
+ # budget — the container branch. Recurses into Array elements and
136
+ # Hash key+value pairs through the public {wire_representable?}.
137
+ # Not part of the public surface; reach for {wire_representable?}
138
+ # instead.
139
+ def container_wire_representable?(value)
140
+ case value
141
+ when ::Array then value.all? { |element| Utils.wire_representable?(element) }
142
+ when ::Hash then value.all? { |key, val| Utils.wire_representable?(key) && Utils.wire_representable?(val) }
143
+ else false
144
+ end
145
+ end
53
146
  end
54
147
  end
55
148
  end
data/lib/kobako/codec.rb CHANGED
@@ -4,7 +4,8 @@ require_relative "codec/error"
4
4
 
5
5
  module Kobako
6
6
  # Host-side MessagePack codec for the kobako wire contract — the
7
- # byte-level layer (SPEC.md → Wire Codec). Two consumers sit on top:
7
+ # byte-level layer ({docs/wire-codec.md}[link:../../docs/wire-codec.md]).
8
+ # Two consumers sit on top:
8
9
  # +Kobako::RPC+ pins the RPC framing (Request / Response)
9
10
  # and +Kobako::Outcome+ owns the per-+#run+ outcome envelope (Result
10
11
  # body / Panic map).
data/lib/kobako/errors.rb CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  # Top-level Kobako namespace.
4
4
  module Kobako
5
- # Three-class error taxonomy (SPEC.md Error Scenarios).
5
+ # Three-class error taxonomy (docs/behavior.md § Error Scenarios).
6
6
  #
7
- # Every `Kobako::Sandbox#run` invocation either returns a value or raises
7
+ # Every `Kobako::Sandbox` invocation (`#eval` or `#run`) either returns a value or raises
8
8
  # exactly one of these three classes. Attribution is decided after the
9
- # guest binary returns control to the host (SPEC "Step 1 — Wasm trap"
10
- # then "Step 2 — Outcome envelope tag").
9
+ # guest binary returns control to the host (docs/behavior.md
10
+ # "Step 1 — Wasm trap" then "Step 2 — Outcome envelope tag").
11
11
  #
12
12
  # Three top-level branches:
13
13
  #
@@ -20,7 +20,7 @@ module Kobako
20
20
  # * {ServiceError} — service / capability layer (a Service RPC that
21
21
  # failed and was not rescued inside the script).
22
22
  #
23
- # Subclasses pinned by SPEC "Error Classes":
23
+ # Subclasses pinned by docs/behavior.md Error Classes:
24
24
  #
25
25
  # * {HandleTableExhausted} < {SandboxError} — id cap hit (B-21).
26
26
  # * {ServiceError::Disconnected} < {ServiceError} — `:disconnected`
@@ -35,7 +35,7 @@ module Kobako
35
35
  # violation that signals a corrupted guest execution environment
36
36
  # (zero-length OUTCOME_BUFFER, unknown outcome tag — SPEC E-02 / E-03).
37
37
  #
38
- # Two named subclasses cover the configured per-run caps from B-01:
38
+ # Two named subclasses cover the configured per-invocation caps from B-01:
39
39
  #
40
40
  # * {TimeoutError} — wall-clock +timeout+ exceeded (E-19).
41
41
  # * {MemoryLimitError} — guest +memory.grow+ would exceed
@@ -47,13 +47,13 @@ module Kobako
47
47
  # first.
48
48
  class TrapError < Error; end
49
49
 
50
- # Wall-clock timeout cap exhausted. {SPEC.md E-19}[link:../../SPEC.md]:
50
+ # Wall-clock timeout cap exhausted. {docs/behavior.md E-19}[link:../../docs/behavior.md]:
51
51
  # the absolute deadline +entry_time + timeout+ passed and the next guest
52
52
  # wasm safepoint trapped. The Sandbox is unrecoverable after this point;
53
53
  # discard and recreate before another execution.
54
54
  class TimeoutError < TrapError; end
55
55
 
56
- # Linear-memory cap exhausted. {SPEC.md E-20}[link:../../SPEC.md]:
56
+ # Linear-memory cap exhausted. {docs/behavior.md E-20}[link:../../docs/behavior.md]:
57
57
  # a guest +memory.grow+ would have pushed linear memory past the
58
58
  # configured +memory_limit+. The Sandbox is unrecoverable after this
59
59
  # point; discard and recreate before another execution.
@@ -88,7 +88,7 @@ module Kobako
88
88
  @details = details
89
89
  end
90
90
 
91
- # SPEC "Error Classes": ServiceError::Disconnected is raised
91
+ # docs/behavior.md Error Classes: ServiceError::Disconnected is raised
92
92
  # when the RPC target Handle resolves to the `:disconnected` sentinel
93
93
  # in the HandleTable (ABA protection rule — id exists but entry was
94
94
  # invalidated). E-14.
@@ -103,9 +103,21 @@ module Kobako
103
103
  # directly), it surfaces as a SandboxError.
104
104
  class HandleTableError < SandboxError; end
105
105
 
106
- # SPEC "Error Classes": HandleTableExhausted is the canonical
106
+ # docs/behavior.md Error Classes: HandleTableExhausted is the canonical
107
107
  # SandboxError subclass for the id-cap-hit path (B-21). Inherits from
108
108
  # HandleTableError so a single `rescue Kobako::HandleTableError` covers
109
109
  # both lookup-failure and cap-exhaustion paths.
110
110
  class HandleTableExhausted < HandleTableError; end
111
+
112
+ # docs/behavior.md Error Classes: BytecodeError is the SandboxError
113
+ # subclass raised when a `#preload(binary:)` snippet fails structural
114
+ # validation during the first invocation's snippet replay against a
115
+ # fresh `mrb_state` (E-37 RITE version mismatch, E-38 corrupt body).
116
+ # Bytecode that loads cleanly and then raises at top level is E-36
117
+ # and surfaces as plain `SandboxError` with the natural mruby class
118
+ # preserved. Inherits from SandboxError so a single
119
+ # `rescue Kobako::SandboxError` covers both source and bytecode
120
+ # snippet failures while callers wanting bytecode-specific handling
121
+ # can `rescue Kobako::BytecodeError` directly.
122
+ class BytecodeError < SandboxError; end
111
123
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
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; {docs/behavior.md B-14}[link:../../docs/behavior.md])
7
+ # and as a +#run+ argument auto-wrapped by the host
8
+ # ({docs/behavior.md B-34}[link:../../docs/behavior.md]).
9
+ #
10
+ # SPEC pins the binary layout to fixext 4 with a 4-byte big-endian u32
11
+ # payload ({docs/wire-codec.md}[link:../../docs/wire-codec.md]
12
+ # § Ext Types → ext 0x01). ID 0 is reserved as the invalid sentinel;
13
+ # the maximum valid ID is 0x7fff_ffff (2^31 - 1).
14
+ #
15
+ # The constructor is internal to the Host Gem. +Kobako::Handle.new+ is
16
+ # privatised so Host App code cannot fabricate a Handle from a bare
17
+ # integer; legitimate Handle instances enter Host App code only as
18
+ # fields on raised error objects. The Host Gem itself constructs
19
+ # Handles through {.from_wire}, which exists at exactly two call
20
+ # sites: +Kobako::Codec::Factory#unpack_handle+ (wire decode) and
21
+ # +Kobako::Codec::Utils.deep_wrap+ / +Kobako::RPC::Dispatcher#wrap_as_handle+
22
+ # (allocator paths). Both live inside +lib/kobako/+ and are not part
23
+ # of any public surface.
24
+ #
25
+ # The mruby counterpart +Kobako::Handle+ lives inside the Wasm guest
26
+ # under the same canonical name and shares neither code nor instances
27
+ # with this host-side class.
28
+ class Handle < Data.define(:id)
29
+ # Inclusive lower bound on the wire Handle ID. ID 0 is reserved as
30
+ # the invalid sentinel and is never allocated.
31
+ MIN_ID = 1
32
+ # Inclusive upper bound on the wire Handle ID. The cap matches the
33
+ # u32 signed-positive range so Handle IDs fit in a signed integer
34
+ # on either side of the wire without re-encoding.
35
+ MAX_ID = 0x7fff_ffff
36
+
37
+ # steep:ignore:start
38
+ def initialize(id:)
39
+ raise ArgumentError, "Handle id must be Integer" unless id.is_a?(Integer)
40
+ raise ArgumentError, "Handle id #{id} out of range [#{MIN_ID}, #{MAX_ID}]" unless id.between?(MIN_ID, MAX_ID)
41
+
42
+ super
43
+ end
44
+ # steep:ignore:end
45
+
46
+ private_class_method :new
47
+
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 +HandleTable+. Both
56
+ # paths live inside +lib/kobako/+ and treat this method as a
57
+ # package-private constructor.
58
+ def self.from_wire(id)
59
+ allocate.tap { |handle| handle.send(:initialize, id: id) }
60
+ end
61
+ end
62
+ end