kobako 0.2.1 → 0.3.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +1 -1
  3. data/README.md +123 -57
  4. data/data/kobako.wasm +0 -0
  5. data/ext/kobako/Cargo.toml +2 -2
  6. data/ext/kobako/src/wasm/cache.rs +3 -3
  7. data/ext/kobako/src/wasm/dispatch.rs +87 -36
  8. data/ext/kobako/src/wasm/host_state.rs +189 -52
  9. data/ext/kobako/src/wasm/instance.rs +367 -152
  10. data/ext/kobako/src/wasm.rs +19 -5
  11. data/lib/kobako/capture.rb +12 -10
  12. data/lib/kobako/codec/decoder.rb +3 -2
  13. data/lib/kobako/codec/encoder.rb +1 -1
  14. data/lib/kobako/codec/error.rb +3 -2
  15. data/lib/kobako/codec/factory.rb +11 -7
  16. data/lib/kobako/codec/utils.rb +3 -2
  17. data/lib/kobako/codec.rb +2 -1
  18. data/lib/kobako/errors.rb +22 -10
  19. data/lib/kobako/invocation.rb +112 -0
  20. data/lib/kobako/outcome/panic.rb +2 -2
  21. data/lib/kobako/outcome.rb +20 -13
  22. data/lib/kobako/rpc/dispatcher.rb +9 -9
  23. data/lib/kobako/rpc/envelope.rb +3 -3
  24. data/lib/kobako/rpc/fault.rb +3 -2
  25. data/lib/kobako/rpc/handle.rb +3 -2
  26. data/lib/kobako/rpc/handle_table.rb +7 -7
  27. data/lib/kobako/rpc/namespace.rb +3 -3
  28. data/lib/kobako/rpc/server.rb +14 -12
  29. data/lib/kobako/sandbox.rb +147 -125
  30. data/lib/kobako/sandbox_options.rb +73 -0
  31. data/lib/kobako/snippet/binary.rb +30 -0
  32. data/lib/kobako/snippet/source.rb +28 -0
  33. data/lib/kobako/snippet/table.rb +174 -0
  34. data/lib/kobako/snippet.rb +20 -0
  35. data/lib/kobako/version.rb +1 -1
  36. data/sig/kobako/errors.rbs +3 -0
  37. data/sig/kobako/invocation.rbs +23 -0
  38. data/sig/kobako/sandbox.rbs +17 -18
  39. data/sig/kobako/sandbox_options.rbs +32 -0
  40. data/sig/kobako/snippet/binary.rbs +12 -0
  41. data/sig/kobako/snippet/source.rbs +13 -0
  42. data/sig/kobako/snippet/table.rbs +36 -0
  43. data/sig/kobako/snippet.rbs +4 -0
  44. data/sig/kobako/wasm.rbs +3 -1
  45. metadata +13 -1
@@ -7,20 +7,20 @@ module Kobako
7
7
  # Host-side mapping from opaque integer Handle IDs to Ruby objects
8
8
  # (capability proxies). One table is owned per +Kobako::RPC::Server+
9
9
  # instance (and therefore per +Kobako::Sandbox+ instance). See
10
- # {SPEC.md B-15}[link:../../../SPEC.md].
10
+ # {docs/behavior.md B-15}[link:../../../docs/behavior.md].
11
11
  #
12
- # Lifecycle invariants ({SPEC.md}[link:../../../SPEC.md]):
12
+ # Lifecycle invariants ({docs/behavior.md}[link:../../../docs/behavior.md]):
13
13
  #
14
- # - {SPEC.md B-15}[link:../../../SPEC.md] — Handle IDs are allocated by
14
+ # - {docs/behavior.md B-15}[link:../../../docs/behavior.md] — Handle IDs are allocated by
15
15
  # a monotonically increasing counter scoped to a single `#run`. The
16
16
  # first ID issued in a run is 1; ID 0 is reserved as the invalid
17
17
  # sentinel and is never returned by #alloc.
18
18
  #
19
- # - {SPEC.md B-19}[link:../../../SPEC.md] — When between `#run`
19
+ # - {docs/behavior.md B-19}[link:../../../docs/behavior.md] — When between `#run`
20
20
  # invocations (via `#reset!`), every Handle issued under the old state
21
21
  # becomes invalid.
22
22
  #
23
- # - {SPEC.md B-21}[link:../../../SPEC.md] — The cap is `0x7fff_ffff`
23
+ # - {docs/behavior.md B-21}[link:../../../docs/behavior.md] — The cap is `0x7fff_ffff`
24
24
  # (2³¹ − 1). Allocation beyond the cap raises immediately — no silent
25
25
  # truncation, no wrap, no ID reuse.
26
26
  class HandleTable
