kobako 0.1.2 → 0.2.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +95 -60
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +1 -1
  6. data/ext/kobako/src/wasm/cache.rs +39 -1
  7. data/ext/kobako/src/wasm/dispatch.rs +20 -20
  8. data/ext/kobako/src/wasm/host_state.rs +261 -34
  9. data/ext/kobako/src/wasm/instance.rs +467 -272
  10. data/ext/kobako/src/wasm.rs +50 -19
  11. data/lib/kobako/capture.rb +46 -0
  12. data/lib/kobako/codec/decoder.rb +66 -0
  13. data/lib/kobako/codec/encoder.rb +37 -0
  14. data/lib/kobako/codec/error.rb +33 -0
  15. data/lib/kobako/codec/factory.rb +155 -0
  16. data/lib/kobako/codec/utils.rb +55 -0
  17. data/lib/kobako/codec.rb +27 -0
  18. data/lib/kobako/errors.rb +24 -1
  19. data/lib/kobako/outcome/panic.rb +42 -0
  20. data/lib/kobako/outcome.rb +133 -0
  21. data/lib/kobako/rpc/dispatcher.rb +169 -0
  22. data/lib/kobako/rpc/envelope.rb +118 -0
  23. data/lib/kobako/{wire/exception.rb → rpc/fault.rb} +6 -4
  24. data/lib/kobako/{wire → rpc}/handle.rb +4 -2
  25. data/lib/kobako/{registry → rpc}/handle_table.rb +9 -9
  26. data/lib/kobako/{registry/service_group.rb → rpc/namespace.rb} +20 -11
  27. data/lib/kobako/rpc/server.rb +156 -0
  28. data/lib/kobako/rpc.rb +11 -0
  29. data/lib/kobako/sandbox.rb +149 -69
  30. data/lib/kobako/version.rb +1 -1
  31. data/lib/kobako/wasm.rb +6 -16
  32. data/lib/kobako.rb +2 -0
  33. data/sig/kobako/capture.rbs +13 -0
  34. data/sig/kobako/codec/decoder.rbs +11 -0
  35. data/sig/kobako/codec/encoder.rbs +7 -0
  36. data/sig/kobako/codec/error.rbs +18 -0
  37. data/sig/kobako/codec/factory.rbs +31 -0
  38. data/sig/kobako/codec/utils.rbs +9 -0
  39. data/sig/kobako/errors.rbs +52 -0
  40. data/sig/kobako/outcome/panic.rbs +34 -0
  41. data/sig/kobako/outcome.rbs +24 -0
  42. data/sig/kobako/rpc/dispatcher.rbs +33 -0
  43. data/sig/kobako/rpc/envelope.rbs +51 -0
  44. data/sig/kobako/rpc/fault.rbs +20 -0
  45. data/sig/kobako/rpc/handle.rbs +19 -0
  46. data/sig/kobako/rpc/handle_table.rbs +25 -0
  47. data/sig/kobako/rpc/namespace.rbs +24 -0
  48. data/sig/kobako/rpc/server.rbs +37 -0
  49. data/sig/kobako/rpc.rbs +4 -0
  50. data/sig/kobako/sandbox.rbs +53 -0
  51. data/sig/kobako/wasm.rbs +37 -0
  52. data/sig/kobako.rbs +0 -1
  53. metadata +37 -17
  54. data/lib/kobako/registry/dispatcher.rb +0 -168
  55. data/lib/kobako/registry.rb +0 -160
  56. data/lib/kobako/sandbox/outcome_decoder.rb +0 -100
  57. data/lib/kobako/sandbox/output_buffer.rb +0 -79
  58. data/lib/kobako/wire/codec/decoder.rb +0 -87
  59. data/lib/kobako/wire/codec/encoder.rb +0 -41
  60. data/lib/kobako/wire/codec/error.rb +0 -35
  61. data/lib/kobako/wire/codec/factory.rb +0 -136
  62. data/lib/kobako/wire/codec.rb +0 -44
  63. data/lib/kobako/wire/envelope/payloads.rb +0 -145
  64. data/lib/kobako/wire/envelope.rb +0 -147
  65. data/lib/kobako/wire.rb +0 -40
