kobako 0.27.0 → 0.29.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 (183) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +241 -0
  4. data/Cargo.lock +132 -126
  5. data/README.md +240 -147
  6. data/ROADMAP.md +28 -22
  7. data/SECURITY.md +2 -2
  8. data/crates/kobako-runtime/CHANGELOG.md +117 -0
  9. data/crates/kobako-runtime/Cargo.toml +27 -6
  10. data/crates/kobako-runtime/README.md +16 -10
  11. data/crates/kobako-runtime/src/dispatch.rs +8 -0
  12. data/crates/kobako-runtime/src/error.rs +8 -9
  13. data/crates/kobako-runtime/src/filesystem/directory/spelling.rs +194 -0
  14. data/crates/kobako-runtime/src/filesystem/directory.rs +1082 -0
  15. data/crates/kobako-runtime/src/filesystem/memory.rs +1100 -0
  16. data/crates/kobako-runtime/src/filesystem/overlay/exclusive.rs +88 -0
  17. data/crates/kobako-runtime/src/filesystem/overlay/stack.rs +808 -0
  18. data/crates/kobako-runtime/src/filesystem/overlay.rs +1955 -0
  19. data/crates/kobako-runtime/src/filesystem/path.rs +60 -0
  20. data/crates/kobako-runtime/src/filesystem/temporary.rs +167 -0
  21. data/crates/kobako-runtime/src/filesystem.rs +392 -0
  22. data/crates/kobako-runtime/src/grant.rs +20 -0
  23. data/crates/kobako-runtime/src/lib.rs +12 -2
  24. data/crates/kobako-runtime/src/mount.rs +178 -0
  25. data/crates/kobako-runtime/src/profile.rs +4 -4
  26. data/crates/kobako-runtime/src/runtime.rs +43 -17
  27. data/crates/kobako-runtime/src/snapshot.rs +33 -15
  28. data/crates/kobako-runtime/src/yielder.rs +14 -6
  29. data/crates/kobako-transport/CHANGELOG.md +45 -0
  30. data/crates/kobako-transport/Cargo.toml +1 -1
  31. data/crates/kobako-transport/README.md +21 -12
  32. data/crates/kobako-transport/src/abi.rs +11 -14
  33. data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
  34. data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
  35. data/crates/kobako-transport/src/envelope/fault.rs +2 -1
  36. data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
  37. data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
  38. data/crates/kobako-transport/src/envelope/reply.rs +103 -61
  39. data/crates/kobako-transport/src/envelope/request.rs +305 -0
  40. data/crates/kobako-transport/src/envelope/run.rs +1 -2
  41. data/crates/kobako-transport/src/envelope/tag.rs +49 -0
  42. data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
  43. data/crates/kobako-transport/src/envelope.rs +19 -12
  44. data/crates/kobako-transport/src/lib.rs +0 -2
  45. data/crates/kobako-wasmtime/CHANGELOG.md +163 -0
  46. data/crates/kobako-wasmtime/Cargo.toml +23 -16
  47. data/crates/kobako-wasmtime/README.md +6 -4
  48. data/crates/kobako-wasmtime/src/abi.rs +67 -36
  49. data/crates/kobako-wasmtime/src/ambient.rs +64 -5
  50. data/crates/kobako-wasmtime/src/cache.rs +204 -127
  51. data/crates/kobako-wasmtime/src/capture.rs +4 -11
  52. data/crates/kobako-wasmtime/src/config.rs +69 -15
  53. data/crates/kobako-wasmtime/src/descriptors.rs +167 -0
  54. data/crates/kobako-wasmtime/src/dispatch.rs +95 -87
  55. data/crates/kobako-wasmtime/src/driver.rs +174 -125
  56. data/crates/kobako-wasmtime/src/exports.rs +88 -34
  57. data/crates/kobako-wasmtime/src/guest_mem.rs +151 -138
  58. data/crates/kobako-wasmtime/src/imports.rs +46 -0
  59. data/crates/kobako-wasmtime/src/instance_pre.rs +19 -58
  60. data/crates/kobako-wasmtime/src/invocation.rs +84 -412
  61. data/crates/kobako-wasmtime/src/io.rs +89 -0
  62. data/crates/kobako-wasmtime/src/lib.rs +7 -24
  63. data/crates/kobako-wasmtime/src/limiter.rs +253 -0
  64. data/crates/kobako-wasmtime/src/preview1/args.rs +60 -0
  65. data/crates/kobako-wasmtime/src/preview1/beneath.rs +188 -0
  66. data/crates/kobako-wasmtime/src/preview1/differential/mount.rs +609 -0
  67. data/crates/kobako-wasmtime/src/preview1/differential.rs +372 -0
  68. data/crates/kobako-wasmtime/src/preview1/errno.rs +267 -0
  69. data/crates/kobako-wasmtime/src/preview1/fd.rs +672 -0
  70. data/crates/kobako-wasmtime/src/preview1/memory.rs +205 -0
  71. data/crates/kobako-wasmtime/src/preview1/mount_view.rs +2499 -0
  72. data/crates/kobako-wasmtime/src/preview1/path.rs +421 -0
  73. data/crates/kobako-wasmtime/src/preview1/poll.rs +333 -0
  74. data/crates/kobako-wasmtime/src/preview1/sock.rs +30 -0
  75. data/crates/kobako-wasmtime/src/preview1.rs +461 -0
  76. data/crates/kobako-wasmtime/src/trap.rs +66 -59
  77. data/crates/kobako-wasmtime/src/wasi.rs +317 -0
  78. data/crates/kobako-wasmtime/tests/compile_posture.rs +43 -0
  79. data/crates/kobako-wasmtime/tests/guest_file.rs +1054 -0
  80. data/crates/kobako-wasmtime/tests/preamble_flags.rs +70 -0
  81. data/crates/kobako-wasmtime/tests/snippet_compile.rs +95 -0
  82. data/crates/kobako-wasmtime/tests/support/mod.rs +30 -0
  83. data/crates/kobako-wasmtime/tests/unanswered_call.rs +86 -0
  84. data/crates/kobako-wasmtime/tests/unsupported_call.rs +51 -0
  85. data/data/kobako.wasm +0 -0
  86. data/ext/kobako/Cargo.toml +4 -2
  87. data/ext/kobako/src/runtime/bridge.rs +177 -113
  88. data/ext/kobako/src/runtime/errors.rs +26 -32
  89. data/ext/kobako/src/runtime/escape.rs +53 -0
  90. data/ext/kobako/src/runtime/filesystem/grants.rs +91 -0
  91. data/ext/kobako/src/runtime/filesystem/ruby.rs +400 -0
  92. data/ext/kobako/src/runtime/filesystem.rs +236 -0
  93. data/ext/kobako/src/runtime/gvl.rs +11 -13
  94. data/ext/kobako/src/runtime/pinned_value.rs +31 -0
  95. data/ext/kobako/src/runtime/snapshot.rs +127 -0
  96. data/ext/kobako/src/runtime.rs +190 -312
  97. data/lib/kobako/capture.rb +1 -4
  98. data/lib/kobako/catalog/extensions.rb +21 -59
  99. data/lib/kobako/catalog/flags.rb +29 -0
  100. data/lib/kobako/catalog/grants.rb +30 -0
  101. data/lib/kobako/catalog/handles.rb +7 -54
  102. data/lib/kobako/catalog/mount.rb +27 -0
  103. data/lib/kobako/catalog/services.rb +17 -60
  104. data/lib/kobako/catalog/snippets.rb +41 -69
  105. data/lib/kobako/catalog.rb +13 -10
  106. data/lib/kobako/codec/decoder.rb +10 -28
  107. data/lib/kobako/codec/encoder.rb +3 -22
  108. data/lib/kobako/codec/error.rb +6 -27
  109. data/lib/kobako/codec/ext_types.rb +72 -94
  110. data/lib/kobako/codec/handle_walk.rb +65 -130
  111. data/lib/kobako/codec/nesting.rb +3 -8
  112. data/lib/kobako/codec/state.rb +21 -11
  113. data/lib/kobako/codec/utils.rb +6 -34
  114. data/lib/kobako/codec/value_budget.rb +86 -0
  115. data/lib/kobako/codec.rb +10 -22
  116. data/lib/kobako/constant_name.rb +8 -0
  117. data/lib/kobako/context.rb +83 -117
  118. data/lib/kobako/errors.rb +57 -132
  119. data/lib/kobako/execution.rb +16 -21
  120. data/lib/kobako/extension.rb +23 -40
  121. data/lib/kobako/filesystem.rb +38 -0
  122. data/lib/kobako/handle.rb +7 -12
  123. data/lib/kobako/outcome.rb +51 -80
  124. data/lib/kobako/payload/arguments.rb +1 -16
  125. data/lib/kobako/payload.rb +4 -12
  126. data/lib/kobako/pool.rb +25 -55
  127. data/lib/kobako/runtime.rb +7 -21
  128. data/lib/kobako/sandbox.rb +128 -137
  129. data/lib/kobako/sandbox_options.rb +71 -90
  130. data/lib/kobako/snippet/bytecode.rb +11 -0
  131. data/lib/kobako/snippet/source.rb +1 -16
  132. data/lib/kobako/snippet.rb +2 -10
  133. data/lib/kobako/transport/block_failure.rb +35 -0
  134. data/lib/kobako/transport/call.rb +1 -14
  135. data/lib/kobako/transport/dispatcher.rb +127 -206
  136. data/lib/kobako/transport/error.rb +6 -20
  137. data/lib/kobako/transport/exposure.rb +10 -17
  138. data/lib/kobako/transport/reflection.rb +108 -63
  139. data/lib/kobako/transport/run.rb +17 -71
  140. data/lib/kobako/transport/yielder.rb +20 -94
  141. data/lib/kobako/transport.rb +4 -15
  142. data/lib/kobako/unresolved.rb +4 -11
  143. data/lib/kobako/usage.rb +2 -6
  144. data/lib/kobako/version.rb +1 -1
  145. data/lib/kobako.rb +1 -0
  146. data/sig/kobako/catalog/extensions.rbs +2 -2
  147. data/sig/kobako/catalog/flags.rbs +13 -0
  148. data/sig/kobako/catalog/grants.rbs +13 -0
  149. data/sig/kobako/catalog/mount.rbs +28 -0
  150. data/sig/kobako/catalog/services.rbs +3 -5
  151. data/sig/kobako/catalog/snippets.rbs +5 -5
  152. data/sig/kobako/catalog.rbs +1 -0
  153. data/sig/kobako/codec/ext_types.rbs +10 -8
  154. data/sig/kobako/codec/handle_walk.rbs +9 -7
  155. data/sig/kobako/codec/state.rbs +7 -1
  156. data/sig/kobako/codec/utils.rbs +2 -2
  157. data/sig/kobako/codec/value_budget.rbs +23 -0
  158. data/sig/kobako/codec.rbs +4 -2
  159. data/sig/kobako/constant_name.rbs +3 -0
  160. data/sig/kobako/context.rbs +15 -8
  161. data/sig/kobako/errors.rbs +10 -4
  162. data/sig/kobako/filesystem.rbs +88 -0
  163. data/sig/kobako/guest_yielder.rbs +9 -0
  164. data/sig/kobako/outcome.rbs +6 -8
  165. data/sig/kobako/pool.rbs +1 -1
  166. data/sig/kobako/runtime.rbs +21 -45
  167. data/sig/kobako/sandbox.rbs +11 -1
  168. data/sig/kobako/sandbox_options.rbs +25 -7
  169. data/sig/kobako/snapshot.rbs +20 -0
  170. data/sig/kobako/snippet/bytecode.rbs +12 -0
  171. data/sig/kobako/transport/block_failure.rbs +13 -0
  172. data/sig/kobako/transport/dispatcher.rbs +15 -19
  173. data/sig/kobako/transport/exposure.rbs +1 -1
  174. data/sig/kobako/transport/reflection.rbs +19 -4
  175. data/sig/kobako/transport/run.rbs +1 -3
  176. data/sig/kobako/transport/yielder.rbs +5 -9
  177. data/sig/kobako/transport.rbs +9 -8
  178. metadata +75 -10
  179. data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
  180. data/crates/kobako-wasmtime/src/frames.rs +0 -273
  181. data/lib/kobako/snippet/binary.rb +0 -26
  182. data/rust-toolchain.toml +0 -15
  183. data/sig/kobako/snippet/binary.rbs +0 -12
