microsandbox-rb 0.10.0 → 0.11.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.
@@ -62,6 +62,24 @@ module Microsandbox
62
62
  build("deny", destination, direction, protocol, protocols, port, ports)
63
63
  end
64
64
 
65
+ # Allow plain DNS (UDP/53 and TCP/53) to the sandbox gateway — the rule
66
+ # the composable profiles prepend automatically. Use it in custom policies
67
+ # that need name resolution. Expands to the same rule as the core's
68
+ # `Rule::allow_dns` (egress → group :host, udp+tcp, port 53).
69
+ # @return [Hash]
70
+ def allow_dns
71
+ {"action" => "allow", "direction" => "egress",
72
+ "destination_kind" => "group", "destination" => "host",
73
+ "protocols" => %w[udp tcp], "ports" => ["53"]}
74
+ end
75
+
76
+ # Deny plain DNS to the sandbox gateway — the deny counterpart of
77
+ # {allow_dns}, useful as an override placed before profile-generated rules.
78
+ # @return [Hash]
79
+ def deny_dns
80
+ allow_dns.merge("action" => "deny")
81
+ end
82
+
65
83
  # @api private
66
84
  def build(action, destination, direction, protocol, protocols, port, ports)
67
85
  rule = {"action" => action, "direction" => direction.to_s}
@@ -84,20 +102,29 @@ module Microsandbox
84
102
  end
85
103
  end
86
104
 
87
- # A sandbox network policy: a preset, or a custom set of allow/deny {Rule}s
88
- # with per-direction default actions and bulk domain denials.
105
+ # A sandbox network policy: composed profiles, a terminal preset, or a custom
106
+ # set of allow/deny {Rule}s with per-direction default actions and bulk
107
+ # domain denials.
89
108
  #
90
- # Pass to {Sandbox.create} via `network:` — either a {NetworkPolicy}, a preset
91
- # name (String/Symbol), or a plain Hash with the same keys as {custom}.
109
+ # Pass to {Sandbox.create} via `network:` — a {NetworkPolicy}, an Array of
110
+ # profile names (`[:public, :host]`), a single profile or preset name
111
+ # (String/Symbol), or a plain Hash with the same keys as {custom}.
92
112
  #
93
- # @example presets
94
- # Sandbox.create("b", image: "alpine", network: NetworkPolicy.public_only)
113
+ # Profiles (runtime v0.6.7) compose: `:public` (public internet), `:private`
114
+ # (LAN/private ranges), `:host` (the host machine/gateway). Any non-empty
115
+ # combination automatically allows gateway DNS. The default policy — public
116
+ # internet only — equals `from_profiles(:public)`.
117
+ #
118
+ # @example profiles
119
+ # Sandbox.create("b", image: "alpine", network: [:public, :private])
95
120
  # Sandbox.create("b", image: "alpine", network: :none)
121
+ # Sandbox.create("b", image: "alpine", network: NetworkPolicy.from_profiles(:host))
96
122
  #
97
123
  # @example custom
98
124
  # policy = Microsandbox::NetworkPolicy.custom(
99
125
  # default_egress: :deny,
100
126
  # rules: [
127
+ # Microsandbox::Rule.allow_dns,
101
128
  # Microsandbox::Rule.allow(destination: "api.openai.com", protocol: :tcp, port: "443"),
102
129
  # ],
103
130
  # deny_domain_suffixes: [".ads.example"],
@@ -106,18 +133,47 @@ module Microsandbox
106
133
  #
107
134
  # Mirrors `NetworkPolicy` / `Network` in the official Python/Node/Go SDKs.
108
135
  class NetworkPolicy
