microsandbox-rb 0.6.0 → 0.7.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.
@@ -7,23 +7,32 @@
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::sync::Arc;
10
11
  use std::time::Duration;
11
12
 
12
13
  use chrono::{DateTime, Utc};
13
- use magnus::{function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby};
14
+ use magnus::{
15
+ function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby, TryConvert, Value,
16
+ };
14
17
  use microsandbox::logs::{
15
18
  LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
16
19
  };
17
20
  use microsandbox::sandbox::{
18
- AttachOptionsBuilder, FsEntry, FsEntryKind, FsMetadata, Patch, PullPolicy, RlimitResource,
19
- SandboxFilter, SandboxHandle, SandboxMetrics, SandboxStatus, SandboxStopResult,
20
- SecurityProfile,
21
+ AttachOptionsBuilder, DiskImageFormat, FsEntry, FsEntryKind, FsMetadata, HostPermissions,
22
+ Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource, SandboxBuilder,
23
+ SandboxFilter, SandboxHandle, SandboxMetrics, SandboxStatus, SandboxStopResult, SecretBuilder,
24
+ SecurityProfile, StatVirtualization,
21
25
  };
22
26
  use microsandbox::LogLevel;
27
+ use microsandbox::MicrosandboxResult;
23
28
  use microsandbox::RegistryAuth;
29
+ use microsandbox_network::builder::ViolationActionBuilder;
30
+ use microsandbox_network::dns::Nameserver;
24
31
  use microsandbox_network::policy::{
25
32
  Action, Destination, DestinationGroup, Direction, NetworkPolicy, PortRange, Protocol, Rule,
26
33
  };
34
+ use tokio::sync::Mutex;
35
+ use tokio::task::JoinHandle;
27
36
 
28
37
  use crate::conv;
29
38
  use crate::error;