@@ -38,7 +38,7 @@ module Kobako
38
38
  # allocated Handle ID in +[1, RPC::Handle::MAX_ID]+. Raises
39
39
  # +Kobako::HandleTableExhausted+ if the next ID would exceed the cap.
40
40
  # The cap is anchored on +RPC::Handle+ — the wire codec and the
41
- # allocator share the same invariant ({SPEC.md B-21}[link:../../../SPEC.md]).
41
+ # allocator share the same invariant ({docs/behavior.md B-21}[link:../../../docs/behavior.md]).
42
42
  def alloc(object)
43
43
  id = @next_id
44
44
  cap = RPC::Handle::MAX_ID
@@ -66,7 +66,7 @@ module Kobako
66
66
  end
67
67
 
68
68
  # Clear all entries AND reset the counter to 1. Called at the per-run
69
- # boundary — see {SPEC.md B-19}[link:../../../SPEC.md].
69
+ # boundary — see {docs/behavior.md B-19}[link:../../../docs/behavior.md].
70
70
  # Returns +self+.
71
71
  def reset!
72
72
  @entries.clear
@@ -3,12 +3,12 @@
3
3
  module Kobako
4
4
  module RPC
5
5
  # A named grouping of Members for one Sandbox
6
- # ({SPEC.md B-07..B-11}[link:../../../SPEC.md]). Returned by
6
+ # ({docs/behavior.md B-07..B-11}[link:../../../docs/behavior.md]). Returned by
7
7
  # +Sandbox#define+. Each instance owns a flat name→object table of
8
8
  # Members; member binding is validated against {NAME_PATTERN}.
9
9
  class Namespace
10
10
  # Ruby constant-name pattern shared by Namespace and Member names
11
- # ({SPEC.md B-07/B-08 Notes}[link:../../../SPEC.md]).
11
+ # ({docs/behavior.md B-07/B-08 Notes}[link:../../../docs/behavior.md]).
12
12
  NAME_PATTERN = /\A[A-Z]\w*\z/
13
13
 
14
14
  attr_reader :name, :members
@@ -26,7 +26,7 @@ module Kobako
26
26
  # object that responds to the methods guest code will invoke. Returns
27
27
  # +self+ for chaining. Raises +ArgumentError+ when +member+ does not
28
28
  # match the constant pattern, or a Member of the same name is already
29
- # bound ({SPEC.md B-11}[link:../../../SPEC.md]).
29
+ # bound ({docs/behavior.md B-11}[link:../../../docs/behavior.md]).
30
30
  def bind(member, object)
31
31
  member_str = validate_member_name!(member)
32
32
  raise ArgumentError, "Member #{@name}::#{member_str} is already bound" if @members.key?(member_str)
@@ -12,7 +12,7 @@ module Kobako
12
12
  # Kobako::RPC::Server — per-Sandbox host-side RPC coordinator. Maintains
13
13
  # the Namespace / Member registry, owns the HandleTable, and routes
14
14
  # incoming Requests to the resolved Service object
15
- # ({SPEC.md B-07..B-21}[link:../../../SPEC.md]).
15
+ # ({docs/behavior.md B-07..B-21}[link:../../../docs/behavior.md]).
16
16
  #
17
17
  # Public API:
18
18
  #
@@ -39,14 +39,14 @@ module Kobako
39
39
  @sealed = false
40
40
  end
41
41
 
42
- # Declare or retrieve the Namespace named +name+ (idempotent — SPEC.md B-10).
42
+ # Declare or retrieve the Namespace named +name+ (idempotent — docs/behavior.md B-10).
43
43
  # +name+ is a constant-form name as a +Symbol+ or +String+ (must satisfy
44
44
  # +Namespace::NAME_PATTERN+). Returns the +Kobako::RPC::Namespace+ for
45
45
  # that name, creating it if it does not exist. Raises +ArgumentError+
46
46
  # when +name+ is malformed, or when called after the owning Sandbox has
47
- # been sealed by +#run+.
47
+ # been sealed by its first invocation ({docs/behavior.md B-07}[link:../../../docs/behavior.md]).
48
48
  def define(name)
49
- raise ArgumentError, "cannot define after Sandbox#run has been invoked" if @sealed
49
+ raise ArgumentError, "cannot define after first Sandbox invocation" if @sealed
50
50
 
51
51
  name_str = name.to_s
52
52
  unless Namespace::NAME_PATTERN.match?(name_str)
@@ -91,8 +91,9 @@ module Kobako
91
91
  @namespaces.empty?
92
92
  end
93
93
 
