kobako 0.27.0-aarch64-linux → 0.29.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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +241 -0
- data/README.md +240 -147
- data/ROADMAP.md +28 -22
- data/SECURITY.md +2 -2
- data/crates/kobako-runtime/CHANGELOG.md +117 -0
- data/crates/kobako-runtime/README.md +16 -10
- data/crates/kobako-transport/CHANGELOG.md +45 -0
- data/crates/kobako-transport/README.md +21 -12
- data/crates/kobako-wasmtime/CHANGELOG.md +163 -0
- data/crates/kobako-wasmtime/README.md +6 -4
- 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 +1 -4
- data/lib/kobako/catalog/extensions.rb +21 -59
- data/lib/kobako/catalog/flags.rb +29 -0
- data/lib/kobako/catalog/grants.rb +30 -0
- data/lib/kobako/catalog/handles.rb +7 -54
- data/lib/kobako/catalog/mount.rb +27 -0
- data/lib/kobako/catalog/services.rb +17 -60
- data/lib/kobako/catalog/snippets.rb +41 -69
- data/lib/kobako/catalog.rb +13 -10
- data/lib/kobako/codec/decoder.rb +10 -28
- data/lib/kobako/codec/encoder.rb +3 -22
- data/lib/kobako/codec/error.rb +6 -27
- data/lib/kobako/codec/ext_types.rb +72 -94
- data/lib/kobako/codec/handle_walk.rb +65 -130
- data/lib/kobako/codec/nesting.rb +3 -8
- data/lib/kobako/codec/state.rb +21 -11
- data/lib/kobako/codec/utils.rb +6 -34
- data/lib/kobako/codec/value_budget.rb +86 -0
- data/lib/kobako/codec.rb +10 -22
- data/lib/kobako/constant_name.rb +8 -0
- data/lib/kobako/context.rb +83 -117
- data/lib/kobako/errors.rb +57 -132
- data/lib/kobako/execution.rb +16 -21
- data/lib/kobako/extension.rb +23 -40
- data/lib/kobako/filesystem.rb +38 -0
- data/lib/kobako/handle.rb +7 -12
- data/lib/kobako/outcome.rb +51 -80
- data/lib/kobako/payload/arguments.rb +1 -16
- data/lib/kobako/payload.rb +4 -12
- data/lib/kobako/pool.rb +25 -55
- data/lib/kobako/runtime.rb +7 -21
- data/lib/kobako/sandbox.rb +128 -137
- data/lib/kobako/sandbox_options.rb +71 -90
- data/lib/kobako/snippet/bytecode.rb +11 -0
- data/lib/kobako/snippet/source.rb +1 -16
- data/lib/kobako/snippet.rb +2 -10
- data/lib/kobako/transport/block_failure.rb +35 -0
- data/lib/kobako/transport/call.rb +1 -14
- data/lib/kobako/transport/dispatcher.rb +127 -206
- data/lib/kobako/transport/error.rb +6 -20
- data/lib/kobako/transport/exposure.rb +10 -17
- data/lib/kobako/transport/reflection.rb +108 -63
- data/lib/kobako/transport/run.rb +17 -71
- data/lib/kobako/transport/yielder.rb +20 -94
- data/lib/kobako/transport.rb +4 -15
- data/lib/kobako/unresolved.rb +4 -11
- data/lib/kobako/usage.rb +2 -6
- data/lib/kobako/version.rb +1 -1
- data/lib/kobako.rb +1 -0
- data/sig/kobako/catalog/extensions.rbs +2 -2
- data/sig/kobako/catalog/flags.rbs +13 -0
- data/sig/kobako/catalog/grants.rbs +13 -0
- data/sig/kobako/catalog/mount.rbs +28 -0
- data/sig/kobako/catalog/services.rbs +3 -5
- data/sig/kobako/catalog/snippets.rbs +5 -5
- data/sig/kobako/catalog.rbs +1 -0
- data/sig/kobako/codec/ext_types.rbs +10 -8
- data/sig/kobako/codec/handle_walk.rbs +9 -7
- data/sig/kobako/codec/state.rbs +7 -1
- data/sig/kobako/codec/utils.rbs +2 -2
- data/sig/kobako/codec/value_budget.rbs +23 -0
- data/sig/kobako/codec.rbs +4 -2
- data/sig/kobako/constant_name.rbs +3 -0
- data/sig/kobako/context.rbs +15 -8
- data/sig/kobako/errors.rbs +10 -4
- data/sig/kobako/filesystem.rbs +88 -0
- data/sig/kobako/guest_yielder.rbs +9 -0
- data/sig/kobako/outcome.rbs +6 -8
- data/sig/kobako/pool.rbs +1 -1
- data/sig/kobako/runtime.rbs +21 -45
- data/sig/kobako/sandbox.rbs +11 -1
- data/sig/kobako/sandbox_options.rbs +25 -7
- data/sig/kobako/snapshot.rbs +20 -0
- data/sig/kobako/snippet/bytecode.rbs +12 -0
- data/sig/kobako/transport/block_failure.rbs +13 -0
- data/sig/kobako/transport/dispatcher.rbs +15 -19
- data/sig/kobako/transport/exposure.rbs +1 -1
- data/sig/kobako/transport/reflection.rbs +19 -4
- data/sig/kobako/transport/run.rbs +1 -3
- data/sig/kobako/transport/yielder.rbs +5 -9
- data/sig/kobako/transport.rbs +9 -8
- metadata +30 -9
- data/lib/kobako/snippet/binary.rb +0 -26
- data/rust-toolchain.toml +0 -15
- data/sig/kobako/snippet/binary.rbs +0 -12
data/lib/kobako/sandbox.rb
CHANGED
|
@@ -10,205 +10,196 @@ require_relative "catalog"
|
|
|
10
10
|
require_relative "context"
|
|
11
11
|
|
|
12
12
|
module Kobako
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# the +Kobako::Catalog::Services+ / +Catalog::Snippets+ / +Catalog::Extensions+
|
|
18
|
-
# registries. Each +#eval+ / +#run+ seals that config on the first call and
|
|
19
|
-
# drives one guest invocation through a fresh +Kobako::Context+ — its own
|
|
20
|
-
# Handle table, dispatch +Proc+, captures, and usage — so the reusable
|
|
21
|
-
# Sandbox holds no per-invocation state. The underlying wasmtime Engine and
|
|
22
|
-
# compiled Module are cached at process scope by the native ext and never
|
|
23
|
-
# surface to Ruby — constructing many Sandboxes amortises both costs
|
|
24
|
-
# automatically.
|
|
25
|
-
#
|
|
26
|
-
# A run's observables — the captured +#stdout+ / +#stderr+ (bounded by
|
|
27
|
-
# +stdout_limit+ / +stderr_limit+, enforced inside the WASI pipe) with their
|
|
28
|
-
# +#stdout_truncated?+ / +#stderr_truncated?+ predicates, and +#usage+ — live
|
|
29
|
-
# on the +Kobako::Execution+ each +#eval+ / +#run+ returns, or on the one its
|
|
30
|
-
# raised error carries. The Sandbox itself keeps none of them, so nothing a
|
|
31
|
-
# run observes carries into the next one.
|
|
13
|
+
# Runs untrusted mruby scripts in an isolated Wasm instance. Configure it
|
|
14
|
+
# once, binding Services and preloading snippets, then call #eval or #run as
|
|
15
|
+
# often as needed: each call returns an Execution and leaves nothing behind
|
|
16
|
+
# for the next.
|
|
32
17
|
class Sandbox
|
|
33
18
|
extend Forwardable
|
|
34
19
|
|
|
35
|
-
|
|
20
|
+
# The Guest Binary this Sandbox runs.
|
|
21
|
+
attr_reader :wasm_path
|
|
36
22
|
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
|
|
23
|
+
# The SandboxOptions this Sandbox was built with. Each option is also
|
|
24
|
+
# readable on the Sandbox itself, as in #timeout.
|
|
25
|
+
attr_reader :options
|
|
40
26
|
|
|
41
|
-
|
|
27
|
+
def_delegators :@options, :timeout, :setup_timeout, :memory_limit, :stdout_limit, :stderr_limit,
|
|
28
|
+
:open_file_limit, :profile, :gvl
|
|
29
|
+
|
|
30
|
+
# Build a Sandbox on the Guest Binary at +wasm_path+, the one bundled
|
|
31
|
+
# with the gem by default. The other keywords are the options
|
|
32
|
+
# SandboxOptions describes.
|
|
42
33
|
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# requested isolation profile (+profile+) — is forwarded verbatim to
|
|
47
|
-
# +Kobako::SandboxOptions+, which owns the DEFAULT fallbacks and
|
|
48
|
-
# normalisation. The constructed +SandboxOptions+ is exposed as
|
|
49
|
-
# +#options+ and every option remains readable directly on Sandbox via
|
|
50
|
-
# +Forwardable+ delegation. The runtime builds the requested profile —
|
|
51
|
-
# +:hermetic+ (the default) denies the guest ambient time and entropy,
|
|
52
|
-
# +:permissive+ leaves them live — and construction refuses a runtime
|
|
53
|
-
# whose declared profile falls below the request, raising
|
|
54
|
-
# +Kobako::SetupError+ before any invocation entry point runs.
|
|
34
|
+
# Raises ArgumentError for an invalid option, and SetupError when the
|
|
35
|
+
# runtime cannot be built or provides less isolation than +profile+ asks
|
|
36
|
+
# for.
|
|
55
37
|
def initialize(wasm_path: nil, **)
|
|
56
38
|
@wasm_path = wasm_path || Kobako::Runtime.default_path
|
|
57
39
|
@options = SandboxOptions.new(**)
|
|
58
40
|
@services = Kobako::Catalog::Services.new
|
|
59
41
|
@snippets = Catalog::Snippets.new
|
|
42
|
+
@flags = Catalog::Flags.new
|
|
60
43
|
@extensions = Catalog::Extensions.new
|
|
44
|
+
@grants = Catalog::Grants.new
|
|
45
|
+
@sealed = false
|
|
61
46
|
@runtime = build_runtime!
|
|
62
47
|
end
|
|
63
48
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
# (+"MyService::KV"+ or a top-level +"File"+). Returns +self+ for
|
|
67
|
-
# chaining.
|
|
49
|
+
# Make +object+ reachable from the guest as the constant at +path+, a
|
|
50
|
+
# Symbol or String such as <tt>"MyService::KV"</tt>. Returns +self+.
|
|
68
51
|
#
|
|
69
|
-
# The guest reaches the public methods +object+'s own class and
|
|
70
|
-
# itself define in source,
|
|
71
|
-
#
|
|
72
|
-
#
|
|
52
|
+
# The guest reaches the public methods that +object+'s own class and
|
|
53
|
+
# +object+ itself define in source, nothing inherited or mixed in. An
|
|
54
|
+
# +object+ defining a private <tt>respond_to_guest?(name)</tt> decides
|
|
55
|
+
# instead, on every call.
|
|
73
56
|
#
|
|
74
|
-
# Called with only
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# guest dispatch to an unfilled fillable surfaces as
|
|
78
|
-
# +Kobako::ServiceError+ when left unrescued.
|
|
57
|
+
# Called with only +path+, it declares a fillable Service: the guest sees
|
|
58
|
+
# the constant, and Context#bind supplies the object for each invocation.
|
|
59
|
+
# A call to one left unfilled fails as a ServiceError.
|
|
79
60
|
#
|
|
80
|
-
# Raises
|
|
81
|
-
# collides with an existing binding
|
|
82
|
-
#
|
|
83
|
-
# invocation has sealed Service registration.
|
|
61
|
+
# Raises ArgumentError for a malformed path, a path under the reserved
|
|
62
|
+
# +Kobako+ namespace, a path that collides with an existing binding, or a
|
|
63
|
+
# call after the first invocation.
|
|
84
64
|
def bind(path, object = Unresolved)
|
|
65
|
+
refuse_once_invoked!("bind")
|
|
85
66
|
@services.bind(path, object)
|
|
86
67
|
self
|
|
87
68
|
end
|
|
88
69
|
|
|
89
|
-
# Install
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
# +name+ / +source+ / +backend+ / +depends_on+; +Kobako::Extension+ is
|
|
93
|
-
# the bundled value type. Returns +self+.
|
|
70
|
+
# Install Extensions, each guest source paired with an optional host
|
|
71
|
+
# backend. Any object answering +name+, +source+, +backend+ and
|
|
72
|
+
# +depends_on+ works; Extension is the bundled one. Returns +self+.
|
|
94
73
|
#
|
|
95
|
-
# Raises
|
|
96
|
-
#
|
|
97
|
-
# an unmet +depends_on+.
|
|
74
|
+
# Raises ArgumentError for a malformed Extension, a call after the first
|
|
75
|
+
# invocation, or, at the first invocation, a missing dependency.
|
|
98
76
|
def install(*extensions)
|
|
99
|
-
|
|
77
|
+
refuse_once_invoked!("install")
|
|
100
78
|
|
|
101
79
|
extensions.each { |extension| @extensions.install(extension, snippets: @snippets, services: @services) }
|
|
102
80
|
self
|
|
103
81
|
end
|
|
104
82
|
|
|
105
|
-
#
|
|
83
|
+
# Mount +filesystem+, a Kobako::Filesystem, at the absolute guest path
|
|
84
|
+
# +at+, where the guest's +File+ reaches it. +access+ is +:read+ or
|
|
85
|
+
# +:read_write+; a write beneath a read-only mount fails as
|
|
86
|
+
# <tt>Errno::EROFS</tt> in the guest. Returns +self+.
|
|
106
87
|
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
# is the dedupe key that rejects a duplicate +code:+ snippet.
|
|
113
|
-
# * +preload(binary: bytes)+ — +bytes+ is precompiled RITE
|
|
114
|
-
# bytecode as a +String+. The canonical name, when present,
|
|
115
|
-
# lives in the bytecode's embedded +debug_info+ and is resolved
|
|
116
|
-
# by the guest at load time; the host treats the bytes as
|
|
117
|
-
# opaque. Structural failures surface as +Kobako::BytecodeError+
|
|
118
|
-
# on the first invocation.
|
|
88
|
+
# A given block decides, in +access+'s place, each open and each change
|
|
89
|
+
# that reaches the mount, asked at most once for each with the guest path
|
|
90
|
+
# and +:read+ or +:read_write+. A falsy answer,
|
|
91
|
+
# or a StandardError, fails that call as <tt>Errno::EACCES</tt> in the
|
|
92
|
+
# guest and leaves the script running.
|
|
119
93
|
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
94
|
+
# Raises TypeError for a path that is no String or something other than
|
|
95
|
+
# a Filesystem, and ArgumentError for a malformed, duplicate, or nested
|
|
96
|
+
# path, an unknown access, or a call after the first invocation.
|
|
97
|
+
def mount(at, filesystem, access: :read, &policy)
|
|
98
|
+
refuse_once_invoked!("mount")
|
|
99
|
+
Runtime.validate_mount!(at, access, filesystem)
|
|
100
|
+
@grants.register(Catalog::Mount.new(at: at, access: access, filesystem: filesystem, policy: policy))
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Register a snippet that every invocation runs, in the order registered,
|
|
105
|
+
# before its own code. Returns +self+.
|
|
123
106
|
#
|
|
124
|
-
#
|
|
107
|
+
# [<tt>preload(code: source, name: Name)</tt>]
|
|
108
|
+
# mruby source, named <tt>(snippet:Name)</tt> in backtraces.
|
|
109
|
+
# [<tt>preload(binary: bytes)</tt>]
|
|
110
|
+
# Precompiled RITE bytecode, trusted as host code: the unmodified
|
|
111
|
+
# compiler output of the mruby release the Guest Binary is built on.
|
|
125
112
|
#
|
|
126
|
-
# Raises
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
# is not a +String+, when +name+ does not match the constant
|
|
130
|
-
# pattern, when +name+ duplicates an already-registered +code:+ form
|
|
131
|
-
# snippet, or when called after the first invocation has sealed the
|
|
132
|
-
# snippet table.
|
|
113
|
+
# Raises ArgumentError for a malformed or duplicate snippet, or a call
|
|
114
|
+
# after the first invocation. A snippet that fails to load fails every
|
|
115
|
+
# invocation with SandboxError, or BytecodeError for bytecode.
|
|
133
116
|
def preload(code: nil, name: nil, binary: nil)
|
|
134
|
-
|
|
117
|
+
refuse_once_invoked!("preload")
|
|
135
118
|
|
|
136
119
|
@snippets.register(code: code, name: name, binary: binary)
|
|
137
120
|
self
|
|
138
121
|
end
|
|
139
122
|
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
|
|
123
|
+
# Set the flag +name+, a Symbol or String, to +value+ for every
|
|
124
|
+
# invocation; an empty +value+ is a plain switch. A Guest Binary that
|
|
125
|
+
# does not recognise the flag fails each invocation with
|
|
126
|
+
# UnsupportedFlagError, unless +advisory+ says the guest may skip it.
|
|
127
|
+
# Mark a flag advisory only when a script cannot tell it was skipped.
|
|
128
|
+
# Returns +self+.
|
|
129
|
+
#
|
|
130
|
+
# Raises ArgumentError for a malformed or duplicate flag, or a call after
|
|
131
|
+
# the first invocation.
|
|
132
|
+
def flag(name, value = "", advisory: false)
|
|
133
|
+
refuse_once_invoked!("flag")
|
|
134
|
+
|
|
135
|
+
@flags.register(name, value, advisory: advisory)
|
|
136
|
+
self
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Call +call+ on the preloaded top-level constant +target+ with +args+
|
|
140
|
+
# and +kwargs+, and return the Execution. A given block receives the
|
|
141
|
+
# invocation's Context before the guest runs.
|
|
142
|
+
#
|
|
143
|
+
# Raises TypeError when +target+ is not a Symbol or String, and
|
|
144
|
+
# ArgumentError when it is not a constant name or the arguments carry a
|
|
145
|
+
# Handle or a non-Symbol keyword. A failed run raises as #eval does.
|
|
150
146
|
def run(target, *args, **kwargs, &block)
|
|
151
147
|
request = Transport::Run.new(entrypoint: target, args: args, kwargs: kwargs)
|
|
152
|
-
new_invocation.run(request, &block)
|
|
148
|
+
Codec.within_value_budget(@options.value_budget) { new_invocation.run(request, &block) }
|
|
153
149
|
end
|
|
154
150
|
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
# Source delivery uses the WASI stdin three-frame protocol
|
|
160
|
-
# ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]):
|
|
161
|
-
# Frame 1 carries the preamble (Service registry snapshot), Frame 2 the
|
|
162
|
-
# user source UTF-8 bytes, and Frame 3 the snippet table registered via
|
|
163
|
-
# +#preload+. Each frame is prefixed by a 4-byte big-endian u32 length;
|
|
164
|
-
# Frame 1 and Frame 3 are mandatory-presence — an empty registry sends a
|
|
165
|
-
# present, empty frame rather than an absent one.
|
|
166
|
-
#
|
|
167
|
-
# The first invocation seals the Service registry and snippet table;
|
|
168
|
-
# subsequent +#bind+ / +#preload+ calls raise +ArgumentError+.
|
|
151
|
+
# Evaluate +code+, mruby source as a String, and return the Execution,
|
|
152
|
+
# whose +value+ is the last expression. A given block receives the
|
|
153
|
+
# invocation's Context before the guest runs.
|
|
169
154
|
#
|
|
170
|
-
# Raises
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
155
|
+
# Raises TrapError when the engine stopped the guest, a deadline or
|
|
156
|
+
# memory budget included; SandboxError when the guest failed, +code+ not
|
|
157
|
+
# being a String or the Guest Binary not starting the run included; and
|
|
158
|
+
# ServiceError when a Service call failed and the guest left it
|
|
159
|
+
# unrescued.
|
|
175
160
|
def eval(code, &block)
|
|
176
161
|
raise SandboxError, "code must be a String, got #{code.class}" unless code.is_a?(String)
|
|
177
162
|
|
|
178
|
-
new_invocation.eval(code, &block)
|
|
163
|
+
Codec.within_value_budget(@options.value_budget) { new_invocation.eval(code, &block) }
|
|
179
164
|
end
|
|
180
165
|
|
|
181
166
|
private
|
|
182
167
|
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
# +SandboxOptions#enforce_floor!+ owns the ladder comparison, so a
|
|
186
|
-
# runtime that cannot honor the request never runs guest code.
|
|
168
|
+
# A runtime whose declared posture falls below the request never runs
|
|
169
|
+
# guest code.
|
|
187
170
|
def build_runtime!
|
|
188
|
-
runtime = Kobako::Runtime.from_path(@wasm_path,
|
|
189
|
-
@options.stdout_limit, @options.stderr_limit, @options.profile,
|
|
190
|
-
@options.gvl)
|
|
171
|
+
runtime = Kobako::Runtime.from_path(@wasm_path, **@options.to_h)
|
|
191
172
|
@options.enforce_floor!(runtime.profile)
|
|
192
173
|
runtime
|
|
193
174
|
end
|
|
194
175
|
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
# run's Handle table, resolved Extension backends, captures, and usage, so
|
|
198
|
-
# no per-invocation state is written back onto the shared config.
|
|
176
|
+
# Per-invocation state lives on the Context, so nothing an invocation
|
|
177
|
+
# does is written back onto the shared config.
|
|
199
178
|
def new_invocation
|
|
200
179
|
begin_invocation!
|
|
201
|
-
Context.new(runtime: @runtime, services: @services,
|
|
180
|
+
Context.new(runtime: @runtime, services: @services, extensions: @extensions, grants: @grants,
|
|
181
|
+
preamble: preamble(@snippets.entries))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The one place the Preamble's parts are put together, for a compile
|
|
185
|
+
# and for every invocation alike.
|
|
186
|
+
def preamble(snippets)
|
|
187
|
+
[@services.paths, snippets, @flags.entries]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Registration closes at the first invocation, so every invocation ships
|
|
191
|
+
# the same Services, snippets, flags, Extensions and mounts.
|
|
192
|
+
def refuse_once_invoked!(verb)
|
|
193
|
+
raise ArgumentError, "cannot #{verb} after first Sandbox invocation" if @sealed
|
|
202
194
|
end
|
|
203
195
|
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
# +#install+ all gate on. Per-invocation provider resolution and observable
|
|
208
|
-
# state live on the +Context+, not here.
|
|
196
|
+
# Sealing before the dependency check keeps a Sandbox whose check failed
|
|
197
|
+
# closed to further registration. The sealed snippets are compiled once,
|
|
198
|
+
# before the first invocation replays them.
|
|
209
199
|
def begin_invocation!
|
|
210
|
-
@
|
|
211
|
-
@extensions.
|
|
200
|
+
@sealed = true
|
|
201
|
+
@extensions.assert_dependencies!
|
|
202
|
+
@snippets.compile { |entries| @runtime.compile(preamble(entries)) }
|
|
212
203
|
end
|
|
213
204
|
end
|
|
214
205
|
end
|
|
@@ -3,77 +3,89 @@
|
|
|
3
3
|
require_relative "errors"
|
|
4
4
|
|
|
5
5
|
module Kobako
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# Steep-friendly shape — see +.rubocop.yml+ for the rationale).
|
|
6
|
+
# The options a Sandbox was built with, read through Sandbox#options or
|
|
7
|
+
# directly on the Sandbox, as in Sandbox#timeout. A cap left out takes its
|
|
8
|
+
# default, and +nil+ switches it off.
|
|
10
9
|
#
|
|
11
|
-
#
|
|
12
|
-
# +
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
10
|
+
# [+timeout+] Float seconds one invocation may run.
|
|
11
|
+
# [+setup_timeout+] Float seconds the Guest Binary may take to be set up
|
|
12
|
+
# the first time its path is loaded.
|
|
13
|
+
# [+memory_limit+] Integer bytes one invocation may grow guest memory by.
|
|
14
|
+
# [+stdout_limit+, +stderr_limit+]
|
|
15
|
+
# Integer bytes kept from each output channel.
|
|
16
|
+
# [+open_file_limit+]
|
|
17
|
+
# Integer descriptors one invocation may hold open, the
|
|
18
|
+
# standard ones and each Mount's included.
|
|
19
|
+
# [+profile+] The isolation the runtime must provide, one of PROFILES.
|
|
20
|
+
# [+gvl+] How an invocation holds Ruby's GVL, one of GVL_MODES.
|
|
21
|
+
#
|
|
22
|
+
# Raises ArgumentError for a value outside these shapes, and SetupError
|
|
23
|
+
# when the runtime provides less isolation than +profile+ asks for.
|
|
24
|
+
class SandboxOptions < Data.define(:timeout, :setup_timeout, :memory_limit, :stdout_limit, :stderr_limit,
|
|
25
|
+
:open_file_limit, :profile, :gvl)
|
|
26
|
+
# The default +timeout+: 60 seconds.
|
|
26
27
|
DEFAULT_TIMEOUT_SECONDS = 60.0
|
|
27
28
|
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
# The default +setup_timeout+: 10 seconds.
|
|
30
|
+
DEFAULT_SETUP_TIMEOUT_SECONDS = 10.0
|
|
31
|
+
|
|
32
|
+
# The default +memory_limit+: 4 MiB of growth past the guest's starting
|
|
33
|
+
# memory, sized for a guest whose every value takes 16 bytes.
|
|
34
|
+
DEFAULT_MEMORY_LIMIT = 4 << 20
|
|
32
35
|
|
|
33
|
-
#
|
|
36
|
+
# The default +stdout_limit+ and +stderr_limit+: 1 MiB each.
|
|
34
37
|
DEFAULT_OUTPUT_LIMIT = 1 << 20
|
|
35
38
|
|
|
36
|
-
# The
|
|
37
|
-
#
|
|
39
|
+
# The default +open_file_limit+: 1024, the soft limit Linux gives a
|
|
40
|
+
# process.
|
|
41
|
+
DEFAULT_OPEN_FILE_LIMIT = 1024
|
|
42
|
+
|
|
43
|
+
# The isolation profiles, weakest first. +:hermetic+ denies the guest the
|
|
44
|
+
# clock and entropy; +:permissive+ leaves them live.
|
|
38
45
|
PROFILES = %i[permissive hermetic].freeze
|
|
39
46
|
|
|
40
|
-
#
|
|
41
|
-
# +:permissive+ is the Host App's explicit trade.
|
|
47
|
+
# The default +profile+, the strictest.
|
|
42
48
|
DEFAULT_PROFILE = :hermetic
|
|
43
49
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# Sandboxes on distinct Threads run their guest code in parallel.
|
|
50
|
+
# +:hold+ keeps the GVL through the invocation; +:release+ drops it while
|
|
51
|
+
# the guest runs, so Sandboxes on different Threads run in parallel.
|
|
47
52
|
GVL_MODES = %i[hold release].freeze
|
|
48
53
|
|
|
49
|
-
#
|
|
50
|
-
# execution, so +:release+ is the Host App's explicit opt-in for
|
|
51
|
-
# host-parallel guest execution.
|
|
54
|
+
# The default +gvl+, +:hold+.
|
|
52
55
|
DEFAULT_GVL = :hold
|
|
53
56
|
|
|
54
57
|
def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
|
|
58
|
+
setup_timeout: DEFAULT_SETUP_TIMEOUT_SECONDS,
|
|
55
59
|
memory_limit: DEFAULT_MEMORY_LIMIT,
|
|
56
60
|
stdout_limit: DEFAULT_OUTPUT_LIMIT,
|
|
57
61
|
stderr_limit: DEFAULT_OUTPUT_LIMIT,
|
|
62
|
+
open_file_limit: DEFAULT_OPEN_FILE_LIMIT,
|
|
58
63
|
profile: DEFAULT_PROFILE,
|
|
59
64
|
gvl: DEFAULT_GVL)
|
|
60
|
-
timeout = normalize_timeout(timeout)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
timeout = normalize_timeout(timeout, "timeout")
|
|
66
|
+
setup_timeout = normalize_timeout(setup_timeout, "setup_timeout")
|
|
67
|
+
memory_limit = normalize_limit(memory_limit, "memory_limit")
|
|
68
|
+
stdout_limit = normalize_limit(stdout_limit, "stdout_limit")
|
|
69
|
+
stderr_limit = normalize_limit(stderr_limit, "stderr_limit")
|
|
70
|
+
open_file_limit = normalize_limit(open_file_limit, "open_file_limit")
|
|
71
|
+
profile = normalize_choice(profile, PROFILES, "profile")
|
|
72
|
+
gvl = normalize_choice(gvl, GVL_MODES, "gvl")
|
|
66
73
|
super
|
|
67
74
|
end
|
|
68
75
|
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
# The least guest memory one value occupies, so a guest payload may hold
|
|
77
|
+
# one value for each of these bytes of +memory_limit+
|
|
78
|
+
# (docs/wire/payload-msgpack.md § Value Budget).
|
|
79
|
+
VALUE_BYTES = 16
|
|
80
|
+
private_constant :VALUE_BYTES
|
|
81
|
+
|
|
82
|
+
def value_budget # :nodoc:
|
|
83
|
+
memory_limit&./(VALUE_BYTES)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Both fallbacks fail closed: an unknown declaration ranks below every
|
|
87
|
+
# request, and an unknown request refuses every declaration.
|
|
88
|
+
def enforce_floor!(declared) # :nodoc:
|
|
77
89
|
return if (PROFILES.index(declared) || -1) >= (PROFILES.index(profile) || PROFILES.size)
|
|
78
90
|
|
|
79
91
|
raise Kobako::SetupError, "runtime declares isolation profile #{declared.inspect}, " \
|
|
@@ -82,39 +94,19 @@ module Kobako
|
|
|
82
94
|
|
|
83
95
|
private
|
|
84
96
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
# or never, both of which would surprise callers more than an early
|
|
89
|
-
# +ArgumentError+.
|
|
90
|
-
def normalize_timeout(timeout)
|
|
97
|
+
# A zero or negative timeout would either fire instantly or never, both
|
|
98
|
+
# more surprising than an early ArgumentError.
|
|
99
|
+
def normalize_timeout(timeout, name)
|
|
91
100
|
return nil if timeout.nil?
|
|
92
|
-
raise ArgumentError, "
|
|
101
|
+
raise ArgumentError, "#{name} must be Numeric or nil, got #{timeout.class}" unless timeout.is_a?(Numeric)
|
|
93
102
|
|
|
94
103
|
seconds = timeout.to_f
|
|
95
|
-
raise ArgumentError, "
|
|
104
|
+
raise ArgumentError, "#{name} must be > 0 (got #{timeout})" unless seconds.positive? && seconds.finite?
|
|
96
105
|
|
|
97
106
|
seconds
|
|
98
107
|
end
|
|
99
108
|
|
|
100
|
-
|
|
101
|
-
# to mean unbounded. Must be a positive Integer when set; Float or
|
|
102
|
-
# zero/negative values are rejected.
|
|
103
|
-
def normalize_memory_limit(memory_limit)
|
|
104
|
-
return nil if memory_limit.nil?
|
|
105
|
-
unless memory_limit.is_a?(Integer) && memory_limit.positive?
|
|
106
|
-
raise ArgumentError, "memory_limit must be a positive Integer or nil, got #{memory_limit.inspect}"
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
memory_limit
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Coerce a per-channel output cap (+stdout_limit+ / +stderr_limit+)
|
|
113
|
-
# into the byte cap the ext expects, or +nil+ to leave the channel
|
|
114
|
-
# uncapped. Same shape as +normalize_memory_limit+: a positive Integer
|
|
115
|
-
# when set, Float / zero / negative rejected. +name+ tags the
|
|
116
|
-
# +ArgumentError+ with the offending keyword.
|
|
117
|
-
def normalize_output_limit(limit, name)
|
|
109
|
+
def normalize_limit(limit, name)
|
|
118
110
|
return nil if limit.nil?
|
|
119
111
|
unless limit.is_a?(Integer) && limit.positive?
|
|
120
112
|
raise ArgumentError, "#{name} must be a positive Integer or nil, got #{limit.inspect}"
|
|
@@ -123,23 +115,12 @@ module Kobako
|
|
|
123
115
|
limit
|
|
124
116
|
end
|
|
125
117
|
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def normalize_profile(profile)
|
|
131
|
-
return profile if PROFILES.include?(profile)
|
|
132
|
-
|
|
133
|
-
raise ArgumentError, "profile must be one of #{PROFILES.map(&:inspect).join(", ")}, got #{profile.inspect}"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# Validate +gvl+ against GVL_MODES. Like +profile+ it has no +nil+
|
|
137
|
-
# form: the scheduling mode is requested as an explicit Symbol, so
|
|
138
|
-
# anything off the set — +nil+ included — is rejected.
|
|
139
|
-
def normalize_gvl(gvl)
|
|
140
|
-
return gvl if GVL_MODES.include?(gvl)
|
|
118
|
+
# Unlike the caps there is no +nil+ form: every choice, the weakest
|
|
119
|
+
# included, is requested by name, as in +:permissive+.
|
|
120
|
+
def normalize_choice(value, choices, name)
|
|
121
|
+
return value if choices.include?(value)
|
|
141
122
|
|
|
142
|
-
raise ArgumentError, "
|
|
123
|
+
raise ArgumentError, "#{name} must be one of #{choices.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
143
124
|
end
|
|
144
125
|
end
|
|
145
126
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kobako
|
|
4
|
+
module Snippet
|
|
5
|
+
class Bytecode < Data.define(:body) # :nodoc:
|
|
6
|
+
# Names the snippet form the guest replays this entry as. The wire's
|
|
7
|
+
# discriminant byte is assigned by the core envelope, not here.
|
|
8
|
+
KIND = :bytecode
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -2,22 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Kobako
|
|
4
4
|
module Snippet
|
|
5
|
-
|
|
6
|
-
# +#preload(code:, name:)+ entry held by +Kobako::Catalog::Snippets+.
|
|
7
|
-
#
|
|
8
|
-
# +name+ is the canonical +Symbol+ identity baked into the loaded
|
|
9
|
-
# IREP's +debug_info+; backtrace frames originating in this snippet
|
|
10
|
-
# surface as +(snippet:Name):line+. +body+ is the UTF-8 mruby source
|
|
11
|
-
# detached from the caller's reference at +Catalog::Snippets#register+
|
|
12
|
-
# time so later mutation of the original String cannot bleed through.
|
|
13
|
-
#
|
|
14
|
-
# The class is a +Data.define+ subclass — frozen, value-equal, and
|
|
15
|
-
# carries no mutation API. Callers (chiefly +Catalog::Snippets+)
|
|
16
|
-
# construct instances via keyword form +Source.new(name: ..., body: ...)+.
|
|
17
|
-
# Wire-form construction is the registry's responsibility: as a leaf
|
|
18
|
-
# carrier this Source stays pure and +Catalog::Snippets#entries+ reads
|
|
19
|
-
# its attributes off the outside rather than asking it to project itself.
|
|
20
|
-
class Source < Data.define(:name, :body)
|
|
5
|
+
class Source < Data.define(:name, :body) # :nodoc:
|
|
21
6
|
# Names the snippet form the guest replays this entry as. The wire's
|
|
22
7
|
# discriminant byte is assigned by the core envelope, not here.
|
|
23
8
|
KIND = :source
|
data/lib/kobako/snippet.rb
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "snippet/
|
|
3
|
+
require_relative "snippet/bytecode"
|
|
4
4
|
require_relative "snippet/source"
|
|
5
5
|
|
|
6
6
|
module Kobako
|
|
7
|
-
|
|
8
|
-
# held by +Kobako::Catalog::Snippets+.
|
|
9
|
-
#
|
|
10
|
-
# +Source+ represents a single +#preload(code:, name:)+ entry; +Binary+
|
|
11
|
-
# represents a single +#preload(binary:)+ entry. Both are plain value
|
|
12
|
-
# objects with no dependency on the +Catalog::Snippets+ registry that
|
|
13
|
-
# holds them — the registry reads their attributes externally into the
|
|
14
|
-
# tuples the native side frames.
|
|
15
|
-
module Snippet
|
|
7
|
+
module Snippet # :nodoc:
|
|
16
8
|
end
|
|
17
9
|
end
|