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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../codec"
4
+
5
+ module Kobako
6
+ module Wire
7
+ # Outcome-path envelopes (SPEC.md Outcome Envelope): Result and Panic
8
+ # value objects plus the tagged Outcome wrapper that frames them on
9
+ # the wire. The RPC-path counterparts (Request / Response) live in
10
+ # the parent +envelope.rb+ file.
11
+ module Envelope
12
+ # ============================================================
13
+ # Result (SPEC.md Outcome Envelope → Result)
14
+ # ============================================================
15
+ #
16
+ # Success Outcome payload. SPEC pins the Result envelope as a
17
+ # 1-element msgpack array carrying the value, keeping framing
18
+ # symmetric with the Panic envelope so the value position is never
19
+ # ambiguous.
20
+ Result = Data.define(:value)
21
+
22
+ def self.encode_result(value)
23
+ Codec::Encoder.encode([value])
24
+ end
25
+
26
+ def self.decode_result(bytes)
27
+ arr = Codec::Decoder.decode(bytes)
28
+ unless arr.is_a?(Array) && arr.length == 1
29
+ raise Codec::InvalidType, "Result envelope must be a 1-element array, got #{arr.inspect}"
30
+ end
31
+
32
+ Result.new(arr[0])
33
+ end
34
+
35
+ # ============================================================
36
+ # Panic (SPEC.md Outcome Envelope → Panic)
37
+ # ============================================================
38
+ #
39
+ # Failure Outcome payload. Encoded as a msgpack **map** keyed by
40
+ # name (forward-compatibility — unknown keys are silently ignored).
41
+ # Required: "origin" / "class" / "message". Optional: "backtrace"
42
+ # (array of str), "details" (any wire-legal value).
43
+ Panic = Data.define(:origin, :klass, :message, :backtrace, :details) do
44
+ def initialize(origin:, klass:, message:, backtrace: [], details: nil)
45
+ raise ArgumentError, "Panic origin must be String" unless origin.is_a?(String)
46
+ raise ArgumentError, "Panic class must be String" unless klass.is_a?(String)
47
+ raise ArgumentError, "Panic message must be String" unless message.is_a?(String)
48
+ unless backtrace.is_a?(Array) && backtrace.all?(String)
49
+ raise ArgumentError, "Panic backtrace must be Array of String"
50
+ end
51
+
52
+ super
53
+ end
54
+ end
55
+
56
+ Panic::ORIGIN_SANDBOX = "sandbox"
57
+ Panic::ORIGIN_SERVICE = "service"
58
+
59
+ def self.encode_panic(panic)
60
+ Codec::Encoder.encode(panic_map(panic))
61
+ end
62
+
63
+ # SPEC: Panic is a msgpack MAP keyed by name. Required keys always
64
+ # emitted; "backtrace" emitted only when non-empty (keep the wire
65
+ # compact); "details" only when non-nil. Ruby Hash preserves
66
+ # insertion order so the resulting msgpack map carries the keys in
67
+ # the order added below.
68
+ def self.panic_map(panic)
69
+ map = { "origin" => panic.origin, "class" => panic.klass, "message" => panic.message }
70
+ map["backtrace"] = panic.backtrace unless panic.backtrace.empty?
71
+ map["details"] = panic.details unless panic.details.nil?
72
+ map
73
+ end
74
+ private_class_method :panic_map
75
+
76
+ def self.decode_panic(bytes)
77
+ map = Codec::Decoder.decode(bytes)
78
+ raise Codec::InvalidType, "Panic envelope must be a map, got #{map.class}" unless map.is_a?(Hash)
79
+
80
+ Codec.translate_value_object_error do
81
+ Panic.new(
82
+ origin: map["origin"], klass: map["class"], message: map["message"],
83
+ backtrace: map["backtrace"] || [], details: map["details"]
84
+ )
85
+ end
86
+ end
87
+
88
+ # ============================================================
89
+ # Outcome (SPEC.md Outcome Envelope)
90
+ # ============================================================
91
+ #
92
+ # OUTCOME_BUFFER wrapper: one-byte tag (+0x01+ Result, +0x02+ Panic)
93
+ # followed by the msgpack payload of the corresponding envelope.
94
+ # Callers construct an +Outcome+ by wrapping the payload directly —
95
+ # +Outcome.new(Result.new(value))+ or +Outcome.new(panic)+ — so the
96
+ # contract reads symmetrically across both variants.
97
+ Outcome = Data.define(:payload) do
98
+ def initialize(payload:)
99
+ unless payload.is_a?(Result) || payload.is_a?(Panic)
100
+ raise ArgumentError, "Outcome payload must be Result or Panic, got #{payload.class}"
101
+ end
102
+
103
+ super
104
+ end
105
+
106
+ def result? = payload.is_a?(Result)
107
+ def panic? = payload.is_a?(Panic)
108
+ end
109
+
110
+ def self.encode_outcome(outcome)
111
+ tag, body = encode_outcome_payload(outcome.payload)
112
+ out = String.new(encoding: Encoding::ASCII_8BIT)
113
+ out << [tag].pack("C")
114
+ out << body
115
+ out
116
+ end
117
+
118
+ def self.encode_outcome_payload(payload)
119
+ case payload
120
+ when Result then [OUTCOME_TAG_RESULT, encode_result(payload.value)]
121
+ when Panic then [OUTCOME_TAG_PANIC, encode_panic(payload)]
122
+ end
123
+ end
124
+ private_class_method :encode_outcome_payload
125
+
126
+ def self.decode_outcome(bytes)
127
+ bytes = bytes.b
128
+ raise Codec::InvalidType, "Outcome bytes must not be empty" if bytes.empty?
129
+
130
+ tag = bytes.getbyte(0)
131
+ body = bytes.byteslice(1, bytes.bytesize - 1)
132
+ Outcome.new(decode_outcome_payload(tag, body))
133
+ end
134
+
135
+ def self.decode_outcome_payload(tag, body)
136
+ case tag
137
+ when OUTCOME_TAG_RESULT then decode_result(body)
138
+ when OUTCOME_TAG_PANIC then decode_panic(body)
139
+ else raise Codec::InvalidType, format("unknown outcome tag 0x%<tag>02x", tag: tag)
140
+ end
141
+ end
142
+ private_class_method :decode_outcome_payload
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "handle"
4
+ require_relative "exception"
5
+ require_relative "codec"
6
+
7
+ module Kobako
8
+ module Wire
9
+ # Envelope-layer encoders/decoders for the kobako wire contract.
10
+ #
11
+ # SPEC.md → Wire Contract pins the logical shape of every host↔guest
12
+ # message and SPEC.md → Wire Codec → Envelope Frame Layout pins the
13
+ # binary framing. This module assembles the four envelope kinds
14
+ # (Request, Response, Result, Panic) and the outer Outcome wrapper on
15
+ # top of the lower-level {Codec::Encoder} / {Codec::Decoder} primitives.
16
+ #
17
+ # The contract collapses into two wire paths:
18
+ #
19
+ # - **RPC path** (lives in this file): Request / Response — guest
20
+ # calls a Service, host returns a value or an Exception.
21
+ # - **Outcome path** (lives in +envelope/payloads.rb+): Result /
22
+ # Panic wrapped in an Outcome envelope — the host reads this
23
+ # after +__kobako_run+ to surface either the script's last
24
+ # expression or a Sandbox/Service panic.
25
+ #
26
+ # The envelope objects are plain Value Objects; they own the field
27
+ # invariants (raising +ArgumentError+ on violation). The encode/decode
28
+ # helpers around them own the msgpack framing and translate value-
29
+ # object faults into the wire-layer +Codec::InvalidType+ taxonomy.
30
+ module Envelope
31
+ # ---------------- Outcome tag bytes (SPEC.md Outcome Envelope) -----
32
+
33
+ # First byte of the OUTCOME_BUFFER for a Result envelope.
34
+ OUTCOME_TAG_RESULT = 0x01
35
+ # First byte of the OUTCOME_BUFFER for a Panic envelope.
36
+ OUTCOME_TAG_PANIC = 0x02
37
+
38
+ # ---------------- Response status bytes (SPEC.md Response Shape) ---
39
+
40
+ # Response variant marker for the success branch.
41
+ STATUS_OK = 0
42
+ # Response variant marker for the error branch.
43
+ STATUS_ERROR = 1
44
+
45
+ # ============================================================
46
+ # Request (SPEC.md Wire Codec → Request)
47
+ # ============================================================
48
+ #
49
+ # 4-element msgpack array: [target, method, args, kwargs]. +target+
50
+ # is either a String ("Group::Member") or a {Handle}. SPEC pins
51
+ # +kwargs+ map keys to ext 0x00 Symbol (→ Wire Codec → Ext Types);
52
+ # enforced at construction so the Value Object is the single source
53
+ # of truth.
54
+ Request = Data.define(:target, :method_name, :args, :kwargs) do
55
+ def initialize(target:, method:, args: [], kwargs: {})
56
+ unless target.is_a?(String) || target.is_a?(Handle)
57
+ raise ArgumentError, "Request target must be String or Handle, got #{target.class}"
58
+ end
59
+ raise ArgumentError, "Request method must be String" unless method.is_a?(String)
60
+ raise ArgumentError, "Request args must be Array" unless args.is_a?(Array)
61
+
62
+ validate_kwargs!(kwargs)
63
+ super(target: target, method_name: method, args: args, kwargs: kwargs)
64
+ end
65
+
66
+ private
67
+
68
+ def validate_kwargs!(kwargs)
69
+ raise ArgumentError, "Request kwargs must be Hash" unless kwargs.is_a?(Hash)
70
+
71
+ kwargs.each_key do |k|
72
+ raise ArgumentError, "Request kwargs keys must be Symbol, got #{k.class}" unless k.is_a?(Symbol)
73
+ end
74
+ end
75
+ end
76
+
77
+ # Encode a {Request} to bytes. The Value Object's own invariants
78
+ # are the contract; this method does not re-check the shape.
79
+ def self.encode_request(request)
80
+ Codec::Encoder.encode([request.target, request.method_name, request.args, request.kwargs])
81
+ end
82
+
83
+ def self.decode_request(bytes)
84
+ arr = Codec::Decoder.decode(bytes)
85
+ unless arr.is_a?(Array) && arr.length == 4
86
+ raise Codec::InvalidType, "Request must be a 4-element array, got #{arr.inspect}"
87
+ end
88
+
89
+ target, method_name, args, kwargs = arr
90
+ Codec.translate_value_object_error do
91
+ Request.new(target: target, method: method_name, args: args, kwargs: kwargs)
92
+ end
93
+ end
94
+
95
+ # ============================================================
96
+ # Response (SPEC.md Wire Codec → Response)
97
+ # ============================================================
98
+ #
99
+ # 2-element msgpack array: [status, value-or-error]. +status+ is 0
100
+ # (success) or 1 (error). For success the second element is the
101
+ # return value; for error it is an {Exception} (ext 0x02 envelope).
102
+ Response = Data.define(:status, :payload) do
103
+ def self.ok(value)
104
+ new(status: STATUS_OK, payload: value)
105
+ end
106
+
107
+ def self.err(exception)
108
+ unless exception.is_a?(Exception)
109
+ raise ArgumentError, "Response.err requires Kobako::Wire::Exception, got #{exception.class}"
110
+ end
111
+
112
+ new(status: STATUS_ERROR, payload: exception)
113
+ end
114
+
115
+ def initialize(status:, payload:)
116
+ unless [STATUS_OK, STATUS_ERROR].include?(status)
117
+ raise ArgumentError, "Response status must be 0 or 1, got #{status.inspect}"
118
+ end
119
+ if status == STATUS_ERROR && !payload.is_a?(Exception)
120
+ raise ArgumentError, "Response status=1 payload must be Kobako::Wire::Exception"
121
+ end
122
+
123
+ super
124
+ end
125
+
126
+ def ok? = status == STATUS_OK
127
+ def err? = status == STATUS_ERROR
128
+ end
129
+
130
+ def self.encode_response(response)
131
+ Codec::Encoder.encode([response.status, response.payload])
132
+ end
133
+
134
+ def self.decode_response(bytes)
135
+ arr = Codec::Decoder.decode(bytes)
136
+ unless arr.is_a?(Array) && arr.length == 2
137
+ raise Codec::InvalidType, "Response must be a 2-element array, got #{arr.inspect}"
138
+ end
139
+
140
+ status, payload = arr
141
+ Codec.translate_value_object_error { Response.new(status: status, payload: payload) }
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ require_relative "envelope/payloads"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Wire
5
+ # Wire-level value object for an ext-0x02 Exception envelope.
6
+ #
7
+ # SPEC pins the payload (Wire Codec → Ext Types → ext 0x02) to a
8
+ # msgpack map with exactly three keys:
9
+ # * "type" — one of "runtime", "argument", "disconnected", "undefined"
10
+ # * "message" — human-readable string
11
+ # * "details" — any wire-legal value, or nil when absent
12
+ #
13
+ # This object holds the *encoded* form. Reifying the corresponding Ruby
14
+ # exception class (RuntimeError, ArgumentError, Kobako::ServiceError, ...)
15
+ # is the responsibility of the dispatch layer, not the codec.
16
+ #
17
+ # Built on +Data.define+ so equality, hash, and immutability are
18
+ # inherited from the value-object machinery; only the field invariants
19
+ # ride on top.
20
+ Exception = Data.define(:type, :message, :details) do
21
+ # +VALID_TYPES+ is attached to the Exception class below this block.
22
+ # Reach it through +self.class::VALID_TYPES+ — Data.define's block
23
+ # scope resolves bare constants against the enclosing +Wire+ module,
24
+ # so a bare +VALID_TYPES+ would raise +NameError+. Same pattern as
25
+ # +Wire::Handle+.
26
+ def initialize(type:, message:, details: nil)
27
+ valid_types = self.class::VALID_TYPES
28
+ raise ArgumentError, "type must be String" unless type.is_a?(String)
29
+ raise ArgumentError, "message must be String" unless message.is_a?(String)
30
+ raise ArgumentError, "type=#{type.inspect} not one of #{valid_types.inspect}" unless valid_types.include?(type)
31
+
32
+ super
33
+ end
34
+ end
35
+
36
+ Exception::VALID_TYPES = %w[runtime argument disconnected undefined].freeze
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Wire
5
+ # Wire-level value object for an ext-0x01 Capability Handle.
6
+ #
7
+ # SPEC pins the binary layout to fixext 4 with a 4-byte big-endian u32
8
+ # payload (Wire Codec → Ext Types → ext 0x01). ID 0 is reserved as the
9
+ # invalid sentinel; the maximum valid ID is 0x7fff_ffff (2^31 - 1).
10
+ #
11
+ # This is intentionally a thin value object built on +Data.define+ so
12
+ # equality, hash, and immutability are inherited. The runtime-facing
13
+ # +Kobako::Handle+ class lives at a higher layer and may add behaviour
14
+ # (HandleTable bookkeeping, reset semantics). The codec only needs to
15
+ # carry the opaque integer ID across the wire.
16
+ Handle = Data.define(:id) do
17
+ # +MIN_ID+ / +MAX_ID+ live on the Handle class (defined below this
18
+ # block), not in this block's binding — Data.define's block scope
19
+ # resolves bare constants against the enclosing +Wire+ module, so
20
+ # +MIN_ID+ would raise +NameError+. Use +self.class::CONST+ to
21
+ # reach the constants attached to the Handle class itself. Do not
22
+ # "simplify" this back to bare +MIN_ID+/+MAX_ID+.
23
+ def initialize(id:)
24
+ min = self.class::MIN_ID
25
+ max = self.class::MAX_ID
26
+ raise ArgumentError, "Handle id must be Integer" unless id.is_a?(Integer)
27
+ raise ArgumentError, "Handle id #{id} out of range [#{min}, #{max}]" unless id.between?(min, max)
28
+
29
+ super
30
+ end
31
+ end
32
+
33
+ Handle::MIN_ID = 1
34
+ Handle::MAX_ID = 0x7fff_ffff
35
+ end
36
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Host-side namespace for the kobako wire contract (SPEC.md → Wire
4
+ # Contract). The wire is split into two layers, mirrored on the Rust
5
+ # side by the +codec+ / +envelope+ modules in the +kobako-wasm+ crate:
6
+ #
7
+ # - {Codec} — byte-level MessagePack codec (SPEC.md → Wire Codec):
8
+ # {Codec::Encoder}, {Codec::Decoder}, {Codec::Factory}, plus the
9
+ # {Codec::Error} taxonomy. This is the layer that emits and
10
+ # consumes raw bytes; ext types 0x01 (Capability Handle) and
11
+ # 0x02 (Exception envelope) are registered exactly once on the
12
+ # Factory, where the numeric codes live as module-private constants
13
+ # alongside the Rust-side +codec::EXT_HANDLE+ / +codec::EXT_ERRENV+.
14
+ #
15
+ # - {Envelope} — logical message framing (SPEC.md → Wire Contract):
16
+ # {Envelope::Request} / {Envelope::Response} / {Envelope::Result}
17
+ # / {Envelope::Panic} / {Envelope::Outcome} value objects and
18
+ # their encode/decode helpers, built on top of {Codec}.
19
+ #
20
+ # {Handle} and {Exception} are value objects that travel through both
21
+ # layers; they live directly under +Wire+ so neither layer "owns" them.
22
+ #
23
+ # The namespace is intentionally self-contained — it does not depend
24
+ # on the native extension or on +lib/kobako.rb+ — so it can be required
25
+ # directly from tests that run on a clean checkout (no compiled artifacts).
26
+ module Kobako
27
+ # See the file-level documentation above for the layer split. The
28
+ # module body is intentionally empty: the byte-level codec lives in
29
+ # {Wire::Codec}, the logical framing in {Wire::Envelope}, and the
30
+ # shared value objects ({Wire::Handle} / {Wire::Exception}) load
31
+ # themselves into this namespace via the +require_relative+ calls
32
+ # below.
33
+ module Wire
34
+ end
35
+ end
36
+
37
+ require_relative "wire/handle"
38
+ require_relative "wire/exception"
39
+ require_relative "wire/codec"
40
+ require_relative "wire/envelope"
data/lib/kobako.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kobako/version"
4
+ require "kobako/kobako"
5
+ require_relative "kobako/errors"
6
+ require_relative "kobako/wasm"
7
+ require_relative "kobako/sandbox"
data/sig/kobako.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Kobako
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kobako
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Aotokitsuruya
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: msgpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.7'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.7'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rb_sys
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.9.91
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.9.91
40
+ description: kobako provides an in-process Wasm sandbox (wasmtime + mruby) with a
41
+ MessagePack-based host/guest RPC, allowing Ruby applications to execute untrusted
42
+ mruby scripts under capability-based Service injection.
43
+ email:
44
+ - contact@aotoki.me
45
+ executables: []
46
+ extensions:
47
+ - ext/kobako/extconf.rb
48
+ extra_rdoc_files: []
49
+ files:
50
+ - Cargo.lock
51
+ - Cargo.toml
52
+ - LICENSE
53
+ - README.md
54
+ - data/kobako.wasm
55
+ - ext/kobako/Cargo.toml
56
+ - ext/kobako/extconf.rb
57
+ - ext/kobako/src/lib.rs
58
+ - ext/kobako/src/wasm.rs
59
+ - ext/kobako/src/wasm/cache.rs
60
+ - ext/kobako/src/wasm/dispatch.rs
61
+ - ext/kobako/src/wasm/host_state.rs
62
+ - ext/kobako/src/wasm/instance.rs
63
+ - lib/kobako.rb
64
+ - lib/kobako/errors.rb
65
+ - lib/kobako/registry.rb
66
+ - lib/kobako/registry/dispatcher.rb
67
+ - lib/kobako/registry/handle_table.rb
68
+ - lib/kobako/registry/service_group.rb
69
+ - lib/kobako/sandbox.rb
70
+ - lib/kobako/sandbox/outcome_decoder.rb
71
+ - lib/kobako/sandbox/output_buffer.rb
72
+ - lib/kobako/version.rb
73
+ - lib/kobako/wasm.rb
74
+ - lib/kobako/wire.rb
75
+ - lib/kobako/wire/codec.rb
76
+ - lib/kobako/wire/codec/decoder.rb
77
+ - lib/kobako/wire/codec/encoder.rb
78
+ - lib/kobako/wire/codec/error.rb
79
+ - lib/kobako/wire/codec/factory.rb
80
+ - lib/kobako/wire/envelope.rb
81
+ - lib/kobako/wire/envelope/payloads.rb
82
+ - lib/kobako/wire/exception.rb
83
+ - lib/kobako/wire/handle.rb
84
+ - sig/kobako.rbs
85
+ homepage: https://github.com/elct9620/kobako
86
+ licenses:
87
+ - Apache-2.0
88
+ metadata:
89
+ allowed_push_host: https://rubygems.org
90
+ homepage_uri: https://github.com/elct9620/kobako
91
+ source_code_uri: https://github.com/elct9620/kobako
92
+ changelog_uri: https://github.com/elct9620/kobako/blob/main/CHANGELOG.md
93
+ bug_tracker_uri: https://github.com/elct9620/kobako/issues
94
+ rubygems_mfa_required: 'true'
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 3.3.0
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: 3.3.11
108
+ requirements: []
109
+ rubygems_version: 3.6.9
110
+ specification_version: 4
111
+ summary: Embeddable Wasm sandbox for running untrusted mruby code from Ruby applications.
112
+ test_files: []