kobako 0.28.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +170 -0
  4. data/Cargo.lock +111 -82
  5. data/README.md +154 -36
  6. data/ROADMAP.md +5 -0
  7. data/SECURITY.md +1 -1
  8. data/crates/kobako-runtime/CHANGELOG.md +86 -0
  9. data/crates/kobako-runtime/Cargo.toml +27 -6
  10. data/crates/kobako-runtime/README.md +13 -8
  11. data/crates/kobako-runtime/src/dispatch.rs +8 -0
  12. data/crates/kobako-runtime/src/filesystem/directory/spelling.rs +194 -0
  13. data/crates/kobako-runtime/src/filesystem/directory.rs +1082 -0
  14. data/crates/kobako-runtime/src/filesystem/memory.rs +1100 -0
  15. data/crates/kobako-runtime/src/filesystem/overlay/exclusive.rs +88 -0
  16. data/crates/kobako-runtime/src/filesystem/overlay/stack.rs +808 -0
  17. data/crates/kobako-runtime/src/filesystem/overlay.rs +1955 -0
  18. data/crates/kobako-runtime/src/filesystem/path.rs +60 -0
  19. data/crates/kobako-runtime/src/filesystem/temporary.rs +167 -0
  20. data/crates/kobako-runtime/src/filesystem.rs +392 -0
  21. data/crates/kobako-runtime/src/grant.rs +20 -0
  22. data/crates/kobako-runtime/src/lib.rs +11 -1
  23. data/crates/kobako-runtime/src/mount.rs +178 -0
  24. data/crates/kobako-runtime/src/profile.rs +4 -4
  25. data/crates/kobako-runtime/src/runtime.rs +12 -0
  26. data/crates/kobako-runtime/src/snapshot.rs +20 -5
  27. data/crates/kobako-runtime/src/yielder.rs +11 -6
  28. data/crates/kobako-transport/CHANGELOG.md +17 -0
  29. data/crates/kobako-transport/Cargo.toml +1 -1
  30. data/crates/kobako-transport/README.md +1 -1
  31. data/crates/kobako-transport/src/abi.rs +1 -1
  32. data/crates/kobako-transport/src/envelope/fault.rs +2 -1
  33. data/crates/kobako-transport/src/envelope/preamble.rs +12 -12
  34. data/crates/kobako-transport/src/envelope/reply.rs +32 -14
  35. data/crates/kobako-transport/src/envelope/request.rs +18 -8
  36. data/crates/kobako-wasmtime/CHANGELOG.md +119 -0
  37. data/crates/kobako-wasmtime/Cargo.toml +21 -16
  38. data/crates/kobako-wasmtime/README.md +5 -3
  39. data/crates/kobako-wasmtime/src/abi.rs +66 -32
  40. data/crates/kobako-wasmtime/src/ambient.rs +63 -4
  41. data/crates/kobako-wasmtime/src/cache.rs +185 -74
  42. data/crates/kobako-wasmtime/src/config.rs +58 -1
  43. data/crates/kobako-wasmtime/src/descriptors.rs +167 -0
  44. data/crates/kobako-wasmtime/src/dispatch.rs +47 -29
  45. data/crates/kobako-wasmtime/src/driver.rs +46 -44
  46. data/crates/kobako-wasmtime/src/guest_mem.rs +13 -7
  47. data/crates/kobako-wasmtime/src/imports.rs +46 -0
  48. data/crates/kobako-wasmtime/src/instance_pre.rs +17 -45
  49. data/crates/kobako-wasmtime/src/invocation.rs +35 -46
  50. data/crates/kobako-wasmtime/src/io.rs +5 -202
  51. data/crates/kobako-wasmtime/src/lib.rs +4 -0
  52. data/crates/kobako-wasmtime/src/preview1/args.rs +60 -0
  53. data/crates/kobako-wasmtime/src/preview1/beneath.rs +188 -0
  54. data/crates/kobako-wasmtime/src/preview1/differential/mount.rs +609 -0
  55. data/crates/kobako-wasmtime/src/preview1/differential.rs +372 -0
  56. data/crates/kobako-wasmtime/src/preview1/errno.rs +267 -0
  57. data/crates/kobako-wasmtime/src/preview1/fd.rs +672 -0
  58. data/crates/kobako-wasmtime/src/preview1/memory.rs +205 -0
  59. data/crates/kobako-wasmtime/src/preview1/mount_view.rs +2499 -0
  60. data/crates/kobako-wasmtime/src/preview1/path.rs +421 -0
  61. data/crates/kobako-wasmtime/src/preview1/poll.rs +333 -0
  62. data/crates/kobako-wasmtime/src/preview1/sock.rs +30 -0
  63. data/crates/kobako-wasmtime/src/preview1.rs +461 -0
  64. data/crates/kobako-wasmtime/src/trap.rs +39 -19
  65. data/crates/kobako-wasmtime/src/wasi.rs +317 -0
  66. data/crates/kobako-wasmtime/tests/compile_posture.rs +43 -0
  67. data/crates/kobako-wasmtime/tests/guest_file.rs +1054 -0
  68. data/crates/kobako-wasmtime/tests/preamble_flags.rs +3 -2
  69. data/crates/kobako-wasmtime/tests/snippet_compile.rs +4 -3
  70. data/crates/kobako-wasmtime/tests/support/mod.rs +9 -13
  71. data/crates/kobako-wasmtime/tests/unanswered_call.rs +86 -0
  72. data/crates/kobako-wasmtime/tests/unsupported_call.rs +1 -0
  73. data/data/kobako.wasm +0 -0
  74. data/ext/kobako/Cargo.toml +4 -2
  75. data/ext/kobako/src/runtime/bridge.rs +154 -81
  76. data/ext/kobako/src/runtime/escape.rs +53 -0
  77. data/ext/kobako/src/runtime/filesystem/grants.rs +91 -0
  78. data/ext/kobako/src/runtime/filesystem/ruby.rs +400 -0
  79. data/ext/kobako/src/runtime/filesystem.rs +236 -0
  80. data/ext/kobako/src/runtime/gvl.rs +7 -1
  81. data/ext/kobako/src/runtime/pinned_value.rs +31 -0
  82. data/ext/kobako/src/runtime/snapshot.rs +127 -0
  83. data/ext/kobako/src/runtime.rs +120 -199
  84. data/lib/kobako/catalog/flags.rb +2 -14
  85. data/lib/kobako/catalog/grants.rb +30 -0
  86. data/lib/kobako/catalog/mount.rb +27 -0
  87. data/lib/kobako/catalog/services.rb +11 -0
  88. data/lib/kobako/catalog/snippets.rb +5 -9
  89. data/lib/kobako/catalog.rb +11 -0
  90. data/lib/kobako/codec/decoder.rb +1 -0
  91. data/lib/kobako/codec/ext_types.rb +1 -1
  92. data/lib/kobako/codec/state.rb +19 -3
  93. data/lib/kobako/codec/value_budget.rb +86 -0
  94. data/lib/kobako/codec.rb +7 -0
  95. data/lib/kobako/context.rb +38 -11
  96. data/lib/kobako/errors.rb +15 -14
  97. data/lib/kobako/filesystem.rb +38 -0
  98. data/lib/kobako/pool.rb +2 -2
  99. data/lib/kobako/sandbox.rb +40 -12
  100. data/lib/kobako/sandbox_options.rb +39 -12
  101. data/lib/kobako/snippet/{binary.rb → bytecode.rb} +1 -1
  102. data/lib/kobako/snippet.rb +1 -1
  103. data/lib/kobako/transport/block_failure.rb +35 -0
  104. data/lib/kobako/transport/dispatcher.rb +30 -26
  105. data/lib/kobako/transport/exposure.rb +5 -7
  106. data/lib/kobako/transport/reflection.rb +79 -23
  107. data/lib/kobako/transport/yielder.rb +8 -30
  108. data/lib/kobako/transport.rb +1 -0
  109. data/lib/kobako/version.rb +1 -1
  110. data/lib/kobako.rb +1 -0
  111. data/sig/kobako/catalog/flags.rbs +0 -4
  112. data/sig/kobako/catalog/grants.rbs +13 -0
  113. data/sig/kobako/catalog/mount.rbs +28 -0
  114. data/sig/kobako/catalog/services.rbs +4 -0
  115. data/sig/kobako/catalog/snippets.rbs +1 -1
  116. data/sig/kobako/catalog.rbs +1 -0
  117. data/sig/kobako/codec/state.rbs +7 -1
  118. data/sig/kobako/codec/value_budget.rbs +23 -0
  119. data/sig/kobako/codec.rbs +2 -0
  120. data/sig/kobako/context.rbs +14 -6
  121. data/sig/kobako/errors.rbs +4 -4
  122. data/sig/kobako/filesystem.rbs +88 -0
  123. data/sig/kobako/guest_yielder.rbs +9 -0
  124. data/sig/kobako/pool.rbs +1 -1
  125. data/sig/kobako/runtime.rbs +21 -49
  126. data/sig/kobako/sandbox.rbs +7 -1
  127. data/sig/kobako/sandbox_options.rbs +24 -2
  128. data/sig/kobako/snapshot.rbs +20 -0
  129. data/sig/kobako/snippet/bytecode.rbs +12 -0
  130. data/sig/kobako/transport/block_failure.rbs +13 -0
  131. data/sig/kobako/transport/dispatcher.rbs +3 -3
  132. data/sig/kobako/transport/exposure.rbs +1 -1
  133. data/sig/kobako/transport/reflection.rbs +14 -1
  134. data/sig/kobako/transport/yielder.rbs +3 -7
  135. data/sig/kobako/transport.rbs +2 -2
  136. metadata +60 -7
  137. data/sig/kobako/snippet/binary.rbs +0 -12
