microsandbox-rb 0.9.2 → 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.
@@ -1,16 +1,21 @@
1
1
  //! Snapshot management: `Microsandbox::Native::Snapshot`.
2
2
  //!
3
- //! Snapshots capture a stopped sandbox's upper layer into a portable artifact
3
+ //! Snapshots capture a stopped sandbox's disk state into a portable artifact
4
4
  //! that a later `Sandbox.create(from_snapshot:)` can boot from. Exposed as
5
5
  //! singleton functions returning plain Hashes/Arrays (shaped into value objects
6
6
  //! by the Ruby layer) — there is no long-lived handle to own.
7
+ //!
8
+ //! v0.6.7 descriptor contract: an artifact is identified by its own `name`
9
+ //! (`Snapshot::builder(name).from_sandbox(src)`), lives at `dest_dir/<name>`,
10
+ //! and its descriptor file is `snapshot.json`. `manifest.json` artifacts from
11
+ //! v0.6.6 are auto-migrated by the core on first backend connect.
7
12
 
8
13
  use std::path::{Path, PathBuf};
9
14
 
10
15
  use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
11
16
  use microsandbox::snapshot::{
12
- ExportOpts, Snapshot, SnapshotDestination, SnapshotFormat, SnapshotHandle,
13
- SnapshotVerifyReport, UpperVerifyStatus,
17
+ SaveOpts, Snapshot, SnapshotFormat, SnapshotHandle, SnapshotScope, SnapshotVerifyReport,
18
+ UpperVerifyStatus,
14
19
  };
15
20
 
16
21
  use crate::conv;
@@ -24,6 +29,13 @@ fn format_str(format: SnapshotFormat) -> &'static str {
24
29
  }
25
30
  }
26
31
 
32
+ fn scope_str(scope: SnapshotScope) -> &'static str {
33
+ match scope {
34
+ SnapshotScope::Disk => "disk",
35
+ SnapshotScope::Resumable => "resumable",
36
+ }
37
+ }
38
+
27
39
  /// Parse a manifest's RFC 3339 `created_at` into epoch-ms (nil if unparseable).
