microsandbox-rb 0.16.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| {
@@ -390,12 +407,22 @@ impl Sandbox {
390
407
  .transpose()?;
391
408
  let max_connections = conv::opt::<usize>(opts, "max_connections")?;
392
409
  let trust_host_cas = conv::opt::<bool>(opts, "trust_host_cas")?;
410
+ // strict: fail-closed hostname-policy enforcement (v0.6.18). A
411
+ // hostname-rule allow must be backed by an inspectable request
412
+ // authority (plain-HTTP Host, or SNI/authority under TLS interception);
413
+ // otherwise the flow is denied before the upstream dial. Mirrors the
414
+ // Python SDK's `Network(strict=...)`. Create-only here to match the
415
+ // Python SDK's `modify` surface, which does not expose `strict`; the
416
+ // core's generated network config patch (ConfigPatch derive) does
417
+ // carry a `strict` field, so this is SDK parity, not a core limit.
418
+ let strict = conv::opt::<bool>(opts, "strict")?;
393
419
  if dns.is_some()
394
420
  || tls.is_some()
395
421
  || ipv4_pool.is_some()
396
422
  || ipv6_pool.is_some()
397
423
  || max_connections.is_some()
398
424
  || trust_host_cas.is_some()
425
+ || strict.is_some()
399
426
  {
400
427
  b = b.network(move |mut n| {
401
428
  if let Some(dns) = dns {
@@ -450,6 +477,9 @@ impl Sandbox {
450
477
  if let Some(t) = trust_host_cas {
451
478
  n = n.trust_host_cas(t);
452
479
  }
480
+ if let Some(s) = strict {
481
+ n = n.strict(s);
482
+ }
453
483
  n
454
484
  });
455
485
  }
@@ -475,6 +505,18 @@ impl Sandbox {
475
505
  })
476
506
  });
477
507
  }
508
+ // proxy: outbound SOCKS4/SOCKS5 proxy for egress traffic (v0.6.17,
509
+ // upstream #1234/#1507). The Ruby layer (`OutboundProxy.coerce`) has
510
+ // already validated the shape and normalized it to the Python SDK's
511
+ // `_to_dict()` wire form: {protocol:, address:, user_id?:,
512
+ // credentials?: {username:, password: {kind: "env", var:}}}. Only the
513
+ // password's env var NAME travels; the core resolves it host-side.
514
+ // Applied exactly as sdk/python/src/helpers.rs does; the address is
515
+ // parsed by the core's proxy builder (an invalid one surfaces as
516
+ // NetworkBuilder(InvalidOutboundProxy) → InvalidConfigError, see error.rs).
517
+ if let Some(proxy) = conv::opt::<RHash>(opts, "proxy")? {
518
+ b = apply_outbound_proxy(b, proxy)?;
519
+ }
478
520
  // init: hand guest PID 1 to an init system. The Ruby layer normalizes
479
521
  // `init:` to a Hash { cmd:, args?:, env?: }. `init_with` with empty
480
522
  // args/env builds the same HandoffInit as the plain `init(cmd)`, so route
@@ -1192,7 +1234,7 @@ fn patch_bytes(h: RHash, key: &str) -> Result<Vec<u8>, Error> {
1192
1234
  /// Parse the `patches` option into core `Patch` operations. The Ruby layer
1193
1235
  /// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash carrying a
1194
1236
  /// `kind` discriminator plus the variant-specific fields.
1195
- fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
1237
+ fn parse_patches(opts: RHash, host_paths: HostPaths) -> Result<Vec<Patch>, Error> {
1196
1238
  let mut out = Vec::new();
1197
1239
  for h in conv::opt_hash_vec(opts, "patches")? {
1198
1240
  let kind = patch_str(h, "kind")?;
@@ -1216,13 +1258,13 @@ fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
1216
1258
  content: patch_str(h, "content")?,
1217
1259
  },
1218
1260
  "copy_file" => Patch::CopyFile {
1219
- src: patch_str(h, "src")?.into(),
1261
+ src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
1220
1262
  dst: patch_str(h, "dst")?,
1221
1263
  mode,
1222
1264
  replace,
1223
1265
  },
1224
1266
  "copy_dir" => Patch::CopyDir {
1225
- src: patch_str(h, "src")?.into(),
1267
+ src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
1226
1268
  dst: patch_str(h, "dst")?,
1227
1269
  replace,
1228
1270
  },
@@ -1420,6 +1462,63 @@ fn parse_dns(d: RHash) -> Result<DnsSpec, Error> {
1420
1462
  })
1421
1463
  }
1422
1464
 