@@ -2,8 +2,9 @@
2
2
 
3
3
  module Kobako
4
4
  module Codec
5
- # Whether a Capability Handle crossed the decode in flight, kept per
6
- # thread so plain instance variables stay sound.
5
+ # Whether a Capability Handle crossed the decode in flight, and the
6
+ # value budget a decode is held to, kept per thread so plain instance
7
+ # variables stay sound.
7
8
  class State # :nodoc:
8
9
  # Thread-local slot holding the calling thread's State.
9
10
  STATE_KEY = :__kobako_codec_state__
@@ -18,6 +19,21 @@ module Kobako
18
19
 
19
20
  def initialize
20
21
  @carried_handle = false
22
+ @value_budget = nil
23
+ end
24
+
25
+ # The most values a decode on this thread may build, or nil for no
26
+ # budget.
27
+ attr_reader :value_budget
28
+
29
+ # A nested invocation on the same thread restores the outer one's
30
+ # budget when it returns.
31
+ def within_value_budget(budget)
32
+ outer = @value_budget
33
+ @value_budget = budget
34
+ yield
35
+ ensure
36
+ @value_budget = outer
21
37
  end
22
38
 
23
39
  # ExtTypes#unpack_handle is the sole chokepoint every Handle passes
@@ -30,7 +46,7 @@ module Kobako
30
46
 