109
- # Canonical preset names keyed by every accepted alias.
136
+ # Composable profile names (runtime v0.6.7).
137
+ PROFILES = %w[public private host].freeze
138
+
139
+ # Canonical preset names keyed by every accepted alias. Only the terminal
140
+ # presets survive v0.6.7; the removed ones get migration guidance in
141
+ # {canonical_preset}.
110
142
  PRESET_ALIASES = {
111
143
  "none" => "none", "disabled" => "none", "disable" => "none", "airgapped" => "none",
112
- "public" => "public_only", "public_only" => "public_only", "public-only" => "public_only",
113
- "default" => "public_only",
114
- "all" => "allow_all", "allow_all" => "allow_all", "allow-all" => "allow_all",
115
- "non_local" => "non_local", "non-local" => "non_local", "nonlocal" => "non_local"
144
+ "all" => "allow_all", "allow_all" => "allow_all", "allow-all" => "allow_all"
145
+ }.freeze
146
+
147
+ # Removed v0.6.6 preset spellings → the exact profile replacement, used to
148
+ # build actionable migration errors. (`public_only` expanded to precisely
149
+ # `from_profiles(:public)`, `non_local` to `from_profiles(:public, :private)` —
150
+ # the replacements are rule-for-rule equivalent.)
151
+ REMOVED_PRESETS = {
152
+ "public_only" => "network: [:public] (or NetworkPolicy.from_profiles(:public); " \
153
+ "this is still the default policy)",
154
+ "public-only" => "network: [:public]",
155
+ "non_local" => "network: [:public, :private] " \
156
+ "(or NetworkPolicy.from_profiles(:public, :private))",
157
+ "non-local" => "network: [:public, :private]",
158
+ "nonlocal" => "network: [:public, :private]"
116
159
  }.freeze
117
160
 
118
161
  class << self
119
- # @return [NetworkPolicy] allow only public internet (the default)
120
- def public_only = preset("public_only")
162
+ # Compose a policy from network profiles (`:public`, `:private`, `:host`).
163
+ # Order and duplicates don't matter — the runtime expands the set into a
164
+ # canonical rule list plus a gateway-DNS allow. An empty set is a valid
165
+ # deny-egress/allow-ingress policy with no rules (and no DNS).
166
+ # @return [NetworkPolicy]
167
+ def from_profiles(*profiles)
168
+ list = normalize_profiles(profiles)
169
+ if list.empty?
170
+ # Same semantics as the runtime's from_profiles([]) — expressed as an
171
+ # explicit empty custom policy so the wire needs no empty-array case.
172
+ custom(default_egress: :deny, default_ingress: :allow, rules: [])
173
+ else
174
+ new("profiles" => list)
175
+ end
176
+ end
121
177
 
122
178
  # @return [NetworkPolicy] block all network access
123
179
  def none = preset("none")
@@ -125,19 +181,17 @@ module Microsandbox
125
181
  # @return [NetworkPolicy] permit all traffic
126
182
  def allow_all = preset("allow_all")
127
183
 
128
- # @return [NetworkPolicy] allow public internet plus private/LAN egress
129
- def non_local = preset("non_local")
130
-
131
- # @return [NetworkPolicy] a bare preset policy
184
+ # @return [NetworkPolicy] a bare preset policy (:none or :allow_all)
132
185
  def preset(name)
133
186
  new("preset" => canonical_preset(name))
134
187
  end
135
188
 
136
189
  # Build a custom policy — an ordered rule list with per-direction default
137
- # actions. A custom policy stands on its own (no preset); to start from a
138
- # preset, use the preset factories (optionally with `deny_domains:` via the
139
- # Hash form passed to {Sandbox.create}). `preset:` and custom rules/defaults
140
- # are mutually exclusive, mirroring the official SDKs.
190
+ # actions. A custom policy stands on its own; to start from a profile
191
+ # base, use the Hash form passed to {Sandbox.create} (`profiles:` composes
192
+ # with `rules:`/deny lists). A terminal `preset:` stays exclusive with
193
+ # custom rules/defaults, mirroring the official SDKs. Custom policies
194
+ # need an explicit {Rule.allow_dns} if the sandbox should resolve names.
141
195
  #
142
196
  # @param default_egress [:deny, :allow, nil] fall-through for unmatched
143
197
  # outbound traffic (default :deny)
@@ -163,26 +217,64 @@ module Microsandbox
163
217
  def coerce(network)
164
218
  case network
165
219
  when NetworkPolicy then network.to_h
166
- when String, Symbol then {"preset" => canonical_preset(network)}
220
+ when Array then from_profiles(*network).to_h
221
+ when String, Symbol then coerce_name(network)
167
222
  when Hash then from_hash(network)
168
223
  else
169
224
  raise ArgumentError,