94
- # Structured Frame 1 description. Called by +Sandbox#run+ when assembling
95
- # stdin Frame 1 ({SPEC.md B-02}[link:../../../SPEC.md]). Returns an
94
+ # Structured Frame 1 description. Called by +Sandbox#eval+ when
95
+ # assembling stdin Frame 1
96
+ # ({docs/behavior.md B-02}[link:../../../docs/behavior.md]). Returns an
96
97
  # unencoded preamble array — an +Array+ of two-element +[name, members]+
97
98
  # arrays, one per declared namespace.
98
99
  def to_preamble
@@ -100,7 +101,7 @@ module Kobako
100
101
  end
101
102
 
102
103
  # Encode the preamble as msgpack bytes for stdin Frame 1 delivery
103
- # ({SPEC.md B-02}[link:../../../SPEC.md]). Uses plain MessagePack (no
104
+ # ({docs/behavior.md B-02}[link:../../../docs/behavior.md]). Uses plain MessagePack (no
104
105
  # kobako ext types) because the preamble contains only strings — no
105
106
  # Handles or Fault envelopes. Structure:
106
107
  # +[["Namespace", ["MemberA", "MemberB"]], ...]+. Returns a binary
@@ -109,8 +110,8 @@ module Kobako
109
110
  MessagePack.pack(to_preamble)
110
111
  end
111
112
 
112
- # Mark the Server as sealed. Called by +Sandbox#run+ on first run.
113
- # After sealing, #define raises ArgumentError. Idempotent.
113
+ # Mark the Server as sealed. Called by +Sandbox+ on the first
114
+ # invocation. After sealing, #define raises ArgumentError. Idempotent.
114
115
  def seal!
115
116
  @sealed = true
116
117
  self
@@ -121,14 +122,15 @@ module Kobako
121
122
  @sealed
122
123
  end
123
124
 
124
- # Reset the HandleTable for a new +#run+ boundary. Called by +Sandbox#run+
125
- # before each invocation ({SPEC.md B-19}[link:../../../SPEC.md]).
125
+ # Reset the HandleTable for a new invocation boundary. Called by
126
+ # +Sandbox+ before each invocation
127
+ # ({docs/behavior.md B-19}[link:../../../docs/behavior.md]).
126
128
  def reset_handles!
127
129
  @handle_table.reset!
128
130
  end
129
131
 
130
132
  # Dispatch a single RPC request and return the encoded response bytes
131
- # ({SPEC.md B-12}[link:../../../SPEC.md]). +request_bytes+ is a
133
+ # ({docs/behavior.md B-12}[link:../../../docs/behavior.md]). +request_bytes+ is a
132
134
  # msgpack-encoded Request envelope. Called by the Rust ext from inside
133
135
  # +__kobako_dispatch+. Always returns a binary +String+ — never raises.
134
136
  # Delegates to +Dispatcher.dispatch+ which reifies any failure as a
@@ -1,15 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "forwardable"
4
+
3
5
  require_relative "capture"
4
6
  require_relative "errors"
7
+ require_relative "invocation"
5
8
  require_relative "outcome"
6
9
  require_relative "rpc/server"
7
10
  require_relative "rpc/envelope"
11
+ require_relative "sandbox_options"
12
+ require_relative "snippet"
8
13
 
9
14
  module Kobako
10
15
  # Kobako::Sandbox — the user-facing entry point for executing guest mruby
11
16
  # scripts inside a wasmtime-hosted Wasm module
12
- # ({SPEC.md B-01}[link:../../SPEC.md]).
17
+ # ({docs/behavior.md B-01}[link:../../docs/behavior.md]).
13
18
  #
14
19
  # The Sandbox owns the +Kobako::Wasm::Instance+, the per-instance RPC Server
15
20
  # (which itself owns the per-run HandleTable), and the per-channel byte
@@ -18,7 +23,7 @@ module Kobako
18
23
  # never surface to Ruby — constructing many Sandboxes amortises both costs
19
24
  # automatically.
20
25
  #
21
- # Output capture policy ({SPEC.md B-04}[link:../../SPEC.md]): the
26
+ # Output capture policy ({docs/behavior.md B-04}[link:../../docs/behavior.md]): the
22
27
  # per-channel cap (+stdout_limit+ / +stderr_limit+) is enforced inside the
23
28
  # WASI pipe — the host buffer stops growing at the cap, subsequent guest
24
29
  # writes on that channel fail or are dropped, and +#run+ still returns
@@ -27,47 +32,41 @@ module Kobako
27
32
  # +#stdout_truncated?+ / +#stderr_truncated?+ are the only way to observe
28
33
  # that the cap was hit.
29
34
  class Sandbox