31
47
  # Record that an ext 0x01 Capability Handle crossed the current
32
48
  # decode; #track_handles reports it to the bracketing caller.
33
- def record_handle!
49
+ def record_handle
34
50
  @carried_handle = true
35
51
  end
36
52
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Kobako
6
+ module Codec
7
+ # Counts the values a payload holds before the decoder builds any, so a
8
+ # guest cannot make the host build more objects than its budget allows
9
+ # (docs/wire/payload-msgpack.md § Value Budget). Every value takes at
10
+ # least one byte, so a payload no longer than the budget needs no count.
11
+ #
12
+ # The count stops at the first byte it cannot frame and leaves that
13
+ # payload for the decoder to refuse in its own words.
14
+ module ValueBudget # :nodoc:
15
+ # Marker to the body width of a scalar with no length field.
16
+ SCALARS = {
17
+ 0xc0 => 0, 0xc2 => 0, 0xc3 => 0, 0xca => 4, 0xcb => 8,
18
+ 0xcc => 1, 0xcd => 2, 0xce => 4, 0xcf => 8,
19
+ 0xd0 => 1, 0xd1 => 2, 0xd2 => 4, 0xd3 => 8,
20
+ 0xd4 => 2, 0xd5 => 3, 0xd6 => 5, 0xd7 => 9, 0xd8 => 17
21
+ }.freeze
22
+ # Marker to a container's count width and the values each entry holds.
23
+ COUNTS = { 0xdc => [2, 1], 0xdd => [4, 1], 0xde => [2, 2], 0xdf => [4, 2] }.freeze
24
+ # Marker to a str, bin, or ext's length width and type tag width.
25
+ LENGTHS = {
26
+ 0xc4 => [1, 0], 0xc5 => [2, 0], 0xc6 => [4, 0],
27
+ 0xd9 => [1, 0], 0xda => [2, 0], 0xdb => [4, 0],
28
+ 0xc7 => [1, 1], 0xc8 => [2, 1], 0xc9 => [4, 1]
29
+ }.freeze
30
+ UINT = { 1 => "C", 2 => "n", 4 => "N" }.freeze
31
+ private_constant :SCALARS, :COUNTS, :LENGTHS, :UINT
32
+
33
+ def self.assert_within!(bytes, budget)
34
+ return if budget.nil? || bytes.bytesize <= budget
35
+
36
+ remaining = budget
37
+ pending = 1
38
+ pos = 0
39
+ while pending.positive?
40
+ pos, children = step(bytes, pos)
41
+ return if pos.nil?
42
+ raise InvalidTypeError, "payload holds more than #{budget} values" if (remaining -= 1).negative?
43
+
44
+ pending += children - 1
45
+ end
46
+ end
47
+
48
+ # The position after the value at +pos+, nil for one the payload
49
+ # cannot frame, and the values it holds directly.
50
+ def self.step(bytes, pos)
51
+ marker = bytes.getbyte(pos) or return [nil, 0]
52
+ pos += 1
53
+ case marker
54
+ when 0x00..0x7f, 0xe0..0xff then [pos, 0]
55
+ when 0x80..0x8f then [pos, (marker & 0x0f) * 2]
56
+ when 0x90..0x9f then [pos, marker & 0x0f]
57
+ when 0xa0..0xbf then [pos + (marker & 0x1f), 0]
58
+ else step_framed(bytes, pos, marker)
59
+ end
60
+ end
61
+
62
+ def self.step_framed(bytes, pos, marker)
63
+ return [pos + SCALARS.fetch(marker), 0] if SCALARS.key?(marker)
64
+
65
+ width, per = COUNTS[marker]
66
+ return [pos + width, uint(bytes, pos, width) * per] if width && per
67
+
68
+ width, tag = LENGTHS[marker]
69
+ return [nil, 0] unless width && tag
70
+
71
+ [pos + width + tag + uint(bytes, pos, width), 0]
72
+ end
73
+
74
+ # A length cut short reads as zero, so the count runs off the end and
75
+ # stops there.
76
+ def self.uint(bytes, pos, width)
77
+ slice = bytes.byteslice(pos, width).to_s
78
+ return 0 if slice.bytesize < width
79
+
80
+ slice.unpack1(UINT.fetch(width)) # : Integer
81
+ end
82
+
83
+ private_class_method :step, :step_framed, :uint
84
+ end
85
+ end
86
+ end
data/lib/kobako/codec.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "codec/utils"
5
5
  require_relative "codec/handle_walk"