@@ -10,205 +10,196 @@ require_relative "catalog"
10
10
  require_relative "context"
11
11
 
12
12
  module Kobako
13
- # Kobako::Sandbox — the user-facing entry point for executing guest mruby
14
- # scripts inside a wasmtime-hosted Wasm module.
15
- #
16
- # The Sandbox owns the reusable configuration — the +Kobako::Runtime+ and
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
- attr_reader :wasm_path, :options
20
+ # The Guest Binary this Sandbox runs.
21
+ attr_reader :wasm_path
36
22
 
37
- # Per-option accessors forward to the immutable +SandboxOptions+ Value
38
- # Object so the Host App still reads them off Sandbox directly.
39
- def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile, :gvl
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
- # Build a fresh Sandbox.
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
- # +wasm_path+ is the absolute path to the Guest Binary; defaults to the
44
- # gem-bundled +data/kobako.wasm+. Every other keyword — the four caps
45
- # (+stdout_limit+, +stderr_limit+, +timeout+, +memory_limit+) and the
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
- # Bind +object+ as the Service reachable at +path+ — a Symbol or
65
- # String of one or more +::+-separated constant-form segments
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 +object+
70
- # itself define in source, fixed at this call — nothing inherited, mixed
71
- # in, or built into the platform. An +object+ defining a private
72
- # +respond_to_guest?(name)+ decides instead, asked on every call.
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 a +path+, it declares a fillable Service:
75
- # +bind(path)+ reserves the path for +Kobako::Unresolved+, so the guest
76
- # sees the constant while the host defers the object it stands for. A
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 +ArgumentError+ when a segment is malformed, when +path+
81
- # collides with an existing binding (a name is a bound Service or a
82
- # grouping prefix, never both), or when called after the first
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 one or more Extensions — each a guest idiom (+source+) paired
90
- # with an optional host +backend+, composed onto the Sandbox through
91
- # +#preload+ and +#bind+. An Extension is any object exposing
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 +ArgumentError+ for a malformed Extension, a call after the
96
- # first invocation seals registration, or — at that first invocation —
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
- raise ArgumentError, "cannot install after first Sandbox invocation" if @services.sealed?
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
- # Register a snippet on this Sandbox in one of two forms:
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
- # * +preload(code: source, name: Name)+ — +source+ is mruby source
108
- # as a +String+ and +Name+ matches +/\A[A-Z]\w*\z/+. Compile
109
- # failures surface as +Kobako::SandboxError+ on the first
110
- # invocation's replay. The +name+
111
- # becomes the snippet's +(snippet:Name)+ backtrace filename and
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
- # Subsequent invocations (+#eval+ or +#run+) replay every registered
121
- # snippet — in insertion order — against the fresh +mrb_state+
122
- # before per-invocation source or entrypoint resolution.
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
- # Returns +self+ to allow chaining.
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 +ArgumentError+ when neither form's keyword set is
127
- # supplied, when both forms are mixed (e.g., +code:+ and +binary:+
128
- # together, or +binary:+ paired with +name:+), when +code+ / +bytes+
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
- raise ArgumentError, "cannot preload after first Sandbox invocation" if @services.sealed?
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
- # Dispatch into a preloaded entrypoint constant. Delegates host
141
- # pre-flight and argument encoding to +Kobako::Transport::Run+ /
142
- # +Kobako::Transport::Run#payload+: a non-Symbol/String +target+ raises
143
- # +TypeError+, while a +target+ failing the constant pattern, a forged
144
- # +Kobako::Handle+ in +args+ / +kwargs+, or a non-Symbol +kwargs+ key
145
- # raise +ArgumentError+. The guest resolves +target+ as a top-level
146
- # constant, calls +#call+ on it with +args+ / +kwargs+, and returns the
147
- # deserialized result. The first invocation seals the Service registry
148
- # and snippet table. Runtime errors follow the same three-class
149
- # taxonomy as +#eval+.
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
- # Execute a guest mruby source string in a fresh +mrb_state+. +code+ is
156
- # the mruby source as a UTF-8 String. Returns the deserialized last
157
- # expression of the source.
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 +Kobako::TrapError+ on a Wasm trap or wire-violation fallback;
171
- # +Kobako::SandboxError+ when the guest ran to completion but failed
172
- # (including when +code+ is +nil+ or not a String, or when a preloaded
173
- # snippet's replay raises); +Kobako::ServiceError+ on an unrescued
174
- # Service capability failure.
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
- # Construct the +Runtime+ with the requested isolation profile and
184
- # refuse one whose declared posture falls below the request —
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, @options.timeout, @options.memory_limit,
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
- # Seal the config on the first invocation and return a fresh
196
- # per-invocation +Context+ for the verb to drive. The Context owns this
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, snippets: @snippets, extensions: @extensions)
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
- # Per-invocation prologue on the config tier: seals the Service and
205
- # Extension registries on the first call (idempotent — asserting Extension
206
- # dependencies then). The Service seal is the one +#bind+ / +#preload+ /
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
- @services.seal!
211
- @extensions.seal!
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
- # Kobako::SandboxOptions — immutable Value Object holding the four
7
- # per-Sandbox configuration caps and the requested isolation profile.
8
- # Built on the +class X < Data.define(...)+ subclass form (the
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
- # The +initialize+ normalises every option before delegating to Data's
12
- # +super+: +timeout+ to Float seconds, +memory_limit+ / +stdout_limit+ /
13
- # +stderr_limit+ to positive Integer bytes. Each cap is +nil+-disablable
14
- # (an absent argument takes its DEFAULT; an explicit +nil+ leaves the
15
- # bound off), so all four behave uniformly. +profile+ is the one
16
- # non-cap: a Symbol on the PROFILES ladder naming the posture the
17
- # runtime builds, which is also the weakest the Host App accepts —
18
- # +nil+ is rejected because the weakest posture is requested as an
19
- # explicit +:permissive+. Anything that survives +SandboxOptions.new+
20
- # is a wire-ready bundle the +Kobako::Runtime+ constructor consumes
21
- # as-is. The options also own the ladder comparison
22
- # (+#enforce_floor!+) that +Kobako::Sandbox+ delegates its
23
- # construction floor check to.
24
- class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile, :gvl)
25
- # Default wall-clock timeout for a single invocation: 60 seconds.
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
- # Default cap on the per-invocation guest linear-memory delta:
29
- # 1 MiB. The mruby image's initial allocation and prior invocations'
30
- # watermark sit outside this budget.
31
- DEFAULT_MEMORY_LIMIT = 1 << 20
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
- # Default per-channel capture ceiling: 1 MiB.
36
+ # The default +stdout_limit+ and +stderr_limit+: 1 MiB each.
34
37
  DEFAULT_OUTPUT_LIMIT = 1 << 20
