security_box 0.3.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9949a172b6bf1ddf1eddd9756c8dcbad273d8f2409669304227bb2f981c9fef6
4
- data.tar.gz: 499c948e3fd57c221f8aef7f8ad6dd63757b36e9d5a4a62d8c8a70823908f86d
3
+ metadata.gz: 6c7d1c1387ec5b1dffdb9781552e489d4b3d45e7066fdd3c7932e6ba39a8432f
4
+ data.tar.gz: 7770652a22e98d5067c851b5a1c790896b986b6dc7a68ca03ea4a0c3c4bb2110
5
5
  SHA512:
6
- metadata.gz: 1cf51781bb108aa6849ae24bd1db5b812c7e0a7e94f0d1876eb1a860cabc4a6b00241fdc19b4b142b88da8bdf86ed6e9c5dc53009237f36706a4ff637fe96d5f
7
- data.tar.gz: 39dfd3af95bc8bfd2e3bd79ac57b118ee706b2f7da3aee98854aea832094b29342e751b2a41bf630c593fd07289ecf7ac041b1d416682924f276a29be4a7259b
6
+ metadata.gz: 2d4f7f357bc27692a392251edfd887c310f94f6b0e5f47dccd01c9ea420c427f307888791205d82ee54a12ad117351464b6deefc06211cdabcf6c2bdeb86779e
7
+ data.tar.gz: b61989d1e43f6f801b1522643239a96118128825837f680bc57122ea8874be4f6229bde01c8af81f53bcbc8d9985e3100f329e2bcbc56797067d6584ed58cca8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,84 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-09-20
9
+
10
+ ### Added
11
+
12
+ - Folder mounts (read-only by default): `c.mount "host/path" => "/data"` in the
13
+ register DSL (or `mounts:` in `Configuration.build`), with `c.mount_rw` for an
14
+ opt-in writable mount. Guest code can read a mounted host directory
15
+ (`File.read`, `Dir[]`, `File.open`); write attempts into a read-only mount fail
16
+ guest-side with `Errno::EPERM` and leave the host directory untouched.
17
+ - `Configuration#mounts`: a frozen array of frozen `{host:, guest:, mode:}` hashes.
18
+ Mounts are config values — they flow through `#with`, `#fingerprint` (equal
19
+ mounts → equal fingerprint) and RactorPool requests. Relative host paths are
20
+ expanded against `Dir.pwd` at DSL time.
21
+ - Mount validation (`SecurityBox::InvalidConfiguration` on violation): mode must be
22
+ `:read_only`/`:read_write` (wasmtime silently accepts unknown symbols, so the mode
23
+ is never trusted to the runtime); guest paths must be absolute and normalized
24
+ (no `..`, no trailing `/`, not `/`); guest paths must not overlap the reserved
25
+ `/work`, `/usr` or `/src` trees (exact or nested — a collision with the embedded
26
+ VFS is silently shadowed, a mount inside `/usr` breaks the guest boot, and inside
27
+ `/work` it would create a read-only subtree); duplicate guest paths are rejected
28
+ (wasmtime's last-mount-wins is silently surprising); at most 16 mounts.
29
+ - Per-eval host-path validation: a mount whose host directory vanished returns a
30
+ `:sandbox_error` Result with a `security_box:` note instead of raising
31
+ `Wasmtime::Error` out of `#eval` (the store creation now also sits inside the
32
+ eval's rescue, so wasmtime setup failures can never raise).
33
+
34
+ ### Changed
35
+
36
+ - `Sandbox#eval` (and pool equivalents) accept `mounts:` as a per-call override
37
+ (replaces the configuration's mounts, like `env:`).
38
+ - README: new "Folder mounts" section + updated isolation matrix and defense notes
39
+ (a mounted folder's content is fully readable by guest code).
40
+
41
+ ### Investigation (no change needed, documented)
42
+
43
+ - Q6 probe (stage 5): the embedded VFS is **not** guest-writable (`File.write`
44
+ into `/usr`, `/src` or `/` fails with `Errno::ENOENT`; deletes with `ENOTSUP`),
45
+ so no prelude File-write restriction is warranted; WASI remains the primary
46
+ enforcement. Symlink escapes from a mounted directory are blocked by wasmtime
47
+ (`Errno::EPERM`); the guest cannot even create symlinks. Measured mount cost:
48
+ none (warm-boot p50 ~260ms with 0/1/4/8 mounts). Evidence and probes in
49
+ `docs/plan/stages/stage_5.md` and `bin/spike_stage5_mounts.rb`.
50
+
51
+ ## [0.4.0] - 2026-09-15
52
+
53
+ ### Added
54
+
55
+ - `SecurityBox::Pool` (`SecurityBox.pool`): a bounded pool of `:oneshot` sandboxes —
56
+ hard cap on concurrent evals (`size:`), sandbox reuse, `checkout` block and metrics
57
+ (`created`/`evals`/`total_ms`/`avg_ms`). Evals serialize on the GVL (documented).
58
+ - `SecurityBox::RactorPool` (`SecurityBox.ractor_pool`): real parallelism — worker
59
+ Ractors sharing one Ractor-shareable Engine + compiled Module (stage-3 "Plan C").
60
+ Measured ≈0.52–0.53 parallel/serial wall ratio at 4 workers on 6 cores (~1.9x),
61
+ RSS ≈57MB; per-call overrides, statuses and trap recovery behave like
62
+ `Sandbox#eval`; a dead worker degrades to a `:sandbox_error` Result instead of a
63
+ hang.
64
+ - `Configuration#fuel_ms`: rate-based fuel budgeting (~4e6 fuel/ms from the stage-3
65
+ calibration table + ~1e9 boot allowance), exposed via `Configuration#effective_fuel`
66
+ and usable per call. `fuel` and `fuel_ms` are mutually exclusive in the builder DSL
67
+ and in `#with`; switch back with `with(fuel_ms: nil, fuel: ...)`.
68
+ - `docs/plan/stages/stage_4.md` with the worker-mode feasibility investigation.
69
+
70
+ ### Changed
71
+
72
+ - The eval core was extracted from `Sandbox` into `SecurityBox::EvalRun` (shared by
73
+ `Sandbox` and `RactorPool` workers); `Sandbox` is now a thin, thread-safe wrapper.
74
+ - README: the "Ractor note" roadmap item is delivered; concurrency guidance rewritten
75
+ around `Pool`/`RactorPool`.
76
+
77
+ ### Investigation (negative result, documented)
78
+
79
+ - `:worker` mode (long-lived instance amortizing the ~240ms boot) is infeasible on
80
+ wasmtime-rb 48 (sync WASI): `invoke` holds the GVL during compute *and* while the
81
+ guest is blocked on a WASI read; epoch deadlines never fire inside a blocking
82
+ syscall; and per-request limits would require mutating a live Store from another
83
+ thread (unsafe). Evidence and probes in `docs/plan/stages/stage_4.md` and
84
+ `bin/spike_stage4_worker.rb`.
85
+
8
86
  ## [0.3.0] - 2026-09-14
9
87
 
10
88
  ### Added
data/README.md CHANGED
@@ -119,7 +119,8 @@ sandbox.eval("3 * 3").value # => 9
119
119
  ### Per-call limits
120
120
 
121
121
  Options can be passed per call (derived from the configuration without mutating it):
122
- `timeout_ms`, `fuel`, `memory_size`, `stdout_limit`, `stderr_limit`.
122
+ `timeout_ms`, `fuel`, `fuel_ms`, `memory_size`, `stdout_limit`, `stderr_limit`,
123
+ `mounts`.
123
124
 
124
125
  ```ruby
125
126
  # Wall-clock limit via epoch interruption
@@ -182,6 +183,49 @@ Profiles are immutable: duplicate names and unknown names raise
182
183
  `SecurityBox::Configuration#fingerprint` gives every configuration a stable
183
184
  identity (equal settings → equal fingerprint), used to share runtime artifacts.
184
185
 
186
+ ### Folder mounts
187
+
188
+ Host folders can be mounted into the sandbox explicitly. **Mounts are read-only
189
+ by default**; `mount_rw` is the opt-in writable form:
190
+
191
+ ```ruby
192
+ SecurityBox.register(:reader) do |c|
193
+ c.mount "./data" => "/data" # read-only (host path expanded at DSL time)
194
+ c.mount_rw "./state" => "/state" # opt-in writable
195
+ end
196
+
197
+ box = SecurityBox.spawn(:reader)
198
+ box.eval('File.read("/data/input.csv")') # guest reads mounted content
199
+ box.eval('File.write("/state/last.txt", Time.now.to_i)')
200
+
201
+ # Per-call override (replaces the configuration's mounts, like env:)
202
+ SecurityBox.eval('File.read("/data/a.txt")',
203
+ mounts: [{ host: "./data", guest: "/data", mode: :read_only }])
204
+ ```
205
+
206
+ Rules (violations raise `SecurityBox::InvalidConfiguration` at registration):
207
+
208
+ - A mount is one `{host:, guest:, mode:}` pair; `mode` is `:read_only` or
209
+ `:read_write` and the DSL defaults to read-only.
210
+ - Guest paths must be absolute and normalized, must not overlap the reserved
211
+ `/work` (sandbox tmpdir), `/usr` (embedded stdlib) or `/src` (entrypoint)
212
+ trees — exact or nested — and must be unique.
213
+ - At most 16 mounts per configuration.
214
+ - The host path must exist and be a directory at eval time; otherwise the eval
215
+ returns `:sandbox_error` with a `security_box:` note on stderr (instead of
216
+ raising).
217
+
218
+ Security notes:
219
+
220
+ - **A mounted folder's content is fully readable by guest code** — only mount
221
+ directories whose content you are willing to expose to the executed code.
222
+ - Read-only mounts are enforced by wasmtime: writes fail guest-side with
223
+ `Errno::EPERM` and the host directory is never touched. Symlinks inside a
224
+ mounted directory cannot escape it (wasmtime caps path resolution at the
225
+ preopen root), and guest code cannot create symlinks at all.
226
+ - Mounted content can also be **written by the guest** in `mount_rw` mounts —
227
+ treat a writable mount as part of the guest's blast radius.
228
+
185
229
  ### Configuration
186
230
 
187
231
  `SecurityBox::Configuration` is immutable; use `.build` to create and `#with` to derive:
@@ -189,12 +233,14 @@ identity (equal settings → equal fingerprint), used to share runtime artifacts
189
233
  ```ruby
190
234
  config = SecurityBox::Configuration.build(
191
235
  fuel: 10_000_000_000, # deterministic CPU budget
236
+ fuel_ms: nil, # or a millisecond-based budget (mutually exclusive with fuel)
192
237
  timeout_ms: 2_000, # wall-clock limit (epoch interruption)
193
238
  memory_size: 512 * 1024 * 1024, # wasm linear memory limit
194
239
  stdout_limit: 1 << 20, # stdout capture capacity in bytes
195
240
  stderr_limit: 1 << 16, # stderr capture capacity in bytes
196
241
  epoch_interval_ms: 25, # epoch timer granularity
197
- env: { "LANG" => "C" } # guest environment (empty by default)
242
+ env: { "LANG" => "C" }, # guest environment (empty by default)
243
+ mounts: [] # host-folder mounts (see "Folder mounts")
198
244
  )
199
245
 
200
246
  lean = config.with(timeout_ms: 500, fuel: 5_000_000)
@@ -221,6 +267,8 @@ string.
221
267
  |---|---|
222
268
  | `File.write("/etc/passwd", ...)` | `Errno::ENOENT` (guest does not see the host FS) |
223
269
  | `Dir["/*"]` | `[]` (empty root) |
270
+ | `File.write` into a read-only mount | `Errno::EPERM` (wasmtime-enforced; host dir untouched) |
271
+ | symlink escape from a mounted directory | `Errno::EPERM` (path resolution capped at the preopen root) |
224
272
  | `system("ls")` / backticks / `IO.popen` / `Process.spawn` | `SecurityError` (hardening prelude) |
225
273
  | `Kernel#open(...)` | `SecurityError` (pipe form; use `File.open`) |
226
274
  | `fork` | `NotImplementedError` |
@@ -233,12 +281,16 @@ string.
233
281
  Notes:
234
282
 
235
283
  - The guest Ruby version is the one from ruby.wasm (4.0), not necessarily the host's.
236
- - Concurrency: `invoke` holds the GVL, so executions serialize per host process. Scale
237
- horizontally with multiple processes (e.g., Puma workers); a stuck guest still can't
238
- hang the process thanks to the epoch deadline.
239
- - Ractor note: wasmtime `Engine`/`Module` are Ractor-shareable and Ractors run wasm in
240
- parallel (measured ≈3.6x with 4 Ractors on 6 cores); a supported Ractor pool is on
241
- the roadmap (see `docs/plan/stages/stage_3.md`).
284
+ - Mounts widen the trust surface: a mounted folder's content is fully readable by
285
+ guest code (see "Folder mounts" above). Read-only by default; nothing outside
286
+ `/work` is writable without an explicit `mount_rw`.
287
+ - Concurrency: `invoke` holds the GVL, so executions on threads serialize — use
288
+ `RactorPool` (below) for parallelism inside one process, or scale horizontally with
289
+ multiple processes (e.g., Puma workers). A stuck guest still can't hang the process
290
+ thanks to the epoch deadline.
291
+ - Ractor: wasmtime `Engine`/`Module` are Ractor-shareable and Ractors run wasm in
292
+ parallel — measured ≈1.9x wall-time speedup at 4 workers on 6 cores through
293
+ `RactorPool` (see `docs/plan/stages/stage_3.md` and `stage_4.md`).
242
294
  - Memory: the packed image declares a 1528-page (~95.5 MiB) minimum; `memory_size`
243
295
  below that fails instantiation (reported as `:sandbox_error`). Practical minimum is
244
296
  ~128–144MB for small workloads; the 512MB default leaves comfortable headroom.
@@ -246,7 +298,57 @@ Notes:
246
298
  and every eval costs ~1e9 fuel for boot — see the calibration table in
247
299
  `docs/plan/stages/stage_3.md`. Consequently `fuel` below ~1e9 cannot even boot, and
248
300
  `timeout_ms` below ~300ms times out during the guest boot (~500ms is a practical
249
- floor).
301
+ floor). Prefer thinking in milliseconds? Use `fuel_ms` (below).
302
+
303
+ ## Concurrency
304
+
305
+ Two pools are available, with honestly different guarantees (measured in stage 4 —
306
+ `invoke` holds the GVL, so a long-lived `:worker` instance is impossible on the current
307
+ wasmtime-rb; one wasm boot per eval stays on the hot path):
308
+
309
+ ### `Pool` — bounded concurrency on threads
310
+
311
+ ```ruby
312
+ pool = SecurityBox.pool(:lean, size: 4)
313
+ pool.eval("40 + 2").value # => 42
314
+ pool.checkout { |sandbox| sandbox.eval("1 + 1") }
315
+ pool.metrics # => {size:, created:, evals:, total_ms:, avg_ms:}
316
+ pool.shutdown
317
+ ```
318
+
319
+ Caps concurrent evals at `size` and reuses sandbox objects, but evals serialize on the
320
+ GVL — one at a time.
321
+
322
+ ### `RactorPool` — real parallelism
323
+
324
+ ```ruby
325
+ pool = SecurityBox.ractor_pool(:lean, size: 4)
326
+ pool.eval("40 + 2").value # => 42
327
+ pool.shutdown
328
+ ```
329
+
330
+ Worker Ractors share one Engine + compiled Module and run wasm truly in parallel
331
+ (~1.9x wall-time speedup at 4 workers on 6 cores; measured ≈0.53 parallel/serial wall
332
+ ratio across 1M–4M-iteration workloads). Per-call overrides, user errors, timeouts and
333
+ fuel exhaustion behave exactly like `Sandbox#eval`, and the worker survives traps and
334
+ keeps serving. Costs one module deserialize at creation (~0.5s via the disk cache);
335
+ each eval still pays the ~240ms guest boot.
336
+
337
+ ### `fuel_ms` — rate-based fuel budgeting
338
+
339
+ Instead of counting raw fuel, budget approximate wall-time of compute:
340
+
341
+ ```ruby
342
+ config = SecurityBox::Configuration.build(fuel_ms: 100) # ≈100ms of compute + boot allowance
343
+ SecurityBox.spawn(config).eval("i = 0; while i < 5_000_000; i += 1; end").status
344
+ # => :fuel_exhausted (5M tight iterations ≈ 2.4e9 fuel > the 100ms budget)
345
+ ```
346
+
347
+ `fuel_ms` converts to fuel with the conservative stage-3 calibration rate
348
+ (4e6 fuel/ms ≈ 4e9 fuel/s) plus a ~1e9 boot allowance (`Configuration#effective_fuel`).
349
+ The epoch `timeout_ms` remains the mandatory wall-clock backstop. `fuel` and `fuel_ms`
350
+ are mutually exclusive in the builder DSL and in `#with` overrides; a `fuel_ms`
351
+ configuration can be switched back with `with(fuel_ms: nil, fuel: ...)`.
250
352
 
251
353
  ## Running the tests
252
354
 
@@ -284,3 +386,7 @@ bundle exec ruby bin/spike.rb
284
386
  integrity, compiled-module disk cache)
285
387
  - `docs/plan/stages/stage_3.md` — stage 3 findings (named profiles, Ractor
286
388
  parallelism, memory floor, fuel calibration, backtrace)
389
+ - `docs/plan/stages/stage_4.md` — stage 4 findings (worker-mode feasibility, pools,
390
+ `fuel_ms`)
391
+ - `docs/plan/stages/stage_5.md` — stage 5 findings (folder mounts: read-only
392
+ enforcement, reserved-path collisions, symlink escapes, mount cost)
@@ -13,35 +13,70 @@ module SecurityBox
13
13
  DEFAULTS = {
14
14
  image_path: nil, # resolved dynamically (project's build/security_box.wasm)
15
15
  fuel: 10_000_000_000,
16
+ fuel_ms: nil, # rate-based fuel ergonomics (see FUEL_PER_MS); nil = use :fuel
16
17
  timeout_ms: 2_000,
17
18
  memory_size: 512 * 1024 * 1024,
18
19
  stdout_limit: 1 << 20,
19
20
  stderr_limit: 1 << 16,
20
21
  epoch_interval_ms: 25,
21
- env: {}.freeze
22
+ env: {}.freeze,
23
+ mounts: [].freeze
22
24
  }.freeze
23
25
 
24
- attr_reader :image_path, :fuel, :timeout_ms, :memory_size,
25
- :stdout_limit, :stderr_limit, :epoch_interval_ms, :env
26
+ # Fuel-per-ms conversion for #fuel_ms, from the stage-3 calibration table
27
+ # (docs/plan/stages/stage_3.md): compute workloads burn 3.2e9–8.4e9 fuel/s;
28
+ # 4e9/s is the conservative floor. The guest boot baseline (~9.1e8 fuel)
29
+ # is added on top so the budget covers boot even for trivial evals.
30
+ FUEL_PER_MS = 4_000_000
31
+ BOOT_FUEL_ALLOWANCE = 1_000_000_000
32
+
33
+ attr_reader :image_path, :fuel, :fuel_ms, :timeout_ms, :memory_size,
34
+ :stdout_limit, :stderr_limit, :epoch_interval_ms, :env, :mounts
26
35
 
27
36
  def self.build(**options)
28
37
  new(**DEFAULTS.merge(options)).freeze
29
38
  end
30
39
 
31
- def initialize(image_path: nil, fuel:, timeout_ms:, memory_size:,
32
- stdout_limit:, stderr_limit:, epoch_interval_ms:, env:)
40
+ def initialize(image_path: nil, fuel:, fuel_ms:, timeout_ms:, memory_size:,
41
+ stdout_limit:, stderr_limit:, epoch_interval_ms:, env:, mounts:)
33
42
  @image_path = image_path || default_image_path
34
43
  @fuel = Integer(fuel)
44
+ @fuel_ms = fuel_ms.nil? ? nil : Integer(fuel_ms)
35
45
  @timeout_ms = Integer(timeout_ms)
36
46
  @memory_size = Integer(memory_size)
37
47
  @stdout_limit = Integer(stdout_limit)
38
48
  @stderr_limit = Integer(stderr_limit)
39
49
  @epoch_interval_ms = Integer(epoch_interval_ms)
40
50
  @env = env.freeze
51
+ @mounts = Mounts.normalize(mounts)
41
52
  freeze
42
53
  end
43
54
 
55
+ # The fuel budget actually applied to each evaluation. When :fuel_ms is
56
+ # set it takes precedence: an approximate millisecond-based budget
57
+ # (fuel_ms × FUEL_PER_MS + boot allowance) instead of a raw fuel count.
58
+ # The epoch timeout remains the mandatory wall-clock backstop either way.
59
+ def effective_fuel
60
+ return fuel unless fuel_ms
61
+
62
+ fuel_ms * FUEL_PER_MS + BOOT_FUEL_ALLOWANCE
63
+ end
64
+
65
+ # Derives a copy with `changes` applied (never mutates the receiver).
66
+ # Passing both :fuel and a non-nil :fuel_ms is ambiguous and rejected;
67
+ # overriding :fuel on a configuration that uses :fuel_ms is rejected too
68
+ # (pass fuel_ms: nil first to switch to a raw fuel budget).
44
69
  def with(**changes)
70
+ if changes.key?(:fuel_ms)
71
+ if changes[:fuel_ms] && changes.key?(:fuel)
72
+ raise InvalidConfiguration,
73
+ "fuel and fuel_ms are mutually exclusive overrides"
74
+ end
75
+ elsif changes.key?(:fuel) && @fuel_ms
76
+ raise InvalidConfiguration,
77
+ "configuration uses fuel_ms (#{fuel_ms}); override fuel_ms or clear it with fuel_ms: nil instead of fuel"
78
+ end
79
+
45
80
  self.class.build(**to_h.merge(changes))
46
81
  end
47
82
 
@@ -49,12 +84,14 @@ module SecurityBox
49
84
  {
50
85
  image_path: @image_path,
51
86
  fuel: @fuel,
87
+ fuel_ms: @fuel_ms,
52
88
  timeout_ms: @timeout_ms,
53
89
  memory_size: @memory_size,
54
90
  stdout_limit: @stdout_limit,
55
91
  stderr_limit: @stderr_limit,
56
92
  epoch_interval_ms: @epoch_interval_ms,
57
- env: @env
93
+ env: @env,
94
+ mounts: @mounts
58
95
  }
59
96
  end
60
97
 
@@ -87,9 +124,20 @@ module SecurityBox
87
124
  end
88
125
 
89
126
  def fuel(value)
127
+ raise InvalidConfiguration, "fuel and fuel_ms are mutually exclusive" if @changes.key?(:fuel_ms)
128
+
90
129
  @changes[:fuel] = value
91
130
  end
92
131
 
132
+ # Rate-based fuel ergonomics: sets fuel from an approximate millisecond
133
+ # budget (see Configuration#effective_fuel). Mutually exclusive with
134
+ # an explicit :fuel.
135
+ def fuel_ms(value)
136
+ raise InvalidConfiguration, "fuel and fuel_ms are mutually exclusive" if @changes.key?(:fuel)
137
+
138
+ @changes[:fuel_ms] = value
139
+ end
140
+
93
141
  def timeout_ms(value)
94
142
  @changes[:timeout_ms] = value
95
143
  end
@@ -114,6 +162,32 @@ module SecurityBox
114
162
  def env(value)
115
163
  @changes[:env] = value
116
164
  end
165
+
166
+ # Mounts a host directory into the guest, read-only by default:
167
+ # c.mount "host/path" => "/data"
168
+ # Each call appends one mount; use #mount_rw for a writable mount.
169
+ def mount(mapping)
170
+ add_mount(mapping, :read_only)
171
+ end
172
+
173
+ # Opt-in writable mount — the only way to expose a writable host path
174
+ # besides the sandbox-private /work tmpdir:
175
+ # c.mount_rw "host/path" => "/data"
176
+ def mount_rw(mapping)
177
+ add_mount(mapping, :read_write)
178
+ end
179
+
180
+ private
181
+
182
+ def add_mount(mapping, mode)
183
+ unless mapping.is_a?(Hash) && mapping.size == 1
184
+ raise InvalidConfiguration,
185
+ 'mount expects exactly one pair: c.mount "host/path" => "/data"'
186
+ end
187
+
188
+ host, guest = mapping.first
189
+ @changes[:mounts] = Array(@changes[:mounts]) + [{ host: host, guest: guest, mode: mode }]
190
+ end
117
191
  end
118
192
 
119
193
  private
@@ -135,4 +209,117 @@ module SecurityBox
135
209
  "or pass image_path in the configuration."
136
210
  end
137
211
  end
212
+
213
+ # Validated collection of host-folder mounts (stage 5). A mount is a frozen
214
+ # {host:, guest:, mode:} hash; `mounts` in a Configuration is a frozen array
215
+ # of these, so the value survives #with, #fingerprint and Ractor ports.
216
+ #
217
+ # Validation (InvalidConfiguration on any violation):
218
+ # - mode is :read_only or :read_write (wasmtime silently accepts unknown
219
+ # symbols, so the mode is checked here, never trusted to the runtime)
220
+ # - host: non-empty string; relative paths are expanded against Dir.pwd
221
+ # (existence/directory checks are per-eval, in EvalRun — dirs can vanish)
222
+ # - guest: absolute, normalized path (no "..", no trailing slash), not "/"
223
+ # - guest paths must not overlap /work, /usr or /src (exact or nested):
224
+ # a collision with the embedded VFS is silently shadowed (stage-5 spike:
225
+ # the mount content is invisible), a mount inside /usr even breaks the
226
+ # guest boot, and inside /work it would create a read-only subtree
227
+ # - duplicate guest paths are rejected (wasmtime's last-mount-wins is
228
+ # silently surprising)
229
+ # - at most MAX_MOUNTS mounts
230
+ module Mounts
231
+ MODES = %i[read_only read_write].freeze
232
+ RESERVED_GUEST_PATHS = %w[/work /usr /src].freeze
233
+ MAX_MOUNTS = 16
234
+
235
+ class << self
236
+ # Validates + normalizes `raw` (an array of {host:, guest:, mode:} hashes
237
+ # or nil) and returns a frozen array of frozen, normalized hashes.
238
+ def normalize(raw)
239
+ return [].freeze if raw.nil?
240
+
241
+ raise InvalidConfiguration,
242
+ "mounts must be an Array of {host:, guest:, mode:} hashes, got #{raw.class}" unless raw.is_a?(Array)
243
+
244
+ mounts = raw.map { |entry| normalize_entry(entry) }
245
+ check_duplicates(mounts)
246
+ check_reserved(mounts)
247
+ check_count(mounts)
248
+ mounts.freeze
249
+ end
250
+
251
+ private
252
+
253
+ def normalize_entry(entry)
254
+ unless entry.is_a?(Hash) && entry.keys.sort == %i[guest host mode]
255
+ raise InvalidConfiguration,
256
+ "each mount must be a Hash with exactly :host, :guest and :mode keys, got #{entry.inspect}"
257
+ end
258
+
259
+ mode = entry[:mode]
260
+ unless MODES.include?(mode)
261
+ raise InvalidConfiguration,
262
+ "mount mode must be one of #{MODES.map(&:inspect).join(' or ')}, got #{mode.inspect}"
263
+ end
264
+
265
+ { host: normalize_host(entry[:host]),
266
+ guest: normalize_guest(entry[:guest]),
267
+ mode: mode }.freeze
268
+ end
269
+
270
+ def normalize_host(host)
271
+ unless host.is_a?(String) && !host.empty?
272
+ raise InvalidConfiguration, "mount host path must be a non-empty String, got #{host.inspect}"
273
+ end
274
+
275
+ File.expand_path(host).freeze
276
+ end
277
+
278
+ def normalize_guest(guest)
279
+ unless guest.is_a?(String) && !guest.empty?
280
+ raise InvalidConfiguration, "mount guest path must be a non-empty String, got #{guest.inspect}"
281
+ end
282
+ unless guest.start_with?("/")
283
+ raise InvalidConfiguration, "mount guest path must be absolute, got #{guest.inspect}"
284
+ end
285
+
286
+ guest = guest.chomp("/")
287
+ if guest.empty? || File.expand_path(guest, "/") != guest
288
+ raise InvalidConfiguration,
289
+ "mount guest path must be a normalized absolute path (no '..', '.' or trailing '/'), got #{guest.inspect}"
290
+ end
291
+ raise InvalidConfiguration, "mount guest path contains a NUL byte" if guest.include?("\0")
292
+
293
+ guest.freeze
294
+ end
295
+
296
+ def check_duplicates(mounts)
297
+ guests = mounts.map { |mount| mount[:guest] }
298
+ duplicate = guests.find { |guest| guests.count(guest) > 1 }
299
+ return unless duplicate
300
+
301
+ raise InvalidConfiguration, "duplicate guest mount path #{duplicate.inspect}"
302
+ end
303
+
304
+ def check_reserved(mounts)
305
+ mounts.each do |mount|
306
+ guest = mount[:guest]
307
+ conflict = RESERVED_GUEST_PATHS.find do |reserved|
308
+ guest == reserved || guest.start_with?("#{reserved}/") || reserved.start_with?("#{guest}/")
309
+ end
310
+ next unless conflict
311
+
312
+ raise InvalidConfiguration,
313
+ "mount guest path #{guest.inspect} overlaps the reserved path #{conflict.inspect}"
314
+ end
315
+ end
316
+
317
+ def check_count(mounts)
318
+ return if mounts.size <= MAX_MOUNTS
319
+
320
+ raise InvalidConfiguration,
321
+ "too many mounts (#{mounts.size}); the limit is #{MAX_MOUNTS}"
322
+ end
323
+ end
324
+ end
138
325
  end
@@ -6,4 +6,6 @@ module SecurityBox
6
6
  class ImageMissing < Error; end
7
7
 
8
8
  class InvalidConfiguration < Error; end
9
+
10
+ class PoolClosed < Error; end
9
11
  end