6
6
  require_relative "codec/nesting"
7
7
  require_relative "codec/state"
8
+ require_relative "codec/value_budget"
8
9
  require_relative "codec/ext_types"
9
10
  require_relative "codec/encoder"
10
11
  require_relative "codec/decoder"
@@ -23,5 +24,11 @@ module Kobako
23
24
  def self.track_handles(&block)
24
25
  State.current.track_handles(&block)
25
26
  end
27
+
28
+ # Holds every decode the block runs on this thread to +budget+ values,
29
+ # or to none when +budget+ is nil.
30
+ def self.within_value_budget(budget, &block)
31
+ State.current.within_value_budget(budget, &block)
32
+ end
26
33
  end
27
34
  end
@@ -17,12 +17,12 @@ module Kobako
17
17
  #
18
18
  # sandbox.eval("Req::Current.user_id") { |ctx| ctx.bind("Req::Current", request) }
19
19
  class Context
20
- def initialize(runtime:, services:, snippets:, flags:, extensions:) # :nodoc:
20
+ def initialize(runtime:, services:, extensions:, grants:, preamble:) # :nodoc:
21
21
  @runtime = runtime
22
22
  @services = services
23
- @snippets = snippets
24
- @flags = flags
25
23
  @extensions = extensions
24
+ @grants = grants
25
+ @preamble = preamble
26
26
  @resolved = {} # : Hash[String, Transport::Exposure]
27
27
  @overrides = {} # : Hash[String, Transport::Exposure]
28
28
  @handles = Catalog::Handles.new
@@ -45,6 +45,26 @@ module Kobako
45
45
  self
46
46
  end
47
47
 
48
+ # Use +filesystem+ for the mount at +at+ during this invocation only,
49
+ # with its own +access+ and block, as Sandbox#mount takes them. Returns
50
+ # +self+.
51
+ #
52
+ # Raises TypeError for a path that is no String or something other than
53
+ # a Filesystem, and ArgumentError when nothing was mounted at +at+ on the
54
+ # Sandbox, for a malformed path or an unknown access, or when called
55
+ # after the block has returned.
56
+ def mount(at, filesystem, access: :read, &policy)
57
+ raise ArgumentError, "Kobako::Context is spent; ctx.mount is only valid inside the #eval / #run block" if @spent
58
+
59
+ Runtime.validate_mount!(at, access, filesystem)
60
+ mount = Catalog::Mount.new(at: at, access: access, filesystem: filesystem, policy: policy)
61
+ raise ArgumentError, "cannot override unmounted path #{at.inspect}" unless @grants.include?(mount)
62
+
63
+ mounts = @mounts ||= {} # : Hash[String, Catalog::Mount]
64
+ mounts[mount.key] = mount
65
+ self
66
+ end
67
+
48
68
  # A fillable left unfilled raises like an unbound path, so the guest's
49
69
  # call fails closed instead of reaching the sentinel.
50
70
  def lookup(path) # :nodoc:
@@ -57,21 +77,25 @@ module Kobako
57
77
 
58
78
  def eval(code, &block) # :nodoc:
59
79
  collect_overrides(&block) if block
60
- invoke!(:eval) do
61
- @runtime.eval(dispatch_handler, @services.paths, @snippets.entries, @flags.entries, code.b)
62
- end
80
+ invoke!(:eval) { @runtime.eval(dispatch_handler, setup, code.b) }
63
81
  end
