kobako 0.1.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.
@@ -0,0 +1,79 @@
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
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "registry"
5
+ require_relative "wire"
6
+ require_relative "sandbox/output_buffer"
7
+ require_relative "sandbox/outcome_decoder"
8
+
9
+ module Kobako
10
+ # Kobako::Sandbox — the user-facing entry point for executing guest mruby
11
+ # scripts inside a wasmtime-hosted Wasm module
12
+ # ({SPEC.md B-01}[link:../../SPEC.md]).
13
+ #
14
+ # The Sandbox owns the +Kobako::Wasm::Instance+, the per-instance Registry
15
+ # (which itself owns the per-run HandleTable), and bounded stdout / stderr
16
+ # capture buffers. The underlying wasmtime Engine and compiled Module are
17
+ # cached at process scope by the native ext and never surface to Ruby —
18
+ # constructing many Sandboxes amortises both costs automatically.
19
+ #
20
+ # Buffer overflow policy ({SPEC.md B-04}[link:../../SPEC.md]): once an
21
+ # append would push the cumulative byte count past the per-channel
22
+ # `*_limit`, the OutputBuffer truncates — it stores a prefix that fits
23
+ # under the cap and appends a +[truncated]+ marker on the next read.
24
+ # Truncation does NOT raise. The marker constant lives on
25
+ # +Kobako::Sandbox::OutputBuffer::OUTPUT_TRUNCATION_MARKER+.
26
+ class Sandbox
27
+ # Default per-channel capture ceiling: 1 MiB
28
+ # ({SPEC.md B-01 footnote}[link:../../SPEC.md]).
29
+ DEFAULT_OUTPUT_LIMIT = 1 << 20
30
+
31
+ attr_reader :wasm_path, :instance,
32
+ :stdout_buffer, :stderr_buffer,
33
+ :stdout_limit, :stderr_limit, :services
34
+
35
+ # Returns the complete byte content guest wrote to stdout during the most
36
+ # recent +#run+ as a UTF-8 String, or an empty String before any +#run+
37
+ # call. {SPEC.md B-04}[link:../../SPEC.md]: may contain a +[truncated]+
38
+ # marker when the cap was hit.
39
+ def stdout
40
+ @stdout_buffer.to_s
41
+ end
42
+
43
+ # Returns the complete byte content guest wrote to stderr during the most
44
+ # recent +#run+ as a UTF-8 String, or an empty String before any +#run+
45
+ # call. {SPEC.md B-04}[link:../../SPEC.md]: may contain a +[truncated]+
46
+ # marker when the cap was hit.
47
+ def stderr
48
+ @stderr_buffer.to_s
49
+ end
50
+
51
+ # Build a fresh Sandbox.
52
+ #
53
+ # +wasm_path+ is the absolute path to the Guest Binary; defaults to the
54
+ # gem-bundled +data/kobako.wasm+. +stdout_limit+ and +stderr_limit+ cap
55
+ # the per-run byte ceiling for each capture channel (default 1 MiB).
56
+ def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil)
57
+ @wasm_path = wasm_path || Kobako::Wasm.default_path
58
+ @stdout_limit = stdout_limit || DEFAULT_OUTPUT_LIMIT
59
+ @stderr_limit = stderr_limit || DEFAULT_OUTPUT_LIMIT
60
+ @instance = Kobako::Wasm::Instance.from_path(@wasm_path)
61
+ @stdout_buffer = OutputBuffer.new(@stdout_limit)
62
+ @stderr_buffer = OutputBuffer.new(@stderr_limit)
63
+ @services = Kobako::Registry.new
64
+ @instance.set_registry(@services)
65
+ end
66
+
67
+ # Declare or retrieve the Service Group named +name+ on this Sandbox
68
+ # ({SPEC.md B-07, B-09, B-10}[link:../../SPEC.md]). +name+ must be a
69
+ # Symbol or String in constant form. Returns the
70
+ # +Kobako::Registry::ServiceGroup+.
71
+ #
72
+ # Raises +ArgumentError+ when called after +#run+, or when +name+ does
73
+ # not match the constant-name pattern.
74
+ def define(name)
75
+ @services.define(name)
76
+ end
77
+
78
+ # Execute a guest mruby script
79
+ # ({SPEC.md B-02 / B-03}[link:../../SPEC.md]). +source+ is the mruby
80
+ # source code as a UTF-8 String. Returns the deserialized last
81
+ # expression of the script.
82
+ #
83
+ # Source delivery uses the WASI stdin two-frame protocol
84
+ # ({SPEC.md ABI Signatures}[link:../../SPEC.md]): Frame 1 carries the
85
+ # msgpack-encoded preamble (Service Group registry snapshot) and Frame 2
86
+ # carries the user script UTF-8 bytes. Each frame is prefixed by a
87
+ # 4-byte big-endian u32 length.
88
+ #
89
+ # Raises +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
90
+ # +Kobako::SandboxError+ when the guest ran to completion but failed;
91
+ # +Kobako::ServiceError+ on an unrescued Service capability failure.
92
+ def run(source)
93
+ raise SandboxError, "source must be a String, got #{source.class}" unless source.is_a?(String)
94
+
95
+ @services.seal!
96
+ reset_run_state!
97
+ preamble = @services.guest_preamble
98
+ @instance.setup_wasi_pipes(@stdout_limit, @stderr_limit, preamble, source.b)
99
+
100
+ invoke_guest_run
101
+ drain_wasi_output
102
+ outcome_bytes = read_outcome_bytes
103
+ OutcomeDecoder.decode(outcome_bytes)
104
+ end
105
+
106
+ private
107
+
108
+ # Per-run state reset ({SPEC.md B-03}[link:../../SPEC.md]). Capture
109
+ # buffers and the HandleTable counter are zeroed before the guest runs.
110
+ def reset_run_state!
111
+ @services.reset_handles!
112
+ @stdout_buffer.clear
113
+ @stderr_buffer.clear
114
+ end
115
+
116
+ # Drain the WASI stdout/stderr pipes populated during the most recent
117
+ # guest execution into the bounded OutputBuffers
118
+ # ({SPEC.md B-04}[link:../../SPEC.md]). Must be called after
119
+ # `invoke_guest_run` and before the next reset.
120
+ def drain_wasi_output
121
+ stdout_bytes = @instance.take_stdout
122
+ stderr_bytes = @instance.take_stderr
123
+ @stdout_buffer << stdout_bytes unless stdout_bytes.empty?
124
+ @stderr_buffer << stderr_bytes unless stderr_bytes.empty?
125
+ end
126
+
127
+ # Invoke `__kobako_run`. Wraps wasmtime / wire errors in TrapError.
128
+ # Source was already delivered via the stdin two-frame protocol in
129
+ # `setup_wasi_pipes` before this call
130
+ # ({SPEC.md ABI Signatures}[link:../../SPEC.md]).
131
+ def invoke_guest_run
132
+ @instance.run
133
+ rescue Kobako::Wasm::Error => e
134
+ raise TrapError, "guest __kobako_run trapped: #{e.message}"
135
+ end
136
+
137
+ # Pull the OUTCOME_BUFFER bytes out of guest memory. The +len=0+ case
138
+ # is forwarded to {OutcomeDecoder} as an empty String so a single
139
+ # boundary attributes every wire-violation outcome
140
+ # ({SPEC.md ABI Signatures}[link:../../SPEC.md]).
141
+ def read_outcome_bytes
142
+ ptr, len = Kobako::Wasm.unpack_outcome_ptr_len(@instance.take_outcome)
143
+ @instance.read_memory(ptr, len)
144
+ rescue Kobako::Wasm::Error => e
145
+ raise TrapError, "failed to read OUTCOME_BUFFER: #{e.message}"
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # Host-side wasmtime wrapper, surfaced as Ruby classes by the native ext
5
+ # (see ext/kobako/src/wasm.rs). This module is the foundational binding
6
+ # layer for Sandbox (#14), the run path (#16) and RPC dispatch (#18).
7
+ #
8
+ # The classes themselves (Engine / Module / Store / Instance) and the
9
+ # error hierarchy (Error / ModuleNotBuiltError) are defined from Rust at
10
+ # ext load time; this file only adds the pure-Ruby helpers that have no
11
+ # reason to live in Rust.
12
+ module Wasm
13
+ # Absolute path to the gem-bundled `data/kobako.wasm` artifact. Computed
14
+ # from this file's location so it works for both `bundle exec` (running
15
+ # from the repo) and an installed gem (running from the gem dir).
16
+ #
17
+ # Returns a String regardless of whether the file currently exists —
18
+ # call sites that need the file to be present should pass this through
19
+ # `Kobako::Wasm::Module.from_file`, which raises `ModuleNotBuiltError`
20
+ # with a clear remediation message.
21
+ def self.default_path
22
+ File.expand_path("../../data/kobako.wasm", __dir__)
23
+ end
24
+
25
+ # Unpack the +(ptr << 32) | len+ u64 produced by the Rust ext's
26
+ # +__kobako_take_outcome+ export. Returns +[ptr, len]+ as 32-bit
27
+ # unsigned integers. Pure-Ruby helper kept near the ABI surface so
28
+ # Sandbox does not have to carry bit-level wire layout.
29
+ def self.unpack_outcome_ptr_len(packed)
30
+ ptr = (packed >> 32) & 0xffff_ffff
31
+ len = packed & 0xffff_ffff
32
+ [ptr, len]
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,87 @@
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
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,136 @@
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
@@ -0,0 +1,44 @@
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"