microsandbox-rb 0.5.8 → 0.5.10

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.
@@ -15,12 +15,15 @@ use microsandbox::logs::{
15
15
  LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
16
16
  };
17
17
  use microsandbox::sandbox::{
18
- FsEntry, FsEntryKind, FsMetadata, PullPolicy, RlimitResource, SandboxFilter, SandboxHandle,
19
- SandboxMetrics, SandboxStatus, SandboxStopResult, SecurityProfile,
18
+ AttachOptionsBuilder, FsEntry, FsEntryKind, FsMetadata, Patch, PullPolicy, RlimitResource,
19
+ SandboxFilter, SandboxHandle, SandboxMetrics, SandboxStatus, SandboxStopResult,
20
+ SecurityProfile,
20
21
  };
21
22
  use microsandbox::LogLevel;
22
23
  use microsandbox::RegistryAuth;
23
- use microsandbox_network::policy::NetworkPolicy;
24
+ use microsandbox_network::policy::{
25
+ Action, Destination, DestinationGroup, Direction, NetworkPolicy, PortRange, Protocol, Rule,
26
+ };
24
27
 
25
28
  use crate::conv;
26
29
  use crate::error;
@@ -108,6 +111,12 @@ impl Sandbox {
108
111
  }
109
112
  });
110
113
  }
114
+ // patches: rootfs modifications applied before boot. The Ruby layer
115
+ // normalizes each `Microsandbox::Patch.*` into a string-keyed Hash with
116
+ // a `kind` discriminator; mirrors the Python binding's `apply_patch`.
117
+ for patch in parse_patches(opts)? {
118
+ b = b.add_patch(patch);
119
+ }
111
120
  if let Some(net) = conv::opt_string(opts, "network")? {
112
121
  match net.as_str() {
113
122
  "none" | "disabled" | "disable" | "airgapped" => b = b.disable_network(),
@@ -123,6 +132,13 @@ impl Sandbox {
123
132
  }
124
133
  }
125
134
  }
135
+ // Custom network policy: an ordered allow/deny rule list with per-direction
136
+ // defaults and bulk domain denials. The Ruby layer routes bare presets to
137
+ // the `network` key above and full policies here; mirrors the Python
138
+ // binding's `apply_network`.
139
+ if let Some(policy) = parse_network_policy(opts)? {
140
+ b = b.network(move |n| n.policy(policy));
141
+ }
126
142
  if let Some(level) = conv::opt_string(opts, "log_level")? {
127
143
  b = b.log_level(log_level_from_str(&level)?);
128
144
  }
@@ -193,27 +209,36 @@ impl Sandbox {
193
209
  Ok(Sandbox::from_inner(inner))
194
210
  }
195
211
 