64
82
 
65
83
  def run(request, &block) # :nodoc:
66
84
  collect_overrides(&block) if block
67
85
  invoke!(:run, entrypoint: request.entrypoint) do
68
- @runtime.run(dispatch_handler, @services.paths, @snippets.entries, @flags.entries,
69
- request.entrypoint.to_s, request.payload(@handles))
86
+ @runtime.run(dispatch_handler, setup, request.entrypoint.to_s, request.payload(@handles))
70
87
  end
71
88
  end
72
89
 
73
90
  private
74
91
 
92
+ # What the Runtime builds the invocation from: the Preamble's parts,
93
+ # then this invocation's mounts.
94
+ def setup
95
+ paths, snippets, flags = @preamble
96
+ [paths, snippets, flags, @grants.entries(@mounts || {})]
97
+ end
98
+
75
99
  # The Context is spent once the block returns, so a captured +ctx+ used
76
100
  # later raises. A block that raises propagates before the guest drives,
77
101
  # so the guest never runs and no Execution is produced.
@@ -83,17 +107,20 @@ module Kobako
83
107
 
84
108
  # Handed to the Runtime as a call argument, so the Runtime holds no
85
109
  # dispatch state and the Proc stays GC-rooted for the synchronous call.
110
+ # Built once per invocation, so every Call it answers shares one
111
+ # BlockFailure.
86
112
  def dispatch_handler
113
+ failure = Transport::BlockFailure.new
87
114
  lambda do |target, method_name, block_given, payload, guest_yielder|
88
115
  call = Transport::Call.new(target: target, method_name: method_name,
89
116
  block_given: block_given, payload: payload)
90
- Transport::Dispatcher.dispatch(call, self, @handles, guest_yielder)
117
+ Transport::Dispatcher.dispatch(call, self, @handles, failure, guest_yielder)
91
118
  end
92
119
  end
93
120
 
94
121
  # Every Snapshot carries usage and captures, trap or not, so they stay
95
122
  # readable after a rescued trap.
96
- def populate_observability!(snapshot)
123
+ def populate_observability(snapshot)
97
124
  @usage = Usage.new(wall_time: snapshot.wall_time, memory_peak: snapshot.memory_peak)
98
125
  @stdout_capture = Capture.new(bytes: snapshot.stdout, truncated: snapshot.stdout_truncated?)
99
126
  @stderr_capture = Capture.new(bytes: snapshot.stderr, truncated: snapshot.stderr_truncated?)
@@ -137,7 +164,7 @@ module Kobako
137
164
  rescue Kobako::TrapError => e
138
165
  raise with_verb(e, verb)
139
166
  end
140
- populate_observability!(snapshot)
167
+ populate_observability(snapshot)
141
168
  trap = snapshot.trap_error
142
169
  return settle_outcome(snapshot, verb, entrypoint) unless trap
143
170
 
data/lib/kobako/errors.rb CHANGED
@@ -8,11 +8,7 @@ module Kobako
8
8
  # The base of every error kobako raises.
9
9
  class Error < StandardError; end
10
10
 
11
- # Gives an invocation failure the Execution of the run that failed, so a
12
- # rescue reads its captures and usage as a successful caller would.
13
- # +execution+ is +nil+ when the failure came before the guest ran.
14
- module CarriesExecution
15
- # The Execution of the run that failed.
11
+ module Recorded # :nodoc:
16
12
  attr_reader :execution
17
13
 
18
14
  def with_execution(execution) # :nodoc:
@@ -23,8 +19,11 @@ module Kobako
23
19
 
24
20
  # The engine stopped the guest: a trap, a cap reached, or an outcome that
25
21
  # could not be read. The next invocation on the same Sandbox runs as usual.
22
+ # +execution+ is the failed run's Execution, so a rescue reads its captures
23
+ # and usage as a successful caller would; it is +nil+ when the failure came
24
+ # before the guest ran.
26
25
  class TrapError < Error
27
- include CarriesExecution
26
+ include Recorded
28
27
  end
29
28
 
30
29
  # The invocation ran past its +timeout+.
@@ -41,10 +40,7 @@ module Kobako
41
40
  # The Guest Binary is missing at +wasm_path+.
42
41
  class ModuleNotBuiltError < SetupError; end
43
42
 
44
- # What the guest reported about a failure.
45
- module Diagnosable
46
- # Where the failure came from (<tt>"sandbox"</tt> or <tt>"service"</tt>),
47
- # the name of the class that raised it, and its backtrace.
43
+ module Diagnosable # :nodoc:
48
44
  attr_reader :origin, :klass, :backtrace_lines
49
45
 