30
- # Default per-channel capture ceiling: 1 MiB
31
- # ({SPEC.md B-01}[link:../../SPEC.md]).
32
- DEFAULT_OUTPUT_LIMIT = 1 << 20
33
-
34
- # Default wall-clock timeout for a single +#run+: 60 seconds
35
- # ({SPEC.md B-01}[link:../../SPEC.md]).
36
- DEFAULT_TIMEOUT_SECONDS = 60.0
37
-
38
- # Default cap on guest linear memory growth: 5 MiB
39
- # ({SPEC.md B-01}[link:../../SPEC.md]).
40
- DEFAULT_MEMORY_LIMIT = 5 << 20
35
+ extend Forwardable
41
36
 
42
37
  attr_reader :wasm_path, :instance,
43
- :stdout_limit, :stderr_limit,
44
- :timeout, :memory_limit, :services
38
+ :options,
39
+ :services, :snippets
40
+
41
+ # Per-cap accessors forward to the immutable +SandboxOptions+ Value
42
+ # Object so the Host App still reads them off Sandbox directly.
43
+ def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit
45
44
 
46
45
  # Returns the bytes the guest wrote to stdout during the most recent
47
- # +#run+ as a UTF-8 String, clipped at +stdout_limit+. Empty before any
48
- # +#run+ call. {SPEC.md B-04}[link:../../SPEC.md] — the byte content
49
- # never contains a truncation sentinel; use +#stdout_truncated?+ to
46
+ # invocation as a UTF-8 String, clipped at +stdout_limit+. Empty before
47
+ # any invocation. {docs/behavior.md B-04}[link:../../docs/behavior.md] — the byte
48
+ # content never contains a truncation sentinel; use +#stdout_truncated?+ to
50
49
  # observe overflow.
51
50
  def stdout
52
51
  @stdout_capture.bytes
53
52
  end
54
53
 
55
54
  # Returns the bytes the guest wrote to stderr during the most recent
56
- # +#run+ as a UTF-8 String, clipped at +stderr_limit+. Empty before any
57
- # +#run+ call. Mirror of +#stdout+.
55
+ # invocation as a UTF-8 String, clipped at +stderr_limit+. Empty before
56
+ # any invocation. Mirror of +#stdout+.
58
57
  def stderr
59
58
  @stderr_capture.bytes
60
59
  end
61
60
 
62
- # Returns +true+ iff stdout capture during the most recent +#run+
63
- # exceeded +stdout_limit+ ({SPEC.md B-04}[link:../../SPEC.md]). Resets
64
- # to +false+ at the start of the next +#run+ ({SPEC.md
65
- # B-03}[link:../../SPEC.md]).
61
+ # Returns +true+ iff stdout capture during the most recent invocation
62
+ # exceeded +stdout_limit+ ({docs/behavior.md B-04}[link:../../docs/behavior.md]). Resets
63
+ # to +false+ at the start of the next invocation
64
+ # ({docs/behavior.md B-03}[link:../../docs/behavior.md]).
66
65
  def stdout_truncated?
67
66
  @stdout_capture.truncated?
68
67
  end
69
68
 
70
- # Returns +true+ iff stderr capture during the most recent +#run+
69
+ # Returns +true+ iff stderr capture during the most recent invocation
71
70
  # exceeded +stderr_limit+. Mirror of +#stdout_truncated?+.
72
71
  def stderr_truncated?
73
72
  @stderr_capture.truncated?
@@ -76,115 +75,148 @@ module Kobako
76
75
  # Build a fresh Sandbox.
77
76
  #
78
77
  # +wasm_path+ is the absolute path to the Guest Binary; defaults to the
79
- # gem-bundled +data/kobako.wasm+. +stdout_limit+ and +stderr_limit+ cap
80
- # the per-run byte ceiling for each capture channel (default 1 MiB;
81
- # +nil+ disables the cap). +timeout+ is the wall-clock cap on a single
82
- # +#run+ in seconds ({SPEC.md B-01}[link:../../SPEC.md]; default 60.0,
83
- # +nil+ disables); +memory_limit+ caps guest linear memory growth in
84
- # bytes ({SPEC.md B-01, E-20}[link:../../SPEC.md]; default 5 MiB,
85
- # +nil+ disables).
78
+ # gem-bundled +data/kobako.wasm+. The four caps (+stdout_limit+,
79
+ # +stderr_limit+, +timeout+, +memory_limit+) are forwarded verbatim to
80
+ # +Kobako::SandboxOptions+, which owns their DEFAULT fallback and
81
+ # normalisation. The constructed +SandboxOptions+ is exposed as
82
+ # +#options+ and the four caps remain readable directly on Sandbox via
83
+ # +Forwardable+ delegation.
86
84
  def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil,