170
- "network: expects a preset name, a Microsandbox::NetworkPolicy, or a Hash " \
225
+ "network: expects profile(s) (:public/:private/:host, or an Array of them), " \
226
+ "a preset (:none/:allow_all), a Microsandbox::NetworkPolicy, or a Hash " \
171
227
  "(got #{network.class})"
172
228
  end
173
229
  end
174
230
 
175
231
  private
176
232
 
177
- # A `network:` Hash is either a preset (`preset:` + optional deny lists) or
178
- # a custom policy (`default_egress:`/`default_ingress:`/`rules:` + optional
179
- # deny lists). The two are mutually exclusive: a preset already defines its
180
- # rules and defaults, so layering custom rules/defaults on top would silently
181
- # override them (and diverge from the official SDKs, where preset and custom
182
- # are separate paths). A bare preset (only `preset:`, no deny lists) is
183
- # routed to the preset path by {Sandbox.create}, so its own defaults apply.
233
+ # A bare String/Symbol names a single profile (`:public`/`:private`/
234
+ # `:host`) or a terminal preset (`:none`/`:allow_all` and aliases).
235
+ # `"default"` stays accepted and means the default policy, which is
236
+ # exactly the `:public` profile.
237
+ def coerce_name(name)
238
+ key = name.to_s.downcase
239
+ return {"profiles" => [key]} if PROFILES.include?(key)
240
+ return {"profiles" => ["public"]} if key == "default"
241
+
242
+ {"preset" => canonical_preset(key)}
243
+ end
244
+
245
+ # Validate + stringify a profile list. Removed-preset spellings get the
246
+ # same migration guidance here as in {canonical_preset}, since
247
+ # `network: [:non_local]` is a plausible mis-migration.
248
+ def normalize_profiles(profiles)
249
+ Array(profiles).flatten.map do |p|
250
+ key = p.to_s.downcase
251
+ next key if PROFILES.include?(key)
252
+
253
+ if (replacement = REMOVED_PRESETS[key])
254
+ raise ArgumentError,
255
+ "#{p.inspect} is a removed v0.6.6 network preset, not a profile; use #{replacement}"
256
+ end
257
+ raise ArgumentError,
258
+ "unknown network profile #{p.inspect} (expected :public/:private/:host)"
259
+ end
260
+ end
261
+
262
+ # A `network:` Hash is composed profiles (`profiles:` + optional rules/
263
+ # defaults/deny lists), a terminal preset (`preset:` + optional deny
264
+ # lists), or a custom policy (`default_egress:`/`default_ingress:`/
265
+ # `rules:` + optional deny lists). Profiles compose with everything —
266
+ # they expand to an ordinary rule base that explicit rules append to —
267
+ # but a preset stays exclusive with rules/defaults: it already defines
268
+ # its rules and defaults, so layering more on top would silently override
269
+ # them. A bare preset or bare profile list is routed to its dedicated
270
+ # wire key by {Sandbox.create}, so the runtime's own expansion applies.
184
271
  def from_hash(hash)
185
272
  sym = hash.transform_keys(&:to_sym)
273
+ profiles = sym.key?(:profiles) ? normalize_profiles(sym[:profiles]) : nil
274
+ if profiles && sym.key?(:preset)
275
+ raise ArgumentError, "network profiles: and preset: are mutually exclusive"
276
+ end
277
+
186
278
  if sym.key?(:preset)
187
279
  if sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
188
280
  raise ArgumentError,
@@ -193,6 +285,18 @@ module Microsandbox
193
285
  h = {"preset" => canonical_preset(sym[:preset])}
194
286
  add_deny_lists(h, sym[:deny_domains], sym[:deny_domain_suffixes])
195
287
  h
288
+ elsif profiles
289
+ h = {}
290
+ h["profiles"] = profiles unless profiles.empty?
291
+ h["default_egress"] = action_str(sym[:default_egress]) if sym[:default_egress]
292
+ h["default_ingress"] = action_str(sym[:default_ingress]) if sym[:default_ingress]
293
+ rules = Array(sym[:rules]).map { |r| normalize_rule(r) }
294
+ h["rules"] = rules unless rules.empty?
295
+ add_deny_lists(h, sym[:deny_domains], sym[:deny_domain_suffixes])
296
+ # `profiles: []` alone is the runtime's empty composed policy:
297
+ # deny-egress/allow-ingress with no rules (and no DNS).
298
+ h = {"default_egress" => "deny", "default_ingress" => "allow", "rules" => []} if h.empty?
299
+ h
196
300
  elsif sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
