microsandbox-rb 0.10.0 → 0.12.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.
@@ -20,7 +20,7 @@ use microsandbox::logs::{
20
20
  use microsandbox::sandbox::{
21
21
  AttachOptionsBuilder, DiskImageFormat, EnvVar, FsEntry, FsEntryKind, FsMetadata,
22
22
  HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
23
- SandboxBuilder, SandboxFilter, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
23
+ RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
24
24
  SandboxModificationPatch, SandboxStatus, SandboxStopResult, SecretBuilder,
25
25
  SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
26
26
  };
@@ -30,7 +30,8 @@ use microsandbox::RegistryAuth;
30
30
  use microsandbox_network::builder::ViolationActionBuilder;
31
31
  use microsandbox_network::dns::Nameserver;
32
32
  use microsandbox_network::policy::{
33
- Action, Destination, DestinationGroup, Direction, NetworkPolicy, PortRange, Protocol, Rule,
33
+ Action, Destination, DestinationGroup, Direction, NetworkPolicy, NetworkProfile, PortRange,
34
+ Protocol, Rule,
34
35
  };
35
36
  use tokio::sync::Mutex;
36
37
  use tokio::task::JoinHandle;
@@ -166,6 +167,10 @@ impl Sandbox {
166
167
  let host_perms = conv::opt_string(m, "host_permissions")?
167
168
  .map(|s| host_permissions_from_str(&s))
168
169
  .transpose()?;
170
+ // v0.6.7: mount-root symlink protection is on by default; this is
171
+ // the per-mount opt-out. Valid for bind/named-directory mounts —
172
+ // the core rejects it elsewhere at build().
173
+ let follow_root_symlinks = conv::opt::<bool>(m, "follow_root_symlinks")?;
169
174
  // bind/named/disk require a source; tmpfs must not have one.
170
175
  match kind.as_str() {
171
176
  "bind" | "named" | "disk" if source.is_some() => {}
@@ -218,6 +223,9 @@ impl Sandbox {
218
223
  if let Some(hp) = host_perms {
219
224
  mb = mb.host_permissions(hp);
220
225
  }
226
+ if let Some(follow) = follow_root_symlinks {
227
+ mb = mb.follow_root_symlinks(follow);
228
+ }
221
229
  mb
222
230
  });
223
231
  }
@@ -230,18 +238,29 @@ impl Sandbox {
230
238
  if let Some(net) = conv::opt_string(opts, "network")? {
231
239
  match net.as_str() {
232
240
  "none" | "disabled" | "disable" | "airgapped" => b = b.disable_network(),
233
- // Default policy is public-only, so no builder call is needed.
234
- "public" | "public_only" | "default" => {}
235
241
  "all" | "allow_all" => b = b.network(|n| n.policy(NetworkPolicy::allow_all())),
236
- "non_local" | "nonlocal" => b = b.network(|n| n.policy(NetworkPolicy::non_local())),
237
242
  other => {
238
243
  return Err(error::base_error(format!(
239
- "unknown network mode {other:?} (expected one of \
240
- public_only/none/allow_all/non_local)"
244
+ "unknown network mode {other:?} (expected none/allow_all; compose \
245
+ public/private/host access via network profiles)"
241
246
  )))
242
247
  }
243
248
  }
244
249
  }
250
+ // Composable network profiles (v0.6.7): expanded to the canonical rule
251
+ // set plus a gateway-DNS allow by the core's
252
+ // `NetworkPolicy::from_profiles`. The Ruby layer routes
253
+ // `network: [:public, :host]` (and single-profile sugar) here. An empty
254
+ // profile set never reaches this key — the Ruby layer sends it as an
255
+ // explicit empty custom policy, which has identical semantics.
256
+ let profile_names = conv::opt_string_vec(opts, "network_profiles")?;
257
+ if !profile_names.is_empty() {
258
+ let profiles = profile_names
259
+ .iter()
260
+ .map(|s| network_profile_from_str(s))
261
+ .collect::<Result<Vec<_>, Error>>()?;
262
+ b = b.network(move |n| n.policy(NetworkPolicy::from_profiles(profiles)));
263
+ }
245
264
  // Custom network policy: an ordered allow/deny rule list with per-direction
246
265
  // defaults and bulk domain denials. The Ruby layer routes bare presets to
247
266
  // the `network` key above and full policies here; mirrors the Python
