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.
- checksums.yaml +4 -4
- data/Cargo.lock +1 -1
- data/README.md +95 -60
- data/data/kobako.wasm +0 -0
- data/ext/kobako/Cargo.toml +1 -1
- data/ext/kobako/src/wasm/cache.rs +39 -1
- data/ext/kobako/src/wasm/dispatch.rs +20 -20
- data/ext/kobako/src/wasm/host_state.rs +261 -34
- data/ext/kobako/src/wasm/instance.rs +467 -272
- data/ext/kobako/src/wasm.rs +50 -19
- data/lib/kobako/capture.rb +46 -0
- data/lib/kobako/codec/decoder.rb +66 -0
- data/lib/kobako/codec/encoder.rb +37 -0
- data/lib/kobako/codec/error.rb +33 -0
- data/lib/kobako/codec/factory.rb +155 -0
- data/lib/kobako/codec/utils.rb +55 -0
- data/lib/kobako/codec.rb +27 -0
- data/lib/kobako/errors.rb +24 -1
- data/lib/kobako/outcome/panic.rb +42 -0
- data/lib/kobako/outcome.rb +133 -0
- data/lib/kobako/rpc/dispatcher.rb +169 -0
- data/lib/kobako/rpc/envelope.rb +118 -0
- data/lib/kobako/{wire/exception.rb → rpc/fault.rb} +6 -4
- data/lib/kobako/{wire → rpc}/handle.rb +4 -2
- data/lib/kobako/{registry → rpc}/handle_table.rb +9 -9
- data/lib/kobako/{registry/service_group.rb → rpc/namespace.rb} +20 -11
- data/lib/kobako/rpc/server.rb +156 -0
- data/lib/kobako/rpc.rb +11 -0
- data/lib/kobako/sandbox.rb +149 -69
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako/wasm.rb +6 -16
- data/lib/kobako.rb +2 -0
- data/sig/kobako/capture.rbs +13 -0
- data/sig/kobako/codec/decoder.rbs +11 -0
- data/sig/kobako/codec/encoder.rbs +7 -0
- data/sig/kobako/codec/error.rbs +18 -0
- data/sig/kobako/codec/factory.rbs +31 -0
- data/sig/kobako/codec/utils.rbs +9 -0
- data/sig/kobako/errors.rbs +52 -0
- data/sig/kobako/outcome/panic.rbs +34 -0
- data/sig/kobako/outcome.rbs +24 -0
- data/sig/kobako/rpc/dispatcher.rbs +33 -0
- data/sig/kobako/rpc/envelope.rbs +51 -0
- data/sig/kobako/rpc/fault.rbs +20 -0
- data/sig/kobako/rpc/handle.rbs +19 -0
- data/sig/kobako/rpc/handle_table.rbs +25 -0
- data/sig/kobako/rpc/namespace.rbs +24 -0
- data/sig/kobako/rpc/server.rbs +37 -0
- data/sig/kobako/rpc.rbs +4 -0
- data/sig/kobako/sandbox.rbs +53 -0
- data/sig/kobako/wasm.rbs +37 -0
- data/sig/kobako.rbs +0 -1
- metadata +37 -17
- data/lib/kobako/registry/dispatcher.rb +0 -168
- data/lib/kobako/registry.rb +0 -160
- data/lib/kobako/sandbox/outcome_decoder.rb +0 -100
- data/lib/kobako/sandbox/output_buffer.rb +0 -79
- data/lib/kobako/wire/codec/decoder.rb +0 -87
- data/lib/kobako/wire/codec/encoder.rb +0 -41
- data/lib/kobako/wire/codec/error.rb +0 -35
- data/lib/kobako/wire/codec/factory.rb +0 -136
- data/lib/kobako/wire/codec.rb +0 -44
- data/lib/kobako/wire/envelope/payloads.rb +0 -145
- data/lib/kobako/wire/envelope.rb +0 -147
- data/lib/kobako/wire.rb +0 -40
data/ext/kobako/src/wasm.rs
CHANGED
|
@@ -4,22 +4,27 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Kobako::Wasm::Instance — wraps wasmtime::Instance + cached TypedFuncs
|
|
6
6
|
//
|
|
7
|
-
// constructed via `Kobako::Wasm::Instance.from_path(path
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// constructed via `Kobako::Wasm::Instance.from_path(path, timeout, memory_limit,
|
|
8
|
+
// stdout_limit, stderr_limit)`.
|
|
9
|
+
// The underlying wasmtime Engine and compiled Module live in a process-scope
|
|
10
|
+
// cache (see the `cache` submodule) and never surface to Ruby (SPEC.md "Code
|
|
10
11
|
// Organization": `ext/` "exposes no Wasm engine types to the Host App or
|
|
11
12
|
// downstream gems").
|
|
12
13
|
//
|
|
13
14
|
// Module layout (per CLAUDE.md principle #2 — one responsibility per file):
|
|
14
15
|
//
|
|
15
|
-
// * `cache` — process-wide Engine + per-path Module cache
|
|
16
|
-
//
|
|
16
|
+
// * `cache` — process-wide Engine + per-path Module cache and the
|
|
17
|
+
// process-singleton epoch ticker thread.
|
|
18
|
+
// * `host_state` — HostState (per-Store context), StoreCell wrapper, the
|
|
19
|
+
// [`KobakoLimiter`] memory cap, and the trap marker
|
|
20
|
+
// types ([`TimeoutTrap`] / [`MemoryLimitTrap`]).
|
|
17
21
|
// * `instance` — Kobako::Wasm::Instance and its run-path methods.
|
|
18
|
-
// * `dispatch` — `
|
|
22
|
+
// * `dispatch` — `__kobako_dispatch` host-import dispatch helpers.
|
|
19
23
|
//
|
|
20
24
|
// This file is the façade: it owns the Ruby error class lazy-resolvers,
|
|
21
|
-
// the `wasm_err`
|
|
22
|
-
// init() that registers
|
|
25
|
+
// the `wasm_err` / `timeout_err` / `memory_limit_err` constructors shared
|
|
26
|
+
// by every submodule, and the Ruby init() that registers
|
|
27
|
+
// `Kobako::Wasm::Instance` and its methods.
|
|
23
28
|
|
|
24
29
|
mod cache;
|
|
25
30
|
mod dispatch;
|
|
@@ -47,10 +52,36 @@ pub(crate) static WASM_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
|
|
|
47
52
|
wasm.const_get("Error").unwrap()
|
|
48
53
|
});
|
|
49
54
|
|
|
55
|
+
pub(crate) static WASM_TIMEOUT_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
|
|
56
|
+
let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
|
|
57
|
+
let wasm: RModule = kobako.const_get("Wasm").unwrap();
|
|
58
|
+
wasm.const_get("TimeoutError").unwrap()
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
pub(crate) static WASM_MEMORY_LIMIT_ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| {
|
|
62
|
+
let kobako: RModule = ruby.class_object().const_get("Kobako").unwrap();
|
|
63
|
+
let wasm: RModule = kobako.const_get("Wasm").unwrap();
|
|
64
|
+
wasm.const_get("MemoryLimitError").unwrap()
|
|
65
|
+
});
|
|
66
|
+
|
|
50
67
|
pub(crate) fn wasm_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
51
68
|
MagnusError::new(ruby.get_inner(&WASM_ERROR), msg.into())
|
|
52
69
|
}
|
|
53
70
|
|
|
71
|
+
/// Construct a `Kobako::Wasm::TimeoutError` magnus error. Surfaces the
|
|
72
|
+
/// SPEC.md E-19 wall-clock cap path so the Sandbox layer can rewrap it
|
|
73
|
+
/// as `Kobako::TimeoutError`.
|
|
74
|
+
pub(crate) fn timeout_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
75
|
+
MagnusError::new(ruby.get_inner(&WASM_TIMEOUT_ERROR), msg.into())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Construct a `Kobako::Wasm::MemoryLimitError` magnus error. Surfaces
|
|
79
|
+
/// the SPEC.md E-20 linear-memory cap path so the Sandbox layer can
|
|
80
|
+
/// rewrap it as `Kobako::MemoryLimitError`.
|
|
81
|
+
pub(crate) fn memory_limit_err(ruby: &Ruby, msg: impl Into<String>) -> MagnusError {
|
|
82
|
+
MagnusError::new(ruby.get_inner(&WASM_MEMORY_LIMIT_ERROR), msg.into())
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
// ---------------------------------------------------------------------------
|
|
55
86
|
// Ruby init
|
|
56
87
|
// ---------------------------------------------------------------------------
|
|
@@ -60,21 +91,21 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
|
|
|
60
91
|
|
|
61
92
|
// Error hierarchy. ModuleNotBuiltError is the headline error for the
|
|
62
93
|
// common pre-build state where `data/kobako.wasm` has not yet been
|
|
63
|
-
// produced (e.g. fresh clone before `rake compile`).
|
|
94
|
+
// produced (e.g. fresh clone before `rake compile`). TimeoutError and
|
|
95
|
+
// MemoryLimitError carry the SPEC.md B-01 per-run cap paths up to the
|
|
96
|
+
// Sandbox layer.
|
|
64
97
|
let base_err = wasm.define_error("Error", ruby.exception_standard_error())?;
|
|
65
98
|
wasm.define_error("ModuleNotBuiltError", base_err)?;
|
|
99
|
+
wasm.define_error("TimeoutError", base_err)?;
|
|
100
|
+
wasm.define_error("MemoryLimitError", base_err)?;
|
|
66
101
|
|
|
67
102
|
let instance = wasm.define_class("Instance", ruby.class_object())?;
|
|
68
|
-
instance.define_singleton_method("from_path", function!(Instance::from_path,
|
|
69
|
-
instance.define_method("
|
|
70
|
-
instance.define_method("
|
|
71
|
-
instance.define_method("
|
|
72
|
-
instance.define_method("
|
|
73
|
-
instance.define_method("
|
|
74
|
-
instance.define_method("set_registry", method!(Instance::set_registry, 1))?;
|
|
75
|
-
instance.define_method("setup_wasi_pipes", method!(Instance::setup_wasi_pipes, 4))?;
|
|
76
|
-
instance.define_method("take_stdout", method!(Instance::take_stdout, 0))?;
|
|
77
|
-
instance.define_method("take_stderr", method!(Instance::take_stderr, 0))?;
|
|
103
|
+
instance.define_singleton_method("from_path", function!(Instance::from_path, 5))?;
|
|
104
|
+
instance.define_method("server=", method!(Instance::set_server, 1))?;
|
|
105
|
+
instance.define_method("run", method!(Instance::run, 2))?;
|
|
106
|
+
instance.define_method("stdout", method!(Instance::stdout, 0))?;
|
|
107
|
+
instance.define_method("stderr", method!(Instance::stderr, 0))?;
|
|
108
|
+
instance.define_method("outcome!", method!(Instance::outcome, 0))?;
|
|
78
109
|
|
|
79
110
|
Ok(())
|
|
80
111
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
# Host-side captured prefix of guest stdout / stderr produced during a
|
|
5
|
+
# single +Kobako::Sandbox#run+, paired with the truncation flag the WASI
|
|
6
|
+
# pipe sets when the guest wrote past the configured per-channel cap
|
|
7
|
+
# ({SPEC.md B-04}[link:../../SPEC.md]).
|
|
8
|
+
#
|
|
9
|
+
# Immutable value object: the captured bytes and the truncation flag
|
|
10
|
+
# always travel together and the instance is frozen on construction.
|
|
11
|
+
# Construct via +Capture.from_ext+ for ext-provided binary bytes (handles
|
|
12
|
+
# UTF-8 / ASCII-8BIT fallback) or reach +Capture::EMPTY+ for the pre-run
|
|
13
|
+
# sentinel that +Sandbox+ uses before any +#run+ has executed.
|
|
14
|
+
class Capture
|
|
15
|
+
attr_reader :bytes
|
|
16
|
+
|
|
17
|
+
# Build a Capture wrapping +bytes+ (the captured prefix as a String) and
|
|
18
|
+
# +truncated+ (whether the originating WASI pipe reported the cap was
|
|
19
|
+
# hit). Freezes the instance so callers cannot mutate the pair.
|
|
20
|
+
def initialize(bytes:, truncated:)
|
|
21
|
+
@bytes = bytes
|
|
22
|
+
@truncated = truncated
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns +true+ iff the underlying capture channel exceeded its
|
|
27
|
+
# configured cap during the originating +Sandbox#run+
|
|
28
|
+
# ({SPEC.md B-04}[link:../../SPEC.md]).
|
|
29
|
+
def truncated? = @truncated
|
|
30
|
+
|
|
31
|
+
# Construct a Capture from ext-provided binary bytes. Coerces +bytes+
|
|
32
|
+
# to UTF-8 when the bytes are valid UTF-8, otherwise falls back to
|
|
33
|
+
# ASCII-8BIT so invalid sequences remain inspectable without raising.
|
|
34
|
+
# +bytes+ is not mutated.
|
|
35
|
+
def self.from_ext(bytes, truncated)
|
|
36
|
+
copy = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
37
|
+
copy.force_encoding(Encoding::ASCII_8BIT) unless copy.valid_encoding?
|
|
38
|
+
new(bytes: copy, truncated: truncated)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Pre-run sentinel ({SPEC.md B-05}[link:../../SPEC.md]). Empty UTF-8
|
|
42
|
+
# bytes and +truncated? == false+; reused by every fresh +Sandbox+ and
|
|
43
|
+
# by +Sandbox#run+ between invocations to denote "no capture yet".
|
|
44
|
+
EMPTY = new(bytes: "", truncated: false)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "msgpack"
|
|
4
|
+
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "factory"
|
|
7
|
+
require_relative "utils"
|
|
8
|
+
|
|
9
|
+
module Kobako
|
|
10
|
+
module Codec
|
|
11
|
+
# Module-level entry point for the host side of the kobako wire
|
|
12
|
+
# (SPEC.md → Wire Codec → Type Mapping).
|
|
13
|
+
#
|
|
14
|
+
# Translates msgpack gem exceptions into the kobako error taxonomy
|
|
15
|
+
# ({Truncated}, {InvalidType}, {InvalidEncoding}, {UnsupportedType}) so
|
|
16
|
+
# callers can pattern-match on the SPEC's wire-violation categories
|
|
17
|
+
# without leaking the gem's internal exception classes.
|
|
18
|
+
#
|
|
19
|
+
# Public API is a single function — {.decode}. The decoder is
|
|
20
|
+
# stateless; the +MessagePack::Unpacker+ instance is built per call
|
|
21
|
+
# because callers always decode exactly one wire value at a time.
|
|
22
|
+
module Decoder
|
|
23
|
+
# Decode +bytes+ into one Ruby value and validate transitively
|
|
24
|
+
# against the SPEC type mapping. Raises {Truncated}, {InvalidType},
|
|
25
|
+
# or {InvalidEncoding} on wire violations.
|
|
26
|
+
def self.decode(bytes)
|
|
27
|
+
value = Factory.load(bytes.b)
|
|
28
|
+
validate_utf8!(value)
|
|
29
|
+
value
|
|
30
|
+
# msgpack gem raises these for type/format violations; +ArgumentError+
|
|
31
|
+
# also comes from our ext-type validators (Handle id range, Exception
|
|
32
|
+
# type whitelist).
|
|
33
|
+
rescue ::MessagePack::UnknownExtTypeError, ::MessagePack::MalformedFormatError,
|
|
34
|
+
::MessagePack::StackError, ::ArgumentError => e
|
|
35
|
+
raise InvalidType, e.message
|
|
36
|
+
# +UnpackError+ is the gem's umbrella class for short-read /
|
|
37
|
+
# incomplete-buffer faults; +EOFError+ covers underflow at the
|
|
38
|
+
# buffer edge.
|
|
39
|
+
rescue ::MessagePack::UnpackError, ::EOFError => e
|
|
40
|
+
raise Truncated, e.message
|
|
41
|
+
rescue ::EncodingError => e
|
|
42
|
+
raise InvalidEncoding, e.message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# SPEC pins +str+ family payloads to UTF-8 (Wire Codec → str/bin
|
|
46
|
+
# Encoding Rules). The msgpack gem returns UTF-8-tagged Strings for
|
|
47
|
+
# str family but does not validate the bytes; +bin+ family decodes
|
|
48
|
+
# to ASCII-8BIT. Walk the tree once and reject invalid UTF-8 in any
|
|
49
|
+
# str-typed leaf via {Utils.assert_utf8!}. {Kobako::RPC::Fault}
|
|
50
|
+
# payloads are validated transitively: +Factory.unpack_fault+
|
|
51
|
+
# feeds the inner ext-0x02 bytes back through this Decoder, so their
|
|
52
|
+
# +str+ fields are already covered by the time control returns here.
|
|
53
|
+
class << self
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate_utf8!(value)
|
|
57
|
+
case value
|
|
58
|
+
when String then Utils.assert_utf8!(value, "str payload") if value.encoding == Encoding::UTF_8
|
|
59
|
+
when Array then value.each { |v| validate_utf8!(v) }
|
|
60
|
+
when Hash then value.each { |pair| validate_utf8!(pair) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "msgpack"
|
|
4
|
+
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "factory"
|
|
7
|
+
|
|
8
|
+
module Kobako
|
|
9
|
+
module Codec
|
|
10
|
+
# Module-level entry point for the host side of the kobako wire
|
|
11
|
+
# (SPEC.md → Wire Codec → Type Mapping).
|
|
12
|
+
#
|
|
13
|
+
# The codec backbone is the official +msgpack+ gem: integers, floats,
|
|
14
|
+
# strings, arrays, and maps go through the gem's narrowest-encoding
|
|
15
|
+
# logic; the three kobako-specific ext types (0x00 Symbol, 0x01
|
|
16
|
+
# Capability Handle, 0x02 Exception envelope) are registered on
|
|
17
|
+
# the cached {Kobako::Codec::Factory} singleton.
|
|
18
|
+
#
|
|
19
|
+
# Public API is a single function — {.encode}. The codec is stateless;
|
|
20
|
+
# there is no buffer accumulator and no streaming write API. Callers
|
|
21
|
+
# that need to concatenate multiple encodings build the bytes
|
|
22
|
+
# themselves.
|
|
23
|
+
module Encoder
|
|
24
|
+
# Encode +value+ to wire bytes (binary-encoded String).
|
|
25
|
+
# Wire violations surface as +UnsupportedType+: SPEC's 12-entry type
|
|
26
|
+
# mapping is a closed set, and anything outside it is rejected by
|
|
27
|
+
# the msgpack gem itself (arbitrary objects raise +NoMethodError+
|
|
28
|
+
# from missing +to_msgpack+, integers outside i64..u64 raise
|
|
29
|
+
# +RangeError+).
|
|
30
|
+
def self.encode(value)
|
|
31
|
+
Factory.dump(value)
|
|
32
|
+
rescue ::RangeError, ::NoMethodError => e
|
|
33
|
+
raise UnsupportedType, e.message
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
module Codec
|
|
5
|
+
# Base class for all wire-codec faults raised by the pure-Ruby host codec.
|
|
6
|
+
#
|
|
7
|
+
# The wire codec implements the binary contract pinned in SPEC.md
|
|
8
|
+
# (Wire Codec → Type Mapping). Every wire violation surfaces as a
|
|
9
|
+
# subclass of {Error} so callers can pattern-match on the specific
|
|
10
|
+
# fault while still rescuing all codec faults via this base class.
|
|
11
|
+
#
|
|
12
|
+
# Higher layers (e.g. the Sandbox dispatch loop) translate these into
|
|
13
|
+
# the public {Kobako::SandboxError} / {Kobako::TrapError} taxonomy.
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Input ended before the type prefix or payload was fully consumed.
|
|
17
|
+
class Truncated < Error; end
|
|
18
|
+
|
|
19
|
+
# The type byte at the current position is not in the 12-entry kobako
|
|
20
|
+
# type mapping (e.g. an unknown ext code, or a reserved msgpack tag).
|
|
21
|
+
class InvalidType < Error; end
|
|
22
|
+
|
|
23
|
+
# A msgpack `str` payload was not valid UTF-8, or an ext 0x00 Symbol
|
|
24
|
+
# payload was not valid UTF-8 — both are wire violations per SPEC.
|
|
25
|
+
class InvalidEncoding < Error; end
|
|
26
|
+
|
|
27
|
+
# The encoder was handed a Ruby object whose type has no wire
|
|
28
|
+
# representation (e.g. Range, Time). Higher layers may catch this
|
|
29
|
+
# and re-route the value through Handle allocation, but at the
|
|
30
|
+
# codec level it is a hard error.
|
|
31
|
+
class UnsupportedType < Error; end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
require "forwardable"
|
|
5
|
+
require "msgpack"
|
|
6
|
+
|
|
7
|
+
require_relative "error"
|
|
8
|
+
require_relative "utils"
|
|
9
|
+
require_relative "../rpc/handle"
|
|
10
|
+
require_relative "../rpc/fault"
|
|
11
|
+
|
|
12
|
+
module Kobako
|
|
13
|
+
module Codec
|
|
14
|
+
# Cached +MessagePack::Factory+ that owns the kobako wire ext-type
|
|
15
|
+
# registration (SPEC.md → Wire Codec → Ext Types).
|
|
16
|
+
#
|
|
17
|
+
# The factory is the single place in the host gem that touches the
|
|
18
|
+
# msgpack API — both {Encoder} and {Decoder} delegate through it, so
|
|
19
|
+
# the three kobako ext codes (0x00 Symbol, 0x01 Capability Handle,
|
|
20
|
+
# 0x02 Exception envelope) are configured exactly once at first use.
|
|
21
|
+
#
|
|
22
|
+
# Lifecycle is owned by +Singleton+ from the Ruby standard library:
|
|
23
|
+
# +Factory.instance+ is lazy, thread-safe, and process-wide. Class-level
|
|
24
|
+
# +Factory.dump+ / +Factory.load+ shortcuts are exposed via
|
|
25
|
+
# +SingleForwardable+ so callers do not have to spell the +.instance+
|
|
26
|
+
# hop at every call site; the instance-level +#dump+ / +#load+ are in
|
|
27
|
+
# turn delegated to the wrapped +MessagePack::Factory+ via +Forwardable+.
|
|
28
|
+
class Factory
|
|
29
|
+
include Singleton
|
|
30
|
+
extend Forwardable
|
|
31
|
+
extend SingleForwardable
|
|
32
|
+
|
|
33
|
+
# MessagePack ext type code reserved for Symbol
|
|
34
|
+
# (SPEC.md → Wire Codec → Ext Types → ext 0x00). Class-private —
|
|
35
|
+
# mirrors +codec::EXT_SYMBOL+ on the Rust side.
|
|
36
|
+
EXT_SYMBOL = 0x00
|
|
37
|
+
# MessagePack ext type code reserved for Capability Handle
|
|
38
|
+
# (SPEC.md → Wire Codec → Ext Types → ext 0x01). Class-private —
|
|
39
|
+
# mirrors +codec::EXT_HANDLE+ on the Rust side.
|
|
40
|
+
EXT_HANDLE = 0x01
|
|
41
|
+
# MessagePack ext type code reserved for Exception envelope
|
|
42
|
+
# (SPEC.md → Wire Codec → Ext Types → ext 0x02). Class-private —
|
|
43
|
+
# mirrors +codec::EXT_ERRENV+ on the Rust side.
|
|
44
|
+
EXT_ERRENV = 0x02
|
|
45
|
+
private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
|
|
46
|
+
|
|
47
|
+
# Instance-level pass-through onto the wrapped +MessagePack::Factory+.
|
|
48
|
+
# Spelled +def_instance_delegators+ rather than +def_delegators+ because
|
|
49
|
+
# the class also extends +SingleForwardable+ (see the +extend+ block
|
|
50
|
+
# above), which defines its own +def_delegators+ that shadows
|
|
51
|
+
# +Forwardable+'s — the unambiguous forms keep both delegation tiers
|
|
52
|
+
# wired to the right scope.
|
|
53
|
+
def_instance_delegators :@factory, :dump, :load
|
|
54
|
+
|
|
55
|
+
# Class-level shortcuts so callers can write +Factory.dump(v)+ instead
|
|
56
|
+
# of +Factory.instance.dump(v)+; both resolve to the same singleton.
|
|
57
|
+
def_single_delegators :instance, :dump, :load
|
|
58
|
+
|
|
59
|
+
def initialize
|
|
60
|
+
@factory = MessagePack::Factory.new
|
|
61
|
+
register_symbol
|
|
62
|
+
register_handle
|
|
63
|
+
register_fault
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def register_symbol
|
|
69
|
+
@factory.register_type(
|
|
70
|
+
EXT_SYMBOL, Symbol,
|
|
71
|
+
packer: method(:pack_symbol),
|
|
72
|
+
unpacker: method(:unpack_symbol)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Symbol-to-name packer — extracted to a real method so Steep can
|
|
77
|
+
# resolve the proc shape without tripping on +lambda(&:name)+'s
|
|
78
|
+
# +Symbol#to_proc+ inference path.
|
|
79
|
+
def pack_symbol(symbol)
|
|
80
|
+
symbol.name
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Validate the ext-0x00 payload as UTF-8 and intern. Raises
|
|
84
|
+
# {InvalidEncoding} on invalid bytes — SPEC forbids the
|
|
85
|
+
# binary-encoding fallback that msgpack-gem's default unpacker
|
|
86
|
+
# would otherwise apply. The re-tag step lives here because the
|
|
87
|
+
# msgpack ext-type unpacker hands us binary bytes; the assertion
|
|
88
|
+
# itself is shared with {Decoder} via {Utils.assert_utf8!}.
|
|
89
|
+
def unpack_symbol(payload)
|
|
90
|
+
name = payload.b.force_encoding(Encoding::UTF_8)
|
|
91
|
+
Utils.assert_utf8!(name, "ext 0x00 payload")
|
|
92
|
+
name.to_sym
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def register_handle
|
|
96
|
+
@factory.register_type(
|
|
97
|
+
EXT_HANDLE, RPC::Handle,
|
|
98
|
+
packer: ->(handle) { [handle.id].pack("N") },
|
|
99
|
+
unpacker: ->(payload) { unpack_handle(payload) }
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def register_fault
|
|
104
|
+
@factory.register_type(
|
|
105
|
+
EXT_ERRENV, RPC::Fault,
|
|
106
|
+
packer: ->(fault) { pack_fault(fault) },
|
|
107
|
+
unpacker: ->(payload) { unpack_fault(payload) }
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Peel off the fixext-4 frame, hand the bytes to +RPC::Handle.new+, and
|
|
112
|
+
# translate the +ArgumentError+ raised by Handle's invariants into
|
|
113
|
+
# a wire-layer +InvalidType+ via {Codec::Utils.wire_boundary}.
|
|
114
|
+
# The Value Object owns the id-range contract; this method only
|
|
115
|
+
# owns the frame shape.
|
|
116
|
+
def unpack_handle(payload)
|
|
117
|
+
bytes = payload.b
|
|
118
|
+
raise InvalidType, "ext 0x01 payload must be 4 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 4
|
|
119
|
+
|
|
120
|
+
id = bytes.unpack1("N") # : Integer
|
|
121
|
+
Codec::Utils.wire_boundary { RPC::Handle.new(id) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Encode the inner ext-0x02 map via {Encoder} (not +factory.dump+) so
|
|
125
|
+
# the embedded payload flows through the same boundary as a top-level
|
|
126
|
+
# encode — nested kobako values (Handle, nested Fault) reach the
|
|
127
|
+
# registered ext-type packers via the cached singleton.
|
|
128
|
+
def pack_fault(fault)
|
|
129
|
+
Encoder.encode("type" => fault.type, "message" => fault.message, "details" => fault.details)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Peel the embedded msgpack map and hand it to +RPC::Fault.new+;
|
|
133
|
+
# translate the value-object's +ArgumentError+ into +InvalidType+
|
|
134
|
+
# at the wire boundary. Inner decode goes through {Decoder} (not
|
|
135
|
+
# +factory.load+) so the embedded +str+ payloads flow through the
|
|
136
|
+
# same UTF-8 validation as a top-level decode.
|
|
137
|
+
#
|
|
138
|
+
# This establishes a runtime cycle Factory → Decoder → Factory: the
|
|
139
|
+
# singleton instance feeds +Decoder.decode+, which re-enters this
|
|
140
|
+
# method when a nested ext 0x02 appears inside +details+. The recursion
|
|
141
|
+
# is bounded by msgpack nesting depth — identical to nested Array /
|
|
142
|
+
# Hash payloads — so no extra guard is needed. Do not switch back to
|
|
143
|
+
# +factory.load+ to "simplify": that path bypasses UTF-8 validation
|
|
144
|
+
# and re-opens the Decoder's special case for Fault (removed in M5).
|
|
145
|
+
def unpack_fault(payload)
|
|
146
|
+
map = Decoder.decode(payload)
|
|
147
|
+
raise InvalidType, "ext 0x02 payload must be a map" unless map.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
Codec::Utils.wire_boundary do
|
|
150
|
+
RPC::Fault.new(type: map["type"], message: map["message"], details: map["details"])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module Kobako
|
|
6
|
+
module Codec
|
|
7
|
+
# Wire-codec helpers shared by the host-side encoders and decoders.
|
|
8
|
+
# The single concern today is UTF-8 assertion at the wire boundary
|
|
9
|
+
# (SPEC.md → Wire Codec → str/bin Encoding Rules and Ext Types →
|
|
10
|
+
# ext 0x00). Two call sites lean on this:
|
|
11
|
+
#
|
|
12
|
+
# - {Decoder} validates +str+ family payloads as it walks the
|
|
13
|
+
# decoded value tree.
|
|
14
|
+
# - {Factory} validates the +ext 0x00+ Symbol payload after
|
|
15
|
+
# re-tagging the binary bytes as UTF-8.
|
|
16
|
+
#
|
|
17
|
+
# Encoding setup (re-tagging binary as UTF-8 when needed) stays at
|
|
18
|
+
# the caller — only the assertion shape is shared. The helper does
|
|
19
|
+
# not mutate +string+; it only inspects +String#valid_encoding?+
|
|
20
|
+
# against +string+'s current encoding tag.
|
|
21
|
+
module Utils
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Raise {InvalidEncoding} unless +string+'s bytes are valid under
|
|
25
|
+
# its current encoding tag. +label+ is the caller-supplied prefix
|
|
26
|
+
# for the error message (e.g. +"str payload"+, +"ext 0x00 payload"+).
|
|
27
|
+
def assert_utf8!(string, label)
|
|
28
|
+
return if string.valid_encoding?
|
|
29
|
+
|
|
30
|
+
raise InvalidEncoding, "#{label} is not valid UTF-8"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Run +block+ at the wire boundary: every wire Value Object
|
|
34
|
+
# (Handle / Fault / Request / Response / Panic) raises
|
|
35
|
+
# +ArgumentError+ when an invariant is violated at construction,
|
|
36
|
+
# and the wire boundary surfaces those violations to callers as
|
|
37
|
+
# {InvalidType} so the public taxonomy stays
|
|
38
|
+
# {Kobako::Codec::Error} and never leaks +ArgumentError+ from the
|
|
39
|
+
# Ruby standard library.
|
|
40
|
+
#
|
|
41
|
+
# Wrap any block that constructs a wire Value Object from decoded
|
|
42
|
+
# bytes with this helper to keep the five decode sites uniform —
|
|
43
|
+
# Request / Response in +Kobako::RPC+, Panic map in
|
|
44
|
+
# +Kobako::Outcome+, and the Handle / Fault ext-type unpackers in
|
|
45
|
+
# {Factory}. Do not use it for general-purpose validation outside
|
|
46
|
+
# the wire boundary — host-layer +ArgumentError+ values should
|
|
47
|
+
# propagate unchanged.
|
|
48
|
+
def wire_boundary
|
|
49
|
+
yield
|
|
50
|
+
rescue ::ArgumentError => e
|
|
51
|
+
raise InvalidType, e.message
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/kobako/codec.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "codec/error"
|
|
4
|
+
|
|
5
|
+
module Kobako
|
|
6
|
+
# Host-side MessagePack codec for the kobako wire contract — the
|
|
7
|
+
# byte-level layer (SPEC.md → Wire Codec). Two consumers sit on top:
|
|
8
|
+
# +Kobako::RPC+ pins the RPC framing (Request / Response)
|
|
9
|
+
# and +Kobako::Outcome+ owns the per-+#run+ outcome envelope (Result
|
|
10
|
+
# body / Panic map).
|
|
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
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
require_relative "codec/utils"
|
|
25
|
+
require_relative "codec/factory"
|
|
26
|
+
require_relative "codec/encoder"
|
|
27
|
+
require_relative "codec/decoder"
|
data/lib/kobako/errors.rb
CHANGED
|
@@ -34,8 +34,31 @@ module Kobako
|
|
|
34
34
|
# (trap, OOM, unreachable) or when the wire layer detected a structural
|
|
35
35
|
# violation that signals a corrupted guest execution environment
|
|
36
36
|
# (zero-length OUTCOME_BUFFER, unknown outcome tag — SPEC E-02 / E-03).
|
|
37
|
+
#
|
|
38
|
+
# Two named subclasses cover the configured per-run caps from B-01:
|
|
39
|
+
#
|
|
40
|
+
# * {TimeoutError} — wall-clock +timeout+ exceeded (E-19).
|
|
41
|
+
# * {MemoryLimitError} — guest +memory.grow+ would exceed
|
|
42
|
+
# +memory_limit+ (E-20).
|
|
43
|
+
#
|
|
44
|
+
# Host Apps that only care about "guest is unrecoverable, discard the
|
|
45
|
+
# Sandbox" can rescue +TrapError+ and ignore the subclass; Host Apps that
|
|
46
|
+
# want to surface a specific reason to operators can rescue the subclass
|
|
47
|
+
# first.
|
|
37
48
|
class TrapError < Error; end
|
|
38
49
|
|
|
50
|
+
# Wall-clock timeout cap exhausted. {SPEC.md E-19}[link:../../SPEC.md]:
|
|
51
|
+
# the absolute deadline +entry_time + timeout+ passed and the next guest
|
|
52
|
+
# wasm safepoint trapped. The Sandbox is unrecoverable after this point;
|
|
53
|
+
# discard and recreate before another execution.
|
|
54
|
+
class TimeoutError < TrapError; end
|
|
55
|
+
|
|
56
|
+
# Linear-memory cap exhausted. {SPEC.md E-20}[link:../../SPEC.md]:
|
|
57
|
+
# a guest +memory.grow+ would have pushed linear memory past the
|
|
58
|
+
# configured +memory_limit+. The Sandbox is unrecoverable after this
|
|
59
|
+
# point; discard and recreate before another execution.
|
|
60
|
+
class MemoryLimitError < TrapError; end
|
|
61
|
+
|
|
39
62
|
# Sandbox / wire layer. Raised when the guest ran to completion but
|
|
40
63
|
# execution failed due to a mruby script error, a protocol fault, or a
|
|
41
64
|
# host-side wire decode failure on an otherwise valid outcome tag.
|
|
@@ -74,7 +97,7 @@ module Kobako
|
|
|
74
97
|
|
|
75
98
|
# HandleTable lookup-failure error (unknown id passed to #fetch /
|
|
76
99
|
# #release). A SandboxError subclass: per the wire-layer rule, an
|
|
77
|
-
# unknown Handle id surfaces as a `type="undefined"` Response.
|
|
100
|
+
# unknown Handle id surfaces as a `type="undefined"` Response.error
|
|
78
101
|
# envelope inside RpcDispatcher and never reaches the Host App
|
|
79
102
|
# directly; outside that path (e.g. tests poking the HandleTable
|
|
80
103
|
# directly), it surfaces as a SandboxError.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
module Outcome
|
|
5
|
+
# SPEC.md → Outcome Envelope → Panic envelope ({SPEC.md Outcome
|
|
6
|
+
# Envelope}[link:../../../SPEC.md]). Wire-shaped failure record
|
|
7
|
+
# carried in the OUTCOME_BUFFER when the guest run terminates with
|
|
8
|
+
# an uncaught top-level exception.
|
|
9
|
+
#
|
|
10
|
+
# This is the **wire data**, not a raisable Ruby exception. The
|
|
11
|
+
# mapping from Panic to a three-layer Ruby exception (TrapError /
|
|
12
|
+
# SandboxError / ServiceError) happens at +Kobako::Outcome.decode+
|
|
13
|
+
# via +build_panic_error+ — callers never raise +Panic+ directly.
|
|
14
|
+
#
|
|
15
|
+
# The five fields mirror SPEC: +origin+ ("sandbox" / "service"),
|
|
16
|
+
# +klass+ (the guest-side exception class name as a String),
|
|
17
|
+
# +message+, +backtrace+ (Array of String), +details+ (any
|
|
18
|
+
# wire-legal value, nil when absent). Required-field validation is
|
|
19
|
+
# enforced at construction; the +ORIGIN_SANDBOX+ / +ORIGIN_SERVICE+
|
|
20
|
+
# constants pin the two SPEC-defined origin values.
|
|
21
|
+
#
|
|
22
|
+
# Built on the +class X < Data.define(...)+ subclass form so the
|
|
23
|
+
# class body is fully Steep-visible; ruby/rbs upstream documents
|
|
24
|
+
# this as the Steep-friendly shape and the +Style/DataInheritance+
|
|
25
|
+
# cop is disabled on that basis (see +.rubocop.yml+).
|
|
26
|
+
class Panic < Data.define(:origin, :klass, :message, :backtrace, :details)
|
|
27
|
+
ORIGIN_SANDBOX = "sandbox"
|
|
28
|
+
ORIGIN_SERVICE = "service"
|
|
29
|
+
|
|
30
|
+
def initialize(origin:, klass:, message:, backtrace: [], details: nil)
|
|
31
|
+
raise ArgumentError, "Panic origin must be String" unless origin.is_a?(String)
|
|
32
|
+
raise ArgumentError, "Panic class must be String" unless klass.is_a?(String)
|
|
33
|
+
raise ArgumentError, "Panic message must be String" unless message.is_a?(String)
|
|
34
|
+
unless backtrace.is_a?(Array) && backtrace.all?(String)
|
|
35
|
+
raise ArgumentError, "Panic backtrace must be Array of String"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|