197
301
  # An explicit custom policy: the caller chose the rule list and/or the
198
302
  # fall-through defaults (which default to :deny / :allow).
@@ -231,10 +335,14 @@ module Microsandbox
231
335
 
232
336
  def canonical_preset(name)
233
337
  key = name.to_s.downcase
338
+ if (replacement = REMOVED_PRESETS[key])
339
+ raise ArgumentError,
340
+ "the #{name.inspect} network preset was removed in runtime v0.6.7; use #{replacement}"
341
+ end
234
342
  PRESET_ALIASES[key] ||
235
343
  raise(ArgumentError,
236
344
  "unknown network preset #{name.inspect} " \
237
- "(expected one of public_only/none/allow_all/non_local)")
345
+ "(expected none/allow_all, or profiles :public/:private/:host)")
238
346
  end
239
347
 
240
348
  def action_str(action)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Factory for an OCI sandbox's writable **root disk** spec (runtime v0.6.7),
5
+ # passed to {Sandbox.create} via `root_disk:`. Three kinds:
6
+ #
7
+ # - {managed} — the default: a sparse ext4 upper file created and resized by
8
+ # the runtime (4 GiB cap when no size is given). A bare Integer passed to
9
+ # `root_disk:` means the same thing.
10
+ # - {tmpfs} — RAM-backed upper, so the rootfs is pristine on every boot.
11
+ # Default size is half the sandbox memory; the size must not exceed sandbox
12
+ # memory (no swap in the guest). Cannot be snapshotted or patched.
13
+ # - {disk} — a user-supplied disk image attached writable as the upper. The
14
+ # file determines its own size; the runtime never creates, resizes, or
15
+ # deletes it. Cannot be snapshotted or patched.
16
+ #
17
+ # @example
18
+ # Sandbox.create("worker", image: "python", root_disk: 8192)
19
+ # Sandbox.create("ci", image: "python", root_disk: Microsandbox::RootDisk.tmpfs(2048))
20
+ # Sandbox.create("warm", image: "python",
21
+ # root_disk: Microsandbox::RootDisk.disk("./scratch.img", fstype: "ext4"))
22
+ #
23
+ # Mirrors the `RootDisk` factory in the official Python/Node/Go SDKs.
24
+ module RootDisk
25
+ module_function
26
+
27
+ # The managed sparse-ext4 upper (the default kind).
28
+ # @param size_mib [Integer, nil] size cap in MiB (default 4096)
29
+ # @return [Hash]
30
+ def managed(size_mib = nil)
31
+ h = {"kind" => "managed"}
32
+ h["size_mib"] = Integer(size_mib) if size_mib
33
+ h
34
+ end
35
+
36
+ # A RAM-backed tmpfs upper — pristine rootfs on every boot.
37
+ # @param size_mib [Integer, nil] size in MiB (default: half sandbox memory)
38
+ # @return [Hash]
39
+ def tmpfs(size_mib = nil)
40
+ h = {"kind" => "tmpfs"}
41
+ h["size_mib"] = Integer(size_mib) if size_mib
42
+ h
43
+ end
44
+
45
+ # A user-supplied disk image attached writable as the upper.
46
+ # @param path [String] path to the image file
47
+ # @param format ["raw", "qcow2", nil] image format (inferred from the
48
+ # .img/.raw/.qcow2 extension when omitted)
49
+ # @param fstype [String, nil] filesystem type inside the image, e.g. "ext4"
50
+ # @return [Hash]
51
+ def disk(path, format: nil, fstype: nil)
52
+ h = {"kind" => "disk", "path" => path.to_s}
53
+ h["format"] = format.to_s if format
54
+ h["fstype"] = fstype.to_s if fstype
55
+ h
56
+ end
57
+ end
58
+ end
@@ -142,20 +142,16 @@ module Microsandbox
142
142
 
143
143
  # Snapshot this (stopped) sandbox under a bare name, resolved under the
144
144
  # snapshots dir. Convenience equivalent of
