microsandbox-rb 0.17.0 → 0.17.1

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.
@@ -7,6 +7,7 @@
7
7
  //! as a plain Ruby `Hash`/`Array`/`String` and shaped into value objects by the
8
8
  //! Ruby layer.
9
9
 
10
+ use std::path::PathBuf;
10
11
  use std::sync::Arc;
11
12
  use std::time::Duration;
12
13
 
@@ -39,6 +40,7 @@ use tokio::task::JoinHandle;
39
40
  use crate::conv;
40
41
  use crate::error;
41
42
  use crate::exec::ExecHandle;
43
+ use crate::host_path::{self, HostPaths};
42
44
  use crate::runtime::{block_on, ruby};
43
45
  use crate::stream::{LogStream, MetricsStream};
44
46
 
@@ -75,6 +77,9 @@ impl Sandbox {
75
77
  /// Shared by `create` (blocking) and `create_with_progress` (streaming pull).
76
78
  fn build_builder(name: String, opts: RHash) -> Result<SandboxBuilder, Error> {
77
79
  let mut b = microsandbox::Sandbox::builder(name);
80
+ // Relative host paths are anchored to the current directory here, once,
81
+ // before the core persists them (see host_path.rs).
82
+ let host_paths = HostPaths::for_default_backend();
78
83
 
79
84
  if let Some(v) = conv::opt_string(opts, "image")? {
80
85
  if let Some(fstype) = conv::opt_string(opts, "fstype")? {
@@ -83,13 +88,17 @@ impl Sandbox {
83
88
  // image_with(disk().fstype()). (A bare `image` string otherwise
84
89
  // auto-detects OCI vs disk by extension.) Errors in disk()/fstype()
85
90
  // are captured on the builder and surface at create().
86
- b = b.image_with(move |i| i.disk(v).fstype(fstype));
91
+ let path = host_paths.pin(v, "root disk image path")?;
92
+ b = b.image_with(move |i| i.disk(path).fstype(fstype));
87
93
  } else {
88
- b = b.image(v);
94
+ b = match host_paths.pin_image(v)? {
95
+ host_path::Image::Text(text) => b.image(text),
96
+ host_path::Image::Path(path) => b.image(path),
97
+ };
89
98
  }
90
99
  }
91
100
  if let Some(v) = conv::opt_string(opts, "from_snapshot")? {
92
- b = b.from_snapshot(v);
101
+ b = b.from_snapshot(host_paths.pin_snapshot(v)?);
93
102
  }
94
103
  if let Some(v) = conv::opt_u8(opts, "cpus")? {
95
104
  b = b.cpus(v);
@@ -208,27 +217,35 @@ impl Sandbox {
208
217
  // the per-mount opt-out. Valid for bind/named-directory mounts —
209
218
  // the core rejects it elsewhere at build().
210
219
  let follow_root_symlinks = conv::opt::<bool>(m, "follow_root_symlinks")?;
211
- // bind/named/disk require a source; tmpfs must not have one.
212
- match kind.as_str() {
213
- "bind" | "named" | "disk" if source.is_some() => {}
214
- "bind" | "named" | "disk" => {
220
+ // bind/named/disk require a source; tmpfs must not have one. A
221
+ // bind or disk source is a host path; a named source is a volume
222
+ // name and is never resolved.
223
+ let backing = match (kind.as_str(), source) {
224
+ ("bind", Some(host)) => {
225
+ MountBacking::Bind(host_paths.pin(host, "bind mount host path")?)
226
+ }
227
+ ("disk", Some(host)) => {
228
+ MountBacking::Disk(host_paths.pin(host, "disk mount host path")?)
229
+ }
230
+ ("named", Some(name)) => MountBacking::Named(name),
231
+ ("bind" | "named" | "disk", None) => {
215
232
  return Err(error::base_error(format!(
216
233
  "volume mount kind {kind:?} requires a source"
217
234
  )))
218
235
  }
219
- "tmpfs" => {}
220
- other => {
236
+ ("tmpfs", _) => MountBacking::Tmpfs,
237
+ (other, _) => {
221
238
  return Err(error::base_error(format!(
222
239
  "unknown volume mount kind {other:?} (expected bind/named/tmpfs/disk)"
223
240
  )))
224
241
  }
225
- }
242
+ };
226
243
  b = b.volume(guest, move |mut mb| {
227
- mb = match kind.as_str() {
228
- "named" => mb.named(source.unwrap()),
229
- "tmpfs" => mb.tmpfs(),
230
- "disk" => mb.disk(source.unwrap()),
231
- _ => mb.bind(source.unwrap()), // "bind"
244
+ mb = match backing {
245
+ MountBacking::Named(name) => mb.named(name),
246
+ MountBacking::Tmpfs => mb.tmpfs(),
247
+ MountBacking::Disk(host) => mb.disk(host),
248
+ MountBacking::Bind(host) => mb.bind(host),
232
249
  };
233
250
  if let Some(n) = size_mib {
234
251
  mb = mb.size(n);
@@ -272,7 +289,7 @@ impl Sandbox {
272
289
  // patches: rootfs modifications applied before boot. The Ruby layer
273
290
  // normalizes each `Microsandbox::Patch.*` into a string-keyed Hash with
274
291
  // a `kind` discriminator; mirrors the Python binding's `apply_patch`.
275
- for patch in parse_patches(opts)? {
292
+ for patch in parse_patches(opts, host_paths)? {
276
293
  b = b.add_patch(patch);
277
294
  }
278
295
  if let Some(net) = conv::opt_string(opts, "network")? {
@@ -326,7 +343,7 @@ impl Sandbox {
326
343
  // layer maps the deprecated `oci_upper_size:` kwarg to the managed kind
327
344
  // before it reaches the wire.
328
345
  if let Some(v) = conv::opt::<Value>(opts, "root_disk")? {
329
- let spec = parse_root_disk(v)?;
346
+ let spec = parse_root_disk(v, host_paths)?;
330
347
  b = b.root_disk_with(move |d| spec.apply(d));
331
348
  }
332
349
  // Registry connection settings, for private / non-default registries:
@@ -374,7 +391,7 @@ impl Sandbox {
374
391
  .map(parse_dns)
375
392
  .transpose()?;
376
393
  let tls = conv::opt::<RHash>(opts, "tls")?
377
- .map(parse_tls)
394
+ .map(|t| parse_tls(t, host_paths))
378
395
  .transpose()?;
379
396
  let ipv4_pool = conv::opt_string(opts, "ipv4_pool")?
380
397
  .map(|s| {
@@ -1217,7 +1234,7 @@ fn patch_bytes(h: RHash, key: &str) -> Result<Vec<u8>, Error> {
1217
1234
  /// Parse the `patches` option into core `Patch` operations. The Ruby layer
1218
1235
  /// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash carrying a
1219
1236
  /// `kind` discriminator plus the variant-specific fields.
1220
- fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
1237
+ fn parse_patches(opts: RHash, host_paths: HostPaths) -> Result<Vec<Patch>, Error> {
1221
1238
  let mut out = Vec::new();
1222
1239
  for h in conv::opt_hash_vec(opts, "patches")? {
1223
1240
  let kind = patch_str(h, "kind")?;
@@ -1241,13 +1258,13 @@ fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
1241
1258
  content: patch_str(h, "content")?,
1242
1259
  },
1243
1260
  "copy_file" => Patch::CopyFile {
1244
- src: patch_str(h, "src")?.into(),
1261
+ src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
1245
1262
  dst: patch_str(h, "dst")?,
1246
1263
  mode,
1247
1264
  replace,
1248
1265
  },
1249
1266
  "copy_dir" => Patch::CopyDir {
1250
- src: patch_str(h, "src")?.into(),
1267
+ src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
1251
1268
  dst: patch_str(h, "dst")?,
1252
1269
  replace,
1253
1270
  },
@@ -1584,20 +1601,25 @@ struct TlsSpec {
1584
1601
  verify_upstream: Option<bool>,
1585
1602
  intercepted_ports: Option<Vec<u16>>,
1586
1603
  block_quic: Option<bool>,
1587
- upstream_ca_cert: Option<String>,
1588
- intercept_ca_cert: Option<String>,
1589
- intercept_ca_key: Option<String>,
1604
+ upstream_ca_cert: Option<PathBuf>,
1605
+ intercept_ca_cert: Option<PathBuf>,
1606
+ intercept_ca_key: Option<PathBuf>,
1590
1607
  }
1591
1608
 
1592
- fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
1609
+ fn parse_tls(t: RHash, host_paths: HostPaths) -> Result<TlsSpec, Error> {
1610
+ let pin = |key: &str, label: &str| {
1611
+ conv::opt_string(t, key)?
1612
+ .map(|path| host_paths.pin(path, label))
1613
+ .transpose()
1614
+ };
1593
1615
  Ok(TlsSpec {
1594
1616
  bypass: conv::opt_string_vec(t, "bypass")?,
1595
1617
  verify_upstream: conv::opt::<bool>(t, "verify_upstream")?,
1596
1618
  intercepted_ports: conv::opt::<Vec<u16>>(t, "intercepted_ports")?,
1597
1619
  block_quic: conv::opt::<bool>(t, "block_quic")?,
1598
- upstream_ca_cert: conv::opt_string(t, "upstream_ca_cert")?,
1599
- intercept_ca_cert: conv::opt_string(t, "intercept_ca_cert")?,
1600
- intercept_ca_key: conv::opt_string(t, "intercept_ca_key")?,
1620
+ upstream_ca_cert: pin("upstream_ca_cert", "upstream CA certificate path")?,
1621
+ intercept_ca_cert: pin("intercept_ca_cert", "interception CA certificate path")?,
1622
+ intercept_ca_key: pin("intercept_ca_key", "interception CA key path")?,
1601
1623
  })
1602
1624
  }
1603
1625
 
@@ -1711,6 +1733,14 @@ fn network_profile_from_str(s: &str) -> Result<NetworkProfile, Error> {
1711
1733
  }
1712
1734
  }
1713
1735
 
1736
+ /// What backs a volume mount, with any host path already pinned.
1737
+ enum MountBacking {
1738
+ Bind(PathBuf),
1739
+ Disk(PathBuf),
1740
+ Named(String),
1741
+ Tmpfs,
1742
+ }
1743
+
1714
1744
  /// Parsed `root_disk` create option. Integer shorthand = managed upper of N
1715
1745
  /// MiB (mirrors Python's `root_disk=8192` and the CLI bare-size form); Hash =
1716
1746
  /// explicit kind. Kind/field cross-validation (size on a disk image, etc.)
@@ -1726,7 +1756,7 @@ struct RootDiskSpec {
1726
1756
  enum RootDiskKindSpec {
1727
1757
  Managed,
1728
1758
  Tmpfs,
1729
- Disk(String),
1759
+ Disk(PathBuf),
1730
1760
  Flat,
1731
1761
  }
1732
1762
 
@@ -1754,7 +1784,7 @@ impl RootDiskSpec {
1754
1784
  }
1755
1785
  }
1756
1786
 
1757
- fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1787
+ fn parse_root_disk(v: Value, host_paths: HostPaths) -> Result<RootDiskSpec, Error> {
1758
1788
  if let Ok(mib) = u32::try_convert(v) {
1759
1789
  return Ok(RootDiskSpec {
1760
1790
  kind: RootDiskKindSpec::Managed,
@@ -1777,7 +1807,7 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1777
1807
  let Some(path) = conv::opt_string(h, "path")? else {
1778
1808
  return Err(error::base_error("root_disk disk kind requires path:"));
1779
1809
  };
1780
- RootDiskKindSpec::Disk(path)
1810
+ RootDiskKindSpec::Disk(host_paths.pin(path, "root disk image path")?)
1781
1811
  }
1782
1812
  Some("flat") => RootDiskKindSpec::Flat,
1783
1813
  Some(other) => {
@@ -3,11 +3,12 @@
3
3
  //! Mirrors `sdk/python/src/volume.rs`. Static async CRUD over the volume store;
4
4
  //! results are returned as plain Ruby Hashes/Arrays.
5
5
 
6
+ use std::path::{Component, Path};
6
7
  use std::sync::Arc;
7
8
 
8
9
  use magnus::{function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby};
9
10
  use microsandbox::volume::VolumeHandle;
10
- use microsandbox::Backend;
11
+ use microsandbox::{Backend, MicrosandboxError};
11
12
 
12
13
  use crate::conv;
13
14
  use crate::error;
@@ -104,6 +105,23 @@ fn remove(name: String) -> Result<(), Error> {
104
105
  block_on(microsandbox::Volume::remove(&name)).map_err(error::to_ruby)
105
106
  }
106
107
 
108
+ /// Volume paths are relative to the volume root and never need a `..` segment,
109
+ /// so one is refused before the path reaches the core's resolver (which keeps
110
+ /// its own containment check). Enforced here rather than only in the Ruby
111
+ /// wrapper because `Microsandbox::Native::VolumeFs` is callable directly.
112
+ fn contained(path: &str) -> Result<&str, Error> {
113
+ if Path::new(path)
114
+ .components()
115
+ .any(|part| matches!(part, Component::ParentDir))
116
+ {
117
+ return Err(error::to_ruby(MicrosandboxError::SandboxFsOps(
118
+ "path traversal outside volume root (`..` segments are not allowed in volume paths)"
119
+ .into(),
120
+ )));
121
+ }
122
+ Ok(path)
123
+ }
124
+
107
125
  /// A host-side filesystem view over a named volume — read/write its contents
108
126
  /// without a running sandbox. Mirrors the Python `VolumeFs` / Node `VolumeFs`.
109
127
  /// The core `VolumeFs<'a>` borrows the volume name, so (like the Python binding)
@@ -131,26 +149,30 @@ impl VolumeFs {
131
149
  }
132
150
 
133
151
  fn read(&self, path: String) -> Result<RString, Error> {
152
+ let path = contained(&path)?;
134
153
  let fs = self.fs();
135
- let bytes = block_on(fs.read(&path)).map_err(error::to_ruby)?;
154
+ let bytes = block_on(fs.read(path)).map_err(error::to_ruby)?;
136
155
  Ok(ruby().str_from_slice(bytes.as_ref()))
137
156
  }
138
157
 
139
158
  fn read_text(&self, path: String) -> Result<String, Error> {
159
+ let path = contained(&path)?;
140
160
  let fs = self.fs();
141
- block_on(fs.read_to_string(&path)).map_err(error::to_ruby)
161
+ block_on(fs.read_to_string(path)).map_err(error::to_ruby)
142
162
  }
143
163
 
144
164
  fn write(&self, path: String, data: RString) -> Result<(), Error> {
145
165
  // Copy the bytes out while the GVL is held (GC.compact could move them).
146
166
  let bytes = unsafe { data.as_slice() }.to_vec();
167
+ let path = contained(&path)?;
147
168
  let fs = self.fs();
148
- block_on(fs.write(&path, &bytes)).map_err(error::to_ruby)
169
+ block_on(fs.write(path, &bytes)).map_err(error::to_ruby)
149
170
  }
150
171
 
151
172
  fn list(&self, path: String) -> Result<RArray, Error> {
173
+ let path = contained(&path)?;
152
174
  let fs = self.fs();
153
- let entries = block_on(fs.list(&path)).map_err(error::to_ruby)?;
175
+ let entries = block_on(fs.list(path)).map_err(error::to_ruby)?;
154
176
  let arr = ruby().ary_new();
155
177
  for entry in &entries {
156
178
  arr.push(fs_entry_to_hash(entry))?;
@@ -159,38 +181,47 @@ impl VolumeFs {
159
181
  }
160
182
 
161
183
  fn mkdir(&self, path: String) -> Result<(), Error> {
184
+ let path = contained(&path)?;
162
185
  let fs = self.fs();
163
- block_on(fs.mkdir(&path)).map_err(error::to_ruby)
186
+ block_on(fs.mkdir(path)).map_err(error::to_ruby)
164
187
  }
165
188
 
166
189
  fn remove_file(&self, path: String) -> Result<(), Error> {
190
+ let path = contained(&path)?;
167
191
  let fs = self.fs();
168
- block_on(fs.remove(&path)).map_err(error::to_ruby)
192
+ block_on(fs.remove(path)).map_err(error::to_ruby)
169
193
  }
170
194
 
171
195
  fn remove_dir(&self, path: String) -> Result<(), Error> {
196
+ let path = contained(&path)?;
172
197
  let fs = self.fs();
173
- block_on(fs.remove_dir(&path)).map_err(error::to_ruby)
198
+ block_on(fs.remove_dir(path)).map_err(error::to_ruby)
174
199
  }
175
200
 
176
201
  fn exists(&self, path: String) -> Result<bool, Error> {
202
+ let path = contained(&path)?;
177
203
  let fs = self.fs();
178
- block_on(fs.exists(&path)).map_err(error::to_ruby)
204
+ block_on(fs.exists(path)).map_err(error::to_ruby)
179
205
  }
180
206
 
181
207
  fn copy(&self, from: String, to: String) -> Result<(), Error> {
208
+ let from = contained(&from)?;
209
+ let to = contained(&to)?;
182
210
  let fs = self.fs();
183
- block_on(fs.copy(&from, &to)).map_err(error::to_ruby)
211
+ block_on(fs.copy(from, to)).map_err(error::to_ruby)
184
212
  }
185
213
 
186
214
  fn rename(&self, from: String, to: String) -> Result<(), Error> {
215
+ let from = contained(&from)?;
216
+ let to = contained(&to)?;
187
217
  let fs = self.fs();
188
- block_on(fs.rename(&from, &to)).map_err(error::to_ruby)
218
+ block_on(fs.rename(from, to)).map_err(error::to_ruby)
189
219
  }
190
220
 
191
221
  fn stat(&self, path: String) -> Result<RHash, Error> {
222
+ let path = contained(&path)?;
192
223
  let fs = self.fs();
193
- let meta = block_on(fs.stat(&path)).map_err(error::to_ruby)?;
224
+ let meta = block_on(fs.stat(path)).map_err(error::to_ruby)?;
194
225
  Ok(fs_metadata_to_hash(&meta))
195
226
  }
196
227
  }
@@ -27,7 +27,7 @@ module Microsandbox
27
27
 
28
28
  # @return [Time, nil] last-modified time, if known
29
29
  def modified
30
- @modified_ms && Time.at(@modified_ms / 1000.0)
30
+ Microsandbox.time_from_ms(@modified_ms)
31
31
  end
32
32
 
33
33
  def file? = @type == :file
@@ -64,12 +64,12 @@ module Microsandbox
64
64
 
65
65
  # @return [Time, nil] last-modified time, if known
66
66
  def modified
67
- @modified_ms && Time.at(@modified_ms / 1000.0)
67
+ Microsandbox.time_from_ms(@modified_ms)
68
68
  end
69
69
 
70
70
  # @return [Time, nil] creation time, if known
71
71
  def created
72
- @created_ms && Time.at(@created_ms / 1000.0)
72
+ Microsandbox.time_from_ms(@created_ms)
73
73
  end
74
74
 
75
75
  def inspect
@@ -188,11 +188,7 @@ module Microsandbox
188
188
  sink = FsWriteSink.new(@native.fs_write_stream(path.to_s))
189
189
  return sink unless block_given?
190
190
 
191
- begin
192
- yield sink
193
- ensure
194
- sink.close
195
- end
191
+ Microsandbox.close_after(sink) { yield sink }
196
192
  end
197
193
  end
198
194
 
@@ -18,12 +18,12 @@ module Microsandbox
18
18
 
19
19
  # @return [Time, nil]
20
20
  def created_at
21
- @created_at_ms && Time.at(@created_at_ms / 1000.0)
21
+ Microsandbox.time_from_ms(@created_at_ms)
22
22
  end
23
23
 
24
24
  # @return [Time, nil]
25
25
  def last_used_at
26
- @last_used_at_ms && Time.at(@last_used_at_ms / 1000.0)
26
+ Microsandbox.time_from_ms(@last_used_at_ms)
27
27
  end
28
28
 
29
29
  def inspect
@@ -22,7 +22,7 @@ module Microsandbox
22
22
 
23
23
  # @return [Time] wall-clock capture time
24
24
  def timestamp
25
- Time.at(@timestamp_ms / 1000.0)
25
+ Microsandbox.time_from_ms(@timestamp_ms)
26
26
  end
27
27
 
28
28
  # @return [String] the captured bytes decoded as UTF-8 (lossy — invalid byte
@@ -53,7 +53,7 @@ module Microsandbox
53
53
 
54
54
  # @return [Time] when this snapshot was captured
55
55
  def timestamp
56
- Time.at(@timestamp_ms / 1000.0)
56
+ Microsandbox.time_from_ms(@timestamp_ms)
57
57
  end
58
58
 
59
59
  def inspect
@@ -246,6 +246,14 @@ module Microsandbox
246
246
  if address.empty?
247
247
  raise ArgumentError, "proxy address must be a non-empty \"IP:port\" String (got \"\")"
248
248
  end
249
+ # A URL-style address (`socks5://user:pass@host:1080`) is never valid, and
250
+ # the core would quote it back in its error. Refuse it by shape, without
251
+ # echoing it, so the embedded credentials stay out of logs.
252
+ if address.match?(%r{@|://|\s})
253
+ raise ArgumentError,
254
+ "proxy address must be \"IP:port\" with no scheme, credentials or whitespace " \
255
+ "(pass SOCKS5 credentials as username:/password:)"
256
+ end
249
257
  # Same rules as the Python SDK's OutboundProxy.__post_init__; the address
250
258
  # itself is parsed by the core (which reports e.g. "invalid SOCKS5 proxy
251
259
  # address" as an InvalidConfigError at create time).
@@ -42,13 +42,13 @@ module Microsandbox
42
42
  # @return [Time, nil]
43
43
  def created_at
44
44
  ms = @native.created_at_ms
45
- ms && Time.at(ms / 1000.0)
45
+ Microsandbox.time_from_ms(ms)
46
46
  end
47
47
 
48
48
  # @return [Time, nil]
49
49
  def updated_at
50
50
  ms = @native.updated_at_ms
51
- ms && Time.at(ms / 1000.0)
51
+ Microsandbox.time_from_ms(ms)
52
52
  end
53
53
 
54
54
  # Gracefully stop the sandbox (SIGTERM→SIGKILL escalation, 10s default).
@@ -263,7 +263,7 @@ module Microsandbox
263
263
 
264
264
  # @return [Time] when the stopped state was observed
265
265
  def observed_at
266
- Time.at(@observed_at_ms / 1000.0)
266
+ Microsandbox.time_from_ms(@observed_at_ms)
267
267
  end
268
268
 
269
269
  def inspect
@@ -495,6 +495,7 @@ module Microsandbox
495
495
  # @return [Sandbox, Object] the sandbox, or the block's return value
496
496
  def create(name, **kwargs, &block)
497
497
  opts = build_create_opts(**kwargs)
498
+ Microsandbox.ensure_runtime!
498
499
  sandbox = new(Native::Sandbox.create(name.to_s, opts))
499
500
  return sandbox unless block_given?
500
501
 
@@ -551,6 +552,7 @@ module Microsandbox
551
552
  # @return [Sandbox, Object] the live sandbox, or the block's return value
552
553
  def connect_or_create(name, **kwargs, &block)
553
554
  opts = build_create_opts(**kwargs)
555
+ Microsandbox.ensure_runtime!
554
556
  sandbox = new(Native::Sandbox.connect_or_create(name.to_s, opts))
555
557
  return sandbox unless block_given?
556
558
 
@@ -582,11 +584,15 @@ module Microsandbox
582
584
  "for progress, then call #sandbox and stop it when done"
583
585
  end
584
586
  opts = build_create_opts(**kwargs)
587
+ Microsandbox.ensure_runtime!
585
588
  PullSession.new(Native::Sandbox.create_with_progress(name.to_s, opts))
586
589
  end
587
590
 
588
591
  # @api private
589
- # Shared keyword-option builder for {create}/{create_with_progress}.
592
+ # Shared keyword-option builder for {create}/{connect_or_create}/
593
+ # {create_with_progress}. Pure validation and normalization: the callers
594
+ # provision the runtime ({Microsandbox.ensure_runtime!}) only after this
595
+ # returns, so a rejected option never triggers a runtime download.
590
596
  def build_create_opts(image: nil, cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
591
597
  env: nil, workdir: nil,
592
598
  shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
@@ -610,7 +616,14 @@ module Microsandbox
610
616
  if root_disk && oci_upper_size
611
617
  raise ArgumentError, "pass either root_disk: or oci_upper_size:, not both"
612
618
  end
613
- Microsandbox.ensure_runtime!
619
+ # The core refuses patches on a snapshot-booted sandbox, but only after
620
+ # it has claimed the sandbox name on disk. Refuse the pair before any
621
+ # side effect.
622
+ if from_snapshot && patches && !Array(patches).empty?
623
+ raise ArgumentError,
624
+ "patches: cannot be combined with from_snapshot: (a snapshot restores its own " \
625
+ "root disk; apply changes after boot with sandbox.fs instead)"
626
+ end
614
627
  # `fstype:` names the inner filesystem of a disk-image rootfs, so it only
615
628
  # applies when `image:` is a disk-image path (a local path ending in
616
629
  # .raw/.qcow2/.vmdk). Routing an OCI ref (e.g. "python") through the
@@ -779,7 +792,16 @@ module Microsandbox
779
792
  end
780
793
 
781
794
  def stringify(hash)
782
- hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
795
+ # Anything that converts to pairs is fine (a Hash, ENV, an Array of
796
+ # pairs). For the rest, name the type, not the value: env values are
797
+ # often credentials.
798
+ pairs = begin
799
+ hash.to_h if hash.respond_to?(:to_h)
800
+ rescue TypeError
801
+ nil
802
+ end
803
+ raise ArgumentError, "expected a Hash of names to values (got #{hash.class})" unless pairs
804
+ pairs.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
783
805
  end
784
806
 
785
807
  # Wrap a native page Hash ({"sandboxes" => [...], "next_cursor" => ...})
@@ -844,7 +866,21 @@ module Microsandbox
844
866
  # :inject_basic_auth / :inject_query / :inject_body, and :on_violation (see
845
867
  # {#normalize_violation}).
846
868
  def normalize_secrets(secrets)
847
- Array(secrets).map { |spec| normalize_secret(spec) }
869
+ if secrets.is_a?(Hash)
870
+ # One spec given bare is fine. A Hash keyed by variable name is the
871
+ # shape {Sandbox#modify} takes, where `env:` also means something
872
+ # else (the HOST variable to read); it is not a create-time spec.
873
+ unless secrets.key?(:env) || secrets.key?("env")
874
+ raise ArgumentError,
875
+ "secrets: takes an Array of Hashes like { env:, value:, host: } at create time; " \
876
+ "the { \"NAME\" => { ... } } form belongs to #modify"
877
+ end
878
+ secrets = [secrets]
879
+ end
880
+ Array(secrets).map do |spec|
881
+ raise ArgumentError, "secret spec must be a Hash (got #{spec.class})" unless spec.is_a?(Hash)
882
+ normalize_secret(spec)
883
+ end
848
884
  end
849
885
 
850
886
  # @api private
@@ -1409,9 +1445,11 @@ module Microsandbox
1409
1445
  # plain Hash) into string-keyed Hashes for the native layer. Values are
1410
1446
  # passed through unchanged (mode stays Integer, content stays String).
1411
1447
  def normalize_patches(patches)
1448
+ patches = [patches] if patches.is_a?(Hash) # a single patch, not a list of pairs
1412
1449
  Array(patches).map do |p|
1413
1450
  unless p.is_a?(Hash)
1414
- raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*): #{p.inspect}"
1451
+ # Name the type, not the value: a patch can carry file content.
1452
+ raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*), got #{p.class}"
1415
1453
  end
1416
1454
  p.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
1417
1455
  end
@@ -1957,10 +1995,16 @@ module Microsandbox
1957
1995
 
1958
1996
  # The booted sandbox. Joins the create task (draining any remaining pull
1959
1997
  # progress first), so call it after iterating progress. The returned
1960
- # {Sandbox} is live — stop it when done. Memoized; callable once.
1998
+ # {Sandbox} is live — stop it when done. Memoized: the native result can be
1999
+ # taken only once, so a later call returns the same {Sandbox}, or re-raises
2000
+ # the same error if the create failed.
1961
2001
  # @return [Sandbox]
1962
2002
  def sandbox
2003
+ raise @failure if @failure
1963
2004
  @sandbox ||= Sandbox.new(@native.result)
2005
+ rescue Microsandbox::Error => e
2006
+ @failure ||= e
2007
+ raise
1964
2008
  end
1965
2009
  end
1966
2010
  end
@@ -92,7 +92,7 @@ module Microsandbox
92
92
 
93
93
  # @return [Time, nil]
94
94
  def created_at
95
- @created_at_ms && Time.at(@created_at_ms / 1000.0)
95
+ Microsandbox.time_from_ms(@created_at_ms)
96
96
  end
97
97
 
98
98
  # Re-open this snapshot's artifact (cheap metadata validation), returning a
@@ -173,11 +173,7 @@ module Microsandbox
173
173
  session = SftpClient.new(@native.sftp)
174
174
  return session unless block_given?
175
175
 
176
- begin
177
- yield session
178
- ensure
179
- session.close
180
- end
176
+ Microsandbox.close_after(session) { yield session }
181
177
  end
182
178
 
183
179
  # Close the SSH client session. Idempotent. Sends the graceful protocol
@@ -245,11 +241,7 @@ module Microsandbox
245
241
  client = SshClient.new(@native.ssh_open_client(opts))
246
242
  return client unless block_given?
247
243
 
248
- begin
249
- yield client
250
- ensure
251
- client.close
252
- end
244
+ Microsandbox.close_after(client) { yield client }
253
245
  end
254
246
 
255
247
  # Prepare a reusable SSH server endpoint for the sandbox.
@@ -2,17 +2,20 @@
2
2
 
3
3
  module Microsandbox
4
4
  # A live stream of {LogEntry}s, returned by {Sandbox#log_stream}. Enumerable:
5
- # iterate to consume entries as they are appended. With `follow: true` the
6
- # iteration blocks for new entries until the sandbox stops; otherwise it ends
7
- # once the historical log is drained.
5
+ # iterate to consume entries as they are appended. Without `follow:` the
6
+ # iteration ends once the historical log is drained. With `follow: true` it
7
+ # keeps waiting for new entries and does NOT end when the sandbox stops: the
8
+ # runtime follows the log file, not the sandbox, and there is no way to
9
+ # cancel the wait.
8
10
  #
9
11
  # @note **Single-pass, forward-only, single-consumer.** `each` drains a
10
12
  # one-shot native channel, so it is *not* rewindable: a second `each` — or
11
13
  # any `Enumerable` combinator after a partial drain (`to_a` twice, `count`
12
14
  # then `each`, `first(n)` then `each`) — silently yields nothing. Iterate
13
15
  # exactly once, from one thread. With `follow: true`, `each` blocks the
14
- # calling thread uninterruptibly until the sandbox stops (see DESIGN.md on
15
- # GVL release).
16
+ # calling thread uninterruptibly and indefinitely (see DESIGN.md on GVL
17
+ # release); a thread blocked there also keeps the process from exiting
18
+ # normally, so follow from a process you are prepared to kill.
16
19
  #
17
20
  # @example
18
21
  # sb.log_stream(follow: true).each { |entry| print entry.text }
@@ -8,7 +8,7 @@ module Microsandbox
8
8
  # Versioning section of the README for the full gem-to-runtime map. Must equal
9
9
  # the native ext's Cargo crate version (`Native.version`), enforced by
10
10
  # spec/unit/version_spec.rb.
11
- VERSION = "0.17.0"
11
+ VERSION = "0.17.1"
12
12
 
13
13
  # The upstream microsandbox runtime release this gem build embeds — the `tag`
14
14
  # pinned on the `microsandbox`/`microsandbox-network` git deps in