87
- timeout: DEFAULT_TIMEOUT_SECONDS,
88
- memory_limit: DEFAULT_MEMORY_LIMIT)
85
+ timeout: SandboxOptions::DEFAULT_TIMEOUT_SECONDS,
86
+ memory_limit: SandboxOptions::DEFAULT_MEMORY_LIMIT)
89
87
  @wasm_path = wasm_path || Kobako::Wasm.default_path
90
- @stdout_limit = stdout_limit || DEFAULT_OUTPUT_LIMIT
91
- @stderr_limit = stderr_limit || DEFAULT_OUTPUT_LIMIT
92
- @timeout = normalize_timeout(timeout)
93
- @memory_limit = normalize_memory_limit(memory_limit)
88
+ @options = SandboxOptions.new(timeout: timeout, memory_limit: memory_limit, stdout_limit: stdout_limit,
89
+ stderr_limit: stderr_limit)
94
90
  @services = Kobako::RPC::Server.new
95
- @instance = Kobako::Wasm::Instance.from_path(@wasm_path, @timeout, @memory_limit, @stdout_limit, @stderr_limit)
91
+ @snippets = Snippet::Table.new
92
+ @instance = Kobako::Wasm::Instance.from_path(@wasm_path, @options.timeout, @options.memory_limit,
93
+ @options.stdout_limit, @options.stderr_limit)
96
94
  @instance.server = @services
97
95
  clear_captures!
98
96
  end
99
97
 
100
98
  # Declare or retrieve the Namespace named +name+ on this Sandbox
101
- # ({SPEC.md B-07, B-09, B-10}[link:../../SPEC.md]). +name+ must be a
99
+ # ({docs/behavior.md B-07, B-09, B-10}[link:../../docs/behavior.md]). +name+ must be a
102
100
  # Symbol or String in constant form. Returns the +Kobako::RPC::Namespace+.
103
101
  #
104
- # Raises +ArgumentError+ when called after +#run+, or when +name+ does
105
- # not match the constant-name pattern.
102
+ # Raises +ArgumentError+ when called after the first invocation, or
103
+ # when +name+ does not match the constant-name pattern.
106
104
  def define(name)
107
105
  @services.define(name)
108
106
  end
109
107
 
110
- # Execute a guest mruby script
111
- # ({SPEC.md B-02 / B-03}[link:../../SPEC.md]). +source+ is the mruby
112
- # source code as a UTF-8 String. Returns the deserialized last
113
- # expression of the script.
108
+ # Register a snippet on this Sandbox in one of two forms
109
+ # ({docs/behavior.md B-32}[link:../../docs/behavior.md]):
114
110
  #
115
- # Source delivery uses the WASI stdin two-frame protocol
116
- # ({SPEC.md ABI Signatures}[link:../../SPEC.md]): Frame 1 carries the
117
- # msgpack-encoded preamble (Namespace / Member registry snapshot) and Frame 2
118
- # carries the user script UTF-8 bytes. Each frame is prefixed by a
119
- # 4-byte big-endian u32 length.
111
+ # * +preload(code: source, name: Name)+ +source+ is mruby source
112
+ # as a +String+ and +Name+ matches +/\A[A-Z]\w*\z/+. The +name+
113
+ # becomes the snippet's +(snippet:Name)+ backtrace filename and
114
+ # is the dedupe key for E-33.
115
+ # * +preload(binary: bytes)+ +bytes+ is precompiled RITE
116
+ # bytecode as a +String+. The canonical name, when present,
117
+ # lives in the bytecode's embedded +debug_info+ and is resolved
118
+ # by the guest at load time; the host treats the bytes as
119
+ # opaque. Structural failures
120
+ # ({docs/behavior.md E-37 / E-38}[link:../../docs/behavior.md])
121
+ # surface as +Kobako::BytecodeError+ on the first invocation.
120
122
  #
121
- # Raises +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
122
- # +Kobako::SandboxError+ when the guest ran to completion but failed;
123
- # +Kobako::ServiceError+ on an unrescued Service capability failure.
124
- def run(source)
125
- raise SandboxError, "source must be a String, got #{source.class}" unless source.is_a?(String)
126
-
127
- @services.seal!
128
- reset_run_state!
129
-
130
- run_guest(@services.encoded_preamble, source.b)
131
- read_captures!
132
- take_result!
123
+ # Subsequent invocations (+#eval+ or +#run+) replay every registered
124
+ # snippet in insertion order against the fresh +mrb_state+
125
+ # before per-invocation source or entrypoint resolution.
126
+ #
127
+ # Returns +self+ to allow chaining.
128
+ #
129
+ # Raises +ArgumentError+ when neither form's keyword set is
130
+ # supplied, when both forms are mixed (e.g., +code:+ and +binary:+
131
+ # together, or +binary:+ paired with +name:+), when +code+ / +bytes+
132
+ # is not a +String+, when +name+ does not match the constant
133
+ # pattern ({docs/behavior.md E-34}[link:../../docs/behavior.md]),
134
+ # when +name+ duplicates an already-registered +code:+ form snippet
135
+ # ({docs/behavior.md E-33}[link:../../docs/behavior.md]), or when
136
+ # called after the first invocation
137
+ # ({docs/behavior.md E-35, B-33}[link:../../docs/behavior.md]).
138
+ def preload(code: nil, name: nil, binary: nil)
139
+ raise ArgumentError, "cannot preload after first Sandbox invocation" if @services.sealed?
140
+
141
+ @snippets.register(code: code, name: name, binary: binary)
142
+ self
133
143
  end