50
46
  def initialize(message, origin: nil, klass: nil, backtrace_lines: nil) # :nodoc:
@@ -57,15 +53,19 @@ module Kobako
57
53
 
58
54
  # The guest failed: the script raised or did not compile, the Guest Binary
59
55
  # could not start the run, or what it produced could not be read.
56
+ # +origin+ (<tt>"sandbox"</tt> or <tt>"service"</tt>), +klass+ and
57
+ # +backtrace_lines+ say where it came from, which class raised it, and its
58
+ # backtrace. +execution+ is the failed run's Execution, as on TrapError.
60
59
  class SandboxError < Error
61
60
  include Diagnosable
62
- include CarriesExecution
61
+ include Recorded
63
62
  end
64
63
 
65
- # A Service call failed and the guest left it unrescued.
64
+ # A Service call failed and the guest left it unrescued. It carries
65
+ # +origin+, +klass+, +backtrace_lines+ and +execution+ as SandboxError does.
66
66
  class ServiceError < Error
67
67
  include Diagnosable
68
- include CarriesExecution
68
+ include Recorded
69
69
  end
70
70
 
71
71
  # The call reached no Service method: nothing is bound there, the Handle
@@ -73,7 +73,8 @@ module Kobako
73
73
  # same on purpose, so a target discloses nothing about what it defines.
74
74
  class NoServiceError < ServiceError; end
75
75
 
76
- # The call reached the Service method with arguments that did not fit.
76
+ # The Service method refused its arguments: the call would not bind, or
77
+ # the method raised +ArgumentError+ over what it was given.
77
78
  class ServiceArgumentError < ServiceError; end
78
79
 
79
80
  # Raised at a Service's +yield+ when the guest block raised; rescue it as
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ # The Filesystems a Sandbox can mount for the guest's +File+, each
5
+ # answered in Rust by the kind of the same name. Hand one to
6
+ # Sandbox#mount:
7
+ #
8
+ # sandbox.mount("/data", Kobako::Filesystem::Memory.new("config.yml" => yaml))
9
+ #
10
+ # [<tt>Memory.new(files = {}, limit: 64 MiB, inodes:)</tt>]
11
+ # Files held in memory for as long as the object lives, seeded from a
12
+ # Hash of path to contents. Every invocation shares them.
13
+ # [<tt>Temporary.new(limit: 64 MiB, inodes:)</tt>]
14
+ # Files held in memory that every invocation starts empty.
15
+ # [<tt>Directory.new(path)</tt>]
16
+ # A host directory and everything beneath it. Nothing outside it is
17
+ # reachable, a symbolic link pointing out included.
18
+ # [<tt>Overlay.new.layer(filesystem, access: :read)</tt>]
19
+ # Layers stacked into one view, the first added on top, each +:read+
20
+ # or +:read_write+ on its own. A change lands in a writable Layer, and
21
+ # a read-only one stays as it was. Each +layer+ answers a new Overlay.
22
+ #
23
+ # A Memory or Temporary is bounded as a tmpfs mount is: its files hold
24
+ # at most +limit+ bytes together, and it holds at most +inodes+ files and
25
+ # directories, its root among them; +nil+ lifts either bound. Without
26
+ # +inodes+ it holds one for each 4 KiB of +limit+, and the root. Past
27
+ # either, the guest raises Errno::ENOSPC.
28
+ #
29
+ # Any object answering +open+ and +stat+ mounts too, as a Filesystem
30
+ # written in Ruby; its methods take the names of +File+, +Dir+, and +IO+.
31
+ # Its +stat+ answers +directory?+, +size+, and +ino+, an +ino+ naming one
32
+ # directory for as long as the guest holds it open. Its names match
33
+ # exactly, as a mount's block judges them as given. A name may hold a
34
+ # <tt>\\</tt>, which the guest's paths never separate on, so one joined
35
+ # to a Windows host path is the Filesystem's to refuse.
36
+ module Filesystem
37
+ end
38
+ end
data/lib/kobako/pool.rb CHANGED
@@ -94,7 +94,7 @@ module Kobako
94
94
  done = true
95
95
  sandbox
96
96
  ensure
97
- release_capacity! unless done
97
+ release_capacity unless done
98
98
  end
99
99
 
100
100
  def checkin(sandbox)
@@ -104,7 +104,7 @@ module Kobako
104
104
  end
105
105
  end
106
106
 
107
- def release_capacity!
107
+ def release_capacity
108
108
  @mutex.synchronize do
109
109
  @constructed -= 1
110
110
  @slot_freed.signal
@@ -24,7 +24,8 @@ module Kobako
24
24
  # readable on the Sandbox itself, as in #timeout.
25
25
  attr_reader :options
26
26
 