145
- # `Snapshot.create(name, name: <snapshot-name>)` addressed by this handle.
145
+ # `Snapshot.create(<snapshot-name>, from_sandbox: <this sandbox>)`.
146
+ # (`snapshot_to(path)` was removed in 0.11.0 with runtime v0.6.7 — for
147
+ # explicit placement use `Snapshot.create(name, from_sandbox:, dest_dir:)`;
148
+ # the artifact lands at `dest_dir/<name>`.)
146
149
  # @param name [String] destination snapshot name
147
150
  # @return [SnapshotInfo]
148
151
  def snapshot(name)
149
152
  SnapshotInfo.new(@native.snapshot(name.to_s))
150
153
  end
151
154
 
152
- # Snapshot this (stopped) sandbox to an explicit filesystem path.
153
- # @param path [String] destination directory
154
- # @return [SnapshotInfo]
155
- def snapshot_to(path)
156
- SnapshotInfo.new(@native.snapshot_to(path.to_s))
157
- end
158
-
159
155
  def inspect
160
156
  "#<Microsandbox::SandboxHandle name=#{name.inspect} status=#{status}>"
161
157
  end
@@ -262,13 +258,18 @@ module Microsandbox
262
258
  # `host_permissions:` (:private/:mirror). A bind mount accepts
263
259
  # `quota_mib:` to override the runtime's default guest-write budget
264
260
  # (4 GiB as of `v0.5.10`); the core rejects it on tmpfs/disk/named (for a
265
- # named volume, set its quota via {Volume.create}).
266
- # @param network [String, Symbol, NetworkPolicy, Hash, nil] network policy.
267
- # A preset name ("public_only" (default), "none", "allow_all",
268
- # "non_local"), a {NetworkPolicy} (e.g. {NetworkPolicy.custom}), or a Hash
269
- # describing a custom policy (`default_egress:`, `default_ingress:`,
270
- # `rules:`, `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy}
271
- # and {Rule}.
261
+ # named volume, set its quota via {Volume.create}). Bind/named mounts
262
+ # also accept `follow_root_symlinks: true` to opt out of the default-on
263
+ # mount-root symlink protection (runtime v0.6.7).
264
+ # @param network [Array, String, Symbol, NetworkPolicy, Hash, nil] network
265
+ # policy. Composable profiles (`[:public]` (the default), `[:public,
266
+ # :private]`, `:host`, ), a terminal preset (:none, :allow_all), a
267
+ # {NetworkPolicy} (e.g. {NetworkPolicy.from_profiles},
268
+ # {NetworkPolicy.custom}), or a Hash describing a custom policy
269
+ # (`profiles:`, `default_egress:`, `default_ingress:`, `rules:`,
270
+ # `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy} and
271
+ # {Rule}. The v0.6.6 `public_only`/`non_local` presets were removed in
272
+ # runtime v0.6.7 — use `[:public]` / `[:public, :private]`.
272
273
  # @param dns [Hash, nil] custom DNS: `{ nameservers: [...],
273
274
  # rebind_protection: true, query_timeout_ms: 2000 }`
274
275
  # @param tls [Hash, nil] TLS-interception tuning: `{ bypass: [...patterns],
@@ -296,7 +297,16 @@ module Microsandbox
296
297
  # @param log_level ["error","warn","info","debug","trace", nil] guest log verbosity
297
298
  # @param quiet_logs [Boolean] suppress sandbox process logs
298
299
  # @param security ["default", "restricted", nil] exec security profile
299
- # @param oci_upper_size [Integer, nil] writable upper-layer size cap, in MiB
300
+ # @param root_disk [Integer, Hash, nil] the OCI sandbox's writable root
301
+ # disk (runtime v0.6.7). An Integer is the managed ext4 upper's size cap
302
+ # in MiB (default kind, 4 GiB when unset); a Hash picks a kind via the
303
+ # {RootDisk} factory — `RootDisk.managed(8192)`, `RootDisk.tmpfs(2048)`
304
+ # (RAM-backed, pristine on every boot), or
305
+ # `RootDisk.disk("./scratch.img", format: "raw", fstype: "ext4")`
306
+ # (user-supplied image attached writable).
307
+ # @param oci_upper_size [Integer, nil] deprecated alias for
308
+ # `root_disk: <Integer>` (the managed kind); warns, and conflicts with
309
+ # `root_disk:`
300
310
  # @param max_duration [Integer, nil] hard wall-clock lifetime, in seconds