134
144
 
135
- private
136
-
137
- # Coerce +timeout+ into the Float seconds the ext expects, or +nil+ to
138
- # mean the cap is disabled ({SPEC.md B-01}[link:../../SPEC.md]). Any
139
- # finite non-positive value is rejected a zero or negative timeout
140
- # would either fire instantly or never, both of which would surprise
141
- # callers more than an early +ArgumentError+.
142
- def normalize_timeout(timeout)
143
- return nil if timeout.nil?
144
- raise ArgumentError, "timeout must be Numeric or nil, got #{timeout.class}" unless timeout.is_a?(Numeric)
145
-
146
- seconds = timeout.to_f
147
- raise ArgumentError, "timeout must be > 0 (got #{timeout})" unless seconds.positive? && seconds.finite?
148
-
149
- seconds
145
+ # Dispatch into a preloaded entrypoint constant
146
+ # ({docs/behavior.md B-31}[link:../../docs/behavior.md]). Delegates host
147
+ # pre-flight (E-24 / E-25 / E-29 / E-30) and wire encoding to
148
+ # +Kobako::Invocation+ / +Kobako::Invocation#encode+; the guest
149
+ # resolves +target+ as a top-level constant, calls +#call+ on it
150
+ # with +args+ / +kwargs+, and returns the deserialized result. The
151
+ # first invocation seals the Service registry and snippet table
152
+ # (B-07 / B-33). Runtime errors follow the same three-class taxonomy
153
+ # as +#eval+.
154
+ def run(target, *args, **kwargs)
155
+ invocation = Invocation.new(entrypoint: target, args: args, kwargs: kwargs)
156
+ invoke!(:run) do
157
+ @instance.run(@services.encoded_preamble, @snippets.encode, invocation.encode)
158
+ end
150
159
  end
151
160
 
152
- # Coerce +memory_limit+ into the byte cap the ext expects, or +nil+ to
153
- # mean unbounded ({SPEC.md B-01, E-20}[link:../../SPEC.md]). Must be a
154
- # positive Integer when set; +Float+ or zero/negative values are
155
- # rejected.
156
- def normalize_memory_limit(memory_limit)
157
- return nil if memory_limit.nil?
158
- unless memory_limit.is_a?(Integer) && memory_limit.positive?
159
- raise ArgumentError, "memory_limit must be a positive Integer or nil, got #{memory_limit.inspect}"
160
- end
161
+ # Execute a guest mruby source string in a fresh +mrb_state+
162
+ # ({docs/behavior.md B-02 / B-03 / B-06}[link:../../docs/behavior.md]). +code+ is the
163
+ # mruby source as a UTF-8 String. Returns the deserialized last
164
+ # expression of the source.
165
+ #
166
+ # Source delivery uses the WASI stdin three-frame protocol
167
+ # ({docs/wire-codec.md Invocation channels}[link:../../docs/wire-codec.md]):
168
+ # Frame 1 carries the msgpack-encoded preamble (Namespace / Member
169
+ # registry snapshot), Frame 2 carries the user source UTF-8 bytes, and
170
+ # Frame 3 carries the snippet table registered via +#preload+ (B-32).
171
+ # Each frame is prefixed by a 4-byte big-endian u32 length; Frame 3 is
172
+ # mandatory-presence — an empty snippet table sends an empty msgpack
173
+ # array, never an absent frame.
174
+ #
175
+ # The first invocation seals the Service registry and snippet table
176
+ # ({docs/behavior.md B-07 / B-33}[link:../../docs/behavior.md]); subsequent
177
+ # +#define+ / +#preload+ calls raise +ArgumentError+.
178
+ #
179
+ # Raises +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
180
+ # +Kobako::SandboxError+ when the guest ran to completion but failed
181
+ # (including when +code+ is +nil+ or not a String, or when a preloaded
182
+ # snippet's replay raises — E-36);
183
+ # +Kobako::ServiceError+ on an unrescued Service capability failure.
184
+ def eval(code)
185
+ raise SandboxError, "code must be a String, got #{code.class}" unless code.is_a?(String)
161
186
 