@@ -45,12 +54,22 @@ impl Sandbox {
45
54
  // Lifecycle (singleton methods)
46
55
  //----------------------------------------------------------------------
47
56
 
48
- /// Create and boot a sandbox. `opts` is a string-keyed options Hash.
49
- fn create(name: String, opts: RHash) -> Result<Sandbox, Error> {
57
+ /// Build a configured `SandboxBuilder` from a string-keyed options Hash.
58
+ /// Shared by `create` (blocking) and `create_with_progress` (streaming pull).
59
+ fn build_builder(name: String, opts: RHash) -> Result<SandboxBuilder, Error> {
50
60
  let mut b = microsandbox::Sandbox::builder(name);
51
61
 
52
62
  if let Some(v) = conv::opt_string(opts, "image")? {
53
- b = b.image(v);
63
+ if let Some(fstype) = conv::opt_string(opts, "fstype")? {
64
+ // An explicit fstype means `image` names a disk-image rootfs path
65
+ // whose inner filesystem can't be auto-probed: route through
66
+ // image_with(disk().fstype()). (A bare `image` string otherwise
67
+ // auto-detects OCI vs disk by extension.) Errors in disk()/fstype()
68
+ // are captured on the builder and surface at create().
69
+ b = b.image_with(move |i| i.disk(v).fstype(fstype));
70
+ } else {
71
+ b = b.image(v);
72
+ }
54
73
  }
55
74
  if let Some(v) = conv::opt_string(opts, "from_snapshot")? {
56
75
  b = b.from_snapshot(v);
@@ -89,59 +108,84 @@ impl Sandbox {
89
108
  for (host, guest) in conv::opt_port_map(opts, "ports")? {
90
109
  b = b.port(host, guest);
91
110
  }
92
- // volumes: normalized by the Ruby layer to [guest, kind, source] triples,
93
- // or [guest, kind, source, options] quads where options is a comma-joined
94
- // list (ro/readonly, rw, noexec, nosuid, nodev).
95
- for spec in conv::opt::<Vec<Vec<String>>>(opts, "volumes")?.unwrap_or_default() {
96
- if spec.len() < 3 || spec.len() > 4 {
97
- return Err(error::base_error("invalid volume mount spec"));
98
- }
99
- let (guest, kind, source) = (spec[0].clone(), spec[1].clone(), spec[2].clone());
111
+ // volumes: each mount is normalized by the Ruby layer to a string-keyed
112
+ // Hash guest (req), kind ("bind"/"named"/"tmpfs"/"disk"), source
113
+ // (bind/named/disk), size_mib (tmpfs/disk), format + fstype (disk),
114
+ // readonly/noexec/nosuid/nodev (bool), stat_virtualization,
115
+ // host_permissions. Enum-valued options are validated up front (the
116
+ // volume closure can't return an error); the core validates the rest
117
+ // (e.g. rejecting stat_virtualization on tmpfs/disk) at create().
118
+ for m in conv::opt_hash_vec(opts, "volumes")? {
119
+ let guest = conv::opt_string(m, "guest")?
120
+ .ok_or_else(|| error::base_error("volume mount is missing :guest"))?;
121
+ let kind = conv::opt_string(m, "kind")?
122
+ .ok_or_else(|| error::base_error("volume mount is missing :kind"))?;
123
+ let source = conv::opt_string(m, "source")?;
124
+ let size_mib = conv::opt_u32(m, "size_mib")?;
125
+ let fstype = conv::opt_string(m, "fstype")?;
126
+ let readonly = conv::opt_bool(m, "readonly")?;
127
+ let noexec = conv::opt_bool(m, "noexec")?;
128
+ let nosuid = conv::opt_bool(m, "nosuid")?;
129
+ let nodev = conv::opt_bool(m, "nodev")?;
130
+ let format = conv::opt_string(m, "format")?
131
+ .map(|f| disk_format_from_str(&f))
132
+ .transpose()?;
133
+ let stat_virt = conv::opt_string(m, "stat_virtualization")?
134
+ .map(|s| stat_virtualization_from_str(&s))
135
+ .transpose()?;
136
+ let host_perms = conv::opt_string(m, "host_permissions")?
137
+ .map(|s| host_permissions_from_str(&s))
138
+ .transpose()?;
139
+ // bind/named/disk require a source; tmpfs must not have one.
100
140
  match kind.as_str() {
101
- "bind" | "named" => {}
102
- other => {
141
+ "bind" | "named" | "disk" if source.is_some() => {}
142
+ "bind" | "named" | "disk" => {
103
143
  return Err(error::base_error(format!(
104
- "unknown volume mount kind {other:?} (expected \"bind\" or \"named\")"
144
+ "volume mount kind {kind:?} requires a source"
105
145
  )))
106
146
  }
107
- }
108
- // Validate options up front (the volume closure cannot return an error).
109
- let mount_opts: Vec<String> = spec
110
- .get(3)
111
- .map(|s| {
112
- s.split(',')
113
- .map(|o| o.trim().to_string())
114
- .filter(|o| !o.is_empty())
115
- .collect()
116
- })
117
- .unwrap_or_default();
118
- for opt in &mount_opts {
119
- match opt.as_str() {
120
- "ro" | "readonly" | "rw" | "noexec" | "nosuid" | "nodev" => {}
121
- other => {
122
- return Err(error::base_error(format!(
123
- "unknown volume mount option {other:?} \
124
- (expected ro/rw/noexec/nosuid/nodev)"
125
- )))
126
- }
147
+ "tmpfs" => {}
148
+ other => {
149
+ return Err(error::base_error(format!(
150
+ "unknown volume mount kind {other:?} (expected bind/named/tmpfs/disk)"
151
+ )))
127
152
  }
128
153
  }
129
- b = b.volume(guest, move |m| {
130
- let mut m = if kind == "named" {
131
- m.named(source)
132
- } else {
133
- m.bind(source)
154
+ b = b.volume(guest, move |mut mb| {
155
+ mb = match kind.as_str() {
156
+ "named" => mb.named(source.unwrap()),
157
+ "tmpfs" => mb.tmpfs(),
158
+ "disk" => mb.disk(source.unwrap()),
159
+ _ => mb.bind(source.unwrap()), // "bind"
134
160
  };
135
- for opt in &mount_opts {
136
- m = match opt.as_str() {
137
- "ro" | "readonly" => m.readonly(),
138
- "noexec" => m.noexec(),
139
- "nosuid" => m.nosuid(),
140
- "nodev" => m.nodev(),
141
- _ => m, // "rw" — default; already validated above
142
- };
161
+ if let Some(n) = size_mib {
162
+ mb = mb.size(n);
143
163
  }
144
- m
164
+ if let Some(f) = format {
165
+ mb = mb.format(f);
166
+ }
167
+ if let Some(ft) = fstype {
168
+ mb = mb.fstype(ft);
169
+ }
170
+ if readonly {
171
+ mb = mb.readonly();
172
+ }
173
+ if noexec {
174
+ mb = mb.noexec();
175
+ }
176
+ if nosuid {
177
+ mb = mb.nosuid();
178
+ }
179
+ if nodev {
180
+ mb = mb.nodev();
181
+ }
182
+ if let Some(sv) = stat_virt {
183
+ mb = mb.stat_virtualization(sv);
184
+ }
185
+ if let Some(hp) = host_perms {
186
+ mb = mb.host_permissions(hp);
187
+ }
188
+ mb
145
189
  });
146
190
  }
147
191
  // patches: rootfs modifications applied before boot. The Ruby layer
@@ -207,16 +251,123 @@ impl Sandbox {
207
251
  for (resource, soft, hard) in parse_rlimits(opts)? {
208
252
  b = b.rlimit_range(resource, soft, hard);
209
253
  }
210
- // secrets: normalized by the Ruby layer to [env_var, value, allowed_host]
211
- // triples. Uses the placeholder-based `secret_env` shorthand, which also
212
- // auto-enables TLS interception (required for value substitution).
213
- for spec in conv::opt::<Vec<Vec<String>>>(opts, "secrets")?.unwrap_or_default() {
214
- if spec.len() != 3 {
215
- return Err(error::base_error(
216
- "invalid secret spec (expected [env, value, host])",
217
- ));
218
- }
219
- b = b.secret_env(spec[0].clone(), spec[1].clone(), spec[2].clone());
254
+ // secrets: each entry is normalized by the Ruby layer to a string-keyed
255
+ // Hash env (req), value (req), hosts / host_patterns (allow lists),
256
+ // placeholder, require_tls, inject_{headers,basic_auth,query,body},
257
+ // on_violation. Routed through the full secret builder (`b.secret`), which
258
+ // auto-enables TLS interception. Mirrors the Python/Node SecretEntry.
259
+ for h in conv::opt_hash_vec(opts, "secrets")? {
260
+ let spec = parse_secret(h)?;
261
+ b = b.secret(move |s| spec.apply(s));
262
+ }
263
+ // Sandbox-level secret-leak policy (block / block_and_log /
264
+ // block_and_terminate / passthrough). Applied via the network builder,
265
+ // which accumulates on top of any policy/dns/tls already configured.
266
+ if let Some(v) = conv::opt::<Value>(opts, "on_secret_violation")? {
267
+ let spec = parse_violation_spec(v)?;
268
+ b = b.network(move |n| n.on_secret_violation(move |va| spec.apply(va)));
269
+ }
270
+ // Advanced network configuration (custom DNS, TLS-interception tuning,
271
+ // guest IP pools, connection cap, host-CA trust), applied via the network
272
+ // builder, which accumulates on top of any policy already configured.
273
+ // Mirrors the Python binding's `apply_network`. Parsed up front because
274
+ // the builder closures cannot return an error.
275
+ let dns = conv::opt::<RHash>(opts, "dns")?
276
+ .map(parse_dns)
277
+ .transpose()?;
278
+ let tls = conv::opt::<RHash>(opts, "tls")?
279
+ .map(parse_tls)
280
+ .transpose()?;
281
+ let ipv4_pool = conv::opt_string(opts, "ipv4_pool")?
282
+ .map(|s| {
283
+ s.parse::<ipnetwork::Ipv4Network>()
284
+ .map_err(|e| error::base_error(format!("invalid ipv4_pool {s:?}: {e}")))
285
+ })
286
+ .transpose()?;
287
+ let ipv6_pool = conv::opt_string(opts, "ipv6_pool")?
288
+ .map(|s| {
289
+ s.parse::<ipnetwork::Ipv6Network>()
290
+ .map_err(|e| error::base_error(format!("invalid ipv6_pool {s:?}: {e}")))
291
+ })
292
+ .transpose()?;
293
+ let max_connections = conv::opt::<usize>(opts, "max_connections")?;
294
+ let trust_host_cas = conv::opt::<bool>(opts, "trust_host_cas")?;
295
+ if dns.is_some()
296
+ || tls.is_some()
297
+ || ipv4_pool.is_some()
298
+ || ipv6_pool.is_some()
299
+ || max_connections.is_some()
300
+ || trust_host_cas.is_some()
301
+ {
302
+ b = b.network(move |mut n| {
303
+ if let Some(dns) = dns {
304
+ n = n.dns(move |mut d| {
305
+ if !dns.nameservers.is_empty() {
306
+ d = d.nameservers(dns.nameservers);
307
+ }
308
+ if let Some(rp) = dns.rebind_protection {
309
+ d = d.rebind_protection(rp);
310
+ }
311
+ if let Some(qt) = dns.query_timeout_ms {
312
+ d = d.query_timeout_ms(qt);
313
+ }
314
+ d
315
+ });
316
+ }
317
+ if let Some(tls) = tls {
318
+ n = n.tls(move |mut t| {
319
+ for pat in tls.bypass {
320
+ t = t.bypass(pat);
321
+ }
322
+ if let Some(v) = tls.verify_upstream {
323
+ t = t.verify_upstream(v);
324
+ }
325
+ if let Some(ports) = tls.intercepted_ports {
326
+ t = t.intercepted_ports(ports);
327
+ }
328
+ if let Some(q) = tls.block_quic {
329
+ t = t.block_quic(q);
330
+ }
331
+ if let Some(p) = tls.upstream_ca_cert {
332
+ t = t.upstream_ca_cert(p);
333
+ }
334
+ if let Some(p) = tls.intercept_ca_cert {
335
+ t = t.intercept_ca_cert(p);
336
+ }
337
+ if let Some(p) = tls.intercept_ca_key {
338
+ t = t.intercept_ca_key(p);
339
+ }
340
+ t
341
+ });
342
+ }
343
+ if let Some(p) = ipv4_pool {
344
+ n = n.ipv4_pool(p);
345
+ }
346
+ if let Some(p) = ipv6_pool {
347
+ n = n.ipv6_pool(p);
348
+ }
349
+ if let Some(m) = max_connections {
350
+ n = n.max_connections(m);
351
+ }
352
+ if let Some(t) = trust_host_cas {
353
+ n = n.trust_host_cas(t);
354
+ }
355
+ n
356
+ });
357
+ }
358
+ // init: hand guest PID 1 to an init system. The Ruby layer normalizes
359
+ // `init:` to a Hash { cmd:, args?:, env?: }. `init_with` with empty
360
+ // args/env builds the same HandoffInit as the plain `init(cmd)`, so route
361
+ // everything through the one closure-builder.
362
+ if let Some(h) = conv::opt::<RHash>(opts, "init")? {
363
+ let cmd = conv::opt_string(h, "cmd")?
364
+ .ok_or_else(|| error::base_error("init requires a :cmd"))?;
365
+ let args = conv::opt_string_vec(h, "args")?;
366
+ let env = conv::opt_string_map(h, "env")?;
367
+ b = b.init_with(cmd, move |i| i.args(args).envs(env));
368
+ }
369
+ if conv::opt_bool(opts, "ephemeral")? {
370
+ b = b.ephemeral(true);
220
371
  }
221
372
  if conv::opt_bool(opts, "detached")? {
222
373
  b = b.detached(true);
@@ -227,10 +378,29 @@ impl Sandbox {
227
378
  b = b.replace();
228
379
  }
229
380
 
381
+ Ok(b)
382
+ }
383
+
384
+ /// Create and boot a sandbox. `opts` is a string-keyed options Hash.
385
+ fn create(name: String, opts: RHash) -> Result<Sandbox, Error> {
386
+ let b = Self::build_builder(name, opts)?;
230
387
  let inner = block_on(b.create()).map_err(error::to_ruby)?;
231
388
  Ok(Sandbox::from_inner(inner))
232
389
  }
233
390
 
391
+ /// Create a sandbox with streaming image-pull progress. Returns a
392
+ /// `PullSession` whose `recv` yields progress events and whose `result`
393
+ /// resolves to the booted sandbox. Mirrors Python `create_with_progress` /
394
+ /// Node `createWithPullProgress`.
395
+ fn create_with_progress(name: String, opts: RHash) -> Result<PullSession, Error> {
396
+ let b = Self::build_builder(name, opts)?;
397
+ // `create_with_pull_progress` spawns a tokio task, so it must run inside
398
+ // the runtime context even though the call itself is synchronous.
399
+ let (handle, join) =
400
+ block_on(async move { b.create_with_pull_progress() }).map_err(error::to_ruby)?;
401
+ Ok(PullSession::new(handle, join))
402
+ }
403
+
234
404
  /// Restart a previously-defined sandbox by name.
235
405
  fn start(name: String, opts: RHash) -> Result<Sandbox, Error> {
236
406
  let detached = conv::opt_bool(opts, "detached")?;
@@ -495,6 +665,20 @@ impl Sandbox {
495
665
  block_on(fs.copy_to_host(&guest_path, &host_path)).map_err(error::to_ruby)
496
666
  }
497
667
 
668
+ /// Open a streaming reader over a guest file (for files too large to buffer).
669
+ fn fs_read_stream(&self, path: String) -> Result<crate::fs_stream::FsReadStreamHandle, Error> {
670
+ let fs = self.inner.fs();
671
+ let stream = block_on(fs.read_stream(&path)).map_err(error::to_ruby)?;
672
+ Ok(crate::fs_stream::FsReadStreamHandle::new(stream))
673
+ }
674
+
675
+ /// Open a streaming writer to a guest file.
676
+ fn fs_write_stream(&self, path: String) -> Result<crate::fs_stream::FsWriteSinkHandle, Error> {
677
+ let fs = self.inner.fs();
678
+ let sink = block_on(fs.write_stream(&path)).map_err(error::to_ruby)?;
679
+ Ok(crate::fs_stream::FsWriteSinkHandle::new(sink))
680
+ }
681
+
498
682
  //----------------------------------------------------------------------
499
683
  // SSH (mirror SandboxSshOps)
500
684
  //----------------------------------------------------------------------
@@ -685,6 +869,47 @@ fn parse_rlimits(opts: RHash) -> Result<Vec<(RlimitResource, u64, u64)>, Error>
685
869
  Ok(out)
686
870
  }
687
871
 
872
+ //--------------------------------------------------------------------------------------------------
873
+ // Mount enum parsing
874
+ //--------------------------------------------------------------------------------------------------
875
+
876
+ fn disk_format_from_str(s: &str) -> Result<DiskImageFormat, Error> {
877
+ // Delegate the qcow2/raw/vmdk mapping to the core's `FromStr` (single source
878
+ // of truth) but keep the friendlier, option-listing error message.
879
+ s.parse::<DiskImageFormat>().map_err(|_| {
880
+ error::base_error(format!(
881
+ "unknown disk format {s:?} (expected qcow2/raw/vmdk)"
882
+ ))
883
+ })
884
+ }
885
+
886
+ fn stat_virtualization_from_str(s: &str) -> Result<StatVirtualization, Error> {
887
+ use StatVirtualization::*;
888
+ Ok(match s {
889
+ "strict" => Strict,
890
+ "relaxed" => Relaxed,
891
+ "off" => Off,
892
+ other => {
893
+ return Err(error::base_error(format!(
894
+ "unknown stat_virtualization {other:?} (expected strict/relaxed/off)"
895
+ )))
896
+ }
897
+ })
898
+ }
899
+
900
+ fn host_permissions_from_str(s: &str) -> Result<HostPermissions, Error> {
901
+ use HostPermissions::*;
902
+ Ok(match s {
903
+ "private" => Private,
904
+ "mirror" => Mirror,
905
+ other => {
906
+ return Err(error::base_error(format!(
907
+ "unknown host_permissions {other:?} (expected private/mirror)"
908
+ )))
909
+ }
910
+ })
911
+ }
912
+
688
913
  //--------------------------------------------------------------------------------------------------
689
914
  // Patch parsing (mirrors the Python binding's `apply_patch`)
690
915
  //--------------------------------------------------------------------------------------------------
@@ -764,6 +989,198 @@ fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
764
989
  Ok(out)
765
990
  }
766
991
 
992
+ //--------------------------------------------------------------------------------------------------
993
+ // Secret parsing (mirrors the Python/Node SecretEntry surface)
994
+ //--------------------------------------------------------------------------------------------------
995
+
996
+ /// A secret-leak response. Per-secret (`on_violation:`) and sandbox-level
997
+ /// (`on_secret_violation:`) share the same shape.
998
+ enum ViolationSpec {
999
+ Block,
1000
+ BlockAndLog,
1001
+ BlockAndTerminate,
1002
+ Passthrough {
1003
+ hosts: Vec<String>,
1004
+ patterns: Vec<String>,
1005
+ all: bool,
1006
+ },
1007
+ }
1008
+
1009
+ impl ViolationSpec {
1010
+ fn apply(self, mut v: ViolationActionBuilder) -> ViolationActionBuilder {
1011
+ match self {
1012
+ ViolationSpec::Block => v.block(),
1013
+ ViolationSpec::BlockAndLog => v.block_and_log(),
1014
+ ViolationSpec::BlockAndTerminate => v.block_and_terminate(),
1015
+ ViolationSpec::Passthrough {
1016
+ hosts,
1017
+ patterns,
1018
+ all,
1019
+ } => {
1020
+ for h in hosts {
1021
+ v = v.passthrough_host(h);
1022
+ }
1023
+ for p in patterns {
1024
+ v = v.passthrough_host_pattern(p);
1025
+ }
1026
+ if all {
1027
+ v = v.passthrough_all_hosts(true);
1028
+ }
1029
+ v
1030
+ }
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ /// Parse `on_violation:` — a String (block variants) or a Hash describing a
1036
+ /// passthrough action — into a [`ViolationSpec`].
1037
+ fn parse_violation_spec(v: Value) -> Result<ViolationSpec, Error> {
1038
+ if let Ok(s) = String::try_convert(v) {
1039
+ return match s.as_str() {
1040
+ "block" => Ok(ViolationSpec::Block),
1041
+ "block_and_log" => Ok(ViolationSpec::BlockAndLog),
1042
+ "block_and_terminate" => Ok(ViolationSpec::BlockAndTerminate),
1043
+ other => Err(error::base_error(format!(
1044
+ "unknown on_violation {other:?} (expected block/block_and_log/\
1045
+ block_and_terminate, or a Hash with :passthrough_hosts/\
1046
+ :passthrough_host_patterns/:passthrough_all_hosts)"
1047
+ ))),
1048
+ };
1049
+ }
1050
+ let h = RHash::try_convert(v)
1051
+ .map_err(|_| error::base_error("on_violation must be a String or a Hash"))?;
1052
+ Ok(ViolationSpec::Passthrough {
1053
+ hosts: conv::opt_string_vec(h, "passthrough_hosts")?,
1054
+ patterns: conv::opt_string_vec(h, "passthrough_host_patterns")?,
1055
+ all: conv::opt_bool(h, "passthrough_all_hosts")?,
1056
+ })
1057
+ }
1058
+
1059
+ struct SecretSpec {
1060
+ env: String,
1061
+ value: String,
1062
+ hosts: Vec<String>,
1063
+ host_patterns: Vec<String>,
1064
+ placeholder: Option<String>,
1065
+ require_tls: Option<bool>,
1066
+ inject_headers: Option<bool>,
1067
+ inject_basic_auth: Option<bool>,
1068
+ inject_query: Option<bool>,
1069
+ inject_body: Option<bool>,
1070
+ on_violation: Option<ViolationSpec>,
1071
+ }
1072
+
1073
+ fn parse_secret(h: RHash) -> Result<SecretSpec, Error> {
1074
+ let env =
1075
+ conv::opt_string(h, "env")?.ok_or_else(|| error::base_error("secret requires :env"))?;
1076
+ let value =
1077
+ conv::opt_string(h, "value")?.ok_or_else(|| error::base_error("secret requires :value"))?;
1078
+ let hosts = conv::opt_string_vec(h, "hosts")?;
1079
+ let host_patterns = conv::opt_string_vec(h, "host_patterns")?;
1080
+ if hosts.is_empty() && host_patterns.is_empty() {
1081
+ return Err(error::base_error(
1082
+ "secret requires at least one allowed host (:host, :hosts, or :host_patterns)",
1083
+ ));
1084
+ }
1085
+ let on_violation = conv::opt::<Value>(h, "on_violation")?
1086
+ .map(parse_violation_spec)
1087
+ .transpose()?;
1088
+ Ok(SecretSpec {
1089
+ env,
1090
+ value,
1091
+ hosts,
1092
+ host_patterns,
1093
+ placeholder: conv::opt_string(h, "placeholder")?,
1094
+ require_tls: conv::opt::<bool>(h, "require_tls")?,
1095
+ inject_headers: conv::opt::<bool>(h, "inject_headers")?,
1096
+ inject_basic_auth: conv::opt::<bool>(h, "inject_basic_auth")?,
1097
+ inject_query: conv::opt::<bool>(h, "inject_query")?,
1098
+ inject_body: conv::opt::<bool>(h, "inject_body")?,
1099
+ on_violation,
1100
+ })
1101
+ }
1102
+
1103
+ impl SecretSpec {
1104
+ fn apply(self, mut s: SecretBuilder) -> SecretBuilder {
1105
+ s = s.env(self.env).value(self.value);
1106
+ for host in self.hosts {
1107
+ s = s.allow_host(host);
1108
+ }
1109
+ for pat in self.host_patterns {
1110
+ s = s.allow_host_pattern(pat);
1111
+ }
1112
+ if let Some(p) = self.placeholder {
1113
+ s = s.placeholder(p);
1114
+ }
1115
+ if let Some(rt) = self.require_tls {
1116
+ s = s.require_tls_identity(rt);
1117
+ }
1118
+ if let Some(enabled) = self.inject_headers {
1119
+ s = s.inject_headers(enabled);
1120
+ }
1121
+ if let Some(enabled) = self.inject_basic_auth {
1122
+ s = s.inject_basic_auth(enabled);
1123
+ }
1124
+ if let Some(enabled) = self.inject_query {
1125
+ s = s.inject_query(enabled);
1126
+ }
1127
+ if let Some(enabled) = self.inject_body {
1128
+ s = s.inject_body(enabled);
1129
+ }
1130
+ if let Some(action) = self.on_violation {
1131
+ s = s.on_violation(move |v| action.apply(v));
1132
+ }
1133
+ s
1134
+ }
1135
+ }
1136
+
1137
+ //--------------------------------------------------------------------------------------------------
1138
+ // Network connection config parsing (DNS / TLS interception)
1139
+ //--------------------------------------------------------------------------------------------------
1140
+
1141
+ struct DnsSpec {
1142
+ nameservers: Vec<Nameserver>,
1143
+ rebind_protection: Option<bool>,
1144
+ query_timeout_ms: Option<u64>,
1145
+ }
1146
+
1147
+ fn parse_dns(d: RHash) -> Result<DnsSpec, Error> {
1148
+ let mut nameservers = Vec::new();
1149
+ for s in conv::opt_string_vec(d, "nameservers")? {
1150
+ nameservers.push(
1151
+ s.parse::<Nameserver>()
1152
+ .map_err(|e| error::base_error(format!("invalid nameserver {s:?}: {e}")))?,
1153
+ );
1154
+ }
1155
+ Ok(DnsSpec {
1156
+ nameservers,
1157
+ rebind_protection: conv::opt::<bool>(d, "rebind_protection")?,
1158
+ query_timeout_ms: conv::opt::<u64>(d, "query_timeout_ms")?,
1159
+ })
1160
+ }
1161
+
1162
+ struct TlsSpec {
1163
+ bypass: Vec<String>,
1164
+ verify_upstream: Option<bool>,
1165
+ intercepted_ports: Option<Vec<u16>>,
1166
+ block_quic: Option<bool>,
1167
+ upstream_ca_cert: Option<String>,
1168
+ intercept_ca_cert: Option<String>,
1169
+ intercept_ca_key: Option<String>,
1170
+ }
1171
+
1172
+ fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
1173
+ Ok(TlsSpec {
1174
+ bypass: conv::opt_string_vec(t, "bypass")?,
1175
+ verify_upstream: conv::opt::<bool>(t, "verify_upstream")?,
1176
+ intercepted_ports: conv::opt::<Vec<u16>>(t, "intercepted_ports")?,
1177
+ block_quic: conv::opt::<bool>(t, "block_quic")?,
1178
+ upstream_ca_cert: conv::opt_string(t, "upstream_ca_cert")?,
1179
+ intercept_ca_cert: conv::opt_string(t, "intercept_ca_cert")?,
1180
+ intercept_ca_key: conv::opt_string(t, "intercept_ca_key")?,
1181
+ })
1182
+ }
1183
+
767
1184
  //--------------------------------------------------------------------------------------------------
768
1185
  // Network policy parsing (mirrors the Python binding's `apply_network`)
769
1186
  //--------------------------------------------------------------------------------------------------
@@ -1205,7 +1622,7 @@ fn fs_entry_kind_str(kind: FsEntryKind) -> &'static str {
1205
1622
  }
1206
1623
  }
1207
1624
 
1208
- fn fs_entry_to_hash(entry: &FsEntry) -> RHash {
1625
+ pub(crate) fn fs_entry_to_hash(entry: &FsEntry) -> RHash {
1209
1626
  let hash = ruby().hash_new();
1210
1627
  let _ = hash.aset("path", entry.path.clone());
1211
1628
  let _ = hash.aset("type", fs_entry_kind_str(entry.kind));
@@ -1218,7 +1635,7 @@ fn fs_entry_to_hash(entry: &FsEntry) -> RHash {
1218
1635
  hash
1219
1636
  }
1220
1637
 
1221
- fn fs_metadata_to_hash(meta: &FsMetadata) -> RHash {
1638
+ pub(crate) fn fs_metadata_to_hash(meta: &FsMetadata) -> RHash {
1222
1639
  let hash = ruby().hash_new();
1223
1640
  let _ = hash.aset("type", fs_entry_kind_str(meta.kind));
1224
1641
  let _ = hash.aset("size", meta.size);
@@ -1241,6 +1658,11 @@ pub(crate) fn metrics_to_hash(m: &SandboxMetrics) -> RHash {
1241
1658
  let _ = hash.aset("disk_write_bytes", m.disk_write_bytes);
1242
1659
  let _ = hash.aset("net_rx_bytes", m.net_rx_bytes);
1243
1660
  let _ = hash.aset("net_tx_bytes", m.net_tx_bytes);
1661
+ // OCI writable-upper-layer accounting (Option<u64> → Integer or nil), for
1662
+ // sandboxes capped by `oci_upper_size`. Mirrors the Python/Node metrics.
1663
+ let _ = hash.aset("upper_used_bytes", m.upper_used_bytes);
1664
+ let _ = hash.aset("upper_free_bytes", m.upper_free_bytes);
1665
+ let _ = hash.aset("upper_host_allocated_bytes", m.upper_host_allocated_bytes);
1244
1666
  let _ = hash.aset("uptime_secs", m.uptime.as_secs_f64());
1245
1667
  let _ = hash.aset("timestamp_ms", m.timestamp.timestamp_millis());
1246
1668
  hash
@@ -1282,6 +1704,159 @@ fn stop_result_to_hash(result: &SandboxStopResult) -> RHash {
1282
1704
  hash
1283
1705
  }
1284
1706
 
1707
+ //--------------------------------------------------------------------------------------------------
1708
+ // Pull progress (streaming image-pull during create_with_progress)
1709
+ //--------------------------------------------------------------------------------------------------
1710
+
1711
+ /// Convert a core `PullProgress` event into a `{ "kind" => …, fields… }` Hash.
1712
+ /// The match is exhaustive (no wildcard) so a future upstream variant surfaces
1713
+ /// as a compile error rather than a silently-dropped event.
1714
+ fn pull_progress_to_hash(p: &PullProgress) -> RHash {
1715
+ use PullProgress::*;
1716
+ let h = ruby().hash_new();
1717
+ match p {
1718
+ Resolving { reference } => {
1719
+ let _ = h.aset("kind", "resolving");
1720
+ let _ = h.aset("reference", reference.to_string());
1721
+ }
1722
+ Resolved {
1723
+ reference,
1724
+ manifest_digest,
1725
+ layer_count,
1726
+ total_download_bytes,
1727
+ } => {
1728
+ let _ = h.aset("kind", "resolved");
1729
+ let _ = h.aset("reference", reference.to_string());
1730
+ let _ = h.aset("manifest_digest", manifest_digest.to_string());
1731
+ let _ = h.aset("layer_count", *layer_count);
1732
+ let _ = h.aset("total_download_bytes", *total_download_bytes);
1733
+ }
1734
+ LayerDownloadProgress {
1735
+ layer_index,
1736
+ digest,
1737
+ downloaded_bytes,
1738
+ total_bytes,
1739
+ } => {
1740
+ let _ = h.aset("kind", "layer_download_progress");
1741
+ let _ = h.aset("layer_index", *layer_index);
1742
+ let _ = h.aset("digest", digest.to_string());
1743
+ let _ = h.aset("downloaded_bytes", *downloaded_bytes);
1744
+ let _ = h.aset("total_bytes", *total_bytes);
1745
+ }
1746
+ LayerDownloadComplete {
1747
+ layer_index,
1748
+ digest,
1749
+ downloaded_bytes,
1750
+ } => {
1751
+ let _ = h.aset("kind", "layer_download_complete");
1752
+ let _ = h.aset("layer_index", *layer_index);
1753
+ let _ = h.aset("digest", digest.to_string());
1754
+ let _ = h.aset("downloaded_bytes", *downloaded_bytes);
1755
+ }
1756
+ LayerDownloadVerifying {
1757
+ layer_index,
1758
+ digest,
1759
+ } => {
1760
+ let _ = h.aset("kind", "layer_download_verifying");
1761
+ let _ = h.aset("layer_index", *layer_index);
1762
+ let _ = h.aset("digest", digest.to_string());
1763
+ }
1764
+ LayerMaterializeStarted {
1765
+ layer_index,
1766
+ diff_id,
1767
+ } => {
1768
+ let _ = h.aset("kind", "layer_materialize_started");
1769
+ let _ = h.aset("layer_index", *layer_index);
1770
+ let _ = h.aset("diff_id", diff_id.to_string());
1771
+ }
1772
+ LayerMaterializeProgress {
1773
+ layer_index,
1774
+ bytes_read,
1775
+ total_bytes,
1776
+ } => {
1777
+ let _ = h.aset("kind", "layer_materialize_progress");
1778
+ let _ = h.aset("layer_index", *layer_index);
1779
+ let _ = h.aset("bytes_read", *bytes_read);
1780
+ let _ = h.aset("total_bytes", *total_bytes);
1781
+ }
1782
+ LayerMaterializeWriting { layer_index } => {
1783
+ let _ = h.aset("kind", "layer_materialize_writing");
1784
+ let _ = h.aset("layer_index", *layer_index);
1785
+ }
1786
+ LayerMaterializeComplete {
1787
+ layer_index,
1788
+ diff_id,
1789
+ } => {
1790
+ let _ = h.aset("kind", "layer_materialize_complete");
1791
+ let _ = h.aset("layer_index", *layer_index);
1792
+ let _ = h.aset("diff_id", diff_id.to_string());
1793
+ }
1794
+ StitchMergingTrees { layer_count } => {
1795
+ let _ = h.aset("kind", "stitch_merging_trees");
1796
+ let _ = h.aset("layer_count", *layer_count);
1797
+ }
1798
+ StitchWritingFsmeta => {
1799
+ let _ = h.aset("kind", "stitch_writing_fsmeta");
1800
+ }
1801
+ StitchWritingVmdk => {
1802
+ let _ = h.aset("kind", "stitch_writing_vmdk");
1803
+ }
1804
+ StitchComplete => {
1805
+ let _ = h.aset("kind", "stitch_complete");
1806
+ }
1807
+ Complete {
1808
+ reference,
1809
+ layer_count,
1810
+ } => {
1811
+ let _ = h.aset("kind", "complete");
1812
+ let _ = h.aset("reference", reference.to_string());
1813
+ let _ = h.aset("layer_count", *layer_count);
1814
+ }
1815
+ }
1816
+ h
1817
+ }
1818
+
1819
+ /// A streaming image-pull + create session, from `Sandbox.create_with_progress`.
1820
+ /// `recv` yields progress events; `result` awaits the booted sandbox. The pull
1821
+ /// runs as a tokio task; the progress receiver needs `&mut self` and the join
1822
+ /// handle is consumed once, so both sit behind a `tokio::Mutex`.
1823
+ #[magnus::wrap(class = "Microsandbox::Native::PullSession", free_immediately, size)]
1824
+ pub struct PullSession {
1825
+ progress: Arc<Mutex<PullProgressHandle>>,
1826
+ join: Arc<Mutex<Option<JoinHandle<MicrosandboxResult<microsandbox::Sandbox>>>>>,
1827
+ }
1828
+
1829
+ impl PullSession {
1830
+ fn new(
1831
+ progress: PullProgressHandle,
1832
+ join: JoinHandle<MicrosandboxResult<microsandbox::Sandbox>>,
1833
+ ) -> Self {
1834
+ Self {
1835
+ progress: Arc::new(Mutex::new(progress)),
1836
+ join: Arc::new(Mutex::new(Some(join))),
1837
+ }
1838
+ }
1839
+
1840
+ /// Next progress event as a Hash, or nil when the pull is finished.
1841
+ fn recv(&self) -> Result<Option<RHash>, Error> {
1842
+ let progress = Arc::clone(&self.progress);
1843
+ let event = block_on(async move { progress.lock().await.recv().await });
1844
+ Ok(event.map(|p| pull_progress_to_hash(&p)))
1845
+ }
1846
+
1847
+ /// Await the booted sandbox. Call after draining `recv` (it joins the
1848
+ /// create task). Consumes the join handle, so it is callable only once.
1849
+ fn result(&self) -> Result<Sandbox, Error> {
1850
+ let join = Arc::clone(&self.join);
1851
+ let taken = block_on(async move { join.lock().await.take() });
1852
+ let handle = taken.ok_or_else(|| error::base_error("pull session result already taken"))?;
1853
+ let inner = block_on(handle)
1854
+ .map_err(|e| error::base_error(format!("sandbox creation task failed: {e}")))?
1855
+ .map_err(error::to_ruby)?;
1856
+ Ok(Sandbox::from_inner(inner))
1857
+ }
1858
+ }
1859
+
1285
1860
  //--------------------------------------------------------------------------------------------------
1286
1861
  // SandboxHandle — the controllable lightweight handle
1287
1862
  //--------------------------------------------------------------------------------------------------
@@ -1361,6 +1936,27 @@ impl SbHandle {
1361
1936
  let result = block_on(self.inner.wait_until_stopped()).map_err(error::to_ruby)?;
1362
1937
  Ok(stop_result_to_hash(&result))
1363
1938
  }
1939
+
1940
+ /// The sandbox's stored configuration as a JSON string (synchronous — the
1941
+ /// handle already carries it, no runtime round-trip). The Ruby layer parses
1942
+ /// it into a Hash for `#config`. Mirrors the Python/Node `config_json`.
1943
+ fn config_json(&self) -> String {
1944
+ self.inner.config_json().to_string()
1945
+ }
1946
+
1947
+ /// Snapshot this (stopped) sandbox under a bare name (resolved under the
1948
+ /// snapshots directory). Returns the same SnapshotInfo Hash as
1949
+ /// `Snapshot.create`. Mirrors the Python/Node `handle.snapshot(name)`.
1950
+ fn snapshot(&self, name: String) -> Result<RHash, Error> {
1951
+ let snap = block_on(self.inner.snapshot(&name)).map_err(error::to_ruby)?;
1952
+ Ok(crate::snapshot::snapshot_to_hash(&snap))
1953
+ }
1954
+
1955
+ /// Snapshot this (stopped) sandbox to an explicit filesystem path.
1956
+ fn snapshot_to(&self, path: String) -> Result<RHash, Error> {
1957
+ let snap = block_on(self.inner.snapshot_to(path)).map_err(error::to_ruby)?;
1958
+ Ok(crate::snapshot::snapshot_to_hash(&snap))
1959
+ }
1364
1960
  }
1365
1961
 
1366
1962
  //--------------------------------------------------------------------------------------------------
@@ -1450,6 +2046,10 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
1450
2046
  let class = native.define_class("Sandbox", ruby.class_object())?;
1451
2047
 
1452
2048
  class.define_singleton_method("create", function!(Sandbox::create, 2))?;
2049
+ class.define_singleton_method(
2050
+ "create_with_progress",
2051
+ function!(Sandbox::create_with_progress, 2),
2052
+ )?;
1453
2053
  class.define_singleton_method("start", function!(Sandbox::start, 2))?;
1454
2054
  class.define_singleton_method("get", function!(Sandbox::get, 1))?;
1455
2055
  class.define_singleton_method("list", function!(Sandbox::list, 0))?;
@@ -1487,6 +2087,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
1487
2087
  class.define_method("fs_stat", method!(Sandbox::fs_stat, 1))?;
1488
2088
  class.define_method("fs_copy_from_host", method!(Sandbox::fs_copy_from_host, 2))?;
1489
2089
  class.define_method("fs_copy_to_host", method!(Sandbox::fs_copy_to_host, 2))?;
2090
+ class.define_method("fs_read_stream", method!(Sandbox::fs_read_stream, 1))?;
2091
+ class.define_method("fs_write_stream", method!(Sandbox::fs_write_stream, 1))?;
1490
2092
 
1491
2093
  class.define_method("ssh_open_client", method!(Sandbox::ssh_open_client, 1))?;
1492
2094
  class.define_method(
@@ -1513,6 +2115,13 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
1513
2115
  "wait_until_stopped",
1514
2116
  method!(SbHandle::wait_until_stopped, 0),
1515
2117
  )?;
2118
+ handle.define_method("config_json", method!(SbHandle::config_json, 0))?;
2119
+ handle.define_method("snapshot", method!(SbHandle::snapshot, 1))?;
2120
+ handle.define_method("snapshot_to", method!(SbHandle::snapshot_to, 1))?;
2121
+
2122
+ let session = native.define_class("PullSession", ruby.class_object())?;
2123
+ session.define_method("recv", method!(PullSession::recv, 0))?;
2124
+ session.define_method("result", method!(PullSession::result, 0))?;
1516
2125
 
1517
2126
  Ok(())
1518
2127
  }