@@ -1,160 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "msgpack"
4
- require_relative "errors"
5
- require_relative "wire"
6
- require_relative "registry/service_group"
7
- require_relative "registry/handle_table"
8
- require_relative "registry/dispatcher"
9
-
10
- module Kobako
11
- # Kobako::Registry — per-Sandbox container of Service Groups and Handle
12
- # state. Manages capability injection and guest-initiated RPC dispatch
13
- # ({SPEC.md B-07..B-21}[link:../../SPEC.md]).
14
- #
15
- # Public API:
16
- #
17
- # registry = Kobako::Registry.new
18
- # group = registry.define(:MyService) # => ServiceGroup
19
- # group.bind(:KV, kv_object) # => group (chainable)
20
- # registry.to_preamble # => array for Frame 1
21
- # registry.dispatch(request_bytes) # => msgpack bytes (delegated to Dispatcher)
22
- #
23
- # Service Groups are defined in +Kobako::Registry::ServiceGroup+
24
- # (lib/kobako/registry/service_group.rb). The opaque Handle allocator lives
25
- # in +Kobako::Registry::HandleTable+ (lib/kobako/registry/handle_table.rb).
26
- # Dispatch helpers live in +Kobako::Registry::Dispatcher+
27
- # (lib/kobako/registry/dispatcher.rb).
28
- class Registry
29
- # Ruby constant-name pattern shared by Group and Member names
30
- # ({SPEC.md B-07/B-08 Notes}[link:../../SPEC.md]). Referenced by both
31
- # +#define+ here and +ServiceGroup#bind+ — single source of truth so
32
- # the validation rule cannot drift between the two boundaries.
33
- NAME_PATTERN = /\A[A-Z]\w*\z/
34
-
35
- # Build a fresh Registry. +handle_table+ is an internal seam that
36
- # injects a pre-configured +HandleTable+; tests pass one whose +next_id+
37
- # is pinned near +MAX_ID+ to exercise the B-21 cap-exhaustion path
38
- # without 2³¹ allocations. Production callers leave it at the default.
39
- def initialize(handle_table: HandleTable.new)
40
- @groups = {}
41
- @handle_table = handle_table
42
- @sealed = false
43
- end
44
-
45
- # Declare or retrieve the Group named +name+ (idempotent — SPEC.md B-10).
46
- # +name+ is a constant-form name as a +Symbol+ or +String+ (must satisfy
47
- # +NAME_PATTERN+). Returns the +Kobako::Registry::ServiceGroup+ for that
48
- # name, creating it if it does not exist. Raises +ArgumentError+ when
49
- # +name+ is malformed, or when called after the owning Sandbox has been
50
- # sealed by +#run+.
51
- def define(name)
52
- raise ArgumentError, "cannot define after Sandbox#run has been invoked" if @sealed
53
-
54
- name_str = name.to_s
55
- unless NAME_PATTERN.match?(name_str)
56
- raise ArgumentError,
57
- "GroupName must match #{NAME_PATTERN.inspect} (got #{name.inspect})"
58
- end
59
-
60
- @groups[name_str] ||= ServiceGroup.new(name_str)
61
- end
62
-
63
- # Resolve a +target+ path of the form +"GroupName::MemberName"+ to the
64
- # bound Host object. +target+ is a two-level path using the +::+
65
- # separator. Returns the bound Host object. Raises +KeyError+ when the
66
- # group or the member is not bound.
67
- def lookup(target)
68
- group, member_name, group_name = resolve_pair(target)
69
- raise KeyError, "no service group named #{group_name.inspect}" if group.nil?
70
- raise KeyError, "no member #{target.inspect} bound in registry" unless member_name
71
-
72
- group.fetch(member_name)
73
- end
74
-
75
- # Returns +true+ when +target+ (a +"GroupName::MemberName"+ path) resolves
76
- # to a bound member, +false+ otherwise.
77
- def bound?(target)
78
- group, member_name, = resolve_pair(target)
79
- !group.nil? && !member_name.nil? && !group[member_name].nil?
80
- end
81
-
82
- # Returns all declared +Kobako::Registry::ServiceGroup+ instances as an
83
- # +Array+.
84
- def groups
85
- @groups.values
86
- end
87
-
88
- # Returns the number of declared groups as an +Integer+.
89
- def size
90
- @groups.size
91
- end
92
-
93
- # Returns +true+ when no groups have been declared, +false+ otherwise.
94
- def empty?
95
- @groups.empty?
96
- end
97
-
98
- # Structured Frame 1 description. Called by +Sandbox#run+ when assembling
99
- # stdin Frame 1 ({SPEC.md B-02}[link:../../SPEC.md]). Returns an
100
- # unencoded preamble array — an +Array+ of two-element +[name, members]+
101
- # arrays, one per declared group.
102
- def to_preamble
103
- @groups.values.map(&:to_preamble)
104
- end
105
-
106
- # Encode the preamble as msgpack bytes for stdin Frame 1 delivery
107
- # ({SPEC.md B-02}[link:../../SPEC.md]). Uses plain MessagePack (no
108
- # kobako ext types) because the preamble contains only strings — no
109
- # Handles or Exception envelopes. Structure:
110
- # +[["GroupName", ["MemberA", "MemberB"]], ...]+. Returns a binary
111
- # +String+ of msgpack bytes.
112
- def guest_preamble
113
- MessagePack.pack(to_preamble)
114
- end
115
-
116
- # Mark the Registry as sealed. Called by `Sandbox#run` on first run.
117
- # After sealing, #define raises ArgumentError. Idempotent.
118
- def seal!
119
- @sealed = true
120
- self
121
- end
122
-
123
- # Returns +true+ when {#seal!} has been called, +false+ otherwise.
124
- def sealed?
125
- @sealed
126
- end
127
-
128
- # Reset the HandleTable for a new +#run+ boundary. Called by +Sandbox#run+
129
- # before each invocation ({SPEC.md B-19}[link:../../SPEC.md]).
130
- def reset_handles!
131
- @handle_table.reset!
132
- end
133
-
134
- # Dispatch a single RPC request and return the encoded response bytes
135
- # ({SPEC.md B-12}[link:../../SPEC.md]). +request_bytes+ is a
136
- # msgpack-encoded Request envelope. Called by the Rust ext from inside
137
- # +__kobako_rpc_call+. Always returns a binary +String+ — never raises.
138
- # Delegates to +Dispatcher.dispatch+ which reifies any failure as a
139
- # +Response.err+ envelope so the guest sees the failure as a normal RPC
140
- # error rather than a wasm trap.
141
- def dispatch(request_bytes)
142
- Dispatcher.dispatch(request_bytes, self)
143
- end
144
-
145
- # Expose the +Kobako::Registry::HandleTable+ for tests and wire-layer
146
- # Handle wrapping.
147
- attr_reader :handle_table
148
-
149
- private
150
-
151
- # Split +target+ on the +::+ separator and resolve the group half.
152
- # Returns +[group_or_nil, member_str_or_nil, group_name_str]+ so each
153
- # public method ({#lookup} / {#bound?}) only owns its boundary
154
- # semantics (raise vs predicate).
155
- def resolve_pair(target)
156
- group_name, member_name = target.to_s.split("::", 2)
157
- [@groups[group_name], member_name, group_name]
158
- end
159
- end
160
- end
@@ -1,100 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Kobako
4
- class Sandbox
5
- # Pure-function decoder for the OUTCOME_BUFFER bytes returned by
6
- # +__kobako_run+. Maps a tagged msgpack envelope to either the unwrapped
7
- # mruby return value or a raised three-layer
8
- # ({SPEC.md "Error Scenarios"}[link:../../../SPEC.md]) exception.
9
- #
10
- # * tag 0x01, decode OK → return Result.value
11
- # * tag 0x01, decode fails → SandboxError (E-09)
12
- # * tag 0x02, origin="service" → ServiceError (E-13)
13
- # * tag 0x02, origin="sandbox"/missing → SandboxError (E-04..E-07)
14
- # * tag 0x02, decode fails → SandboxError (E-08)
15
- # * unknown tag → TrapError (E-03)
16
- module OutcomeDecoder
17
- module_function
18
-
19
- def decode(bytes)
20
- tag, body = split_outcome_tag(bytes)
21
- case tag
22
- when Kobako::Wire::Envelope::OUTCOME_TAG_RESULT
23
- decode_result(body)
24
- when Kobako::Wire::Envelope::OUTCOME_TAG_PANIC
25
- decode_panic(body)
26
- else
27
- raise trap_for_tag(tag)
28
- end
29
- end
30
-
31
- # TrapError for unknown or absent tag
32
- # ({SPEC.md ABI Signatures}[link:../../../SPEC.md]: len=0 and unknown-tag
33
- # both walk the trap path).
34
- def trap_for_tag(tag)
35
- return TrapError.new("guest exited without writing an outcome (len=0)") if tag.nil?
36
-
37
- TrapError.new(format("unknown outcome tag 0x%<tag>02x", tag: tag))
38
- end
39
-
40
- def split_outcome_tag(bytes)
41
- bytes = bytes.b
42
- [bytes.getbyte(0), bytes.byteslice(1, bytes.bytesize - 1)]
43
- end
44
-
45
- # Decode failure on a known Result tag is a SandboxError (E-09): the
46
- # framing was fine, but the wrapped value is unrepresentable.
47
- def decode_result(body)
48
- Kobako::Wire::Envelope.decode_result(body).value
49
- rescue Kobako::Wire::Codec::Error => e
50
- raise wire_violation_error("result envelope decode failed: #{e.message}")
51
- end
52
-
53
- # Decode failure on a known Panic tag is a SandboxError (E-08). Either
54
- # path raises — on success the decoded Panic is mapped to its three-
55
- # layer exception via +build_panic_error+ and raised; on wire-decode
56
- # failure the rescue path raises the wire-violation +SandboxError+.
57
- # Symmetric with +decode_result+ — both have signature
58
- # "decode body and return value, or raise".
59
- def decode_panic(body)
60
- raise build_panic_error(Kobako::Wire::Envelope.decode_panic(body))
61
- rescue Kobako::Wire::Codec::Error => e
62
- raise wire_violation_error("panic envelope decode failed: #{e.message}")
63
- end
64
-
65
- # Map a decoded Panic envelope into the corresponding three-layer
66
- # Ruby exception. +origin == "service"+ → ServiceError (with the
67
- # +::Disconnected+ subclass selected when the panic carries the
68
- # disconnected sentinel —
69
- # {SPEC "Error Classes"}[link:../../../SPEC.md]); everything else
70
- # → SandboxError.
71
- def build_panic_error(panic)
72
- panic_target_class(panic).new(
73
- panic.message,
74
- origin: panic.origin,
75
- klass: panic.klass,
76
- backtrace_lines: panic.backtrace,
77
- details: panic.details
78
- )
79
- end
80
-
81
- # {SPEC "Error Classes"}[link:../../../SPEC.md]: when
82
- # +origin="service"+ and the panic +class+ field names
83
- # +ServiceError::Disconnected+, surface that subclass so callers can
84
- # rescue the disconnected path specifically (E-14).
85
- def panic_target_class(panic)
86
- return SandboxError unless panic.origin == Kobako::Wire::Envelope::Panic::ORIGIN_SERVICE
87
-
88
- panic.klass == "Kobako::ServiceError::Disconnected" ? ServiceError::Disconnected : ServiceError
89
- end
90
-
91
- def wire_violation_error(message)
92
- SandboxError.new(
93
- message,
94
- origin: Kobako::Wire::Envelope::Panic::ORIGIN_SANDBOX,
95
- klass: "Kobako::WireError"
96
- )
97
- end
98
- end
99
- end
100
- end
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Kobako
4
- class Sandbox
5
- # In-memory bounded byte buffer for one of the guest's output channels.
6
- # Tracks accumulated bytes (binary-encoded) and enforces the per-channel
7
- # cap by truncating-with-marker ({SPEC.md B-04}[link:../../../SPEC.md]).
8
- #
9
- # When the accumulated byte count would exceed the limit, the buffer keeps
10
- # as many leading bytes as fit and seals itself. Subsequent appends are
11
- # discarded. On the next read, +OUTPUT_TRUNCATION_MARKER+ is appended to
12
- # signal the overflow to the caller.
13
- class OutputBuffer
14
- # Marker appended to a buffer that hit its capture limit
15
- # ({SPEC.md B-04}[link:../../../SPEC.md]).
16
- OUTPUT_TRUNCATION_MARKER = "[truncated]"
17
-
18
- attr_reader :limit
19
-
20
- def initialize(limit)
21
- raise ArgumentError, "limit must be a positive Integer" unless limit.is_a?(Integer) && limit.positive?
22
-
23
- @limit = limit
24
- @bytes = String.new(encoding: Encoding::ASCII_8BIT)
25
- @truncated = false
26
- end
27
-
28
- # Append +bytes+ to the buffer. If the append would push the
29
- # cumulative byte count past the limit, the buffer keeps as many
30
- # leading bytes as fit and seals itself; subsequent appends are
31
- # discarded. {SPEC.md B-04}[link:../../../SPEC.md] — truncation is a
32
- # non-error outcome.
33
- def <<(bytes)
34
- return self if @truncated
35
-
36
- appended = bytes.to_s.b
37
- room = @limit - @bytes.bytesize
38
- if appended.bytesize <= room
39
- @bytes << appended
40
- else
41
- @bytes << appended.byteslice(0, room) if room.positive?
42
- @truncated = true
43
- end
44
- self
45
- end
46
-
47
- # Returns +true+ when the buffer was sealed by an overflow.
48
- def truncated?
49
- @truncated
50
- end
51
-
52
- # Returns the number of bytes currently stored.
53
- def bytesize
54
- @bytes.bytesize
55
- end
56
-
57
- # Returns +true+ when the buffer is empty.
58
- def empty?
59
- @bytes.empty?
60
- end
61
-
62
- # Returns the accumulated bytes as a UTF-8 String, with the
63
- # +[truncated]+ marker appended when the buffer overflowed.
64
- def to_s
65
- copy = @bytes.dup
66
- copy << OUTPUT_TRUNCATION_MARKER.b if @truncated
67
- copy.force_encoding(Encoding::UTF_8)
68
- copy.valid_encoding? ? copy : copy.dup.force_encoding(Encoding::ASCII_8BIT)
69
- end
70
-
71
- # Reset the buffer to empty. Used at the per-+#run+ boundary.
72
- def clear
73
- @bytes.clear
74
- @truncated = false
75
- self
76
- end
77
- end
78
- end
79
- end
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "msgpack"
4
-
5
- require_relative "error"
6
- require_relative "../handle"
7
- require_relative "../exception"
8
- require_relative "factory"
9
-
10
- module Kobako
11
- module Wire
12
- module Codec
13
- # Module-level entry point for the host side of the kobako wire
14
- # (SPEC.md → Wire Codec → Type Mapping).
15
- #
16
- # Translates msgpack gem exceptions into the kobako error taxonomy
17
- # ({Truncated}, {InvalidType}, {InvalidEncoding}, {UnsupportedType}) so
18
- # callers can pattern-match on the SPEC's wire-violation categories
19
- # without leaking the gem's internal exception classes.
20
- #
21
- # Public API is a single function — {.decode}. The decoder is
22
- # stateless; the +MessagePack::Unpacker+ instance is built per call
23
- # because callers always decode exactly one wire value at a time.
24
- module Decoder
25
- # The msgpack gem raises these for type/format violations; +ArgumentError+
26
- # also comes from our ext-type validators (Handle id range, Exception
27
- # type whitelist). All surface as {InvalidType}.
28
- INVALID_TYPE_ERRORS = [
29
- ::MessagePack::UnknownExtTypeError,
30
- ::MessagePack::MalformedFormatError,
31
- ::MessagePack::StackError,
32
- ::ArgumentError
33
- ].freeze
34
- private_constant :INVALID_TYPE_ERRORS
35
-
36
- # +UnpackError+ is the gem's umbrella class for short-read / incomplete-buffer
37
- # faults; +EOFError+ covers underflow at the buffer edge. Both map to {Truncated}.
38
- TRUNCATED_ERRORS = [::MessagePack::UnpackError, ::EOFError].freeze
39
- private_constant :TRUNCATED_ERRORS
40
-
41
- # Decode +bytes+ into one Ruby value and validate transitively
42
- # against the SPEC type mapping. Raises {Truncated}, {InvalidType},
43
- # or {InvalidEncoding} on wire violations.
44
- def self.decode(bytes)
45
- value = Factory.instance.load(bytes.b)
46
- validate_utf8!(value)
47
- value
48
- rescue *INVALID_TYPE_ERRORS => e
49
- raise InvalidType, e.message
50
- rescue *TRUNCATED_ERRORS => e
51
- raise Truncated, e.message
52
- rescue ::EncodingError => e
53
- raise InvalidEncoding, e.message
54
- end
55
-
56
- # SPEC pins +str+ family payloads to UTF-8 (Wire Codec → str/bin
57
- # Encoding Rules). The msgpack gem returns UTF-8-tagged Strings for
58
- # str family but does not validate the bytes; +bin+ family decodes
59
- # to ASCII-8BIT. Walk the tree once and reject invalid UTF-8 in any
60
- # str-typed leaf. {Exception} payloads are validated transitively:
61
- # +Factory.unpack_exception+ feeds the inner ext-0x02 bytes back
62
- # through this Decoder, so their +str+ fields are already covered
63
- # by the time control returns here.
64
- def self.validate_utf8!(value)
65
- case value
66
- when String then validate_string_utf8!(value)
67
- when Array then value.each { |v| validate_utf8!(v) }
68
- when Hash then value.each_pair { |k, v| validate_pair_utf8!(k, v) }
69
- end
70
- end
71
- private_class_method :validate_utf8!
72
-
73
- def self.validate_string_utf8!(value)
74
- return unless value.encoding == Encoding::UTF_8
75
- raise InvalidEncoding, "str payload is not valid UTF-8" unless value.valid_encoding?
76
- end
77
- private_class_method :validate_string_utf8!
78
-
79
- def self.validate_pair_utf8!(key, value)
80
- validate_utf8!(key)
81
- validate_utf8!(value)
82
- end
83
- private_class_method :validate_pair_utf8!
84
- end
85
- end
86
- end
87
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "msgpack"
4
-
5
- require_relative "error"
6
- require_relative "../handle"
7
- require_relative "../exception"
8
- require_relative "factory"
9
-
10
- module Kobako
11
- module Wire
12
- module Codec
13
- # Module-level entry point for the host side of the kobako wire
14
- # (SPEC.md → Wire Codec → Type Mapping).
15
- #
16
- # The codec backbone is the official +msgpack+ gem: integers, floats,
17
- # strings, arrays, and maps go through the gem's narrowest-encoding
18
- # logic; the three kobako-specific ext types (0x00 Symbol, 0x01
19
- # Capability Handle, 0x02 Exception envelope) are registered on
20
- # +Factory+ via {Kobako::Wire::Codec::Factory.instance}.
21
- #
22
- # Public API is a single function — {.encode}. The codec is stateless;
23
- # there is no buffer accumulator and no streaming write API. Callers
24
- # that need to concatenate multiple encodings build the bytes
25
- # themselves (see {Envelope.encode_outcome} for the canonical example).
26
- module Encoder
27
- # Encode +value+ to wire bytes (binary-encoded String).
28
- # Wire violations surface as +UnsupportedType+: SPEC's 12-entry type
29
- # mapping is a closed set, and anything outside it is rejected by
30
- # the msgpack gem itself (arbitrary objects raise +NoMethodError+
31
- # from missing +to_msgpack+, integers outside i64..u64 raise
32
- # +RangeError+).
33
- def self.encode(value)
34
- Factory.instance.dump(value)
35
- rescue ::RangeError, ::NoMethodError => e
36
- raise UnsupportedType, e.message
37
- end
38
- end
39
- end
40
- end
41
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Kobako
4
- module Wire
5
- module Codec
6
- # Base class for all wire-codec faults raised by the pure-Ruby host codec.
7
- #
8
- # The wire codec implements the binary contract pinned in SPEC.md
9
- # (Wire Codec → Type Mapping). Every wire violation surfaces as a
10
- # subclass of {Error} so callers can pattern-match on the specific
11
- # fault while still rescuing all codec faults via this base class.
12
- #
13
- # Higher layers (e.g. the Sandbox dispatch loop) translate these into
14
- # the public {Kobako::SandboxError} / {Kobako::TrapError} taxonomy.
15
- class Error < StandardError; end
16
-
17
- # Input ended before the type prefix or payload was fully consumed.
18
- class Truncated < Error; end
19
-
20
- # The type byte at the current position is not in the 12-entry kobako
21
- # type mapping (e.g. an unknown ext code, or a reserved msgpack tag).
22
- class InvalidType < Error; end
23
-
24
- # A msgpack `str` payload was not valid UTF-8, or an ext 0x00 Symbol
25
- # payload was not valid UTF-8 — both are wire violations per SPEC.
26
- class InvalidEncoding < Error; end
27
-
28
- # The encoder was handed a Ruby object whose type has no wire
29
- # representation (e.g. Range, Time). Higher layers may catch this
30
- # and re-route the value through Handle allocation, but at the
31
- # codec level it is a hard error.
32
- class UnsupportedType < Error; end
33
- end
34
- end
35
- end
@@ -1,136 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "msgpack"
4
-
5
- require_relative "error"
6
- require_relative "../handle"
7
- require_relative "../exception"
8
-
9
- module Kobako
10
- module Wire
11
- module Codec
12
- # Cached +MessagePack::Factory+ that owns the kobako wire ext-type
13
- # registration (SPEC.md → Wire Codec → Ext Types).
14
- #
15
- # The factory is the single place in the host gem that touches msgpack
16
- # API — both {Encoder} and {Decoder} delegate through it, so the three
17
- # kobako ext codes (0x00 Symbol, 0x01 Capability Handle, 0x02 Exception
18
- # envelope) are configured exactly once at module load.
19
- module Factory
20
- # MessagePack ext type code reserved for Symbol
21
- # (SPEC.md → Wire Codec → Ext Types → ext 0x00). Module-private —
22
- # mirrors +codec::EXT_SYMBOL+ on the Rust side.
23
- EXT_SYMBOL = 0x00
24
- # MessagePack ext type code reserved for Capability Handle
25
- # (SPEC.md → Wire Codec → Ext Types → ext 0x01). Module-private —
26
- # mirrors +codec::EXT_HANDLE+ on the Rust side.
27
- EXT_HANDLE = 0x01
28
- # MessagePack ext type code reserved for Exception envelope
29
- # (SPEC.md → Wire Codec → Ext Types → ext 0x02). Module-private —
30
- # mirrors +codec::EXT_ERRENV+ on the Rust side.
31
- EXT_ERRENV = 0x02
32
- private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
33
-
34
- # Returns the lazily-built process-wide +MessagePack::Factory+.
35
- def self.instance
36
- @instance ||= build
37
- end
38
-
39
- # Build a fresh factory. Exposed for tests that need an isolated
40
- # instance; production code should call {.instance}.
41
- def self.build
42
- factory = MessagePack::Factory.new
43
- register_symbol_type(factory)
44
- register_handle_type(factory)
45
- register_exception_type(factory)
46
- factory
47
- end
48
-
49
- def self.register_symbol_type(factory)
50
- factory.register_type(
51
- EXT_SYMBOL, Symbol,
52
- packer: lambda(&:name),
53
- unpacker: ->(payload) { decode_symbol(payload) }
54
- )
55
- end
56
- private_class_method :register_symbol_type
57
-
58
- # Validate the ext-0x00 payload as UTF-8 and intern. Raises
59
- # {InvalidEncoding} on invalid bytes — SPEC forbids the
60
- # binary-encoding fallback that msgpack-gem's default unpacker
61
- # would otherwise apply.
62
- def self.decode_symbol(payload)
63
- name = payload.b.force_encoding(Encoding::UTF_8)
64
- raise InvalidEncoding, "ext 0x00 payload is not valid UTF-8" unless name.valid_encoding?
65
-
66
- name.to_sym
67
- end
68
- private_class_method :decode_symbol
69
-
70
- def self.register_handle_type(factory)
71
- factory.register_type(
72
- EXT_HANDLE, Handle,
73
- packer: ->(handle) { [handle.id].pack("N") },
74
- unpacker: ->(payload) { decode_handle(payload) }
75
- )
76
- end
77
- private_class_method :register_handle_type
78
-
79
- def self.register_exception_type(factory)
80
- factory.register_type(
81
- EXT_ERRENV, Exception,
82
- packer: ->(exc) { pack_exception(exc) },
83
- unpacker: ->(payload) { unpack_exception(payload) }
84
- )
85
- end
86
- private_class_method :register_exception_type
87
-
88
- # Peel off the fixext-4 frame, hand the bytes to +Handle.new+, and
89
- # translate the +ArgumentError+ raised by Handle's invariants into
90
- # a wire-layer +InvalidType+ via {Codec.translate_value_object_error}.
91
- # The Value Object owns the id-range contract; this method only
92
- # owns the frame shape.
93
- def self.decode_handle(payload)
94
- bytes = payload.b
95
- raise InvalidType, "ext 0x01 payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
96
-
97
- Codec.translate_value_object_error { Handle.new(bytes.unpack1("N")) }
98
- end
99
- private_class_method :decode_handle
100
-
101
- # Encode the inner ext-0x02 map via {Encoder} (not +factory.dump+) so
102
- # the embedded payload flows through the same boundary as a top-level
103
- # encode — nested kobako values (Handle, nested Exception) reach the
104
- # registered ext-type packers via the cached {.instance}.
105
- def self.pack_exception(exc)
106
- Encoder.encode("type" => exc.type, "message" => exc.message, "details" => exc.details)
107
- end
108
- private_class_method :pack_exception
109
-
110
- # Peel the embedded msgpack map and hand it to +Exception.new+;
111
- # translate the value-object's +ArgumentError+ into +InvalidType+
112
- # at the wire boundary. Inner decode goes through {Decoder} (not
113
- # +factory.load+) so the embedded +str+ payloads flow through the
114
- # same UTF-8 validation as a top-level decode.
115
- #
116
- # This establishes a runtime cycle Factory → Decoder → Factory:
117
- # the cached +.instance+ feeds +Decoder.decode+, which re-enters
118
- # this method when a nested ext 0x02 appears inside +details+. The
119
- # recursion is bounded by msgpack nesting depth — identical to
120
- # nested Array / Hash payloads — so no extra guard is needed.
121
- # Do not switch back to +factory.load+ to "simplify": that path
122
- # bypasses UTF-8 validation and re-opens the Decoder's special
123
- # case for Exception (removed in M5).
124
- def self.unpack_exception(payload)
125
- map = Decoder.decode(payload)
126
- raise InvalidType, "ext 0x02 payload must be a map" unless map.is_a?(Hash)
127
-
128
- Codec.translate_value_object_error do
129
- Exception.new(type: map["type"], message: map["message"], details: map["details"])
130
- end
131
- end
132
- private_class_method :unpack_exception
133
- end
134
- end
135
- end
136
- end
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "codec/error"
4
-
5
- module Kobako
6
- module Wire
7
- # Host-side MessagePack codec for the kobako wire contract — the
8
- # byte-level layer (SPEC.md → Wire Codec). The envelope layer
9
- # (Kobako::Wire::Envelope) sits on top of this and pins the four
10
- # logical message shapes (Request / Response / Result / Panic).
11
- #
12
- # Backed by the official +msgpack+ gem via {Factory}; {Encoder} and
13
- # {Decoder} are thin wrappers that register the three kobako-specific
14
- # ext types (0x00 Symbol, 0x01 Capability Handle, 0x02 Exception
15
- # envelope) on a single +MessagePack::Factory+ instance. The Rust side
16
- # mirrors this layer as the +codec+ module in the +kobako-wasm+ crate;
17
- # the ext-code constants live as module-private values on {Factory}
18
- # alongside +codec::EXT_SYMBOL+ / +codec::EXT_HANDLE+ /
19
- # +codec::EXT_ERRENV+ on that side.
20
- module Codec
21
- # Wire-boundary translator: every wire Value Object (Handle /
22
- # Exception / Request / Response / Panic / Outcome) raises
23
- # +ArgumentError+ when an invariant is violated at construction.
24
- # The wire boundary surfaces those violations to callers as
25
- # +InvalidType+ so the public taxonomy stays +Codec::Error+ and
26
- # never leaks +ArgumentError+ from the Ruby standard library.
27
- #
28
- # Wrap any block that constructs a wire Value Object from decoded
29
- # bytes with this helper to keep the five decode sites (Request /
30
- # Response / Panic / Handle ext / Exception ext) uniform. Do not
31
- # use it for general-purpose validation outside the wire boundary
32
- # — host-layer +ArgumentError+ values should propagate unchanged.
33
- def self.translate_value_object_error
34
- yield
35
- rescue ::ArgumentError => e
36
- raise InvalidType, e.message
37
- end
38
- end
39
- end
40
- end
41
-
42
- require_relative "codec/factory"
43
- require_relative "codec/encoder"
44
- require_relative "codec/decoder"