28
40
  fn created_at_ms(rfc3339: &str) -> Option<i64> {
29
41
  chrono::DateTime::parse_from_rfc3339(rfc3339)
@@ -33,18 +45,31 @@ fn created_at_ms(rfc3339: &str) -> Option<i64> {
33
45
 
34
46
  /// Convert a fully-opened `Snapshot` into the `SnapshotInfo` Hash. Unlike a
35
47
  /// `SnapshotHandle` (a lightweight index row), an opened snapshot carries the
36
- /// full manifest, so this is the richest shape — `create`/`open`/`list_dir`
37
- /// and the `SandboxHandle#snapshot`/`#snapshot_to` shortcuts all funnel here.
48
+ /// full descriptor, so this is the richest shape — `create`/`open`/`list_dir`
49
+ /// and the `SandboxHandle#snapshot` shortcut all funnel here. `format`/
50
+ /// `fstype`/`size_bytes` are nil for checkpoint-state snapshots, and the
51
+ /// `checkpoint_*` keys are nil for file-state ones — the two state families
52
+ /// are disjoint by contract.
38
53
  pub(crate) fn snapshot_to_hash(snap: &Snapshot) -> RHash {
39
54
  let m = snap.manifest();
55
+ let state = snap.state();
56
+ let file = state.as_file();
57
+ let checkpoint = state.as_checkpoint();
40
58
  let hash = ruby().hash_new();
41
59
  let _ = hash.aset("digest", snap.digest().to_string());
42
60
  let _ = hash.aset("path", snap.path().to_string_lossy().into_owned());
43
61
  let _ = hash.aset("size_bytes", snap.size_bytes());
62
+ let _ = hash.aset("scope", scope_str(m.scope));
63
+ let _ = hash.aset("state_kind", state.kind());
44
64
  let _ = hash.aset("image_ref", m.image.reference.clone());
45
65
  let _ = hash.aset("image_manifest_digest", m.image.manifest_digest.clone());
46
- let _ = hash.aset("format", format_str(m.format));
47
- let _ = hash.aset("fstype", m.fstype.clone());
66
+ let _ = hash.aset("format", file.map(|f| format_str(f.format)));
67
+ let _ = hash.aset("fstype", file.map(|f| f.fstype.clone()));
68
+ let _ = hash.aset("checkpoint_id", checkpoint.map(|c| c.checkpoint_id.clone()));
69
+ let _ = hash.aset(
70
+ "checkpoint_manifest_digest",
71
+ checkpoint.map(|c| c.manifest.clone()),
72
+ );
48
73
  let _ = hash.aset("parent_digest", m.parent.clone());
49
74
  let _ = hash.aset("created_at_ms", created_at_ms(&m.created_at));
50
75
  let _ = hash.aset("source_sandbox", m.source_sandbox.clone());
@@ -56,18 +81,21 @@ pub(crate) fn snapshot_to_hash(snap: &Snapshot) -> RHash {
56
81
  hash
57
82
  }
58
83
 
59
- /// Create a snapshot of a stopped sandbox. `opts`: name | path (destination),
60
- /// labels, force, record_integrity. Returns {digest, path, size_bytes}.
61
- fn create(source_sandbox: String, opts: RHash) -> Result<RHash, Error> {
62
- let mut b = Snapshot::builder(source_sandbox);
63
- if let Some(name) = conv::opt_string(opts, "name")? {
64
- b = b.destination(SnapshotDestination::Name(name));
65
- } else if let Some(path) = conv::opt_string(opts, "path")? {
66
- b = b.destination(SnapshotDestination::Path(PathBuf::from(path)));
67
- } else {
68
- return Err(error::base_error(
69
- "snapshot create needs a destination: pass name: or path:",
70
- ));
84
+ /// Create a snapshot artifact named `name` from a stopped sandbox. `opts`:
85
+ /// from_sandbox (required), dest_dir, labels, force, record_integrity,
86
+ /// resumable. Returns the full SnapshotInfo Hash.
87
+ fn create(name: String, opts: RHash) -> Result<RHash, Error> {
88
+ let mut b = Snapshot::builder(name);
89
+ match conv::opt_string(opts, "from_sandbox")? {
90
+ Some(src) => b = b.from_sandbox(src),
91
+ None => {
92
+ return Err(error::base_error(
93
+ "snapshot create needs from_sandbox: (the source sandbox name)",
94
+ ));
95
+ }
96
+ }
97
+ if let Some(dir) = conv::opt_string(opts, "dest_dir")? {
98
+ b = b.dest_dir(PathBuf::from(dir));
71
99
  }
72
100
  for (k, v) in conv::opt_string_map(opts, "labels")? {
73
101
  b = b.label(k, v);
@@ -78,6 +106,9 @@ fn create(source_sandbox: String, opts: RHash) -> Result<RHash, Error> {
78
106
  if conv::opt_bool(opts, "record_integrity")? {
79
107
  b = b.record_integrity();
80
108
  }
109
+ if conv::opt_bool(opts, "resumable")? {
110
+ b = b.resumable();
111
+ }
81
112
 
82
113
  let snap = block_on(b.create()).map_err(error::to_ruby)?;
83
114
  Ok(snapshot_to_hash(&snap))
@@ -92,8 +123,8 @@ fn open(path_or_name: String) -> Result<RHash, Error> {
92
123
  Ok(snapshot_to_hash(&snap))
93
124
  }
94
125
 
95
- /// Walk `dir` and parse each subdirectory's `manifest.json` without touching the
96
- /// local index — for enumerating external/un-imported snapshot collections.
126
+ /// Walk `dir` and parse each subdirectory's `snapshot.json` without touching
127
+ /// the local index — for enumerating external/un-imported snapshot collections.
97
128
  fn list_dir(dir: String) -> Result<RArray, Error> {
98
129
  let snaps = block_on(Snapshot::list_dir(Path::new(&dir))).map_err(error::to_ruby)?;
99
130
  let arr = ruby().ary_new();
@@ -140,7 +171,9 @@ fn remove(name_or_path: String, force: bool) -> Result<(), Error> {
140
171
  }
141
172
 
142
173
  /// Verify a snapshot's recorded upper-layer integrity. Returns
143
- /// {digest, path, upper_status, upper_algorithm?, upper_digest?}.
174
+ /// {digest, path, upper_status, upper_algorithm, upper_digest}. Schema-1
175
+ /// descriptors always record integrity, so the status is always "verified"
176
+ /// on success (mismatches raise SnapshotIntegrityError instead).
144
177
  fn verify(name_or_path: String) -> Result<RHash, Error> {
145
178
  let snap = block_on(Snapshot::open(&name_or_path)).map_err(error::to_ruby)?;
146
179
  let report = block_on(snap.verify()).map_err(error::to_ruby)?;
@@ -149,25 +182,25 @@ fn verify(name_or_path: String) -> Result<RHash, Error> {
149
182
 
150
183
  /// Bundle a snapshot into a `.tar.zst` (or plain `.tar`) archive. `opts`:
151
184
  /// with_parents, with_image, plain_tar.
152
- fn export(name_or_path: String, out_path: String, opts: RHash) -> Result<(), Error> {
153
- let export_opts = ExportOpts {
185
+ fn save(name_or_path: String, out_path: String, opts: RHash) -> Result<(), Error> {
186
+ let save_opts = SaveOpts {
154
187
  with_parents: conv::opt_bool(opts, "with_parents")?,
155
188
  with_image: conv::opt_bool(opts, "with_image")?,
156
189
  plain_tar: conv::opt_bool(opts, "plain_tar")?,
157
190
  };
158
- block_on(Snapshot::export(
191
+ block_on(Snapshot::save(
159
192
  &name_or_path,
160
193
  std::path::Path::new(&out_path),
161
- export_opts,
194
+ save_opts,
162
195
  ))
163
196
  .map_err(error::to_ruby)
164
197
  }
165
198
 
166
- /// Unpack a snapshot archive into the snapshots dir. Returns the imported
199
+ /// Unpack a snapshot archive into the snapshots dir. Returns the loaded
167
200
  /// snapshot's metadata hash. `dest` is an optional explicit directory.
168
- fn import(archive_path: String, dest: Option<String>) -> Result<RHash, Error> {
201
+ fn load(archive_path: String, dest: Option<String>) -> Result<RHash, Error> {
169
202
  let dest_path = dest.map(PathBuf::from);
170
- let handle = block_on(Snapshot::import(
203
+ let handle = block_on(Snapshot::load(
171
204
  std::path::Path::new(&archive_path),
172
205
  dest_path.as_deref(),
173
206
  ))
@@ -180,9 +213,23 @@ fn handle_to_hash(handle: &SnapshotHandle) -> RHash {
180
213
  let _ = hash.aset("digest", handle.digest().to_string());
181
214
  let _ = hash.aset("name", handle.name().map(str::to_string));
182
215
  let _ = hash.aset("parent_digest", handle.parent_digest().map(str::to_string));
216
+ let _ = hash.aset("scope", scope_str(handle.scope()));
217
+ let _ = hash.aset("state_kind", handle.state_kind().to_string());
183
218
  let _ = hash.aset("image_ref", handle.image_ref().to_string());
184
- let _ = hash.aset("format", format_str(handle.format()));
219
+ let _ = hash.aset("format", handle.format().map(format_str));
220
+ let _ = hash.aset("fstype", handle.fstype().map(str::to_string));
221
+ let _ = hash.aset(
222
+ "checkpoint_manifest_digest",
223
+ handle.checkpoint_manifest_digest().map(str::to_string),
224
+ );
185
225
  let _ = hash.aset("size_bytes", handle.size_bytes());
226
+ let _ = hash.aset("locality", handle.locality().to_string());
227
+ let _ = hash.aset("availability", handle.availability().to_string());
228
+ let _ = hash.aset("migration_state", handle.migration_state().to_string());
229
+ let _ = hash.aset(
230
+ "migration_error_code",
231
+ handle.migration_error_code().map(str::to_string),
232
+ );
186
233
  let _ = hash.aset("path", handle.path().to_string_lossy().into_owned());
187
234
  let _ = hash.aset(
188
235
  "created_at_ms",
@@ -196,9 +243,6 @@ fn verify_report_to_hash(report: &SnapshotVerifyReport) -> RHash {
196
243
  let _ = hash.aset("digest", report.digest.clone());
197
244
  let _ = hash.aset("path", report.path.to_string_lossy().into_owned());
198
245
  match &report.upper {
199
- UpperVerifyStatus::NotRecorded => {
200
- let _ = hash.aset("upper_status", "not_recorded");
201
- }
202
246
  UpperVerifyStatus::Verified { algorithm, digest } => {
203
247
  let _ = hash.aset("upper_status", "verified");
204
248
  let _ = hash.aset("upper_algorithm", algorithm.clone());
@@ -218,7 +262,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
218
262
  class.define_singleton_method("reindex", function!(reindex, 1))?;
219
263
  class.define_singleton_method("remove", function!(remove, 2))?;
220
264
  class.define_singleton_method("verify", function!(verify, 1))?;
221
- class.define_singleton_method("export", function!(export, 3))?;
222
- class.define_singleton_method("import", function!(import, 2))?;
265
+ class.define_singleton_method("save", function!(save, 3))?;
266
+ class.define_singleton_method("load", function!(load, 2))?;
223
267
  Ok(())
224
268
  }
@@ -62,6 +62,10 @@ module Microsandbox
62
62
  define_error(:SnapshotSandboxRunningError, "snapshot-sandbox-running")
63
63
  define_error(:SnapshotImageMissingError, "snapshot-image-missing")
64
64
  define_error(:SnapshotIntegrityError, "snapshot-integrity")
65
+ # v0.6.7: the automatic v0.6.6→v0.6.7 snapshot-descriptor migration (run at
66
+ # backend connect / artifact open) was blocked and needs repair. Mirrors the
67
+ # Python SDK's SnapshotMigrationError (code "snapshot-migration").
68
+ define_error(:SnapshotMigrationError, "snapshot-migration")
65
69
 
66
70
  # Networking / secrets errors ---------------------------------------------
67
71
  # NetworkPolicyError now also carries the core's `NetworkBuilder` build/parse
@@ -107,6 +107,44 @@ module Microsandbox
107
107
  def prune
108
108
  ImagePruneReport.new(Native::Image.prune)
109
109
  end
110
+
111
+ # Load images into the cache from a local `docker save` tarball or an
112
+ # OCI Image Layout archive. Pass `"-"` to read the archive from `$stdin`
113
+ # (spooled to a temp file first, like the Python SDK — the core reads
114
+ # seekable files only). Mirrors the Python `Image.load` / Node
115
+ # `imageLoad` (runtime v0.6.7).
116
+ # @param input_path [String] archive path (or "-")
117
+ # @param tag [String, Array<String>, nil] retag the loaded image(s)
118
+ # @return [Array<ImageInfo>] one entry per loaded image
119
+ def load(input_path, tag: nil)
120
+ tags = Array(tag).map(&:to_s)
121
+ path = input_path.to_s
122
+ if path == "-"
123
+ require "tempfile"
124
+ Tempfile.create(["msb-image-load", ".tar"]) do |tmp|
125
+ tmp.binmode
126
+ IO.copy_stream($stdin, tmp)
127
+ tmp.flush
128
+ return Native::Image.load(tmp.path, tags).map { |info| ImageInfo.new(info) }
129
+ end
130
+ end
131
+ Native::Image.load(path, tags).map { |info| ImageInfo.new(info) }
132
+ end
133
+
134
+ # Save cached image(s) into a local archive.
135
+ # Mirrors the Python `Image.save` / Node `imageSave` (runtime v0.6.7).
136
+ # @param reference [String, Array<String>] image reference(s) to save
137
+ # @param output_path [String] destination archive path
138
+ # @param format [:docker, :oci] archive layout (default :docker, the
139
+ # shape `docker load` expects)
140
+ # @return [nil]
141
+ def save(reference, output_path:, format: :docker)
142
+ refs = Array(reference).map(&:to_s)
143
+ raise ArgumentError, "at least one image reference is required" if refs.empty?
144
+
145
+ Native::Image.save(refs, output_path.to_s, format.to_s)
146
+ nil
147
+ end
110
148
  end
111
149
  end
112
150
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # The result of {Sandbox#ping} / {SandboxHandle#ping}: a lightweight health
5
+ # check that confirms the guest agent is reachable without refreshing the
6
+ # sandbox idle timer. Mirrors the official SDKs' `SandboxPingResult`.
7
+ #
8
+ # The native layer reports latency as seconds; {#latency} is the canonical
9
+ # value and {#latency_ms} the convenience the Python/Node SDKs expose.
10
+ class PingResult
11
+ # @return [String] the sandbox name that was pinged
12
+ attr_reader :name
13
+ # @return [Float] round-trip latency in seconds
14
+ attr_reader :latency
15
+
16
+ def initialize(data)
17
+ @name = data["name"]
18
+ @latency = data["latency_secs"]
19
+ end
20
+
21
+ # @return [Float] round-trip latency in milliseconds
22
+ def latency_ms
23
+ @latency * 1000.0
24
+ end
25
+
26
+ def inspect
27
+ "#<Microsandbox::PingResult name=#{@name.inspect} latency_ms=#{format("%.3f", latency_ms)}>"
28
+ end
29
+ end
30
+
31
+ # The result of {Sandbox#touch} / {SandboxHandle#touch}: an explicit refresh of
32
+ # the sandbox idle-activity timer. Mirrors the official SDKs' `SandboxTouchResult`.
33
+ class TouchResult
34
+ # @return [String] the sandbox name that was touched
35
+ attr_reader :name
36
+ # @return [Integer] the agent activity sequence after this touch was recorded
37
+ attr_reader :activity_seq
38
+
39
+ def initialize(data)
40
+ @name = data["name"]
41
+ @activity_seq = data["activity_seq"]
42
+ end
43
+
44
+ def inspect
45
+ "#<Microsandbox::TouchResult name=#{@name.inspect} activity_seq=#{@activity_seq}>"
46
+ end
47
+ end
48
+
49
+ # The plan produced by {Sandbox#modify} / {SandboxHandle#modify}: the classified
50
+ # set of changes a modification requested, and (for a non-dry-run apply) whether
51
+ # they were applied plus any live-resize convergence outcomes. Mirrors the
52
+ # official SDKs' `SandboxModificationPlan`.
53
+ #
54
+ # The nested collections ({#changes}, {#conflicts}, {#warnings},
55
+ # {#resize_status}) are frozen Arrays of frozen, symbol-keyed Hashes carrying
56
+ # the canonical snake_case fields verbatim (e.g. a config change is
57
+ # `{ kind: "config", field:, change:, before:, after:, disposition:, reason: }`;
58
+ # a secret change adds `name:`, `before_ref:`, `after_ref:`, `allow_hosts:`).
59
+ # Enum values stay as the runtime's strings (e.g. a `disposition` of
60
+ # `"next start"`, a `state` of `"guest-refused"`).
61
+ class ModificationPlan
62
+ # @return [String] the sandbox the plan applies to
63
+ attr_reader :sandbox
64
+ # @return [String] the sandbox status used to classify the changes
65
+ attr_reader :status
66
+ # @return [Symbol] :no_restart, :next_start, or :restart
67
+ attr_reader :policy
68
+ # @return [Array<Hash>] the planned changes (config and secret)
69
+ attr_reader :changes
70
+ # @return [Array<Hash>] conflicts that must be resolved before applying
71
+ attr_reader :conflicts
72
+ # @return [Array<Hash>] non-fatal warnings about the patch
73
+ attr_reader :warnings
74
+ # @return [Array<Hash>] live resource-resize outcomes (populated by an apply)
75
+ attr_reader :resize_status
76
+
77
+ def initialize(data)
78
+ @sandbox = data["sandbox"]
79
+ @status = data["status"]
80
+ @applied = data["applied"]
81
+ @policy = data["policy"]&.to_sym
82
+ @changes = freeze_items(data["changes"])
83
+ @conflicts = freeze_items(data["conflicts"])
84
+ @warnings = freeze_items(data["warnings"])
85
+ @resize_status = freeze_items(data["resize_status"])
86
+ end
87
+
88
+ # @return [Boolean] whether the changes were applied (false for a dry run)
89
+ def applied?
90
+ @applied
91
+ end
92
+
93
+ def inspect
94
+ "#<Microsandbox::ModificationPlan sandbox=#{@sandbox.inspect} applied=#{@applied} " \
95
+ "policy=#{@policy} changes=#{@changes.size} conflicts=#{@conflicts.size} " \
96
+ "warnings=#{@warnings.size}>"
97
+ end
98
+
99
+ private
100
+
101
+ # Convert a JSON array of plan entries (string-keyed Hashes) into a frozen
102
+ # Array of frozen, symbol-keyed Hashes. The entries are shallow (all values
103
+ # are scalars or string arrays), so a single `transform_keys` suffices.
104
+ def freeze_items(items)
105
+ Array(items).map { |item| item.transform_keys(&:to_sym).freeze }.freeze
106
+ end
107
+ end
108
+ end
@@ -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)