35
38
 
36
- # The isolation ladder, weakest first — index order is rank order,
37
- # so a floor check is an index comparison.
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
- # Default isolation profile: the strictest rung — opting down to
41
- # +:permissive+ is the Host App's explicit trade.
47
+ # The default +profile+, the strictest.
42
48
  DEFAULT_PROFILE = :hermetic
43
49
 
44
- # The GVL scheduling modes: +:hold+ keeps Ruby's GVL for the whole
45
- # invocation, +:release+ drops it for the guest span so distinct
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
- # Default GVL mode: +:hold+ — holding the GVL matches single-threaded
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
- memory_limit = normalize_memory_limit(memory_limit)
62
- stdout_limit = normalize_output_limit(stdout_limit, "stdout_limit")
63
- stderr_limit = normalize_output_limit(stderr_limit, "stderr_limit")
64
- profile = normalize_profile(profile)
65
- gvl = normalize_gvl(gvl)
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
- # Enforce the requested +profile+ as the floor against +declared+ —
70
- # the posture a runtime reports having built — so a runtime that
71
- # cannot honor the request fails construction with
72
- # +Kobako::SetupError+ instead of weakening the posture silently.
73
- # Both fallbacks fail closed: a declaration off the PROFILES ladder
74
- # ranks below every request, and a request off the ladder
75
- # (unreachable past +initialize+) refuses every declaration.
76
- def enforce_floor!(declared)
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
- # Coerce +timeout+ into the Float seconds the ext expects, or +nil+
86
- # to mean the cap is disabled. Any finite non-positive value is
87
- # rejected — a zero or negative timeout would either fire instantly
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, "timeout must be Numeric or nil, got #{timeout.class}" unless timeout.is_a?(Numeric)
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, "timeout must be > 0 (got #{timeout})" unless seconds.positive? && seconds.finite?
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
- # Coerce +memory_limit+ into the byte cap the ext expects, or +nil+
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
- # Validate +profile+ against the PROFILES ladder. Unlike the caps
127
- # there is no +nil+ form: the weakest posture is requested as an
128
- # explicit +:permissive+, so anything off the ladder — +nil+
129
- # included — is rejected.
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, "gvl must be one of #{GVL_MODES.map(&:inspect).join(", ")}, got #{gvl.inspect}"
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
- # Kobako::Snippet::Source — value object representing a single
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
@@ -1,17 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "snippet/binary"
3
+ require_relative "snippet/bytecode"
4
4
  require_relative "snippet/source"
5
5
 
6
6
  module Kobako
7
- # Kobako::Snippet — value-object family for preloaded snippet entries
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