kobako 0.5.0-aarch64-linux
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/.release-please-manifest.json +1 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +201 -0
- data/README.md +408 -0
- data/data/kobako.wasm +0 -0
- data/lib/kobako/3.3/kobako.so +0 -0
- data/lib/kobako/3.4/kobako.so +0 -0
- data/lib/kobako/4.0/kobako.so +0 -0
- data/lib/kobako/capture.rb +43 -0
- data/lib/kobako/catalog/handles.rb +107 -0
- data/lib/kobako/catalog/namespaces.rb +99 -0
- data/lib/kobako/catalog/snippets.rb +149 -0
- data/lib/kobako/catalog.rb +18 -0
- data/lib/kobako/codec/decoder.rb +73 -0
- data/lib/kobako/codec/encoder.rb +37 -0
- data/lib/kobako/codec/error.rb +34 -0
- data/lib/kobako/codec/factory.rb +162 -0
- data/lib/kobako/codec/utils.rb +145 -0
- data/lib/kobako/codec.rb +31 -0
- data/lib/kobako/errors.rb +140 -0
- data/lib/kobako/fault.rb +40 -0
- data/lib/kobako/handle.rb +60 -0
- data/lib/kobako/namespace.rb +67 -0
- data/lib/kobako/outcome/panic.rb +42 -0
- data/lib/kobako/outcome.rb +166 -0
- data/lib/kobako/runtime.rb +30 -0
- data/lib/kobako/sandbox.rb +314 -0
- data/lib/kobako/sandbox_options.rb +70 -0
- data/lib/kobako/snapshot.rb +40 -0
- data/lib/kobako/snippet/binary.rb +29 -0
- data/lib/kobako/snippet/source.rb +28 -0
- data/lib/kobako/snippet.rb +18 -0
- data/lib/kobako/transport/dispatcher.rb +195 -0
- data/lib/kobako/transport/error.rb +24 -0
- data/lib/kobako/transport/request.rb +78 -0
- data/lib/kobako/transport/response.rb +69 -0
- data/lib/kobako/transport/run.rb +141 -0
- data/lib/kobako/transport/yield.rb +91 -0
- data/lib/kobako/transport/yielder.rb +89 -0
- data/lib/kobako/transport.rb +24 -0
- data/lib/kobako/usage.rb +41 -0
- data/lib/kobako/version.rb +5 -0
- data/lib/kobako.rb +10 -0
- data/release-please-config.json +24 -0
- data/sig/kobako/capture.rbs +11 -0
- data/sig/kobako/catalog/handles.rbs +19 -0
- data/sig/kobako/catalog/namespaces.rbs +17 -0
- data/sig/kobako/catalog/snippets.rbs +27 -0
- data/sig/kobako/catalog.rbs +4 -0
- data/sig/kobako/codec/decoder.rbs +12 -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 +19 -0
- data/sig/kobako/errors.rbs +55 -0
- data/sig/kobako/fault.rbs +19 -0
- data/sig/kobako/handle.rbs +18 -0
- data/sig/kobako/namespace.rbs +19 -0
- data/sig/kobako/outcome/panic.rbs +34 -0
- data/sig/kobako/outcome.rbs +24 -0
- data/sig/kobako/runtime.rbs +23 -0
- data/sig/kobako/sandbox.rbs +55 -0
- data/sig/kobako/sandbox_options.rbs +32 -0
- data/sig/kobako/snapshot.rbs +15 -0
- data/sig/kobako/snippet/binary.rbs +12 -0
- data/sig/kobako/snippet/source.rbs +13 -0
- data/sig/kobako/snippet.rbs +4 -0
- data/sig/kobako/transport/dispatcher.rbs +34 -0
- data/sig/kobako/transport/error.rbs +6 -0
- data/sig/kobako/transport/request.rbs +32 -0
- data/sig/kobako/transport/response.rbs +30 -0
- data/sig/kobako/transport/run.rbs +27 -0
- data/sig/kobako/transport/yield.rbs +34 -0
- data/sig/kobako/transport/yielder.rbs +21 -0
- data/sig/kobako/transport.rbs +4 -0
- data/sig/kobako/usage.rbs +11 -0
- data/sig/kobako.rbs +3 -0
- metadata +145 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
module Outcome
|
|
5
|
+
# Wire-contract Outcome Envelope → Panic envelope ({docs/wire-contract.md
|
|
6
|
+
# Outcome Envelope}[link:../../../docs/wire-contract.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
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "outcome/panic"
|
|
4
|
+
require_relative "transport/error"
|
|
5
|
+
|
|
6
|
+
module Kobako
|
|
7
|
+
# Host-facing boundary for the OUTCOME_BUFFER produced by
|
|
8
|
+
# +__kobako_eval+. Takes raw outcome bytes — a one-byte tag followed by
|
|
9
|
+
# the msgpack-encoded body — and maps them to either the unwrapped
|
|
10
|
+
# mruby return value or a raised three-layer
|
|
11
|
+
# ({docs/behavior.md Error Scenarios}[link:../../docs/behavior.md]) exception.
|
|
12
|
+
#
|
|
13
|
+
# Self-contained: this module owns the wire framing (tag bytes,
|
|
14
|
+
# body decoding), and the +Panic+ wire record lives at
|
|
15
|
+
# +Kobako::Outcome::Panic+. The byte-level msgpack codec at
|
|
16
|
+
# +Kobako::Codec+ is invoked for the body itself; otherwise
|
|
17
|
+
# nothing in +Transport+ participates.
|
|
18
|
+
#
|
|
19
|
+
# * tag 0x01, decode OK → return decoded value
|
|
20
|
+
# * tag 0x01, decode fails → SandboxError (E-09)
|
|
21
|
+
# * tag 0x02, origin="service" → ServiceError (E-13)
|
|
22
|
+
# * tag 0x02, origin="sandbox"/missing → SandboxError (E-04..E-07)
|
|
23
|
+
# * tag 0x02, decode fails → SandboxError (E-08)
|
|
24
|
+
# * unknown tag → TrapError (E-03)
|
|
25
|
+
module Outcome
|
|
26
|
+
# First byte of the OUTCOME_BUFFER for the success branch — body is
|
|
27
|
+
# the bare msgpack encoding of the returned value
|
|
28
|
+
# ({docs/wire-contract.md Outcome Envelope}[link:../../docs/wire-contract.md]).
|
|
29
|
+
TYPE_VALUE = 0x01
|
|
30
|
+
# First byte of the OUTCOME_BUFFER for the failure branch — body is
|
|
31
|
+
# the msgpack Panic map.
|
|
32
|
+
TYPE_PANIC = 0x02
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def decode(bytes)
|
|
37
|
+
tag, body = split_tag(bytes)
|
|
38
|
+
case tag
|
|
39
|
+
when TYPE_VALUE
|
|
40
|
+
decode_value(body)
|
|
41
|
+
when TYPE_PANIC
|
|
42
|
+
decode_panic(body)
|
|
43
|
+
else
|
|
44
|
+
raise build_trap_error(tag)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# TrapError for unknown or absent tag
|
|
49
|
+
# ({docs/wire-codec.md ABI Signatures}[link:../../docs/wire-codec.md]:
|
|
50
|
+
# zero-length output and unrecognised first byte both walk the trap
|
|
51
|
+
# path). The user-facing message stays in caller vocabulary — the
|
|
52
|
+
# raw tag byte (or absence) belongs in +details+ for operators, not
|
|
53
|
+
# in the message a caller sees.
|
|
54
|
+
def build_trap_error(tag)
|
|
55
|
+
if tag.nil?
|
|
56
|
+
TrapError.new("Sandbox exited without producing a result")
|
|
57
|
+
else
|
|
58
|
+
TrapError.new(
|
|
59
|
+
"Sandbox produced an unrecognised result; the runtime is corrupted, " \
|
|
60
|
+
"discard this Sandbox before another invocation"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def split_tag(bytes)
|
|
66
|
+
bytes = bytes.b
|
|
67
|
+
return [nil, "".b] if bytes.empty?
|
|
68
|
+
|
|
69
|
+
tag = bytes.getbyte(0) # : Integer
|
|
70
|
+
body = bytes.byteslice(1, bytes.bytesize - 1) # : String
|
|
71
|
+
[tag, body]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Decode failure on the success tag is a SandboxError (E-09): the
|
|
75
|
+
# framing was fine, but the carried value is unrepresentable. The
|
|
76
|
+
# specific codec fault is stashed in +details+ rather
|
|
77
|
+
# than spliced into the message — callers cannot act on the inner
|
|
78
|
+
# "Symbol payload must be …" wording, but operators triaging a
|
|
79
|
+
# corrupted Sandbox runtime still need it.
|
|
80
|
+
def decode_value(body)
|
|
81
|
+
Kobako::Codec::Decoder.decode(body)
|
|
82
|
+
rescue Kobako::Codec::Error => e
|
|
83
|
+
raise build_transport_error(
|
|
84
|
+
"Sandbox produced an invalid result value",
|
|
85
|
+
detail: e.message
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Decode failure on the panic tag is a SandboxError (E-08). Either
|
|
90
|
+
# path raises — on success the decoded Panic is mapped to its three-
|
|
91
|
+
# layer exception via +build_panic_error+ and raised; on wire-decode
|
|
92
|
+
# failure the rescue path raises the wire-violation +SandboxError+.
|
|
93
|
+
def decode_panic(body)
|
|
94
|
+
raise build_panic_error(build_panic_record(body))
|
|
95
|
+
rescue Kobako::Codec::Error => e
|
|
96
|
+
raise build_transport_error(
|
|
97
|
+
"Sandbox produced an invalid panic record",
|
|
98
|
+
detail: e.message
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Build a +Panic+ value object from the msgpack-decoded body. Raises
|
|
103
|
+
# +Kobako::Codec::InvalidType+ when the body is not a map or when
|
|
104
|
+
# required keys are missing — both routed by +decode_panic+ to
|
|
105
|
+
# +build_transport_error+. The decode runs in block form so
|
|
106
|
+
# +Panic.new+'s +ArgumentError+ invariants surface as +InvalidType+
|
|
107
|
+
# through the decoder boundary; the message itself is never user-
|
|
108
|
+
# facing — it lands in +details+ via the rescue chain above.
|
|
109
|
+
def build_panic_record(body)
|
|
110
|
+
Kobako::Codec::Decoder.decode(body) do |map|
|
|
111
|
+
raise Kobako::Codec::InvalidType, "panic body must be a map, got #{map.class}" unless map.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
Panic.new(
|
|
114
|
+
origin: map["origin"], klass: map["class"], message: map["message"],
|
|
115
|
+
backtrace: map["backtrace"] || [], details: map["details"]
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Map a decoded Panic record into the corresponding three-layer
|
|
121
|
+
# Ruby exception. +origin == "service"+ → ServiceError; everything
|
|
122
|
+
# else → SandboxError.
|
|
123
|
+
def build_panic_error(panic)
|
|
124
|
+
panic_target_class(panic).new(
|
|
125
|
+
panic.message,
|
|
126
|
+
origin: panic.origin,
|
|
127
|
+
klass: panic.klass,
|
|
128
|
+
backtrace_lines: panic.backtrace,
|
|
129
|
+
details: panic.details
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# {docs/behavior.md Error Scenarios}[link:../../docs/behavior.md]: map
|
|
134
|
+
# the panic +class+ field to the matching Ruby exception subclass so
|
|
135
|
+
# callers can rescue specific failure paths. +origin="service"+ →
|
|
136
|
+
# +ServiceError+; +origin="sandbox"+ plus
|
|
137
|
+
# +class="Kobako::BytecodeError"+ selects the +BytecodeError+
|
|
138
|
+
# subclass (E-37 / E-38). Everything else falls back to the base
|
|
139
|
+
# class for the origin.
|
|
140
|
+
def panic_target_class(panic)
|
|
141
|
+
case panic.origin
|
|
142
|
+
when Panic::ORIGIN_SERVICE
|
|
143
|
+
ServiceError
|
|
144
|
+
else
|
|
145
|
+
panic.klass == "Kobako::BytecodeError" ? BytecodeError : SandboxError
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Lift the wire-violation fallback to the real
|
|
150
|
+
# +Kobako::Transport::Error+ class so callers can +rescue+ it
|
|
151
|
+
# specifically instead of pattern-matching on +error.klass+. The
|
|
152
|
+
# +klass+ field is still populated so existing operator-side
|
|
153
|
+
# tooling that greps on the string continues to work.
|
|
154
|
+
# +detail+ carries the inner codec / framing message, stashed
|
|
155
|
+
# directly into +details+ for operator diagnosis without polluting
|
|
156
|
+
# the user-facing +#message+.
|
|
157
|
+
def build_transport_error(message, detail: nil)
|
|
158
|
+
Kobako::Transport::Error.new(
|
|
159
|
+
message,
|
|
160
|
+
origin: Panic::ORIGIN_SANDBOX,
|
|
161
|
+
klass: "Kobako::Transport::Error",
|
|
162
|
+
details: detail
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
# Host-side wasmtime runtime, surfaced as a Ruby class by the native ext
|
|
5
|
+
# (see ext/kobako/src/runtime.rs). The +Kobako::Runtime+ class wraps the
|
|
6
|
+
# wasmtime engine + compiled module + Store; it is the only Ruby-visible
|
|
7
|
+
# wasmtime type and the foundational binding layer for +Kobako::Sandbox+.
|
|
8
|
+
#
|
|
9
|
+
# This file reopens the magnus-defined class only to add the pure-Ruby
|
|
10
|
+
# +.default_path+ helper. Every other method (+#from_path+ singleton,
|
|
11
|
+
# +#eval+ / +#run+, capture and usage readers) is registered from Rust
|
|
12
|
+
# at ext load time.
|
|
13
|
+
class Runtime
|
|
14
|
+
# Absolute path to the gem-bundled +data/kobako.wasm+ artifact. Computed
|
|
15
|
+
# from this file's location so it works for both +bundle exec+ (running
|
|
16
|
+
# from the repo) and an installed gem (running from the gem dir).
|
|
17
|
+
#
|
|
18
|
+
# Returns a String regardless of whether the file currently exists —
|
|
19
|
+
# call sites that need the file to be present should pass this through
|
|
20
|
+
# +Kobako::Runtime.from_path+, which raises
|
|
21
|
+
# +Kobako::ModuleNotBuiltError+ with a clear remediation message.
|
|
22
|
+
# Raises +Kobako::SetupError+ if +__dir__+ is unavailable so that
|
|
23
|
+
# +rescue Kobako::SetupError+ around +Kobako::Sandbox.new+ catches every
|
|
24
|
+
# construction-layer failure uniformly, including this path-resolution one.
|
|
25
|
+
def self.default_path
|
|
26
|
+
dir = __dir__ or raise Kobako::SetupError, "Kobako::Runtime.default_path requires __dir__"
|
|
27
|
+
File.expand_path("../../data/kobako.wasm", dir)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
require_relative "capture"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "outcome"
|
|
8
|
+
require_relative "sandbox_options"
|
|
9
|
+
require_relative "usage"
|
|
10
|
+
require_relative "transport"
|
|
11
|
+
require_relative "catalog"
|
|
12
|
+
|
|
13
|
+
module Kobako
|
|
14
|
+
# Kobako::Sandbox — the user-facing entry point for executing guest mruby
|
|
15
|
+
# scripts inside a wasmtime-hosted Wasm module
|
|
16
|
+
# ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
|
|
17
|
+
#
|
|
18
|
+
# The Sandbox owns the +Kobako::Runtime+, the per-Sandbox
|
|
19
|
+
# +Kobako::Catalog::Handles+ ({docs/behavior.md B-19}[link:../../docs/behavior.md]),
|
|
20
|
+
# the per-instance +Kobako::Catalog::Namespaces+ (which receives the
|
|
21
|
+
# +Catalog::Handles+ by injection so guest→host dispatch and host→guest
|
|
22
|
+
# auto-wrap share one allocator), and the dispatch +Proc+ /
|
|
23
|
+
# +yield_to_guest+ lambda installed on the Runtime via
|
|
24
|
+
# +Runtime#on_dispatch=+ ({docs/behavior.md B-12}[link:../../docs/behavior.md]).
|
|
25
|
+
# The underlying wasmtime Engine and compiled Module are cached at process
|
|
26
|
+
# scope by the native ext and never surface to Ruby — constructing many
|
|
27
|
+
# Sandboxes amortises both costs automatically.
|
|
28
|
+
#
|
|
29
|
+
# Output capture policy ({docs/behavior.md B-04}[link:../../docs/behavior.md]): the
|
|
30
|
+
# per-channel cap (+stdout_limit+ / +stderr_limit+) is enforced inside the
|
|
31
|
+
# WASI pipe — the host buffer stops growing at the cap, subsequent guest
|
|
32
|
+
# writes on that channel fail or are dropped, and +#run+ still returns
|
|
33
|
+
# normally. +#stdout+ / +#stderr+ return the captured prefix as a UTF-8
|
|
34
|
+
# String; the byte content never carries a truncation sentinel.
|
|
35
|
+
# +#stdout_truncated?+ / +#stderr_truncated?+ are the only way to observe
|
|
36
|
+
# that the cap was hit.
|
|
37
|
+
class Sandbox
|
|
38
|
+
extend Forwardable
|
|
39
|
+
|
|
40
|
+
attr_reader :wasm_path, :options
|
|
41
|
+
|
|
42
|
+
# Per-cap accessors forward to the immutable +SandboxOptions+ Value
|
|
43
|
+
# Object so the Host App still reads them off Sandbox directly.
|
|
44
|
+
def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit
|
|
45
|
+
|
|
46
|
+
# Returns the bytes the guest wrote to stdout during the most recent
|
|
47
|
+
# invocation as a UTF-8 String, clipped at +stdout_limit+. Empty before
|
|
48
|
+
# any invocation. {docs/behavior.md B-04}[link:../../docs/behavior.md] — the byte
|
|
49
|
+
# content never contains a truncation sentinel; use +#stdout_truncated?+ to
|
|
50
|
+
# observe overflow.
|
|
51
|
+
def stdout
|
|
52
|
+
@stdout_capture.bytes
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns the bytes the guest wrote to stderr during the most recent
|
|
56
|
+
# invocation as a UTF-8 String, clipped at +stderr_limit+. Empty before
|
|
57
|
+
# any invocation. Mirror of +#stdout+.
|
|
58
|
+
def stderr
|
|
59
|
+
@stderr_capture.bytes
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns +true+ iff stdout capture during the most recent invocation
|
|
63
|
+
# exceeded +stdout_limit+ ({docs/behavior.md B-04}[link:../../docs/behavior.md]). Resets
|
|
64
|
+
# to +false+ at the start of the next invocation
|
|
65
|
+
# ({docs/behavior.md B-03}[link:../../docs/behavior.md]).
|
|
66
|
+
def stdout_truncated?
|
|
67
|
+
@stdout_capture.truncated?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns +true+ iff stderr capture during the most recent invocation
|
|
71
|
+
# exceeded +stderr_limit+. Mirror of +#stdout_truncated?+.
|
|
72
|
+
def stderr_truncated?
|
|
73
|
+
@stderr_capture.truncated?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the +Kobako::Usage+ value object for the most recent
|
|
77
|
+
# invocation ({docs/behavior.md B-35}[link:../../docs/behavior.md]).
|
|
78
|
+
# Carries +wall_time+ (Float seconds the guest export call spent
|
|
79
|
+
# inside wasmtime) and +memory_peak+ (Integer bytes, high-water of
|
|
80
|
+
# the per-invocation +memory.grow+ delta past the entry-time
|
|
81
|
+
# baseline). Returns +Kobako::Usage::EMPTY+ before any invocation;
|
|
82
|
+
# populated on every outcome — including +TrapError+ — so the Host
|
|
83
|
+
# App can read it after rescuing a trap to diagnose budget
|
|
84
|
+
# consumption.
|
|
85
|
+
attr_reader :usage
|
|
86
|
+
|
|
87
|
+
# Build a fresh Sandbox.
|
|
88
|
+
#
|
|
89
|
+
# +wasm_path+ is the absolute path to the Guest Binary; defaults to the
|
|
90
|
+
# gem-bundled +data/kobako.wasm+. The four caps (+stdout_limit+,
|
|
91
|
+
# +stderr_limit+, +timeout+, +memory_limit+) are forwarded verbatim to
|
|
92
|
+
# +Kobako::SandboxOptions+, which owns their DEFAULT fallback and
|
|
93
|
+
# normalisation. The constructed +SandboxOptions+ is exposed as
|
|
94
|
+
# +#options+ and the four caps remain readable directly on Sandbox via
|
|
95
|
+
# +Forwardable+ delegation.
|
|
96
|
+
def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil,
|
|
97
|
+
timeout: SandboxOptions::DEFAULT_TIMEOUT_SECONDS,
|
|
98
|
+
memory_limit: SandboxOptions::DEFAULT_MEMORY_LIMIT)
|
|
99
|
+
@wasm_path = wasm_path || Kobako::Runtime.default_path
|
|
100
|
+
@options = SandboxOptions.new(timeout: timeout, memory_limit: memory_limit, stdout_limit: stdout_limit,
|
|
101
|
+
stderr_limit: stderr_limit)
|
|
102
|
+
@handler = Catalog::Handles.new
|
|
103
|
+
@services = Kobako::Catalog::Namespaces.new(handler: @handler)
|
|
104
|
+
@snippets = Catalog::Snippets.new
|
|
105
|
+
@runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
|
|
106
|
+
@options.stdout_limit, @options.stderr_limit)
|
|
107
|
+
install_dispatch_proc!
|
|
108
|
+
reset_invocation_state!
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Declare or retrieve the Namespace named +name+ on this Sandbox
|
|
112
|
+
# ({docs/behavior.md B-07, B-09, B-10}[link:../../docs/behavior.md]). +name+ must be a
|
|
113
|
+
# Symbol or String in constant form. Returns the
|
|
114
|
+
# +Kobako::Namespace+.
|
|
115
|
+
#
|
|
116
|
+
# Raises +ArgumentError+ when called after the first invocation, or
|
|
117
|
+
# when +name+ does not match the constant-name pattern.
|
|
118
|
+
def define(name)
|
|
119
|
+
@services.define(name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Register a snippet on this Sandbox in one of two forms
|
|
123
|
+
# ({docs/behavior.md B-32}[link:../../docs/behavior.md]):
|
|
124
|
+
#
|
|
125
|
+
# * +preload(code: source, name: Name)+ — +source+ is mruby source
|
|
126
|
+
# as a +String+ and +Name+ matches +/\A[A-Z]\w*\z/+. The +name+
|
|
127
|
+
# becomes the snippet's +(snippet:Name)+ backtrace filename and
|
|
128
|
+
# is the dedupe key for E-33.
|
|
129
|
+
# * +preload(binary: bytes)+ — +bytes+ is precompiled RITE
|
|
130
|
+
# bytecode as a +String+. The canonical name, when present,
|
|
131
|
+
# lives in the bytecode's embedded +debug_info+ and is resolved
|
|
132
|
+
# by the guest at load time; the host treats the bytes as
|
|
133
|
+
# opaque. Structural failures
|
|
134
|
+
# ({docs/behavior.md E-37 / E-38}[link:../../docs/behavior.md])
|
|
135
|
+
# surface as +Kobako::BytecodeError+ on the first invocation.
|
|
136
|
+
#
|
|
137
|
+
# Subsequent invocations (+#eval+ or +#run+) replay every registered
|
|
138
|
+
# snippet — in insertion order — against the fresh +mrb_state+
|
|
139
|
+
# before per-invocation source or entrypoint resolution.
|
|
140
|
+
#
|
|
141
|
+
# Returns +self+ to allow chaining.
|
|
142
|
+
#
|
|
143
|
+
# Raises +ArgumentError+ when neither form's keyword set is
|
|
144
|
+
# supplied, when both forms are mixed (e.g., +code:+ and +binary:+
|
|
145
|
+
# together, or +binary:+ paired with +name:+), when +code+ / +bytes+
|
|
146
|
+
# is not a +String+, when +name+ does not match the constant
|
|
147
|
+
# pattern ({docs/behavior.md E-34}[link:../../docs/behavior.md]),
|
|
148
|
+
# when +name+ duplicates an already-registered +code:+ form snippet
|
|
149
|
+
# ({docs/behavior.md E-33}[link:../../docs/behavior.md]), or when
|
|
150
|
+
# called after the first invocation
|
|
151
|
+
# ({docs/behavior.md E-35, B-33}[link:../../docs/behavior.md]).
|
|
152
|
+
def preload(code: nil, name: nil, binary: nil)
|
|
153
|
+
raise ArgumentError, "cannot preload after first Sandbox invocation" if @services.sealed?
|
|
154
|
+
|
|
155
|
+
@snippets.register(code: code, name: name, binary: binary)
|
|
156
|
+
self
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Dispatch into a preloaded entrypoint constant
|
|
160
|
+
# ({docs/behavior.md B-31}[link:../../docs/behavior.md]). Delegates host
|
|
161
|
+
# pre-flight and wire encoding to +Kobako::Transport::Run+ /
|
|
162
|
+
# +Kobako::Transport::Run#encode+: a non-Symbol/String +target+ raises
|
|
163
|
+
# +TypeError+ (E-24), while a +target+ failing the constant pattern
|
|
164
|
+
# (E-25), a forged +Kobako::Handle+ in +args+ / +kwargs+ (E-29), or a
|
|
165
|
+
# non-Symbol +kwargs+ key (E-30) raise +ArgumentError+. The guest
|
|
166
|
+
# resolves +target+ as a top-level constant, calls +#call+ on it with
|
|
167
|
+
# +args+ / +kwargs+, and returns the deserialized result. The first
|
|
168
|
+
# invocation seals the Service registry and snippet table (B-07 /
|
|
169
|
+
# B-33). Runtime errors follow the same three-class taxonomy as +#eval+.
|
|
170
|
+
def run(target, *args, **kwargs)
|
|
171
|
+
run_envelope = Transport::Run.new(entrypoint: target, args: args, kwargs: kwargs)
|
|
172
|
+
invoke!(:run) do
|
|
173
|
+
@runtime.run(@services.encode, @snippets.encode, run_envelope.encode(@handler))
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Execute a guest mruby source string in a fresh +mrb_state+
|
|
178
|
+
# ({docs/behavior.md B-02 / B-03 / B-06}[link:../../docs/behavior.md]). +code+ is the
|
|
179
|
+
# mruby source as a UTF-8 String. Returns the deserialized last
|
|
180
|
+
# expression of the source.
|
|
181
|
+
#
|
|
182
|
+
# Source delivery uses the WASI stdin three-frame protocol
|
|
183
|
+
# ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]):
|
|
184
|
+
# Frame 1 carries the msgpack-encoded preamble (Namespace / Member
|
|
185
|
+
# registry snapshot), Frame 2 carries the user source UTF-8 bytes, and
|
|
186
|
+
# Frame 3 carries the snippet table registered via +#preload+ (B-32).
|
|
187
|
+
# Each frame is prefixed by a 4-byte big-endian u32 length; Frame 3 is
|
|
188
|
+
# mandatory-presence — an empty snippet table sends an empty msgpack
|
|
189
|
+
# array, never an absent frame.
|
|
190
|
+
#
|
|
191
|
+
# The first invocation seals the Service registry and snippet table
|
|
192
|
+
# ({docs/behavior.md B-07 / B-33}[link:../../docs/behavior.md]); subsequent
|
|
193
|
+
# +#define+ / +#preload+ calls raise +ArgumentError+.
|
|
194
|
+
#
|
|
195
|
+
# Raises +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
|
|
196
|
+
# +Kobako::SandboxError+ when the guest ran to completion but failed
|
|
197
|
+
# (including when +code+ is +nil+ or not a String, or when a preloaded
|
|
198
|
+
# snippet's replay raises — E-36);
|
|
199
|
+
# +Kobako::ServiceError+ on an unrescued Service capability failure.
|
|
200
|
+
def eval(code)
|
|
201
|
+
raise SandboxError, "code must be a String, got #{code.class}" unless code.is_a?(String)
|
|
202
|
+
|
|
203
|
+
invoke!(:eval) do
|
|
204
|
+
@runtime.eval(@services.encode, code.b, @snippets.encode)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# Configure the +Runtime+'s host↔guest dispatch wiring
|
|
211
|
+
# ({docs/behavior.md B-12}[link:../../docs/behavior.md]). Builds a
|
|
212
|
+
# lambda that re-enters the guest via
|
|
213
|
+
# +Runtime#yield_to_active_invocation+ (B-24) and a dispatch +Proc+
|
|
214
|
+
# that routes guest→host calls through the stateless
|
|
215
|
+
# +Transport::Dispatcher+, capturing +@services+ / +@handler+ in the
|
|
216
|
+
# closure. Both are registered on the +Runtime+ once at construction
|
|
217
|
+
# time so the wasm ext callback can fire without further setup.
|
|
218
|
+
def install_dispatch_proc!
|
|
219
|
+
yield_to_guest = ->(args_bytes) { @runtime.yield_to_active_invocation(args_bytes) }
|
|
220
|
+
@runtime.on_dispatch = lambda do |request_bytes|
|
|
221
|
+
Transport::Dispatcher.dispatch(request_bytes, @services, @handler, yield_to_guest)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Per-invocation prologue ({docs/behavior.md B-03 / B-07 /
|
|
226
|
+
# B-33}[link:../../docs/behavior.md]). Seals the Service / snippet
|
|
227
|
+
# registries on first call (idempotent) and zeros the per-invocation
|
|
228
|
+
# capability state — capture buffers, truncation predicates, and the
|
|
229
|
+
# +Catalog::Handles+ counter — before the guest runs. The
|
|
230
|
+
# +Catalog::Handles+ itself is held as +@handler+ and never exposed beyond
|
|
231
|
+
# this class: SPEC.md Terminology pins it as "Not exposed to the
|
|
232
|
+
# Host App" (B-19 / B-20 / E-29).
|
|
233
|
+
def begin_invocation!
|
|
234
|
+
@services.seal!
|
|
235
|
+
@handler.reset!
|
|
236
|
+
reset_invocation_state!
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Reset all per-invocation observable state to its pre-invocation
|
|
240
|
+
# sentinels — both per-channel captures
|
|
241
|
+
# ({docs/behavior.md B-05}[link:../../docs/behavior.md]) and the
|
|
242
|
+
# per-last-invocation usage record
|
|
243
|
+
# ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Shared by
|
|
244
|
+
# +#initialize+ (first-time setup) and +#begin_invocation!+
|
|
245
|
+
# (between-invocation reset) so both paths agree on what
|
|
246
|
+
# "pre-invocation state" means.
|
|
247
|
+
def reset_invocation_state!
|
|
248
|
+
@stdout_capture = Capture::EMPTY
|
|
249
|
+
@stderr_capture = Capture::EMPTY
|
|
250
|
+
@usage = Usage::EMPTY
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Read the per-last-invocation +wall_time+ and +memory_peak+ from
|
|
254
|
+
# the ext and wrap them as a +Kobako::Usage+ value object
|
|
255
|
+
# ({docs/behavior.md B-35}[link:../../docs/behavior.md]). Runs in
|
|
256
|
+
# the +invoke!+ +ensure+ block so the usage record is populated on
|
|
257
|
+
# every outcome — value return, +Kobako::TrapError+ (including
|
|
258
|
+
# +TimeoutError+ / +MemoryLimitError+), +Kobako::SandboxError+,
|
|
259
|
+
# and +Kobako::ServiceError+. On the success path the same figures
|
|
260
|
+
# already arrive via +Snapshot#usage+; on the trap path the Snapshot
|
|
261
|
+
# never reaches Ruby so the ext readout here is the only source.
|
|
262
|
+
#
|
|
263
|
+
# The ext returns a positional 2-tuple +[wall_time, memory_peak]+
|
|
264
|
+
# whose order matches the +Kobako::Usage+ field order; the
|
|
265
|
+
# destructure-then-kwargs handoff below is the explicit
|
|
266
|
+
# positional→keyword conversion point.
|
|
267
|
+
def read_usage!
|
|
268
|
+
wall_time, memory_peak = @runtime.usage
|
|
269
|
+
@usage = Usage.new(wall_time: wall_time, memory_peak: memory_peak)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Pick the +TrapError+ subclass to re-raise based on +err+'s actual
|
|
273
|
+
# class. Cap-trap subclasses
|
|
274
|
+
# ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md])
|
|
275
|
+
# preserve their named identity; everything else collapses to the
|
|
276
|
+
# base +Kobako::TrapError+. The ext already raises the right subclass
|
|
277
|
+
# directly, so this is a pure re-attribution that lets +#invoke!+
|
|
278
|
+
# add the verb prefix without erasing +TimeoutError+ /
|
|
279
|
+
# +MemoryLimitError+.
|
|
280
|
+
def trap_class_for(err)
|
|
281
|
+
case err
|
|
282
|
+
when TimeoutError then TimeoutError
|
|
283
|
+
when MemoryLimitError then MemoryLimitError
|
|
284
|
+
else TrapError
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Shared prologue / epilogue + trap-class translator for both
|
|
289
|
+
# invocation verbs. +verb+ is +:eval+ or +:run+; it tags the
|
|
290
|
+
# TrapError message so the failing export is identifiable.
|
|
291
|
+
#
|
|
292
|
+
# The yielded block must return a +Kobako::Snapshot+ — i.e. the
|
|
293
|
+
# value of +Runtime#eval+ / +#run+ (SPEC.md Internal Concepts →
|
|
294
|
+
# Snapshot). The success path unpacks every observable from the
|
|
295
|
+
# Snapshot in one go: +#stdout+ / +#stderr+ pack into +Capture+,
|
|
296
|
+
# +#usage+ packs into +Usage+, +#return_bytes+ feeds +Outcome.decode+.
|
|
297
|
+
# The rescue chain is the single trap-translation boundary —
|
|
298
|
+
# configured-cap paths
|
|
299
|
+
# ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md])
|
|
300
|
+
# surface as named TrapError subclasses; everything else surfaces as
|
|
301
|
+
# the base +TrapError+.
|
|
302
|
+
def invoke!(verb)
|
|
303
|
+
begin_invocation!
|
|
304
|
+
snapshot = yield
|
|
305
|
+
@stdout_capture = snapshot.stdout
|
|
306
|
+
@stderr_capture = snapshot.stderr
|
|
307
|
+
Outcome.decode(snapshot.return_bytes)
|
|
308
|
+
rescue Kobako::TrapError => e
|
|
309
|
+
raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
|
|
310
|
+
ensure
|
|
311
|
+
read_usage!
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
# Kobako::SandboxOptions — immutable Value Object holding the four
|
|
5
|
+
# per-Sandbox configuration caps ({docs/behavior.md B-01,
|
|
6
|
+
# E-20}[link:../../docs/behavior.md]). Built on the +class X <
|
|
7
|
+
# Data.define(...)+ subclass form (the Steep-friendly shape — see
|
|
8
|
+
# +lib/kobako/outcome/panic.rb+).
|
|
9
|
+
#
|
|
10
|
+
# The +initialize+ method does double duty: it applies DEFAULT fallback
|
|
11
|
+
# for absent values and normalises (timeout to Float seconds,
|
|
12
|
+
# memory_limit to positive Integer bytes) before delegating to Data's
|
|
13
|
+
# +super+. Anything that survives +SandboxOptions.new+ is a wire-ready
|
|
14
|
+
# cap bundle the +Kobako::Runtime+ constructor consumes as-is.
|
|
15
|
+
class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit)
|
|
16
|
+
# Default wall-clock timeout for a single invocation: 60 seconds
|
|
17
|
+
# ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
|
|
18
|
+
DEFAULT_TIMEOUT_SECONDS = 60.0
|
|
19
|
+
|
|
20
|
+
# Default cap on the per-invocation guest linear-memory delta:
|
|
21
|
+
# 1 MiB ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
|
|
22
|
+
# The mruby image's initial allocation and prior invocations'
|
|
23
|
+
# watermark sit outside this budget — see B-01 Notes.
|
|
24
|
+
DEFAULT_MEMORY_LIMIT = 1 << 20
|
|
25
|
+
|
|
26
|
+
# Default per-channel capture ceiling: 1 MiB
|
|
27
|
+
# ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
|
|
28
|
+
DEFAULT_OUTPUT_LIMIT = 1 << 20
|
|
29
|
+
|
|
30
|
+
def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
|
|
31
|
+
memory_limit: DEFAULT_MEMORY_LIMIT,
|
|
32
|
+
stdout_limit: nil,
|
|
33
|
+
stderr_limit: nil)
|
|
34
|
+
timeout = normalize_timeout(timeout)
|
|
35
|
+
memory_limit = normalize_memory_limit(memory_limit)
|
|
36
|
+
stdout_limit ||= DEFAULT_OUTPUT_LIMIT
|
|
37
|
+
stderr_limit ||= DEFAULT_OUTPUT_LIMIT
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Coerce +timeout+ into the Float seconds the ext expects, or +nil+
|
|
44
|
+
# to mean the cap is disabled. Any finite non-positive value is
|
|
45
|
+
# rejected — a zero or negative timeout would either fire instantly
|
|
46
|
+
# or never, both of which would surprise callers more than an early
|
|
47
|
+
# +ArgumentError+.
|
|
48
|
+
def normalize_timeout(timeout)
|
|
49
|
+
return nil if timeout.nil?
|
|
50
|
+
raise ArgumentError, "timeout must be Numeric or nil, got #{timeout.class}" unless timeout.is_a?(Numeric)
|
|
51
|
+
|
|
52
|
+
seconds = timeout.to_f
|
|
53
|
+
raise ArgumentError, "timeout must be > 0 (got #{timeout})" unless seconds.positive? && seconds.finite?
|
|
54
|
+
|
|
55
|
+
seconds
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Coerce +memory_limit+ into the byte cap the ext expects, or +nil+
|
|
59
|
+
# to mean unbounded. Must be a positive Integer when set; Float or
|
|
60
|
+
# zero/negative values are rejected.
|
|
61
|
+
def normalize_memory_limit(memory_limit)
|
|
62
|
+
return nil if memory_limit.nil?
|
|
63
|
+
unless memory_limit.is_a?(Integer) && memory_limit.positive?
|
|
64
|
+
raise ArgumentError, "memory_limit must be a positive Integer or nil, got #{memory_limit.inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
memory_limit
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "capture"
|
|
4
|
+
require_relative "usage"
|
|
5
|
+
|
|
6
|
+
module Kobako
|
|
7
|
+
# Kobako::Snapshot — per-invocation observable bundle returned from
|
|
8
|
+
# +Kobako::Runtime#eval+ and +#run+.
|
|
9
|
+
#
|
|
10
|
+
# The magnus class (see ext/kobako/src/snapshot.rs) carries seven raw
|
|
11
|
+
# readers: +return_bytes+, +stdout_bytes+, +stdout_truncated+,
|
|
12
|
+
# +stderr_bytes+, +stderr_truncated+, +wall_time+, +memory_peak+. This
|
|
13
|
+
# file reopens the class to add the Ruby-side helpers that pack those
|
|
14
|
+
# raw fields into the user-facing value objects +Kobako::Capture+ and
|
|
15
|
+
# +Kobako::Usage+ — the same shape +Kobako::Sandbox+ exposes to the
|
|
16
|
+
# Host App.
|
|
17
|
+
class Snapshot
|
|
18
|
+
# Wrap the stdout capture pair (+stdout_bytes+, +stdout_truncated+)
|
|
19
|
+
# as a +Kobako::Capture+ value object. {docs/behavior.md
|
|
20
|
+
# B-04}[link:../../docs/behavior.md] — the byte content never carries
|
|
21
|
+
# a truncation sentinel; +#truncated?+ is the only way to observe
|
|
22
|
+
# that the cap was hit.
|
|
23
|
+
def stdout
|
|
24
|
+
Capture.new(bytes: stdout_bytes, truncated: stdout_truncated)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Wrap the stderr capture pair as a +Kobako::Capture+ value object.
|
|
28
|
+
# Mirror of +#stdout+.
|
|
29
|
+
def stderr
|
|
30
|
+
Capture.new(bytes: stderr_bytes, truncated: stderr_truncated)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Wrap the per-last-invocation usage pair (+wall_time+,
|
|
34
|
+
# +memory_peak+) as a +Kobako::Usage+ value object
|
|
35
|
+
# ({docs/behavior.md B-35}[link:../../docs/behavior.md]).
|
|
36
|
+
def usage
|
|
37
|
+
Usage.new(wall_time: wall_time, memory_peak: memory_peak)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|