162
- memory_limit
187
+ invoke!(:eval) do
188
+ @instance.eval(@services.encoded_preamble, code.b, @snippets.encode)
189
+ end
163
190
  end
164
191
 
165
- # Per-run state reset ({SPEC.md B-03}[link:../../SPEC.md]). Capture
166
- # buffers, truncation predicates, and the HandleTable counter are
167
- # zeroed before the guest runs.
168
- def reset_run_state!
192
+ private
193
+
194
+ # Per-invocation prologue ({docs/behavior.md B-03 / B-07 /
195
+ # B-33}[link:../../docs/behavior.md]). Seals the Service / snippet
196
+ # registries on first call (idempotent) and zeros the per-invocation
197
+ # capability state — capture buffers, truncation predicates, and the
198
+ # HandleTable counter — before the guest runs.
199
+ def begin_invocation!
200
+ @services.seal!
169
201
  @services.reset_handles!
170
202
  clear_captures!
171
203
  end
172
204
 
173
- # Reset both per-channel captures to the pre-run sentinel
174
- # ({SPEC.md B-05}[link:../../SPEC.md]). Shared by +#initialize+
175
- # (first-run setup) and +#reset_run_state!+ (between-run reset) so
176
- # both paths agree on what "empty capture" means.
205
+ # Reset both per-channel captures to the pre-invocation sentinel
206
+ # ({docs/behavior.md B-05}[link:../../docs/behavior.md]). Shared by +#initialize+
207
+ # (first-time setup) and +#begin_invocation!+ (between-invocation
208
+ # reset) so both paths agree on what "empty capture" means.
177
209
  def clear_captures!
178
210
  @stdout_capture = Capture::EMPTY
179
211
  @stderr_capture = Capture::EMPTY
180
212
  end
181
213
 
182
214
  # Read the per-channel capture pairs (+[bytes, truncated]+) from the
183
- # ext after a guest run completes and wrap each as a +Capture+ value
215
+ # ext after an invocation completes and wrap each as a +Capture+ value
184
216
  # object. The ext clips +bytes+ to the configured cap and sets
185
217
  # +truncated+ when the guest produced strictly more than +cap+ bytes
186
- # ({SPEC.md B-04}[link:../../SPEC.md]). Mirror of {#clear_captures!}
187
- # at the post-run boundary.
218
+ # ({docs/behavior.md B-04}[link:../../docs/behavior.md]). Mirror of {#clear_captures!}
219
+ # at the post-invocation boundary.
188
220
  def read_captures!
189
221
  out_bytes, out_truncated = @instance.stdout
190
222
  err_bytes, err_truncated = @instance.stderr
@@ -192,37 +224,27 @@ module Kobako
192
224
  @stderr_capture = Capture.from_ext(err_bytes, err_truncated)
193
225
  end
194
226
 
195
- # Drive +Instance#run+ with the two stdin frames (preamble + source).
196
- # Wraps wasmtime / wire errors in TrapError so the Sandbox layer maps
197
- # cleanly to the three-class taxonomy. The configured-cap paths
198
- # (SPEC.md E-19 / E-20) are routed to the named TrapError subclasses
199
- # so callers that want to surface a specific reason can rescue them;
200
- # everything else falls through to the base TrapError.
201
- def run_guest(preamble, source)
202
- @instance.run(preamble, source)
227
+ # Shared prologue / epilogue + trap-class translator for both
228
+ # invocation verbs. +verb+ is +:eval+ or +:run+; it tags the
229
+ # TrapError message so the failing export is identifiable. The
230
+ # rescue chain is the single trap-translation boundary wasmtime /
231
+ # wire failures from the guest call and from the subsequent
232
+ # +Instance#outcome!+ read both flow through here, so an
233
+ # OUTCOME_BUFFER read failure attributes to the same export name as
234
+ # the guest call itself. Configured-cap paths
235
+ # ({docs/behavior.md E-19 / E-20}[link:../../docs/behavior.md]) surface as
236
+ # named TrapError subclasses.
237
+ def invoke!(verb)
238
+ begin_invocation!
239
+ yield
240
+ read_captures!
241
+ Outcome.decode(@instance.outcome!)
203
242
  rescue Kobako::Wasm::TimeoutError => e
204
243
  raise TimeoutError, "guest exceeded timeout: #{e.message}"
205
244
  rescue Kobako::Wasm::MemoryLimitError => e
206
245
  raise MemoryLimitError, "guest exceeded memory_limit: #{e.message}"
207
246
  rescue Kobako::Wasm::Error => e