196
- /// Lightweight metadata for a sandbox by name (running or not).
197
- fn get(name: String) -> Result<RHash, Error> {
212
+ /// A controllable handle for a sandbox by name (running or not). Carries
213
+ /// metadata accessors and the full lifecycle surface (see `SbHandle`).
214
+ fn get(name: String) -> Result<SbHandle, Error> {
198
215
  let handle = block_on(microsandbox::Sandbox::get(&name)).map_err(error::to_ruby)?;
199
- Ok(handle_to_hash(&handle))
216
+ Ok(SbHandle::from_inner(handle))
200
217
  }
201
218
 
202
- /// All sandboxes as metadata hashes.
219
+ /// All sandboxes as controllable handles.
203
220
  fn list() -> Result<RArray, Error> {
204
221
  let handles = block_on(microsandbox::Sandbox::list()).map_err(error::to_ruby)?;
205
- rhash_array(handles.iter().map(handle_to_hash))
222
+ let arr = ruby().ary_new();
223
+ for h in handles {
224
+ arr.push(SbHandle::from_inner(h))?;
225
+ }
226
+ Ok(arr)
206
227
  }
207
228
 
208
- /// Sandboxes filtered by required `key=value` labels (AND-matched). `opts`
209
- /// carries a string→string `labels` map.
229
+ /// Sandboxes filtered by required `key=value` labels (AND-matched), as
230
+ /// controllable handles. `opts` carries a string→string `labels` map.
210
231
  fn list_with(opts: RHash) -> Result<RArray, Error> {
211
232
  let mut filter = SandboxFilter::new();
212
233
  for (k, v) in conv::opt_string_map(opts, "labels")? {
213
234
  filter = filter.label(k, v);
214
235
  }
215
236
  let handles = block_on(microsandbox::Sandbox::list_with(filter)).map_err(error::to_ruby)?;
216
- rhash_array(handles.iter().map(handle_to_hash))
237
+ let arr = ruby().ary_new();
238
+ for h in handles {
239
+ arr.push(SbHandle::from_inner(h))?;
240
+ }
241
+ Ok(arr)
217
242
  }
218
243
 
219
244
  /// Remove a (stopped) sandbox by name.
@@ -270,44 +295,46 @@ impl Sandbox {
270
295
  Ok(ExecHandle::from_core(handle))
271
296
  }
272
297
 
273
- /// Graceful stop (+ wait). `timeout` is optional seconds.
274
- fn stop(&self, timeout: Option<f64>) -> Result<(), Error> {
275
- match timeout {
276
- Some(secs) => block_on(self.inner.stop_with_timeout(Duration::from_secs_f64(secs))),
277
- None => block_on(self.inner.stop()),
278
- }
298
+ /// Graceful stop. Mirrors the official SDKs: the live handle routes through
299
+ /// a freshly fetched `SandboxHandle::stop` (SIGTERM→SIGKILL escalation with
300
+ /// a 10s default). Fine-grained control — a custom timeout or fire-and-
301
+ /// return `request_*` — lives on `SandboxHandle`, obtained via `Sandbox.get`.
302
+ fn stop(&self) -> Result<(), Error> {
303
+ let name = self.inner.name().to_string();
304
+ block_on(async move {
305
+ let handle = microsandbox::sandbox::Sandbox::get(&name).await?;
306
+ handle.stop().await
307
+ })
279
308
  .map_err(error::to_ruby)
280
309
  }
281
310
 
282
- /// Force kill (SIGKILL). `timeout` is optional seconds.
283
- fn kill(&self, timeout: Option<f64>) -> Result<(), Error> {
284
- match timeout {
285
- Some(secs) => block_on(self.inner.kill_with_timeout(Duration::from_secs_f64(secs))),
286
- None => block_on(self.inner.kill()),
287
- }
288
- .map_err(error::to_ruby)
311
+ /// Graceful stop, then wait for the process to exit. Returns an exit-status
312
+ /// Hash (`exit_code`, `success`). Local backend only.
313
+ fn stop_and_wait(&self) -> Result<RHash, Error> {
314
+ let status = block_on(self.inner.stop_and_wait()).map_err(error::to_ruby)?;
315
+ Ok(exit_status_to_hash(status))
289
316
  }
290
317
 
291
- /// Send the graceful-shutdown request and return without waiting.
292
- fn request_stop(&self) -> Result<(), Error> {
293
- block_on(self.inner.request_stop()).map_err(error::to_ruby)
318
+ /// Force kill (SIGKILL).
319
+ fn kill(&self) -> Result<(), Error> {
320
+ block_on(self.inner.kill()).map_err(error::to_ruby)
294
321
  }
295
322
 
296
- /// Send the force-kill request and return without waiting.
297
- fn request_kill(&self) -> Result<(), Error> {
298
- block_on(self.inner.request_kill()).map_err(error::to_ruby)
323
+ /// Trigger a graceful drain (SIGUSR1 on local).
324
+ fn drain(&self) -> Result<(), Error> {
325
+ block_on(self.inner.drain()).map_err(error::to_ruby)
299
326
  }
300
327
 
301
- /// Send the drain request and return without waiting.
302
- fn request_drain(&self) -> Result<(), Error> {
303
- block_on(self.inner.request_drain()).map_err(error::to_ruby)
328
+ /// Wait for the process to exit. Returns an exit-status Hash. Local only.
329
+ fn wait(&self) -> Result<RHash, Error> {
330
+ let status = block_on(self.inner.wait()).map_err(error::to_ruby)?;
331
+ Ok(exit_status_to_hash(status))
304
332
  }
305
333
 
306
- /// Block until the sandbox is observed in a terminal state; returns a
307
- /// stop-result Hash (name, status, exit_code, signal, observed_at_ms, source).
308
- fn wait_until_stopped(&self) -> Result<RHash, Error> {
309
- let result = block_on(self.inner.wait_until_stopped()).map_err(error::to_ruby)?;
310
- Ok(stop_result_to_hash(&result))
334
+ /// Live status fetched from the backend (a round-trip per call).
335
+ fn status(&self) -> Result<String, Error> {
336
+ let status = block_on(self.inner.status()).map_err(error::to_ruby)?;
337
+ Ok(sandbox_status_str(status).to_string())
311
338
  }
312
339
 
313
340
  /// Whether this handle owns the sandbox process lifecycle (a synchronous,
@@ -434,6 +461,120 @@ impl Sandbox {
434
461
  let fs = self.inner.fs();
435
462
  block_on(fs.copy_to_host(&guest_path, &host_path)).map_err(error::to_ruby)
436
463
  }
464
+
465
+ //----------------------------------------------------------------------
466
+ // SSH (mirror SandboxSshOps)
467
+ //----------------------------------------------------------------------
468
+
469
+ /// Open a native in-process SSH client to this sandbox. `opts`: user, term,
470
+ /// sftp (bool, default true).
471
+ fn ssh_open_client(&self, opts: RHash) -> Result<crate::ssh::SshClient, Error> {
472
+ let user = conv::opt_string(opts, "user")?;
473
+ let term = conv::opt_string(opts, "term")?;
474
+ let sftp = conv::opt::<bool>(opts, "sftp")?.unwrap_or(true);
475
+ let ssh = self.inner.ssh();
476
+ let client = block_on(ssh.open_client_with(move |mut b| {
477
+ if let Some(u) = user {
478
+ b = b.user(u);
479
+ }
480
+ if let Some(t) = term {
481
+ b = b.term(t);
482
+ }
483
+ b.sftp(sftp)
484
+ }))
485
+ .map_err(error::to_ruby)?;
486
+ Ok(crate::ssh::SshClient::from_core(client))
487
+ }
488
+
489
+ /// Prepare a reusable SSH server endpoint. `opts`: host_key_path,
490
+ /// authorized_keys_path, user, sftp (bool, default true).
491
+ fn ssh_prepare_server(&self, opts: RHash) -> Result<crate::ssh::SshServer, Error> {
492
+ let host_key_path = conv::opt_string(opts, "host_key_path")?;
493
+ let authorized_keys_path = conv::opt_string(opts, "authorized_keys_path")?;
494
+ let user = conv::opt_string(opts, "user")?;
495
+ let sftp = conv::opt::<bool>(opts, "sftp")?.unwrap_or(true);
496
+ let ssh = self.inner.ssh();
497
+ let server = block_on(ssh.prepare_server_with(move |mut b| {
498
+ if let Some(p) = host_key_path {
499
+ b = b.host_key_path(p);
500
+ }
501
+ if let Some(p) = authorized_keys_path {
502
+ b = b.authorized_keys_path(p);
503
+ }
504
+ if let Some(u) = user {
505
+ b = b.user(u);
506
+ }
507
+ b.sftp(sftp)
508
+ }))
509
+ .map_err(error::to_ruby)?;
510
+ Ok(crate::ssh::SshServer::from_core(server))
511
+ }
512
+
513
+ //----------------------------------------------------------------------
514
+ // Interactive attach (host-TTY coupled)
515
+ //----------------------------------------------------------------------
516
+
517
+ /// Attach an interactive terminal to a command in the sandbox; returns its
518
+ /// exit code. Puts the host terminal in raw mode (requires a real tty) and
519
+ /// blocks until the command exits or the detach sequence is typed. `opts`:
520
+ /// cwd, user, env, detach_keys, rlimits.
521
+ fn attach(&self, cmd: String, args: Vec<String>, opts: RHash) -> Result<i32, Error> {
522
+ let parsed = AttachOpts::parse(args, opts)?;
523
+ block_on(self.inner.attach_with(cmd, move |b| parsed.apply(b))).map_err(error::to_ruby)
524
+ }
525
+
526
+ /// Attach an interactive terminal running the sandbox's default shell.
527
+ fn attach_shell(&self) -> Result<i32, Error> {
528
+ block_on(self.inner.attach_shell()).map_err(error::to_ruby)
529
+ }
530
+ }
531
+
532
+ //--------------------------------------------------------------------------------------------------
533
+ // Attach option parsing
534
+ //--------------------------------------------------------------------------------------------------
535
+
536
+ struct AttachOpts {
537
+ args: Vec<String>,
538
+ cwd: Option<String>,
539
+ user: Option<String>,
540
+ env: Vec<(String, String)>,
541
+ detach_keys: Option<String>,
542
+ rlimits: Vec<(RlimitResource, u64, u64)>,
543
+ }
544
+
545
+ impl AttachOpts {
546
+ fn parse(args: Vec<String>, opts: RHash) -> Result<Self, Error> {
547
+ Ok(Self {
548
+ args,
549
+ cwd: conv::opt_string(opts, "cwd")?,
550
+ user: conv::opt_string(opts, "user")?,
551
+ env: conv::opt_string_map(opts, "env")?,
552
+ detach_keys: conv::opt_string(opts, "detach_keys")?,
553
+ rlimits: parse_rlimits(opts)?,
554
+ })
555
+ }
556
+
557
+ fn apply(self, mut b: AttachOptionsBuilder) -> AttachOptionsBuilder {
558
+ if !self.args.is_empty() {
559
+ b = b.args(self.args);
560
+ }
561
+ if let Some(cwd) = self.cwd {
562
+ b = b.cwd(cwd);
563
+ }
564
+ if let Some(user) = self.user {
565
+ b = b.user(user);
566
+ }
567
+ for (k, v) in self.env {
568
+ b = b.env(k, v);
569
+ }
570
+ if let Some(keys) = self.detach_keys {
571
+ b = b.detach_keys(keys);
572
+ }
573
+ for (resource, soft, hard) in self.rlimits {
574
+ b = b.rlimit_range(resource, soft, hard);
575
+ }
576
+ b
577
+ }
437
578
  }
438
579
 
439
580
  //--------------------------------------------------------------------------------------------------
@@ -511,6 +652,358 @@ fn parse_rlimits(opts: RHash) -> Result<Vec<(RlimitResource, u64, u64)>, Error>
511
652
  Ok(out)
512
653
  }
513
654
 
655
+ //--------------------------------------------------------------------------------------------------
656
+ // Patch parsing (mirrors the Python binding's `apply_patch`)
657
+ //--------------------------------------------------------------------------------------------------
658
+
659
+ /// Read a required string field from a per-patch Hash.
660
+ fn patch_str(h: RHash, key: &str) -> Result<String, Error> {
661
+ conv::opt_string(h, key)?
662
+ .ok_or_else(|| error::base_error(format!("patch is missing required key :{key}")))
663
+ }
664
+
665
+ /// Read a required field as raw bytes (for the binary `file` patch content).
666
+ fn patch_bytes(h: RHash, key: &str) -> Result<Vec<u8>, Error> {
667
+ let s = conv::opt::<RString>(h, key)?
668
+ .ok_or_else(|| error::base_error(format!("patch is missing required key :{key}")))?;
669
+ // Copy out while the GVL is held; the buffer is consumed synchronously here.
670
+ Ok(unsafe { s.as_slice() }.to_vec())
671
+ }
672
+
673
+ /// Parse the `patches` option into core `Patch` operations. The Ruby layer
674
+ /// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash carrying a
675
+ /// `kind` discriminator plus the variant-specific fields.
676
+ fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
677
+ let mut out = Vec::new();
678
+ for h in conv::opt_hash_vec(opts, "patches")? {
679
+ let kind = patch_str(h, "kind")?;
680
+ let mode = conv::opt_u32(h, "mode")?;
681
+ let replace = conv::opt_bool(h, "replace")?;
682
+ let patch = match kind.as_str() {
683
+ "text" => Patch::Text {
684
+ path: patch_str(h, "path")?,
685
+ content: patch_str(h, "content")?,
686
+ mode,
687
+ replace,
688
+ },
689
+ "file" => Patch::File {
690
+ path: patch_str(h, "path")?,
691
+ content: patch_bytes(h, "content")?,
692
+ mode,
693
+ replace,
694
+ },
695
+ "append" => Patch::Append {
696
+ path: patch_str(h, "path")?,
697
+ content: patch_str(h, "content")?,
698
+ },
699
+ "copy_file" => Patch::CopyFile {
700
+ src: patch_str(h, "src")?.into(),
701
+ dst: patch_str(h, "dst")?,
702
+ mode,
703
+ replace,
704
+ },
705
+ "copy_dir" => Patch::CopyDir {
706
+ src: patch_str(h, "src")?.into(),
707
+ dst: patch_str(h, "dst")?,
708
+ replace,
709
+ },
710
+ "symlink" => Patch::Symlink {
711
+ target: patch_str(h, "target")?,
712
+ link: patch_str(h, "link")?,
713
+ replace,
714
+ },
715
+ "mkdir" => Patch::Mkdir {
716
+ path: patch_str(h, "path")?,
717
+ mode,
718
+ },
719
+ "remove" => Patch::Remove {
720
+ path: patch_str(h, "path")?,
721
+ },
722
+ other => {
723
+ return Err(error::base_error(format!(
724
+ "unknown patch kind {other:?} (expected one of \
725
+ text/file/append/copy_file/copy_dir/symlink/mkdir/remove)"
726
+ )))
727
+ }
728
+ };
729
+ out.push(patch);
730
+ }
731
+ Ok(out)
732
+ }
733
+
734
+ //--------------------------------------------------------------------------------------------------
735
+ // Network policy parsing (mirrors the Python binding's `apply_network`)
736
+ //--------------------------------------------------------------------------------------------------
737
+
738
+ /// Parse the `network_policy` option (a Hash normalized by the Ruby layer) into
739
+ /// a core `NetworkPolicy`. Returns `None` when the option is absent (bare
740
+ /// presets travel via the separate `network` key handled in `create`).
741
+ ///
742
+ /// Composition (mirrors the Go SDK's `NetworkConfig`): bulk domain-deny rules
743
+ /// come first (so they outrank later allow rules), then a preset's rules (if a
744
+ /// preset base is given), then the caller's explicit `rules`. Per-direction
745
+ /// defaults come from the explicit `default_egress`/`default_ingress` when set,
746
+ /// else the preset's defaults, else the asymmetric default (deny egress / allow
747
+ /// ingress).
748
+ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
749
+ let Some(np) = conv::opt::<RHash>(opts, "network_policy")? else {
750
+ return Ok(None);
751
+ };
752
+
753
+ // Bulk domain denials → prepended deny-egress rules.
754
+ let mut rules: Vec<Rule> = Vec::new();
755
+ for d in conv::opt_string_vec(np, "deny_domains")? {
756
+ let domain = d
757
+ .parse()
758
+ .map_err(|e| error::base_error(format!("deny_domains {d:?}: {e}")))?;
759
+ rules.push(Rule::deny_egress(Destination::Domain(domain)));
760
+ }
761
+ for s in conv::opt_string_vec(np, "deny_domain_suffixes")? {
762
+ let suffix = s
763
+ .parse()
764
+ .map_err(|e| error::base_error(format!("deny_domain_suffixes {s:?}: {e}")))?;
765
+ rules.push(Rule::deny_egress(Destination::DomainSuffix(suffix)));
766
+ }
767
+
768
+ // Optional preset base (its rules and defaults seed the policy).
769
+ let (preset_egress, preset_ingress) = match conv::opt_string(np, "preset")? {
770
+ Some(p) => {
771
+ let mut base = network_preset(&p)?;
772
+ rules.append(&mut base.rules);
773
+ (Some(base.default_egress), Some(base.default_ingress))
774
+ }
775
+ None => (None, None),
776
+ };
777
+
778
+ // Caller's explicit rules come after preset rules.
779
+ for rd in conv::opt_hash_vec(np, "rules")? {
780
+ rules.push(parse_rule(rd)?);
781
+ }
782
+
783
+ let default_egress = match conv::opt_string(np, "default_egress")? {
784
+ Some(s) => action_from_str(&s)?,
785
+ None => preset_egress.unwrap_or(Action::Deny),
786
+ };
787
+ let default_ingress = match conv::opt_string(np, "default_ingress")? {
788
+ Some(s) => action_from_str(&s)?,
789
+ None => preset_ingress.unwrap_or(Action::Allow),
790
+ };
791
+
792
+ Ok(Some(NetworkPolicy {
793
+ default_egress,
794
+ default_ingress,
795
+ rules,
796
+ }))
797
+ }
798
+
799
+ fn network_preset(p: &str) -> Result<NetworkPolicy, Error> {
800
+ Ok(match p {
801
+ "none" | "disabled" | "disable" | "airgapped" => NetworkPolicy::none(),
802
+ "public" | "public_only" | "public-only" | "default" => NetworkPolicy::public_only(),
803
+ "all" | "allow_all" | "allow-all" => NetworkPolicy::allow_all(),
804
+ "non_local" | "non-local" | "nonlocal" => NetworkPolicy::non_local(),
805
+ other => {
806
+ return Err(error::base_error(format!(
807
+ "unknown network preset {other:?} (expected one of \
808
+ public_only/none/allow_all/non_local)"
809
+ )))
810
+ }
811
+ })
812
+ }
813
+
814
+ fn action_from_str(s: &str) -> Result<Action, Error> {
815
+ match s {
816
+ "allow" => Ok(Action::Allow),
817
+ "deny" => Ok(Action::Deny),
818
+ other => Err(error::base_error(format!(
819
+ "unknown network action {other:?} (expected allow/deny)"
820
+ ))),
821
+ }
822
+ }
823
+
824
+ fn direction_from_str(s: &str) -> Result<Direction, Error> {
825
+ match s {
826
+ "egress" => Ok(Direction::Egress),
827
+ "ingress" => Ok(Direction::Ingress),
828
+ "any" => Ok(Direction::Any),
829
+ other => Err(error::base_error(format!(
830
+ "unknown rule direction {other:?} (expected egress/ingress/any)"
831
+ ))),
832
+ }
833
+ }
834
+
835
+ fn protocol_from_str(s: &str) -> Result<Protocol, Error> {
836
+ match s {
837
+ "tcp" => Ok(Protocol::Tcp),
838
+ "udp" => Ok(Protocol::Udp),
839
+ "icmpv4" => Ok(Protocol::Icmpv4),
840
+ "icmpv6" => Ok(Protocol::Icmpv6),
841
+ other => Err(error::base_error(format!(
842
+ "unknown protocol {other:?} (expected tcp/udp/icmpv4/icmpv6)"
843
+ ))),
844
+ }
845
+ }
846
+
847
+ /// Parse a single rule Hash into a core `Rule`.
848
+ fn parse_rule(rd: RHash) -> Result<Rule, Error> {
849
+ let action = action_from_str(&patch_str(rd, "action")?)?;
850
+ let direction = match conv::opt_string(rd, "direction")? {
851
+ Some(s) => direction_from_str(&s)?,
852
+ None => Direction::Egress,
853
+ };
854
+ let kind = conv::opt_string(rd, "destination_kind")?;
855
+ let raw = conv::opt_string(rd, "destination")?;
856
+ let destination = parse_destination(kind.as_deref(), raw.as_deref())?;
857
+
858
+ let mut protocols = Vec::new();
859
+ for p in conv::opt_string_vec(rd, "protocols")? {
860
+ let proto = protocol_from_str(&p)?;
861
+ if !protocols.contains(&proto) {
862
+ protocols.push(proto);
863
+ }
864
+ }
865
+
866
+ let mut ports = Vec::new();
867
+ for p in conv::opt_string_vec(rd, "ports")? {
868
+ let range = parse_port_range(&p)?;
869
+ if !ports.contains(&range) {
870
+ ports.push(range);
871
+ }
872
+ }
873
+
874
+ Ok(Rule {
875
+ direction,
876
+ destination,
877
+ protocols,
878
+ ports,
879
+ action,
880
+ })
881
+ }
882
+
883
+ /// Parse a port string into a `PortRange`. Accepts a single port (`"443"`) or
884
+ /// an inclusive range (`"8000-9000"`).
885
+ fn parse_port_range(raw: &str) -> Result<PortRange, Error> {
886
+ let invalid = || error::base_error(format!("invalid port {raw:?} (expected N or N-M)"));
887
+ if let Some((lo, hi)) = raw.split_once('-') {
888
+ let lo: u16 = lo.trim().parse().map_err(|_| invalid())?;
889
+ let hi: u16 = hi.trim().parse().map_err(|_| invalid())?;
890
+ if lo > hi {
891
+ return Err(error::base_error(format!(
892
+ "invalid port range {raw:?}: low {lo} exceeds high {hi}"
893
+ )));
894
+ }
895
+ Ok(PortRange::range(lo, hi))
896
+ } else {
897
+ let p: u16 = raw.trim().parse().map_err(|_| invalid())?;
898
+ Ok(PortRange::single(p))
899
+ }
900
+ }
901
+
902
+ /// Resolve a destination from an explicit `kind` + raw value, or — when `kind`
903
+ /// is absent — classify the raw shorthand string. Mirrors the Python binding's
904
+ /// `parse_network_destination` / `parse_shorthand_destination`.
905
+ fn parse_destination(kind: Option<&str>, raw: Option<&str>) -> Result<Destination, Error> {
906
+ let required = |raw: Option<&str>, kind: &str| -> Result<String, Error> {
907
+ raw.map(str::to_string).ok_or_else(|| {
908
+ error::base_error(format!(
909
+ "destination is required for destination kind {kind:?}"
910
+ ))
911
+ })
912
+ };
913
+ match kind {
914
+ Some("any") => Ok(Destination::Any),
915
+ Some("ip") => parse_ip_destination(&required(raw, "ip")?),
916
+ Some("cidr") => parse_cidr_destination(&required(raw, "cidr")?),
917
+ Some("domain") => parse_domain_destination(&required(raw, "domain")?),
918
+ Some("domain_suffix") | Some("domain-suffix") => {
919
+ parse_domain_suffix_destination(&required(raw, "domain_suffix")?)
920
+ }
921
+ Some("group") => parse_group_destination(&required(raw, "group")?),
922
+ Some(other) => Err(error::base_error(format!(
923
+ "unknown destination kind {other:?}"
924
+ ))),
925
+ None => parse_shorthand_destination(raw),
926
+ }
927
+ }
928
+
929
+ fn parse_shorthand_destination(raw: Option<&str>) -> Result<Destination, Error> {
930
+ let Some(raw) = raw else {
931
+ return Ok(Destination::Any);
932
+ };
933
+ if raw == "*" {
934
+ return Ok(Destination::Any);
935
+ }
936
+ if let Some(rest) = raw.strip_prefix("domain=") {
937
+ return parse_domain_destination(rest);
938
+ }
939
+ if let Some(rest) = raw.strip_prefix("suffix=") {
940
+ return parse_domain_suffix_destination(rest);
941
+ }
942
+ if let Some(dest) = maybe_group_destination(raw) {
943
+ return Ok(dest);
944
+ }
945
+ if raw.starts_with('.') {
946
+ return parse_domain_suffix_destination(raw);
947
+ }
948
+ if raw.contains('/') {
949
+ return parse_cidr_destination(raw);
950
+ }
951
+ if raw.parse::<std::net::IpAddr>().is_ok() {
952
+ return parse_ip_destination(raw);
953
+ }
954
+ parse_domain_destination(raw)
955
+ }
956
+
957
+ fn parse_ip_destination(raw: &str) -> Result<Destination, Error> {
958
+ let ip: std::net::IpAddr = raw
959
+ .parse()
960
+ .map_err(|e| error::base_error(format!("invalid IP address {raw:?}: {e}")))?;
961
+ let prefix = if ip.is_ipv4() { 32 } else { 128 };
962
+ let cidr = ipnetwork::IpNetwork::new(ip, prefix)
963
+ .map_err(|e| error::base_error(format!("invalid IP address {raw:?}: {e}")))?;
964
+ Ok(Destination::Cidr(cidr))
965
+ }
966
+
967
+ fn parse_cidr_destination(raw: &str) -> Result<Destination, Error> {
968
+ let cidr: ipnetwork::IpNetwork = raw
969
+ .parse()
970
+ .map_err(|e| error::base_error(format!("invalid CIDR {raw:?}: {e}")))?;
971
+ Ok(Destination::Cidr(cidr))
972
+ }
973
+
974
+ fn parse_domain_destination(raw: &str) -> Result<Destination, Error> {
975
+ let name = raw
976
+ .parse()
977
+ .map_err(|e| error::base_error(format!("invalid domain {raw:?}: {e}")))?;
978
+ Ok(Destination::Domain(name))
979
+ }
980
+
981
+ fn parse_domain_suffix_destination(raw: &str) -> Result<Destination, Error> {
982
+ let name = raw
983
+ .parse()
984
+ .map_err(|e| error::base_error(format!("invalid domain suffix {raw:?}: {e}")))?;
985
+ Ok(Destination::DomainSuffix(name))
986
+ }
987
+
988
+ fn parse_group_destination(raw: &str) -> Result<Destination, Error> {
989
+ maybe_group_destination(raw)
990
+ .ok_or_else(|| error::base_error(format!("unknown destination group {raw:?}")))
991
+ }
992
+
993
+ fn maybe_group_destination(raw: &str) -> Option<Destination> {
994
+ let group = match raw {
995
+ "public" => DestinationGroup::Public,
996
+ "loopback" => DestinationGroup::Loopback,
997
+ "private" => DestinationGroup::Private,
998
+ "link-local" | "link_local" => DestinationGroup::LinkLocal,
999
+ "metadata" => DestinationGroup::Metadata,
1000
+ "multicast" => DestinationGroup::Multicast,
1001
+ "host" => DestinationGroup::Host,
1002
+ _ => return None,
1003
+ };
1004
+ Some(Destination::Group(group))
1005
+ }
1006
+
514
1007
  //--------------------------------------------------------------------------------------------------
515
1008
  // Registry option parsing
516
1009
  //--------------------------------------------------------------------------------------------------
@@ -582,6 +1075,7 @@ struct ExecOpts {
582
1075
  timeout: Option<Duration>,
583
1076
  tty: bool,
584
1077
  stdin: Option<Vec<u8>>,
1078
+ stdin_pipe: bool,
585
1079
  rlimits: Vec<(RlimitResource, u64, u64)>,
586
1080
  }
587
1081
 
@@ -596,6 +1090,7 @@ impl ExecOpts {
596
1090
  timeout: conv::opt_f64(opts, "timeout")?.map(Duration::from_secs_f64),
597
1091
  tty: conv::opt_bool(opts, "tty")?,
598
1092
  stdin,
1093
+ stdin_pipe: conv::opt_bool(opts, "stdin_pipe")?,
599
1094
  rlimits: parse_rlimits(opts)?,
600
1095
  })
