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.
- checksums.yaml +7 -0
- data/Cargo.lock +2347 -0
- data/Cargo.toml +11 -0
- data/LICENSE +201 -0
- data/README.md +228 -0
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +36 -0
- data/ext/kobako/extconf.rb +6 -0
- data/ext/kobako/src/lib.rs +10 -0
- data/ext/kobako/src/wasm/cache.rs +92 -0
- data/ext/kobako/src/wasm/dispatch.rs +110 -0
- data/ext/kobako/src/wasm/host_state.rs +59 -0
- data/ext/kobako/src/wasm/instance.rs +361 -0
- data/ext/kobako/src/wasm.rs +80 -0
- data/lib/kobako/errors.rb +88 -0
- data/lib/kobako/registry/dispatcher.rb +168 -0
- data/lib/kobako/registry/handle_table.rb +107 -0
- data/lib/kobako/registry/service_group.rb +65 -0
- data/lib/kobako/registry.rb +160 -0
- data/lib/kobako/sandbox/outcome_decoder.rb +100 -0
- data/lib/kobako/sandbox/output_buffer.rb +79 -0
- data/lib/kobako/sandbox.rb +148 -0
- data/lib/kobako/version.rb +5 -0
- data/lib/kobako/wasm.rb +35 -0
- data/lib/kobako/wire/codec/decoder.rb +87 -0
- data/lib/kobako/wire/codec/encoder.rb +41 -0
- data/lib/kobako/wire/codec/error.rb +35 -0
- data/lib/kobako/wire/codec/factory.rb +136 -0
- data/lib/kobako/wire/codec.rb +44 -0
- data/lib/kobako/wire/envelope/payloads.rb +145 -0
- data/lib/kobako/wire/envelope.rb +147 -0
- data/lib/kobako/wire/exception.rb +38 -0
- data/lib/kobako/wire/handle.rb +36 -0
- data/lib/kobako/wire.rb +40 -0
- data/lib/kobako.rb +7 -0
- data/sig/kobako.rbs +4 -0
- metadata +112 -0
|
@@ -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
|
data/lib/kobako/wasm.rb
ADDED
|
@@ -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"
|