208
- raise TrapError, "guest __kobako_run trapped: #{e.message}"
209
- end
210
-
211
- # Take OUTCOME_BUFFER bytes from guest memory via +Instance#outcome!+
212
- # and decode them into the Sandbox-level result — the unwrapped mruby
213
- # return value, or a raised three-layer
214
- # ({SPEC.md "Error Scenarios"}[link:../../SPEC.md]) exception. A zero-
215
- # length outcome is delivered to +Kobako::Outcome+ as an empty String
216
- # so a single boundary attributes every wire-violation outcome
217
- # ({SPEC.md ABI Signatures}[link:../../SPEC.md]).
218
- #
219
- # The bang reflects the destructive ext call beneath: the underlying
220
- # +__kobako_take_outcome+ export invalidates the buffer pointer, so this
221
- # method must be called at most once per +#run+.
222
- def take_result!
223
- Outcome.decode(@instance.outcome!)
224
- rescue Kobako::Wasm::Error => e
225
- raise TrapError, "failed to read OUTCOME_BUFFER: #{e.message}"
247
+ raise TrapError, "guest __kobako_#{verb} trapped: #{e.message}"
226
248
  end
227
249
  end
228
250
  end
@@ -0,0 +1,73 @@
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::Wasm::Instance+ 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
+ # steep:ignore:start
31
+ def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
32
+ memory_limit: DEFAULT_MEMORY_LIMIT,
33
+ stdout_limit: nil,
34
+ stderr_limit: nil)
35
+ super(
36
+ timeout: normalize_timeout(timeout),
37
+ memory_limit: normalize_memory_limit(memory_limit),
38
+ stdout_limit: stdout_limit || DEFAULT_OUTPUT_LIMIT,
39
+ stderr_limit: stderr_limit || DEFAULT_OUTPUT_LIMIT
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ # Coerce +timeout+ into the Float seconds the ext expects, or +nil+
46
+ # to mean the cap is disabled. Any finite non-positive value is
47
+ # rejected — a zero or negative timeout would either fire instantly
48
+ # or never, both of which would surprise callers more than an early
49
+ # +ArgumentError+.
50
+ def normalize_timeout(timeout)
51
+ return nil if timeout.nil?
52
+ raise ArgumentError, "timeout must be Numeric or nil, got #{timeout.class}" unless timeout.is_a?(Numeric)
53
+
54
+ seconds = timeout.to_f
55
+ raise ArgumentError, "timeout must be > 0 (got #{timeout})" unless seconds.positive? && seconds.finite?
56
+
57
+ seconds
58
+ end
59
+
60
+ # Coerce +memory_limit+ into the byte cap the ext expects, or +nil+
61
+ # to mean unbounded. Must be a positive Integer when set; Float or
62
+ # zero/negative values are rejected.
63
+ def normalize_memory_limit(memory_limit)
64
+ return nil if memory_limit.nil?
65
+ unless memory_limit.is_a?(Integer) && memory_limit.positive?
66
+ raise ArgumentError, "memory_limit must be a positive Integer or nil, got #{memory_limit.inspect}"
67
+ end
68
+
69
+ memory_limit
70
+ end
71
+ # steep:ignore:end
72
+ end
73
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Snippet
5
+ # Kobako::Snippet::Binary — value object representing a single
6
+ # +#preload(binary:)+ entry held by +Kobako::Snippet::Table+
7
+ # ({docs/behavior.md B-32}[link:../../../docs/behavior.md]).
8
+ #
9
+ # The +body+ is RITE bytecode (as emitted by +mrbc+) carried as an
10
+ # +ASCII_8BIT+ String so msgpack-ruby encodes it as +bin+ family on
11
+ # the wire ({docs/wire-codec.md Invocation channels}[link:../../../docs/wire-codec.md]).
12
+ # The host treats the bytes as opaque — the snippet's canonical
13
+ # name, when present, lives in the bytecode's embedded
14
+ # +debug_info+ and is resolved by the guest at load time;
15
+ # structural validation
16
+ # ({docs/behavior.md E-37 / E-38}[link:../../../docs/behavior.md])
17
+ # is deferred to the first invocation's guest replay.
18
+ #
19
+ # The class is a +Data.define+ subclass — frozen and value-equal.
20
+ # Callers (chiefly +Table+) construct instances via keyword form
21
+ # +Binary.new(body: ...)+. Wire-form construction is the +Table+'s
22
+ # responsibility.
23
+ class Binary < Data.define(:body)
24
+ # The +kind+ field value carried by bytecode snippets in their
25
+ # Frame 3 wire envelope entry
26
+ # ({docs/wire-codec.md Invocation channels}[link:../../../docs/wire-codec.md]).
27
+ KIND = "bytecode"
28
+ end
29
+ end
30
+ end