601
1096
  }
@@ -622,7 +1117,13 @@ impl ExecOpts {
622
1117
  if self.tty {
623
1118
  b = b.tty(true);
624
1119
  }
625
- if let Some(stdin) = self.stdin {
1120
+ // Pipe mode opens a writable stdin sink (lifted out by ExecHandle via
1121
+ // `take_stdin`); bytes mode feeds a fixed buffer and closes. The core's
1122
+ // `StdinMode` is a single enum, so the two are mutually exclusive — pipe
1123
+ // wins if a caller somehow sets both.
1124
+ if self.stdin_pipe {
1125
+ b = b.stdin_pipe();
1126
+ } else if let Some(stdin) = self.stdin {
626
1127
  b = b.stdin_bytes(stdin);
627
1128
  }
628
1129
  for (resource, soft, hard) in self.rlimits {
@@ -713,7 +1214,13 @@ pub(crate) fn metrics_to_hash(m: &SandboxMetrics) -> RHash {
713
1214
  }
714
1215
 
715
1216
  fn sandbox_status_str(status: SandboxStatus) -> &'static str {
1217
+ // Lowercased `Debug` names, matching the official SDKs' `format!("{:?}")`.
1218
+ // `Created`/`Starting` are new in v0.5.8 (cloud-only today). The match is
1219
+ // intentionally exhaustive — no wildcard — so a future upstream variant
1220
+ // surfaces as a compile error rather than a silent fallback.
716
1221
  match status {
1222
+ SandboxStatus::Created => "created",
1223
+ SandboxStatus::Starting => "starting",
717
1224
  SandboxStatus::Running => "running",
718
1225
  SandboxStatus::Draining => "draining",
719
1226
  SandboxStatus::Paused => "paused",
@@ -722,6 +1229,15 @@ fn sandbox_status_str(status: SandboxStatus) -> &'static str {
722
1229
  }
723
1230
  }
724
1231
 
1232
+ /// A `std::process::ExitStatus` as a Ruby Hash: `exit_code` (Integer or nil) and
1233
+ /// `success` (Boolean). Returned by the live `Sandbox#wait` / `#stop_and_wait`.
1234
+ fn exit_status_to_hash(status: std::process::ExitStatus) -> RHash {
1235
+ let hash = ruby().hash_new();
1236
+ let _ = hash.aset("exit_code", status.code());
1237
+ let _ = hash.aset("success", status.success());
1238
+ hash
1239
+ }
1240
+
725
1241
  fn stop_result_to_hash(result: &SandboxStopResult) -> RHash {
726
1242
  let hash = ruby().hash_new();
727
1243
  let _ = hash.aset("name", result.name.clone());
@@ -733,19 +1249,85 @@ fn stop_result_to_hash(result: &SandboxStopResult) -> RHash {
733
1249
  hash
734
1250
  }
735
1251
 
736
- fn handle_to_hash(handle: &SandboxHandle) -> RHash {
737
- let hash = ruby().hash_new();
738
- let _ = hash.aset("name", handle.name().to_string());
739
- let _ = hash.aset("status", sandbox_status_str(handle.status()));
740
- let _ = hash.aset(
741
- "created_at_ms",
742
- handle.created_at().map(|dt| dt.timestamp_millis()),
743
- );
744
- let _ = hash.aset(
745
- "updated_at_ms",
746
- handle.updated_at().map(|dt| dt.timestamp_millis()),
747
- );
748
- hash
1252
+ //--------------------------------------------------------------------------------------------------
1253
+ // SandboxHandle the controllable lightweight handle
1254
+ //--------------------------------------------------------------------------------------------------
1255
+
1256
+ /// Wraps a core `microsandbox::sandbox::SandboxHandle` (returned by
1257
+ /// `Sandbox.get`/`list`/`list_with`). Carries metadata accessors plus the rich
1258
+ /// lifecycle surface that moved off the live `Sandbox` in v0.5.8 — mirroring the
1259
+ /// official Python (`PySandboxHandle`) and Node (`SandboxHandle`) SDKs. Status is
1260
+ /// a synchronous snapshot read off the handle (no round-trip).
1261
+ #[magnus::wrap(class = "Microsandbox::Native::SandboxHandle", free_immediately, size)]
1262
+ pub struct SbHandle {
1263
+ inner: SandboxHandle,
1264
+ }
1265
+
1266
+ impl SbHandle {
1267
+ fn from_inner(inner: SandboxHandle) -> Self {
1268
+ Self { inner }
1269
+ }
1270
+
1271
+ fn name(&self) -> String {
1272
+ self.inner.name().to_string()
1273
+ }
1274
+
1275
+ /// Status snapshot captured when the handle was fetched (synchronous).
1276
+ fn status(&self) -> String {
1277
+ sandbox_status_str(self.inner.status_snapshot()).to_string()
1278
+ }
1279
+
1280
+ fn created_at_ms(&self) -> Option<i64> {
1281
+ self.inner.created_at().map(|dt| dt.timestamp_millis())
1282
+ }
1283
+
1284
+ fn updated_at_ms(&self) -> Option<i64> {
1285
+ self.inner.updated_at().map(|dt| dt.timestamp_millis())
1286
+ }
1287
+
1288
+ /// Graceful stop (SIGTERM→SIGKILL escalation, 10s default) and wait.
1289
+ fn stop(&self) -> Result<(), Error> {
1290
+ block_on(self.inner.stop()).map_err(error::to_ruby)
1291
+ }
1292
+
1293
+ /// Graceful stop with a custom escalation timeout (seconds).
1294
+ fn stop_with_timeout(&self, secs: f64) -> Result<(), Error> {
1295
+ block_on(self.inner.stop_with_timeout(Duration::from_secs_f64(secs)))
1296
+ .map_err(error::to_ruby)
1297
+ }
1298
+
1299
+ /// Force kill (SIGKILL) and wait.
1300
+ fn kill(&self) -> Result<(), Error> {
1301
+ block_on(self.inner.kill()).map_err(error::to_ruby)
1302
+ }
1303
+
1304
+ /// Force kill, waiting up to `secs` for the process to disappear.
1305
+ fn kill_with_timeout(&self, secs: f64) -> Result<(), Error> {
1306
+ block_on(self.inner.kill_with_timeout(Duration::from_secs_f64(secs)))
1307
+ .map_err(error::to_ruby)
1308
+ }
1309
+
1310
+ /// Send the graceful-shutdown request and return without waiting.
1311
+ fn request_stop(&self) -> Result<(), Error> {
1312
+ block_on(self.inner.request_stop()).map_err(error::to_ruby)
1313
+ }
1314
+
1315
+ /// Send the force-kill request and return without waiting.
1316
+ fn request_kill(&self) -> Result<(), Error> {
1317
+ block_on(self.inner.request_kill()).map_err(error::to_ruby)
1318
+ }
1319
+
1320
+ /// Send the drain request (SIGUSR1) and return without waiting.
1321
+ fn request_drain(&self) -> Result<(), Error> {
1322
+ block_on(self.inner.request_drain()).map_err(error::to_ruby)
1323
+ }
1324
+
1325
+ /// Block until the sandbox reaches a terminal state; returns a stop-result
1326
+ /// Hash (name, status, exit_code, signal, observed_at_ms, source).
1327
+ fn wait_until_stopped(&self) -> Result<RHash, Error> {
1328
+ let result = block_on(self.inner.wait_until_stopped()).map_err(error::to_ruby)?;
1329
+ Ok(stop_result_to_hash(&result))
1330
+ }
749
1331
  }
750
1332
 
751
1333
  //--------------------------------------------------------------------------------------------------
@@ -846,15 +1428,12 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
846
1428
  class.define_method("shell", method!(Sandbox::shell, 2))?;
847
1429
  class.define_method("exec_stream", method!(Sandbox::exec_stream, 3))?;
848
1430
  class.define_method("shell_stream", method!(Sandbox::shell_stream, 2))?;
849
- class.define_method("stop", method!(Sandbox::stop, 1))?;
850
- class.define_method("kill", method!(Sandbox::kill, 1))?;
851
- class.define_method("request_stop", method!(Sandbox::request_stop, 0))?;
852
- class.define_method("request_kill", method!(Sandbox::request_kill, 0))?;
853
- class.define_method("request_drain", method!(Sandbox::request_drain, 0))?;
854
- class.define_method(
855
- "wait_until_stopped",
856
- method!(Sandbox::wait_until_stopped, 0),
857
- )?;
1431
+ class.define_method("stop", method!(Sandbox::stop, 0))?;
1432
+ class.define_method("stop_and_wait", method!(Sandbox::stop_and_wait, 0))?;
1433
+ class.define_method("kill", method!(Sandbox::kill, 0))?;
1434
+ class.define_method("drain", method!(Sandbox::drain, 0))?;
1435
+ class.define_method("wait", method!(Sandbox::wait, 0))?;
1436
+ class.define_method("status", method!(Sandbox::status, 0))?;
858
1437
  class.define_method("owns_lifecycle", method!(Sandbox::owns_lifecycle, 0))?;
859
1438
  class.define_method("detach", method!(Sandbox::detach, 0))?;
860
1439
  class.define_method("metrics", method!(Sandbox::metrics, 0))?;
@@ -876,5 +1455,31 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
876
1455
  class.define_method("fs_copy_from_host", method!(Sandbox::fs_copy_from_host, 2))?;
877
1456
  class.define_method("fs_copy_to_host", method!(Sandbox::fs_copy_to_host, 2))?;
878
1457
 
1458
+ class.define_method("ssh_open_client", method!(Sandbox::ssh_open_client, 1))?;
1459
+ class.define_method(
1460
+ "ssh_prepare_server",
1461
+ method!(Sandbox::ssh_prepare_server, 1),
1462
+ )?;
1463
+
1464
+ class.define_method("attach", method!(Sandbox::attach, 3))?;
1465
+ class.define_method("attach_shell", method!(Sandbox::attach_shell, 0))?;
1466
+
1467
+ let handle = native.define_class("SandboxHandle", ruby.class_object())?;
1468
+ handle.define_method("name", method!(SbHandle::name, 0))?;
1469
+ handle.define_method("status", method!(SbHandle::status, 0))?;
1470
+ handle.define_method("created_at_ms", method!(SbHandle::created_at_ms, 0))?;
1471
+ handle.define_method("updated_at_ms", method!(SbHandle::updated_at_ms, 0))?;
1472
+ handle.define_method("stop", method!(SbHandle::stop, 0))?;
1473
+ handle.define_method("stop_with_timeout", method!(SbHandle::stop_with_timeout, 1))?;
1474
+ handle.define_method("kill", method!(SbHandle::kill, 0))?;
1475
+ handle.define_method("kill_with_timeout", method!(SbHandle::kill_with_timeout, 1))?;
1476
+ handle.define_method("request_stop", method!(SbHandle::request_stop, 0))?;
1477
+ handle.define_method("request_kill", method!(SbHandle::request_kill, 0))?;
1478
+ handle.define_method("request_drain", method!(SbHandle::request_drain, 0))?;
1479
+ handle.define_method(
1480
+ "wait_until_stopped",
1481
+ method!(SbHandle::wait_until_stopped, 0),
1482
+ )?;
1483
+
879
1484
  Ok(())
880
1485
  }