1465
+ /// Apply the normalized `proxy` create option (v0.6.17) to the builder.
1466
+ /// Mirrors `sdk/python/src/helpers.rs`: `socks4` takes an optional `user_id`,
1467
+ /// `socks5` optional `credentials` whose password is an env-backed
1468
+ /// [`SecretSource`] (the only kind). Unknown protocols and non-env password
1469
+ /// sources are rejected here (the Ruby layer already does, so these are
1470
+ /// belt-and-braces for callers of the native API).
1471
+ fn apply_outbound_proxy(b: SandboxBuilder, proxy: RHash) -> Result<SandboxBuilder, Error> {
1472
+ let protocol = conv::opt_string(proxy, "protocol")?
1473
+ .ok_or_else(|| error::base_error("proxy requires protocol:"))?;
1474
+ let address = conv::opt_string(proxy, "address")?
1475
+ .ok_or_else(|| error::base_error("proxy requires address:"))?;
1476
+ match protocol.as_str() {
1477
+ "socks4" => {
1478
+ let user_id = conv::opt_string(proxy, "user_id")?;
1479
+ Ok(b.proxy(move |p| {
1480
+ let p = p.socks4(address);
1481
+ match user_id {
1482
+ Some(user_id) => p.user_id(user_id),
1483
+ None => p,
1484
+ }
1485
+ }))
1486
+ }
1487
+ "socks5" => {
1488
+ let credentials = match conv::opt::<RHash>(proxy, "credentials")? {
1489
+ None => None,
1490
+ Some(c) => {
1491
+ let username = conv::opt_string(c, "username")?
1492
+ .ok_or_else(|| error::base_error("SOCKS5 credentials require username:"))?;
1493
+ let password = conv::opt::<RHash>(c, "password")?
1494
+ .ok_or_else(|| error::base_error("SOCKS5 credentials require password:"))?;
1495
+ let kind = conv::opt_string(password, "kind")?.ok_or_else(|| {
1496
+ error::base_error("SOCKS5 password source requires kind:")
1497
+ })?;
1498
+ if kind != "env" {
1499
+ return Err(error::base_error(format!(
1500
+ "unsupported SOCKS5 password source {kind:?}; only env is supported"
1501
+ )));
1502
+ }
1503
+ let var = conv::opt_string(password, "var")?
1504
+ .ok_or_else(|| error::base_error("SOCKS5 password source requires var:"))?;
1505
+ Some((username, SecretSource::env(var)))
1506
+ }
1507
+ };
1508
+ Ok(b.proxy(move |p| {
1509
+ let p = p.socks5(address);
1510
+ match credentials {
1511
+ Some((username, password)) => p.credentials(username, password),
1512
+ None => p,
1513
+ }
1514
+ }))
1515
+ }
1516
+ other => Err(error::base_error(format!(
1517
+ "unsupported outbound proxy protocol {other:?}; expected socks4 or socks5"
1518
+ ))),
1519
+ }
1520
+ }
1521
+
1423
1522
  /// One token bucket of the `rate_limiter` create option (v0.6.9):
1424
1523
  /// `(size, refill_time_ms, one_time_burst)`. Size is bytes for bandwidth
1425
1524
  /// buckets, frames for ops buckets.
@@ -1502,20 +1601,25 @@ struct TlsSpec {
1502
1601
  verify_upstream: Option<bool>,
1503
1602
  intercepted_ports: Option<Vec<u16>>,
1504
1603
  block_quic: Option<bool>,
1505
- upstream_ca_cert: Option<String>,
1506
- intercept_ca_cert: Option<String>,
1507
- intercept_ca_key: Option<String>,
1604
+ upstream_ca_cert: Option<PathBuf>,
1605
+ intercept_ca_cert: Option<PathBuf>,
1606
+ intercept_ca_key: Option<PathBuf>,
1508
1607
  }
1509
1608
 
1510
- 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
+ };
1511
1615
  Ok(TlsSpec {
1512
1616
  bypass: conv::opt_string_vec(t, "bypass")?,
1513
1617
  verify_upstream: conv::opt::<bool>(t, "verify_upstream")?,
1514
1618
  intercepted_ports: conv::opt::<Vec<u16>>(t, "intercepted_ports")?,
1515
1619
  block_quic: conv::opt::<bool>(t, "block_quic")?,
1516
- upstream_ca_cert: conv::opt_string(t, "upstream_ca_cert")?,
1517
- intercept_ca_cert: conv::opt_string(t, "intercept_ca_cert")?,
1518
- 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")?,
1519
1623
  })
1520
1624
  }
1521
1625
 
@@ -1629,6 +1733,14 @@ fn network_profile_from_str(s: &str) -> Result<NetworkProfile, Error> {
1629
1733
  }
1630
1734
  }
1631
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
+
1632
1744
  /// Parsed `root_disk` create option. Integer shorthand = managed upper of N
1633
1745
  /// MiB (mirrors Python's `root_disk=8192` and the CLI bare-size form); Hash =
1634
1746
  /// explicit kind. Kind/field cross-validation (size on a disk image, etc.)
@@ -1644,7 +1756,7 @@ struct RootDiskSpec {
1644
1756
  enum RootDiskKindSpec {
1645
1757
  Managed,
1646
1758
  Tmpfs,
1647
- Disk(String),
1759
+ Disk(PathBuf),
1648
1760
  Flat,
1649
1761
  }
1650
1762
 
@@ -1672,7 +1784,7 @@ impl RootDiskSpec {
1672
1784
  }
1673
1785
  }
1674
1786
 
1675
- fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1787
+ fn parse_root_disk(v: Value, host_paths: HostPaths) -> Result<RootDiskSpec, Error> {
1676
1788
  if let Ok(mib) = u32::try_convert(v) {
1677
1789
  return Ok(RootDiskSpec {
1678
1790
  kind: RootDiskKindSpec::Managed,
@@ -1695,7 +1807,7 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1695
1807
  let Some(path) = conv::opt_string(h, "path")? else {
1696
1808
  return Err(error::base_error("root_disk disk kind requires path:"));
1697
1809
  };
1698
- RootDiskKindSpec::Disk(path)
1810
+ RootDiskKindSpec::Disk(host_paths.pin(path, "root disk image path")?)
1699
1811
  }
1700
1812
  Some("flat") => RootDiskKindSpec::Flat,
1701
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