27
- def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile, :gvl
27
+ def_delegators :@options, :timeout, :setup_timeout, :memory_limit, :stdout_limit, :stderr_limit,
28
+ :open_file_limit, :profile, :gvl
28
29
 
29
30
  # Build a Sandbox on the Guest Binary at +wasm_path+, the one bundled
30
31
  # with the gem by default. The other keywords are the options
@@ -40,6 +41,7 @@ module Kobako
40
41
  @snippets = Catalog::Snippets.new
41
42
  @flags = Catalog::Flags.new
42
43
  @extensions = Catalog::Extensions.new
44
+ @grants = Catalog::Grants.new
43
45
  @sealed = false
44
46
  @runtime = build_runtime!
45
47
  end
@@ -56,8 +58,9 @@ module Kobako
56
58
  # the constant, and Context#bind supplies the object for each invocation.
57
59
  # A call to one left unfilled fails as a ServiceError.
58
60
  #
59
- # Raises ArgumentError for a malformed path, a path that collides with an
60
- # existing binding, or a call after the first invocation.
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.
61
64
  def bind(path, object = Unresolved)
62
65
  refuse_once_invoked!("bind")
63
66
  @services.bind(path, object)
@@ -77,6 +80,27 @@ module Kobako
77
80
  self
78
81
  end
79
82
 
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+.
87
+ #
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.
93
+ #
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
+
80
104
  # Register a snippet that every invocation runs, in the order registered,
81
105
  # before its own code. Returns +self+.
82
106
  #
@@ -121,7 +145,7 @@ module Kobako
121
145
  # Handle or a non-Symbol keyword. A failed run raises as #eval does.
122
146
  def run(target, *args, **kwargs, &block)
123
147
  request = Transport::Run.new(entrypoint: target, args: args, kwargs: kwargs)
124
- new_invocation.run(request, &block)
148
+ Codec.within_value_budget(@options.value_budget) { new_invocation.run(request, &block) }
125
149
  end
126
150
 
127
151
  # Evaluate +code+, mruby source as a String, and return the Execution,
@@ -136,7 +160,7 @@ module Kobako
136
160
  def eval(code, &block)
137
161
  raise SandboxError, "code must be a String, got #{code.class}" unless code.is_a?(String)
138
162
 
139
- new_invocation.eval(code, &block)
163
+ Codec.within_value_budget(@options.value_budget) { new_invocation.eval(code, &block) }
140
164
  end
141
165
 
142
166
  private
@@ -144,9 +168,7 @@ module Kobako
144
168
  # A runtime whose declared posture falls below the request never runs
145
169
  # guest code.
146
170
  def build_runtime!
147
- runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
148
- @options.stdout_limit, @options.stderr_limit, @options.profile,
149
- @options.gvl)
171
+ runtime = Kobako::Runtime.from_path(@wasm_path, **@options.to_h)
150
172
  @options.enforce_floor!(runtime.profile)
151
173
  runtime
152
174
  end
@@ -155,12 +177,18 @@ module Kobako
155
177
  # does is written back onto the shared config.
156
178
  def new_invocation
157
179
  begin_invocation!
158
- Context.new(runtime: @runtime, services: @services, snippets: @snippets, flags: @flags,
159
- 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]
160
188
  end
161
189
 
162
190
  # Registration closes at the first invocation, so every invocation ships
163
- # the same Services, snippets, flags and Extensions.
191
+ # the same Services, snippets, flags, Extensions and mounts.
164
192
  def refuse_once_invoked!(verb)
165
193
  raise ArgumentError, "cannot #{verb} after first Sandbox invocation" if @sealed
166
194
  end
@@ -171,7 +199,7 @@ module Kobako
171
199
  def begin_invocation!
172
200
  @sealed = true
173
201
  @extensions.assert_dependencies!
174
- @snippets.compile { |entries| @runtime.compile(@services.paths, entries, @flags.entries) }
202
+ @snippets.compile { |entries| @runtime.compile(preamble(entries)) }
175
203
  end
176
204
  end
177
205
  end
@@ -8,25 +8,38 @@ module Kobako
8
8
  # default, and +nil+ switches it off.
9
9
  #
10
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.
11
13
  # [+memory_limit+] Integer bytes one invocation may grow guest memory by.
12
14
  # [+stdout_limit+, +stderr_limit+]
13
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.
14
19
  # [+profile+] The isolation the runtime must provide, one of PROFILES.
15
20
  # [+gvl+] How an invocation holds Ruby's GVL, one of GVL_MODES.
16
21
  #
17
22
  # Raises ArgumentError for a value outside these shapes, and SetupError
18
23
  # when the runtime provides less isolation than +profile+ asks for.