301
311
  # @param idle_timeout [Integer, nil] stop after this many idle seconds
302
312
  # @param rlimits [Hash, nil] resource limits: { resource => limit } or
@@ -372,7 +382,7 @@ module Microsandbox
372
382
  patches: nil,
373
383
  from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
374
384
  log_level: nil, quiet_logs: false, security: nil,
375
- oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
385
+ root_disk: nil, oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
376
386
  pull_policy: nil, registry_auth: nil, registry_insecure: false,
377
387
  registry_ca_certs: nil, secrets: nil, on_secret_violation: nil,
378
388
  detached: false, replace: false, replace_with_timeout: nil)
@@ -382,6 +392,9 @@ module Microsandbox
382
392
  if image && from_snapshot
383
393
  raise ArgumentError, "provide either image: or from_snapshot:, not both"
384
394
  end
395
+ if root_disk && oci_upper_size
396
+ raise ArgumentError, "pass either root_disk: or oci_upper_size:, not both"
397
+ end
385
398
  Microsandbox.ensure_runtime!
386
399
  # `fstype:` names the inner filesystem of a disk-image rootfs, so it only
387
400
  # applies when `image:` is a disk-image path (a local path ending in
@@ -425,7 +438,14 @@ module Microsandbox
425
438
  opts["log_level"] = log_level.to_s if log_level
426
439
  opts["quiet_logs"] = true if quiet_logs
427
440
  opts["security"] = security.to_s if security
428
- opts["oci_upper_size"] = Integer(oci_upper_size) if oci_upper_size
441
+ if oci_upper_size
442
+ warn "Microsandbox: oci_upper_size: is deprecated; use root_disk: " \
443
+ "(an Integer size in MiB, or RootDisk.managed/tmpfs/disk)",
444
+ uplevel: 2
445
+ opts["root_disk"] = coerce_root_disk_size(oci_upper_size, "oci_upper_size:")
446
+ elsif root_disk
447
+ opts["root_disk"] = normalize_root_disk(root_disk)
448
+ end
429
449
  opts["max_duration"] = Integer(max_duration) if max_duration
430
450
  opts["idle_timeout"] = Integer(idle_timeout) if idle_timeout
431
451
  opts["rlimits"] = normalize_rlimits(rlimits) if rlimits
@@ -816,6 +836,58 @@ module Microsandbox
816
836
  # { disk: "/img.raw", format: "raw", fstype: "ext4" } # disk-image mount
817
837
  # Any mount may also carry stat_virtualization: (:strict/:relaxed/:off) and
818
838
  # host_permissions: (:private/:mirror); a bind mount may carry quota_mib:.
839
+ # Coerce the `root_disk:` argument — an Integer (managed size in MiB), a
840
+ # {RootDisk} factory Hash, or an equivalent hand-written Hash — into the
841
+ # wire shape, validating kind/field combinations up front (mirrors the
842
+ # Python SDK's extract_root_disk guards).
843
+ def normalize_root_disk(root_disk)
844
+ return coerce_root_disk_size(root_disk, "root_disk:") unless root_disk.is_a?(Hash)
845
+
846
+ spec = root_disk.transform_keys(&:to_s)
847
+ kind = (spec["kind"] || "managed").to_s
848
+ case kind
849
+ when "managed", "tmpfs"
850
+ %w[path format fstype].each do |key|
851
+ if spec[key]
852
+ raise ArgumentError, "root_disk #{key}: is only valid for the disk kind"
853
+ end
854
+ end
855
+ h = {"kind" => kind}
856
+ if spec["size_mib"]
857
+ h["size_mib"] = coerce_root_disk_size(spec["size_mib"], "root_disk size_mib:")
858
+ end
859
+ h
860
+ when "disk", "disk-image", "disk_image"
861
+ path = spec["path"]
862
+ if path.nil? || path.to_s.empty?
863
+ raise ArgumentError, "root_disk disk kind requires path:"
864
+ end
865
+ if spec["size_mib"]
866
+ raise ArgumentError, "root_disk size_mib: is not valid for a disk image " \
867
+ "(the image file determines the size)"
868
+ end
869
+ h = {"kind" => "disk", "path" => path.to_s}
870
+ h["format"] = spec["format"].to_s if spec["format"]
871
+ h["fstype"] = spec["fstype"].to_s if spec["fstype"]
872
+ h
873
+ else
874
+ raise ArgumentError,
875
+ "unknown root_disk kind #{kind.inspect} (expected managed/tmpfs/disk)"
876
+ end
877
+ end
878
+
879
+ # Coerce + range-check a root-disk size. The wire carries u32 MiB; an
880
+ # out-of-range value would otherwise surface as a confusing ext-level
881
+ # type error (or a raw RangeError from the native conversion).
882
+ def coerce_root_disk_size(value, label)
883
+ mib = Integer(value)
884
+ unless mib.positive? && mib <= 4_294_967_295
885
+ raise ArgumentError,
886
+ "#{label} must be a positive size in MiB that fits in 32 bits (got #{mib})"
887
+ end
888
+ mib
889
+ end
890
+
819
891
  def normalize_volumes(volumes)