@@ -261,8 +280,14 @@ impl Sandbox {
261
280
  if let Some(policy) = conv::opt_string(opts, "pull_policy")? {
262
281
  b = b.pull_policy(pull_policy_from_str(&policy)?);
263
282
  }
264
- if let Some(mib) = conv::opt_u32(opts, "oci_upper_size")? {
265
- b = b.oci_upper_size(mib);
283
+ // Writable root-disk spec for OCI sandboxes (v0.6.7): Integer = managed
284
+ // ext4 upper size in MiB (the pre-0.6.7 `oci_upper_size` meaning), Hash =
285
+ // {kind: managed|tmpfs|disk, size_mib, path, format, fstype}. The Ruby
286
+ // layer maps the deprecated `oci_upper_size:` kwarg to the managed kind
287
+ // before it reaches the wire.
288
+ if let Some(v) = conv::opt::<Value>(opts, "root_disk")? {
289
+ let spec = parse_root_disk(v)?;
290
+ b = b.root_disk_with(move |d| spec.apply(d));
266
291
  }
267
292
  // Registry connection settings, for private / non-default registries:
268
293
  // Basic auth (username + password/token), plain-HTTP `insecure`, and
@@ -452,29 +477,44 @@ impl Sandbox {
452
477
  Ok(SbHandle::from_inner(handle))
453
478
  }
454
479
 
455
- /// All sandboxes as controllable handles.
456
- fn list() -> Result<RArray, Error> {
457
- let handles = block_on(microsandbox::Sandbox::list()).map_err(error::to_ruby)?;
480
+ /// Convert one `SandboxPage` into a `{ "sandboxes" => [SbHandle], "next_cursor" => String|nil }`
481
+ /// Ruby Hash (v0.6.8 paginated listing contract).
482
+ fn page_to_hash(page: microsandbox::sandbox::SandboxPage) -> Result<RHash, Error> {
458
483
  let arr = ruby().ary_new();
459
- for h in handles {
484
+ for h in page.sandboxes {
460
485
  arr.push(SbHandle::from_inner(h))?;
461
486
  }
462
- Ok(arr)
463
- }
464
-
465
- /// Sandboxes filtered by required `key=value` labels (AND-matched), as
466
- /// controllable handles. `opts` carries a string→string `labels` map.
467
- fn list_with(opts: RHash) -> Result<RArray, Error> {
468
- let mut filter = SandboxFilter::new();
469
- for (k, v) in conv::opt_string_map(opts, "labels")? {
470
- filter = filter.label(k, v);
471
- }
472
- let handles = block_on(microsandbox::Sandbox::list_with(filter)).map_err(error::to_ruby)?;
473
- let arr = ruby().ary_new();
474
- for h in handles {
475
- arr.push(SbHandle::from_inner(h))?;
476
- }
477
- Ok(arr)
487
+ let hash = ruby().hash_new();
488
+ hash.aset("sandboxes", arr)?;
489
+ hash.aset("next_cursor", page.next_cursor)?;
490
+ Ok(hash)
491
+ }
492
+
493
+ /// First page of sandboxes (default page size), as a page Hash of
494
+ /// controllable handles.
495
+ fn list() -> Result<RHash, Error> {
496
+ let page = block_on(microsandbox::Sandbox::list()).map_err(error::to_ruby)?;
497
+ Self::page_to_hash(page)
498
+ }
499
+
500
+ /// One configured page of sandboxes as a page Hash. `opts` carries an
501
+ /// optional string→string `labels` map (AND-matched), an optional `limit`
502
+ /// (1..=100) and an optional opaque `cursor` from a previous page.
503
+ fn list_with(opts: RHash) -> Result<RHash, Error> {
504
+ let labels = conv::opt_string_map(opts, "labels")?;
505
+ let limit = conv::opt_u32(opts, "limit")?;
506
+ let cursor = conv::opt_string(opts, "cursor")?;
507
+ let page = block_on(microsandbox::Sandbox::list_with(move |mut b| {
508
+ if let Some(limit) = limit {
509
+ b = b.limit(limit);
510
+ }
511
+ if let Some(cursor) = cursor {
512
+ b = b.cursor(cursor);
513
+ }
514
+ b.labels(labels)
515
+ }))
516
+ .map_err(error::to_ruby)?;
517
+ Self::page_to_hash(page)
478
518
  }
479
519
 
480
520
  /// Remove a (stopped) sandbox by name.
@@ -1258,12 +1298,14 @@ fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
1258
1298
  /// a core `NetworkPolicy`. Returns `None` when the option is absent (bare
1259
1299
  /// presets travel via the separate `network` key handled in `create`).
1260
1300
  ///
1261
- /// Composition (mirrors the Go SDK's `NetworkConfig`): bulk domain-deny rules
1262
- /// come first (so they outrank later allow rules), then a preset's rules (if a
1263
- /// preset base is given), then the caller's explicit `rules`. Per-direction
1264
- /// defaults come from the explicit `default_egress`/`default_ingress` when set,
1265
- /// else the preset's defaults, else the asymmetric default (deny egress / allow
1266
- /// ingress).
1301
+ /// Composition order (first-match-wins per direction): bulk domain-deny rules
1302
+ /// come first (so they outrank everything), then the caller's explicit
1303
+ /// `rules`, then the profile/preset base's expansion LAST the same order the
1304
+ /// upstream CLI composes `--net` with explicit rules, so a narrower caller
1305
+ /// override (e.g. `Rule.deny_dns`) beats the base's allows instead of dying
1306
+ /// behind them. Per-direction defaults come from the explicit
1307
+ /// `default_egress`/`default_ingress` when set, else the base's defaults, else
1308
+ /// the asymmetric default (deny egress / allow ingress).
1267
1309
  fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
1268
1310
  let Some(np) = conv::opt::<RHash>(opts, "network_policy")? else {
1269
1311
  return Ok(None);
@@ -1284,21 +1326,40 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
1284
1326
  rules.push(Rule::deny_egress(Destination::DomainSuffix(suffix)));
1285
1327
  }
1286
1328
 
1287
- // Optional preset base (its rules and defaults seed the policy).
1288
- let (preset_egress, preset_ingress) = match conv::opt_string(np, "preset")? {
1289
- Some(p) => {
1290
- let mut base = network_preset(&p)?;
1329
+ // Optional base composable profiles (v0.6.7) or a terminal preset,
1330
+ // mutually exclusive. Its rules and defaults seed the policy.
1331
+ let profile_names = conv::opt_string_vec(np, "profiles")?;
1332
+ let preset = conv::opt_string(np, "preset")?;
1333
+ if !profile_names.is_empty() && preset.is_some() {
1334
+ return Err(error::base_error(
1335
+ "network profiles: and preset: are mutually exclusive",
1336
+ ));
1337
+ }
1338
+ let base = if profile_names.is_empty() {
1339
+ preset.map(|p| network_preset(&p)).transpose()?
1340
+ } else {
1341
+ let profiles = profile_names
1342
+ .iter()
1343
+ .map(|s| network_profile_from_str(s))
1344
+ .collect::<Result<Vec<_>, Error>>()?;
1345
+ Some(NetworkPolicy::from_profiles(profiles))
1346
+ };
1347
+ // Caller's explicit rules go BEFORE the base expansion: under
1348
+ // first-match-wins, appending them after would make any override of a
1349
+ // base-allowed destination silently dead (a more permissive policy than
1350
+ // requested). Mirrors the upstream CLI's `--net` + rules composition.
1351
+ for rd in conv::opt_hash_vec(np, "rules")? {
1352
+ rules.push(parse_rule(rd)?);
1353
+ }
1354
+
1355
+ let (preset_egress, preset_ingress) = match base {
1356
+ Some(mut base) => {
1291
1357
  rules.append(&mut base.rules);
1292
1358
  (Some(base.default_egress), Some(base.default_ingress))
1293
1359
  }
1294
1360
  None => (None, None),
1295
1361
  };
1296
1362
 
1297
- // Caller's explicit rules come after preset rules.
1298
- for rd in conv::opt_hash_vec(np, "rules")? {
1299
- rules.push(parse_rule(rd)?);
1300
- }
1301
-
1302
1363
  let default_egress = match conv::opt_string(np, "default_egress")? {
1303
1364
  Some(s) => action_from_str(&s)?,
1304
1365
  None => preset_egress.unwrap_or(Action::Deny),
@@ -1318,18 +1379,105 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
1318
1379
  fn network_preset(p: &str) -> Result<NetworkPolicy, Error> {
1319
1380
  Ok(match p {
1320
1381
  "none" | "disabled" | "disable" | "airgapped" => NetworkPolicy::none(),
1321
- "public" | "public_only" | "public-only" | "default" => NetworkPolicy::public_only(),
1322
1382
  "all" | "allow_all" | "allow-all" => NetworkPolicy::allow_all(),
1323
- "non_local" | "non-local" | "nonlocal" => NetworkPolicy::non_local(),
1324
1383
  other => {
1325
1384
  return Err(error::base_error(format!(
1326
- "unknown network preset {other:?} (expected one of \
1327
- public_only/none/allow_all/non_local)"
1385
+ "unknown network preset {other:?} (expected none/allow_all; the removed \
1386
+ public_only/non_local presets are replaced by composable profiles)"
1328
1387
  )))
1329
1388
  }
1330
1389
  })
1331
1390
  }
1332
1391
 
1392
+ fn network_profile_from_str(s: &str) -> Result<NetworkProfile, Error> {
1393
+ match s {
1394
+ "public" => Ok(NetworkProfile::Public),
1395
+ "private" => Ok(NetworkProfile::Private),
1396
+ "host" => Ok(NetworkProfile::Host),
1397
+ other => Err(error::base_error(format!(
1398
+ "unknown network profile {other:?} (expected public/private/host)"
1399
+ ))),
1400
+ }
1401
+ }
1402
+
1403
+ /// Parsed `root_disk` create option. Integer shorthand = managed upper of N
1404
+ /// MiB (mirrors Python's `root_disk=8192` and the CLI bare-size form); Hash =
1405
+ /// explicit kind. Kind/field cross-validation (size on a disk image, etc.)
1406
+ /// lives in the Ruby layer and the core builder — this stays a dumb carrier.
1407
+ struct RootDiskSpec {
1408
+ kind: RootDiskKindSpec,
1409
+ size_mib: Option<u32>,
1410
+ format: Option<DiskImageFormat>,
1411
+ fstype: Option<String>,
1412
+ }
1413
+
1414
+ enum RootDiskKindSpec {
1415
+ Managed,
1416
+ Tmpfs,
1417
+ Disk(String),
1418
+ }
1419
+
1420
+ impl RootDiskSpec {
1421
+ fn apply(self, mut d: RootDiskBuilder) -> RootDiskBuilder {
1422
+ match self.kind {
1423
+ RootDiskKindSpec::Managed => {}
1424
+ RootDiskKindSpec::Tmpfs => d = d.tmpfs(),
1425
+ RootDiskKindSpec::Disk(path) => d = d.disk_image(path),
1426
+ }
1427
+ if let Some(mib) = self.size_mib {
1428
+ d = d.size(mib);
1429
+ }
1430
+ if let Some(f) = self.format {
1431
+ d = d.format(f);
1432
+ }
1433
+ if let Some(ft) = self.fstype {
1434
+ d = d.fstype(ft);
1435
+ }
1436
+ d
1437
+ }
1438
+ }
1439
+
1440
+ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
1441
+ if let Ok(mib) = u32::try_convert(v) {
1442
+ return Ok(RootDiskSpec {
1443
+ kind: RootDiskKindSpec::Managed,
1444
+ size_mib: Some(mib),
1445
+ format: None,
1446
+ fstype: None,
1447
+ });
1448
+ }
1449
+ let Ok(h) = RHash::try_convert(v) else {
1450
+ return Err(error::base_error(
1451
+ "root_disk: expects an Integer (managed size in MiB) or a Hash \
1452
+ (use Microsandbox::RootDisk.managed/tmpfs/disk)",
1453
+ ));
1454
+ };
1455
+ let kind = match conv::opt_string(h, "kind")?.as_deref() {
1456
+ None | Some("managed") => RootDiskKindSpec::Managed,
1457
+ Some("tmpfs") => RootDiskKindSpec::Tmpfs,
1458
+ Some("disk" | "disk-image" | "disk_image") => {
1459
+ let Some(path) = conv::opt_string(h, "path")? else {
1460
+ return Err(error::base_error("root_disk disk kind requires path:"));
1461
+ };
1462
+ RootDiskKindSpec::Disk(path)
1463
+ }
1464
+ Some(other) => {
1465
+ return Err(error::base_error(format!(
1466
+ "unknown root_disk kind {other:?} (expected managed/tmpfs/disk)"
1467
+ )))
1468
+ }
1469
+ };
1470
+ let format = conv::opt_string(h, "format")?
1471
+ .map(|f| disk_format_from_str(&f))
1472
+ .transpose()?;
1473
+ Ok(RootDiskSpec {
1474
+ kind,
1475
+ size_mib: conv::opt_u32(h, "size_mib")?,
1476
+ format,
1477
+ fstype: conv::opt_string(h, "fstype")?,
1478
+ })
1479
+ }
1480
+
1333
1481
  fn action_from_str(s: &str) -> Result<Action, Error> {
1334
1482
  match s {
1335
1483
  "allow" => Ok(Action::Allow),
@@ -2143,17 +2291,13 @@ impl SbHandle {
2143
2291
  /// Snapshot this (stopped) sandbox under a bare name (resolved under the
2144
2292
  /// snapshots directory). Returns the same SnapshotInfo Hash as
2145
2293
  /// `Snapshot.create`. Mirrors the Python/Node `handle.snapshot(name)`.
2294
+ /// (`snapshot_to` was removed upstream in v0.6.7 — use `Snapshot.create`
2295
+ /// with `dest_dir:` for explicit placement.)
2146
2296
  fn snapshot(&self, name: String) -> Result<RHash, Error> {
2147
2297
  let snap = block_on(self.inner.snapshot(&name)).map_err(error::to_ruby)?;
2148
2298
  Ok(crate::snapshot::snapshot_to_hash(&snap))
2149
2299
  }
2150
2300
 
2151
- /// Snapshot this (stopped) sandbox to an explicit filesystem path.
2152
- fn snapshot_to(&self, path: String) -> Result<RHash, Error> {
2153
- let snap = block_on(self.inner.snapshot_to(path)).map_err(error::to_ruby)?;
2154
- Ok(crate::snapshot::snapshot_to_hash(&snap))
2155
- }
2156
-
2157
2301
  /// Health-check the guest agent without refreshing the idle timer. A stopped
2158
2302
  /// sandbox raises `SandboxNotRunningError` (the handle does not start it
2159
2303
  /// implicitly). Returns `{ name, latency_secs }`.
@@ -2337,7 +2481,6 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
2337
2481
  )?;
2338
2482
  handle.define_method("config_json", method!(SbHandle::config_json, 0))?;
2339
2483
  handle.define_method("snapshot", method!(SbHandle::snapshot, 1))?;
2340
- handle.define_method("snapshot_to", method!(SbHandle::snapshot_to, 1))?;
2341
2484
  handle.define_method("ping", method!(SbHandle::ping, 0))?;
2342
2485
  handle.define_method("touch", method!(SbHandle::touch, 0))?;
2343
2486
  handle.define_method("modify", method!(SbHandle::modify, 1))?;
@@ -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
  }
@@ -9,7 +9,6 @@ use magnus::{function, method, prelude::*, Error, RArray, RHash, RModule, RStrin
9
9
  use microsandbox::volume::VolumeHandle;
10
10
  use microsandbox::Backend;
11
11
 
12
- use crate::backend::local_backend;
13
12
  use crate::conv;
14
13
  use crate::error;
15
14
  use crate::runtime::{block_on, ruby};
@@ -108,10 +107,13 @@ pub struct VolumeFs {
108
107
  }
109
108
 
110
109
  impl VolumeFs {
111
- /// Resolve the (local) backend once and bind it to `name`.
110
+ /// Resolve the ambient backend once and bind it to `name`. As of v0.6.8
111
+ /// every `VolumeFs` op dispatches through the backend's `VolumeBackend`
112
+ /// trait, which yields precise per-operation `Unsupported` errors on
113
+ /// backends that can't serve it — no local-only downcast needed here.
112
114
  fn for_volume(name: String) -> Result<VolumeFs, Error> {
113
115
  Ok(VolumeFs {
114
- backend: local_backend().map_err(error::to_ruby)?,
116
+ backend: microsandbox::default_backend(),
115
117
  name,
116
118
  })
117
119
  }
@@ -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
@@ -86,4 +90,16 @@ module Microsandbox
86
90
  # cloud backend) is distinct from `UnsupportedOperationError` above.
87
91
  define_error(:CloudHttpError, "cloud-http")
88
92
  define_error(:UnsupportedError, "unsupported")
93
+
94
+ # As of runtime v0.6.8 the core keys Unsupported errors by a structured
95
+ # (operation, reason) pair. The native layer renders both into the message
96
+ # ("sandbox.kill is not supported by this backend: ...") and also attaches
97
+ # them here as structured attributes, mirroring the Python SDK's
98
+ # `UnsupportedError.operation` / `.hint`.
99
+ class UnsupportedError
100
+ # @return [String, nil] the rejected API in Ruby rendering, e.g. "sandbox.kill"
101
+ attr_reader :operation
102
+ # @return [String, nil] why it was rejected / what to use instead
103
+ attr_reader :hint
104
+ end
89
105
  end