19
- class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile, :gvl)
24
+ class SandboxOptions < Data.define(:timeout, :setup_timeout, :memory_limit, :stdout_limit, :stderr_limit,
25
+ :open_file_limit, :profile, :gvl)
20
26
  # The default +timeout+: 60 seconds.
21
27
  DEFAULT_TIMEOUT_SECONDS = 60.0
22
28
 
23
- # The default +memory_limit+: 1 MiB of growth past the guest's starting
24
- # memory.
25
- 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
26
35
 
27
36
  # The default +stdout_limit+ and +stderr_limit+: 1 MiB each.
28
37
  DEFAULT_OUTPUT_LIMIT = 1 << 20
29
38
 
39
+ # The default +open_file_limit+: 1024, the soft limit Linux gives a
40
+ # process.
41
+ DEFAULT_OPEN_FILE_LIMIT = 1024
42
+
30
43
  # The isolation profiles, weakest first. +:hermetic+ denies the guest the
31
44
  # clock and entropy; +:permissive+ leaves them live.
32
45
  PROFILES = %i[permissive hermetic].freeze
@@ -42,20 +55,34 @@ module Kobako
42
55
  DEFAULT_GVL = :hold
43
56
 
44
57
  def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
58
+ setup_timeout: DEFAULT_SETUP_TIMEOUT_SECONDS,
45
59
  memory_limit: DEFAULT_MEMORY_LIMIT,
46
60
  stdout_limit: DEFAULT_OUTPUT_LIMIT,
47
61
  stderr_limit: DEFAULT_OUTPUT_LIMIT,
62
+ open_file_limit: DEFAULT_OPEN_FILE_LIMIT,
48
63
  profile: DEFAULT_PROFILE,
49
64
  gvl: DEFAULT_GVL)
50
- timeout = normalize_timeout(timeout)
51
- memory_limit = normalize_byte_limit(memory_limit, "memory_limit")
52
- stdout_limit = normalize_byte_limit(stdout_limit, "stdout_limit")
53
- stderr_limit = normalize_byte_limit(stderr_limit, "stderr_limit")
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")
54
71
  profile = normalize_choice(profile, PROFILES, "profile")
55
72
  gvl = normalize_choice(gvl, GVL_MODES, "gvl")
56
73
  super
57
74
  end
58
75
 
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
+
59
86
  # Both fallbacks fail closed: an unknown declaration ranks below every
60
87
  # request, and an unknown request refuses every declaration.
61
88
  def enforce_floor!(declared) # :nodoc:
@@ -69,17 +96,17 @@ module Kobako
69
96
 
70
97
  # A zero or negative timeout would either fire instantly or never, both
71
98
  # more surprising than an early ArgumentError.
72
- def normalize_timeout(timeout)
99
+ def normalize_timeout(timeout, name)
73
100
  return nil if timeout.nil?
74
- 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)
75
102
 
76
103
  seconds = timeout.to_f
77
- 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?
78
105
 
79
106
  seconds
80
107
  end
81
108
 
82
- def normalize_byte_limit(limit, name)
109
+ def normalize_limit(limit, name)
83
110
  return nil if limit.nil?
84
111
  unless limit.is_a?(Integer) && limit.positive?
85
112
  raise ArgumentError, "#{name} must be a positive Integer or nil, got #{limit.inspect}"
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Kobako
4
4
  module Snippet
5
- class Binary < Data.define(:body) # :nodoc:
5
+ class Bytecode < Data.define(:body) # :nodoc:
6
6
  # Names the snippet form the guest replays this entry as. The wire's
7
7
  # discriminant byte is assigned by the core envelope, not here.
8
8
  KIND = :bytecode
@@ -1,6 +1,6 @@
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
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kobako
4
+ module Transport
5
+ # The newest failure a guest block raised to a Service during one
6
+ # invocation, whichever open Call carried that block: a Service may run
7
+ # a block stored from an earlier Call, and its failure is still the
8
+ # guest's own.
9
+ class BlockFailure # :nodoc:
10
+ def initialize
11
+ @raised = nil
12
+ end
13
+
14
+ # Only the newest is kept: a Service that rescued an earlier one and
15
+ # yielded again has already handled it.
16
+ def remember(error)
17
+ @raised = error
18
+ end
19
+
20
+ # Identity rather than class: a Service that rescued the block's
21
+ # failure and raised its own has reported something else, and the
22
+ # guest must hear about that instead.
23
+ #
24
+ # The class travels with the message because not every block failure
25
+ # is an exception the guest holds — a block value the guest refused
26
+ # has a class to raise under and no object to continue.
27
+ def fault_text(error)
28
+ raised = @raised
29
+ return if raised.nil? || !error.equal?(raised)
30
+
31
+ "#{raised.klass}: #{raised.message}"
32
+ end
33
+ end
34
+ end
35
+ end