820
892
  volumes.map do |guest, spec|
821
893
  mount = {"guest" => guest.to_s}
@@ -868,6 +940,9 @@ module Microsandbox
868
940
  # read-only; `noexec:`/`nosuid:`/`nodev:` set the matching flags;
869
941
  # `stat_virtualization:`/`host_permissions:` set the passthrough policies
870
942
  # (only valid on bind/named — the core rejects them on tmpfs/disk).
943
+ # `follow_root_symlinks:` opts out of the default-on mount-root symlink
944
+ # protection (runtime v0.6.7; bind/named only — the core silently ignores
945
+ # it on tmpfs/disk mounts, so reject those here instead).
871
946
  def apply_mount_flags(mount, spec)
872
947
  mount["readonly"] = true if spec[:ro] || spec["ro"] || spec[:readonly] || spec["readonly"]
873
948
  mount["noexec"] = true if spec[:noexec] || spec["noexec"]
@@ -878,6 +953,15 @@ module Microsandbox
878
953
  mount["stat_virtualization"] = sv.to_s if sv
879
954
  hp = spec[:host_permissions] || spec["host_permissions"]
880
955
  mount["host_permissions"] = hp.to_s if hp
956
+ follow = spec.fetch(:follow_root_symlinks, spec["follow_root_symlinks"])
957
+ unless follow.nil?
958
+ unless %w[bind named].include?(mount["kind"])
959
+ raise ArgumentError,
960
+ "follow_root_symlinks: only applies to bind/named mounts " \
961
+ "(got a #{mount["kind"]} mount); a #{mount["kind"]} mount has no host root to protect"
962
+ end
963
+ mount["follow_root_symlinks"] = !!follow
964
+ end
881
965
  end
882
966
 
883
967
  # Translate the pre-0.7.0 `options:` array form (e.g. options: %w[ro noexec])
@@ -918,16 +1002,18 @@ module Microsandbox
918
1002
  end
919
1003
  end
920
1004
 
921
- # Route the `network:` argument to either the preset path
922
- # (`opts["network"]`, the original string-preset behavior) or the custom
923
- # policy path (`opts["network_policy"]`). Accepts a preset String/Symbol, a
924
- # {NetworkPolicy}, or a plain Hash.
1005
+ # Route the `network:` argument to the preset path (`opts["network"]`),
1006
+ # the composed-profiles path (`opts["network_profiles"]`), or the custom
1007
+ # policy path (`opts["network_policy"]`). Accepts profile Symbol(s)/Array,
1008
+ # a preset String/Symbol, a {NetworkPolicy}, or a plain Hash.
925
1009
  def apply_network_opts(opts, network)
926
1010
  norm = NetworkPolicy.coerce(network)
927
1011
  return if norm.empty? # e.g. network: {} — leave the default policy in place
928
1012
 
929
1013
  if norm.keys == ["preset"]
930
1014
  opts["network"] = norm["preset"]
1015
+ elsif norm.keys == ["profiles"]
1016
+ opts["network_profiles"] = norm["profiles"]
931
1017
  else
932
1018
  opts["network_policy"] = norm
933
1019
  end