microsandbox-rb 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +234 -1
- data/Cargo.lock +236 -201
- data/DESIGN.md +7 -38
- data/README.md +178 -626
- data/SECURITY.md +65 -0
- data/docs/configuration.md +154 -0
- data/docs/errors.md +74 -0
- data/docs/execution.md +101 -0
- data/docs/filesystem.md +45 -0
- data/docs/images.md +65 -0
- data/docs/lifecycle.md +108 -0
- data/docs/observability.md +99 -0
- data/docs/releasing.md +66 -0
- data/docs/runtime.md +152 -0
- data/docs/surface.md +70 -0
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/error.rs +7 -0
- data/ext/microsandbox/src/host_path.rs +108 -0
- data/ext/microsandbox/src/lib.rs +1 -0
- data/ext/microsandbox/src/sandbox.rs +143 -31
- data/ext/microsandbox/src/volume.rs +43 -12
- data/lib/microsandbox/fs.rb +4 -8
- data/lib/microsandbox/image.rb +2 -2
- data/lib/microsandbox/log_entry.rb +1 -1
- data/lib/microsandbox/metrics.rb +1 -1
- data/lib/microsandbox/outbound_proxy.rb +340 -0
- data/lib/microsandbox/sandbox.rb +73 -10
- data/lib/microsandbox/snapshot.rb +1 -1
- data/lib/microsandbox/ssh.rb +2 -10
- data/lib/microsandbox/streams.rb +8 -5
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox/volume.rb +31 -14
- data/lib/microsandbox-rb.rb +7 -0
- data/lib/microsandbox.rb +89 -31
- data/sig/microsandbox.rbs +40 -1
- metadata +15 -1
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//! as a plain Ruby `Hash`/`Array`/`String` and shaped into value objects by the
|
|
8
8
|
//! Ruby layer.
|
|
9
9
|
|
|
10
|
+
use std::path::PathBuf;
|
|
10
11
|
use std::sync::Arc;
|
|
11
12
|
use std::time::Duration;
|
|
12
13
|
|
|
@@ -39,6 +40,7 @@ use tokio::task::JoinHandle;
|
|
|
39
40
|
use crate::conv;
|
|
40
41
|
use crate::error;
|
|
41
42
|
use crate::exec::ExecHandle;
|
|
43
|
+
use crate::host_path::{self, HostPaths};
|
|
42
44
|
use crate::runtime::{block_on, ruby};
|
|
43
45
|
use crate::stream::{LogStream, MetricsStream};
|
|
44
46
|
|
|
@@ -75,6 +77,9 @@ impl Sandbox {
|
|
|
75
77
|
/// Shared by `create` (blocking) and `create_with_progress` (streaming pull).
|
|
76
78
|
fn build_builder(name: String, opts: RHash) -> Result<SandboxBuilder, Error> {
|
|
77
79
|
let mut b = microsandbox::Sandbox::builder(name);
|
|
80
|
+
// Relative host paths are anchored to the current directory here, once,
|
|
81
|
+
// before the core persists them (see host_path.rs).
|
|
82
|
+
let host_paths = HostPaths::for_default_backend();
|
|
78
83
|
|
|
79
84
|
if let Some(v) = conv::opt_string(opts, "image")? {
|
|
80
85
|
if let Some(fstype) = conv::opt_string(opts, "fstype")? {
|
|
@@ -83,13 +88,17 @@ impl Sandbox {
|
|
|
83
88
|
// image_with(disk().fstype()). (A bare `image` string otherwise
|
|
84
89
|
// auto-detects OCI vs disk by extension.) Errors in disk()/fstype()
|
|
85
90
|
// are captured on the builder and surface at create().
|
|
86
|
-
|
|
91
|
+
let path = host_paths.pin(v, "root disk image path")?;
|
|
92
|
+
b = b.image_with(move |i| i.disk(path).fstype(fstype));
|
|
87
93
|
} else {
|
|
88
|
-
b =
|
|
94
|
+
b = match host_paths.pin_image(v)? {
|
|
95
|
+
host_path::Image::Text(text) => b.image(text),
|
|
96
|
+
host_path::Image::Path(path) => b.image(path),
|
|
97
|
+
};
|
|
89
98
|
}
|
|
90
99
|
}
|
|
91
100
|
if let Some(v) = conv::opt_string(opts, "from_snapshot")? {
|
|
92
|
-
b = b.from_snapshot(v);
|
|
101
|
+
b = b.from_snapshot(host_paths.pin_snapshot(v)?);
|
|
93
102
|
}
|
|
94
103
|
if let Some(v) = conv::opt_u8(opts, "cpus")? {
|
|
95
104
|
b = b.cpus(v);
|
|
@@ -208,27 +217,35 @@ impl Sandbox {
|
|
|
208
217
|
// the per-mount opt-out. Valid for bind/named-directory mounts —
|
|
209
218
|
// the core rejects it elsewhere at build().
|
|
210
219
|
let follow_root_symlinks = conv::opt::<bool>(m, "follow_root_symlinks")?;
|
|
211
|
-
// bind/named/disk require a source; tmpfs must not have one.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
220
|
+
// bind/named/disk require a source; tmpfs must not have one. A
|
|
221
|
+
// bind or disk source is a host path; a named source is a volume
|
|
222
|
+
// name and is never resolved.
|
|
223
|
+
let backing = match (kind.as_str(), source) {
|
|
224
|
+
("bind", Some(host)) => {
|
|
225
|
+
MountBacking::Bind(host_paths.pin(host, "bind mount host path")?)
|
|
226
|
+
}
|
|
227
|
+
("disk", Some(host)) => {
|
|
228
|
+
MountBacking::Disk(host_paths.pin(host, "disk mount host path")?)
|
|
229
|
+
}
|
|
230
|
+
("named", Some(name)) => MountBacking::Named(name),
|
|
231
|
+
("bind" | "named" | "disk", None) => {
|
|
215
232
|
return Err(error::base_error(format!(
|
|
216
233
|
"volume mount kind {kind:?} requires a source"
|
|
217
234
|
)))
|
|
218
235
|
}
|
|
219
|
-
"tmpfs" =>
|
|
220
|
-
other => {
|
|
236
|
+
("tmpfs", _) => MountBacking::Tmpfs,
|
|
237
|
+
(other, _) => {
|
|
221
238
|
return Err(error::base_error(format!(
|
|
222
239
|
"unknown volume mount kind {other:?} (expected bind/named/tmpfs/disk)"
|
|
223
240
|
)))
|
|
224
241
|
}
|
|
225
|
-
}
|
|
242
|
+
};
|
|
226
243
|
b = b.volume(guest, move |mut mb| {
|
|
227
|
-
mb = match
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
244
|
+
mb = match backing {
|
|
245
|
+
MountBacking::Named(name) => mb.named(name),
|
|
246
|
+
MountBacking::Tmpfs => mb.tmpfs(),
|
|
247
|
+
MountBacking::Disk(host) => mb.disk(host),
|
|
248
|
+
MountBacking::Bind(host) => mb.bind(host),
|
|
232
249
|
};
|
|
233
250
|
if let Some(n) = size_mib {
|
|
234
251
|
mb = mb.size(n);
|
|
@@ -272,7 +289,7 @@ impl Sandbox {
|
|
|
272
289
|
// patches: rootfs modifications applied before boot. The Ruby layer
|
|
273
290
|
// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash with
|
|
274
291
|
// a `kind` discriminator; mirrors the Python binding's `apply_patch`.
|
|
275
|
-
for patch in parse_patches(opts)? {
|
|
292
|
+
for patch in parse_patches(opts, host_paths)? {
|
|
276
293
|
b = b.add_patch(patch);
|
|
277
294
|
}
|
|
278
295
|
if let Some(net) = conv::opt_string(opts, "network")? {
|
|
@@ -326,7 +343,7 @@ impl Sandbox {
|
|
|
326
343
|
// layer maps the deprecated `oci_upper_size:` kwarg to the managed kind
|
|
327
344
|
// before it reaches the wire.
|
|
328
345
|
if let Some(v) = conv::opt::<Value>(opts, "root_disk")? {
|
|
329
|
-
let spec = parse_root_disk(v)?;
|
|
346
|
+
let spec = parse_root_disk(v, host_paths)?;
|
|
330
347
|
b = b.root_disk_with(move |d| spec.apply(d));
|
|
331
348
|
}
|
|
332
349
|
// Registry connection settings, for private / non-default registries:
|
|
@@ -374,7 +391,7 @@ impl Sandbox {
|
|
|
374
391
|
.map(parse_dns)
|
|
375
392
|
.transpose()?;
|
|
376
393
|
let tls = conv::opt::<RHash>(opts, "tls")?
|
|
377
|
-
.map(parse_tls)
|
|
394
|
+
.map(|t| parse_tls(t, host_paths))
|
|
378
395
|
.transpose()?;
|
|
379
396
|
let ipv4_pool = conv::opt_string(opts, "ipv4_pool")?
|
|
380
397
|
.map(|s| {
|
|
@@ -390,12 +407,22 @@ impl Sandbox {
|
|
|
390
407
|
.transpose()?;
|
|
391
408
|
let max_connections = conv::opt::<usize>(opts, "max_connections")?;
|
|
392
409
|
let trust_host_cas = conv::opt::<bool>(opts, "trust_host_cas")?;
|
|
410
|
+
// strict: fail-closed hostname-policy enforcement (v0.6.18). A
|
|
411
|
+
// hostname-rule allow must be backed by an inspectable request
|
|
412
|
+
// authority (plain-HTTP Host, or SNI/authority under TLS interception);
|
|
413
|
+
// otherwise the flow is denied before the upstream dial. Mirrors the
|
|
414
|
+
// Python SDK's `Network(strict=...)`. Create-only here to match the
|
|
415
|
+
// Python SDK's `modify` surface, which does not expose `strict`; the
|
|
416
|
+
// core's generated network config patch (ConfigPatch derive) does
|
|
417
|
+
// carry a `strict` field, so this is SDK parity, not a core limit.
|
|
418
|
+
let strict = conv::opt::<bool>(opts, "strict")?;
|
|
393
419
|
if dns.is_some()
|
|
394
420
|
|| tls.is_some()
|
|
395
421
|
|| ipv4_pool.is_some()
|
|
396
422
|
|| ipv6_pool.is_some()
|
|
397
423
|
|| max_connections.is_some()
|
|
398
424
|
|| trust_host_cas.is_some()
|
|
425
|
+
|| strict.is_some()
|
|
399
426
|
{
|
|
400
427
|
b = b.network(move |mut n| {
|
|
401
428
|
if let Some(dns) = dns {
|
|
@@ -450,6 +477,9 @@ impl Sandbox {
|
|
|
450
477
|
if let Some(t) = trust_host_cas {
|
|
451
478
|
n = n.trust_host_cas(t);
|
|
452
479
|
}
|
|
480
|
+
if let Some(s) = strict {
|
|
481
|
+
n = n.strict(s);
|
|
482
|
+
}
|
|
453
483
|
n
|
|
454
484
|
});
|
|
455
485
|
}
|
|
@@ -475,6 +505,18 @@ impl Sandbox {
|
|
|
475
505
|
})
|
|
476
506
|
});
|
|
477
507
|
}
|
|
508
|
+
// proxy: outbound SOCKS4/SOCKS5 proxy for egress traffic (v0.6.17,
|
|
509
|
+
// upstream #1234/#1507). The Ruby layer (`OutboundProxy.coerce`) has
|
|
510
|
+
// already validated the shape and normalized it to the Python SDK's
|
|
511
|
+
// `_to_dict()` wire form: {protocol:, address:, user_id?:,
|
|
512
|
+
// credentials?: {username:, password: {kind: "env", var:}}}. Only the
|
|
513
|
+
// password's env var NAME travels; the core resolves it host-side.
|
|
514
|
+
// Applied exactly as sdk/python/src/helpers.rs does; the address is
|
|
515
|
+
// parsed by the core's proxy builder (an invalid one surfaces as
|
|
516
|
+
// NetworkBuilder(InvalidOutboundProxy) → InvalidConfigError, see error.rs).
|
|
517
|
+
if let Some(proxy) = conv::opt::<RHash>(opts, "proxy")? {
|
|
518
|
+
b = apply_outbound_proxy(b, proxy)?;
|
|
519
|
+
}
|
|
478
520
|
// init: hand guest PID 1 to an init system. The Ruby layer normalizes
|
|
479
521
|
// `init:` to a Hash { cmd:, args?:, env?: }. `init_with` with empty
|
|
480
522
|
// args/env builds the same HandoffInit as the plain `init(cmd)`, so route
|
|
@@ -1192,7 +1234,7 @@ fn patch_bytes(h: RHash, key: &str) -> Result<Vec<u8>, Error> {
|
|
|
1192
1234
|
/// Parse the `patches` option into core `Patch` operations. The Ruby layer
|
|
1193
1235
|
/// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash carrying a
|
|
1194
1236
|
/// `kind` discriminator plus the variant-specific fields.
|
|
1195
|
-
fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
|
|
1237
|
+
fn parse_patches(opts: RHash, host_paths: HostPaths) -> Result<Vec<Patch>, Error> {
|
|
1196
1238
|
let mut out = Vec::new();
|
|
1197
1239
|
for h in conv::opt_hash_vec(opts, "patches")? {
|
|
1198
1240
|
let kind = patch_str(h, "kind")?;
|
|
@@ -1216,13 +1258,13 @@ fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
|
|
|
1216
1258
|
content: patch_str(h, "content")?,
|
|
1217
1259
|
},
|
|
1218
1260
|
"copy_file" => Patch::CopyFile {
|
|
1219
|
-
src: patch_str(h, "src")
|
|
1261
|
+
src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
|
|
1220
1262
|
dst: patch_str(h, "dst")?,
|
|
1221
1263
|
mode,
|
|
1222
1264
|
replace,
|
|
1223
1265
|
},
|
|
1224
1266
|
"copy_dir" => Patch::CopyDir {
|
|
1225
|
-
src: patch_str(h, "src")
|
|
1267
|
+
src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
|
|
1226
1268
|
dst: patch_str(h, "dst")?,
|
|
1227
1269
|
replace,
|
|
1228
1270
|
},
|
|
@@ -1420,6 +1462,63 @@ fn parse_dns(d: RHash) -> Result<DnsSpec, Error> {
|
|
|
1420
1462
|
})
|
|
1421
1463
|
}
|
|
1422
1464
|
|
|
1465
|
+
/// Apply the normalized `proxy` create option (v0.6.17) to the builder.
|
|
1466
|
+
/// Mirrors `sdk/python/src/helpers.rs`: `socks4` takes an optional `user_id`,
|
|
1467
|
+
/// `socks5` optional `credentials` whose password is an env-backed
|
|
1468
|
+
/// [`SecretSource`] (the only kind). Unknown protocols and non-env password
|
|
1469
|
+
/// sources are rejected here (the Ruby layer already does, so these are
|
|
1470
|
+
/// belt-and-braces for callers of the native API).
|
|
1471
|
+
fn apply_outbound_proxy(b: SandboxBuilder, proxy: RHash) -> Result<SandboxBuilder, Error> {
|
|
1472
|
+
let protocol = conv::opt_string(proxy, "protocol")?
|
|
1473
|
+
.ok_or_else(|| error::base_error("proxy requires protocol:"))?;
|
|
1474
|
+
let address = conv::opt_string(proxy, "address")?
|
|
1475
|
+
.ok_or_else(|| error::base_error("proxy requires address:"))?;
|
|
1476
|
+
match protocol.as_str() {
|
|
1477
|
+
"socks4" => {
|
|
1478
|
+
let user_id = conv::opt_string(proxy, "user_id")?;
|
|
1479
|
+
Ok(b.proxy(move |p| {
|
|
1480
|
+
let p = p.socks4(address);
|
|
1481
|
+
match user_id {
|
|
1482
|
+
Some(user_id) => p.user_id(user_id),
|
|
1483
|
+
None => p,
|
|
1484
|
+
}
|
|
1485
|
+
}))
|
|
1486
|
+
}
|
|
1487
|
+
"socks5" => {
|
|
1488
|
+
let credentials = match conv::opt::<RHash>(proxy, "credentials")? {
|
|
1489
|
+
None => None,
|
|
1490
|
+
Some(c) => {
|
|
1491
|
+
let username = conv::opt_string(c, "username")?
|
|
1492
|
+
.ok_or_else(|| error::base_error("SOCKS5 credentials require username:"))?;
|
|
1493
|
+
let password = conv::opt::<RHash>(c, "password")?
|
|
1494
|
+
.ok_or_else(|| error::base_error("SOCKS5 credentials require password:"))?;
|
|
1495
|
+
let kind = conv::opt_string(password, "kind")?.ok_or_else(|| {
|
|
1496
|
+
error::base_error("SOCKS5 password source requires kind:")
|
|
1497
|
+
})?;
|
|
1498
|
+
if kind != "env" {
|
|
1499
|
+
return Err(error::base_error(format!(
|
|
1500
|
+
"unsupported SOCKS5 password source {kind:?}; only env is supported"
|
|
1501
|
+
)));
|
|
1502
|
+
}
|
|
1503
|
+
let var = conv::opt_string(password, "var")?
|
|
1504
|
+
.ok_or_else(|| error::base_error("SOCKS5 password source requires var:"))?;
|
|
1505
|
+
Some((username, SecretSource::env(var)))
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
Ok(b.proxy(move |p| {
|
|
1509
|
+
let p = p.socks5(address);
|
|
1510
|
+
match credentials {
|
|
1511
|
+
Some((username, password)) => p.credentials(username, password),
|
|
1512
|
+
None => p,
|
|
1513
|
+
}
|
|
1514
|
+
}))
|
|
1515
|
+
}
|
|
1516
|
+
other => Err(error::base_error(format!(
|
|
1517
|
+
"unsupported outbound proxy protocol {other:?}; expected socks4 or socks5"
|
|
1518
|
+
))),
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1423
1522
|
/// One token bucket of the `rate_limiter` create option (v0.6.9):
|
|
1424
1523
|
/// `(size, refill_time_ms, one_time_burst)`. Size is bytes for bandwidth
|
|
1425
1524
|
/// buckets, frames for ops buckets.
|
|
@@ -1502,20 +1601,25 @@ struct TlsSpec {
|
|
|
1502
1601
|
verify_upstream: Option<bool>,
|
|
1503
1602
|
intercepted_ports: Option<Vec<u16>>,
|
|
1504
1603
|
block_quic: Option<bool>,
|
|
1505
|
-
upstream_ca_cert: Option<
|
|
1506
|
-
intercept_ca_cert: Option<
|
|
1507
|
-
intercept_ca_key: Option<
|
|
1604
|
+
upstream_ca_cert: Option<PathBuf>,
|
|
1605
|
+
intercept_ca_cert: Option<PathBuf>,
|
|
1606
|
+
intercept_ca_key: Option<PathBuf>,
|
|
1508
1607
|
}
|
|
1509
1608
|
|
|
1510
|
-
fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
|
|
1609
|
+
fn parse_tls(t: RHash, host_paths: HostPaths) -> Result<TlsSpec, Error> {
|
|
1610
|
+
let pin = |key: &str, label: &str| {
|
|
1611
|
+
conv::opt_string(t, key)?
|
|
1612
|
+
.map(|path| host_paths.pin(path, label))
|
|
1613
|
+
.transpose()
|
|
1614
|
+
};
|
|
1511
1615
|
Ok(TlsSpec {
|
|
1512
1616
|
bypass: conv::opt_string_vec(t, "bypass")?,
|
|
1513
1617
|
verify_upstream: conv::opt::<bool>(t, "verify_upstream")?,
|
|
1514
1618
|
intercepted_ports: conv::opt::<Vec<u16>>(t, "intercepted_ports")?,
|
|
1515
1619
|
block_quic: conv::opt::<bool>(t, "block_quic")?,
|
|
1516
|
-
upstream_ca_cert:
|
|
1517
|
-
intercept_ca_cert:
|
|
1518
|
-
intercept_ca_key:
|
|
1620
|
+
upstream_ca_cert: pin("upstream_ca_cert", "upstream CA certificate path")?,
|
|
1621
|
+
intercept_ca_cert: pin("intercept_ca_cert", "interception CA certificate path")?,
|
|
1622
|
+
intercept_ca_key: pin("intercept_ca_key", "interception CA key path")?,
|
|
1519
1623
|
})
|
|
1520
1624
|
}
|
|
1521
1625
|
|
|
@@ -1629,6 +1733,14 @@ fn network_profile_from_str(s: &str) -> Result<NetworkProfile, Error> {
|
|
|
1629
1733
|
}
|
|
1630
1734
|
}
|
|
1631
1735
|
|
|
1736
|
+
/// What backs a volume mount, with any host path already pinned.
|
|
1737
|
+
enum MountBacking {
|
|
1738
|
+
Bind(PathBuf),
|
|
1739
|
+
Disk(PathBuf),
|
|
1740
|
+
Named(String),
|
|
1741
|
+
Tmpfs,
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1632
1744
|
/// Parsed `root_disk` create option. Integer shorthand = managed upper of N
|
|
1633
1745
|
/// MiB (mirrors Python's `root_disk=8192` and the CLI bare-size form); Hash =
|
|
1634
1746
|
/// explicit kind. Kind/field cross-validation (size on a disk image, etc.)
|
|
@@ -1644,7 +1756,7 @@ struct RootDiskSpec {
|
|
|
1644
1756
|
enum RootDiskKindSpec {
|
|
1645
1757
|
Managed,
|
|
1646
1758
|
Tmpfs,
|
|
1647
|
-
Disk(
|
|
1759
|
+
Disk(PathBuf),
|
|
1648
1760
|
Flat,
|
|
1649
1761
|
}
|
|
1650
1762
|
|
|
@@ -1672,7 +1784,7 @@ impl RootDiskSpec {
|
|
|
1672
1784
|
}
|
|
1673
1785
|
}
|
|
1674
1786
|
|
|
1675
|
-
fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
|
|
1787
|
+
fn parse_root_disk(v: Value, host_paths: HostPaths) -> Result<RootDiskSpec, Error> {
|
|
1676
1788
|
if let Ok(mib) = u32::try_convert(v) {
|
|
1677
1789
|
return Ok(RootDiskSpec {
|
|
1678
1790
|
kind: RootDiskKindSpec::Managed,
|
|
@@ -1695,7 +1807,7 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
|
|
|
1695
1807
|
let Some(path) = conv::opt_string(h, "path")? else {
|
|
1696
1808
|
return Err(error::base_error("root_disk disk kind requires path:"));
|
|
1697
1809
|
};
|
|
1698
|
-
RootDiskKindSpec::Disk(path)
|
|
1810
|
+
RootDiskKindSpec::Disk(host_paths.pin(path, "root disk image path")?)
|
|
1699
1811
|
}
|
|
1700
1812
|
Some("flat") => RootDiskKindSpec::Flat,
|
|
1701
1813
|
Some(other) => {
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
//! Mirrors `sdk/python/src/volume.rs`. Static async CRUD over the volume store;
|
|
4
4
|
//! results are returned as plain Ruby Hashes/Arrays.
|
|
5
5
|
|
|
6
|
+
use std::path::{Component, Path};
|
|
6
7
|
use std::sync::Arc;
|
|
7
8
|
|
|
8
9
|
use magnus::{function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby};
|
|
9
10
|
use microsandbox::volume::VolumeHandle;
|
|
10
|
-
use microsandbox::Backend;
|
|
11
|
+
use microsandbox::{Backend, MicrosandboxError};
|
|
11
12
|
|
|
12
13
|
use crate::conv;
|
|
13
14
|
use crate::error;
|
|
@@ -104,6 +105,23 @@ fn remove(name: String) -> Result<(), Error> {
|
|
|
104
105
|
block_on(microsandbox::Volume::remove(&name)).map_err(error::to_ruby)
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/// Volume paths are relative to the volume root and never need a `..` segment,
|
|
109
|
+
/// so one is refused before the path reaches the core's resolver (which keeps
|
|
110
|
+
/// its own containment check). Enforced here rather than only in the Ruby
|
|
111
|
+
/// wrapper because `Microsandbox::Native::VolumeFs` is callable directly.
|
|
112
|
+
fn contained(path: &str) -> Result<&str, Error> {
|
|
113
|
+
if Path::new(path)
|
|
114
|
+
.components()
|
|
115
|
+
.any(|part| matches!(part, Component::ParentDir))
|
|
116
|
+
{
|
|
117
|
+
return Err(error::to_ruby(MicrosandboxError::SandboxFsOps(
|
|
118
|
+
"path traversal outside volume root (`..` segments are not allowed in volume paths)"
|
|
119
|
+
.into(),
|
|
120
|
+
)));
|
|
121
|
+
}
|
|
122
|
+
Ok(path)
|
|
123
|
+
}
|
|
124
|
+
|
|
107
125
|
/// A host-side filesystem view over a named volume — read/write its contents
|
|
108
126
|
/// without a running sandbox. Mirrors the Python `VolumeFs` / Node `VolumeFs`.
|
|
109
127
|
/// The core `VolumeFs<'a>` borrows the volume name, so (like the Python binding)
|
|
@@ -131,26 +149,30 @@ impl VolumeFs {
|
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
fn read(&self, path: String) -> Result<RString, Error> {
|
|
152
|
+
let path = contained(&path)?;
|
|
134
153
|
let fs = self.fs();
|
|
135
|
-
let bytes = block_on(fs.read(
|
|
154
|
+
let bytes = block_on(fs.read(path)).map_err(error::to_ruby)?;
|
|
136
155
|
Ok(ruby().str_from_slice(bytes.as_ref()))
|
|
137
156
|
}
|
|
138
157
|
|
|
139
158
|
fn read_text(&self, path: String) -> Result<String, Error> {
|
|
159
|
+
let path = contained(&path)?;
|
|
140
160
|
let fs = self.fs();
|
|
141
|
-
block_on(fs.read_to_string(
|
|
161
|
+
block_on(fs.read_to_string(path)).map_err(error::to_ruby)
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
fn write(&self, path: String, data: RString) -> Result<(), Error> {
|
|
145
165
|
// Copy the bytes out while the GVL is held (GC.compact could move them).
|
|
146
166
|
let bytes = unsafe { data.as_slice() }.to_vec();
|
|
167
|
+
let path = contained(&path)?;
|
|
147
168
|
let fs = self.fs();
|
|
148
|
-
block_on(fs.write(
|
|
169
|
+
block_on(fs.write(path, &bytes)).map_err(error::to_ruby)
|
|
149
170
|
}
|
|
150
171
|
|
|
151
172
|
fn list(&self, path: String) -> Result<RArray, Error> {
|
|
173
|
+
let path = contained(&path)?;
|
|
152
174
|
let fs = self.fs();
|
|
153
|
-
let entries = block_on(fs.list(
|
|
175
|
+
let entries = block_on(fs.list(path)).map_err(error::to_ruby)?;
|
|
154
176
|
let arr = ruby().ary_new();
|
|
155
177
|
for entry in &entries {
|
|
156
178
|
arr.push(fs_entry_to_hash(entry))?;
|
|
@@ -159,38 +181,47 @@ impl VolumeFs {
|
|
|
159
181
|
}
|
|
160
182
|
|
|
161
183
|
fn mkdir(&self, path: String) -> Result<(), Error> {
|
|
184
|
+
let path = contained(&path)?;
|
|
162
185
|
let fs = self.fs();
|
|
163
|
-
block_on(fs.mkdir(
|
|
186
|
+
block_on(fs.mkdir(path)).map_err(error::to_ruby)
|
|
164
187
|
}
|
|
165
188
|
|
|
166
189
|
fn remove_file(&self, path: String) -> Result<(), Error> {
|
|
190
|
+
let path = contained(&path)?;
|
|
167
191
|
let fs = self.fs();
|
|
168
|
-
block_on(fs.remove(
|
|
192
|
+
block_on(fs.remove(path)).map_err(error::to_ruby)
|
|
169
193
|
}
|
|
170
194
|
|
|
171
195
|
fn remove_dir(&self, path: String) -> Result<(), Error> {
|
|
196
|
+
let path = contained(&path)?;
|
|
172
197
|
let fs = self.fs();
|
|
173
|
-
block_on(fs.remove_dir(
|
|
198
|
+
block_on(fs.remove_dir(path)).map_err(error::to_ruby)
|
|
174
199
|
}
|
|
175
200
|
|
|
176
201
|
fn exists(&self, path: String) -> Result<bool, Error> {
|
|
202
|
+
let path = contained(&path)?;
|
|
177
203
|
let fs = self.fs();
|
|
178
|
-
block_on(fs.exists(
|
|
204
|
+
block_on(fs.exists(path)).map_err(error::to_ruby)
|
|
179
205
|
}
|
|
180
206
|
|
|
181
207
|
fn copy(&self, from: String, to: String) -> Result<(), Error> {
|
|
208
|
+
let from = contained(&from)?;
|
|
209
|
+
let to = contained(&to)?;
|
|
182
210
|
let fs = self.fs();
|
|
183
|
-
block_on(fs.copy(
|
|
211
|
+
block_on(fs.copy(from, to)).map_err(error::to_ruby)
|
|
184
212
|
}
|
|
185
213
|
|
|
186
214
|
fn rename(&self, from: String, to: String) -> Result<(), Error> {
|
|
215
|
+
let from = contained(&from)?;
|
|
216
|
+
let to = contained(&to)?;
|
|
187
217
|
let fs = self.fs();
|
|
188
|
-
block_on(fs.rename(
|
|
218
|
+
block_on(fs.rename(from, to)).map_err(error::to_ruby)
|
|
189
219
|
}
|
|
190
220
|
|
|
191
221
|
fn stat(&self, path: String) -> Result<RHash, Error> {
|
|
222
|
+
let path = contained(&path)?;
|
|
192
223
|
let fs = self.fs();
|
|
193
|
-
let meta = block_on(fs.stat(
|
|
224
|
+
let meta = block_on(fs.stat(path)).map_err(error::to_ruby)?;
|
|
194
225
|
Ok(fs_metadata_to_hash(&meta))
|
|
195
226
|
}
|
|
196
227
|
}
|
data/lib/microsandbox/fs.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Microsandbox
|
|
|
27
27
|
|
|
28
28
|
# @return [Time, nil] last-modified time, if known
|
|
29
29
|
def modified
|
|
30
|
-
|
|
30
|
+
Microsandbox.time_from_ms(@modified_ms)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def file? = @type == :file
|
|
@@ -64,12 +64,12 @@ module Microsandbox
|
|
|
64
64
|
|
|
65
65
|
# @return [Time, nil] last-modified time, if known
|
|
66
66
|
def modified
|
|
67
|
-
|
|
67
|
+
Microsandbox.time_from_ms(@modified_ms)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# @return [Time, nil] creation time, if known
|
|
71
71
|
def created
|
|
72
|
-
|
|
72
|
+
Microsandbox.time_from_ms(@created_ms)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def inspect
|
|
@@ -188,11 +188,7 @@ module Microsandbox
|
|
|
188
188
|
sink = FsWriteSink.new(@native.fs_write_stream(path.to_s))
|
|
189
189
|
return sink unless block_given?
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
yield sink
|
|
193
|
-
ensure
|
|
194
|
-
sink.close
|
|
195
|
-
end
|
|
191
|
+
Microsandbox.close_after(sink) { yield sink }
|
|
196
192
|
end
|
|
197
193
|
end
|
|
198
194
|
|
data/lib/microsandbox/image.rb
CHANGED
|
@@ -18,12 +18,12 @@ module Microsandbox
|
|
|
18
18
|
|
|
19
19
|
# @return [Time, nil]
|
|
20
20
|
def created_at
|
|
21
|
-
|
|
21
|
+
Microsandbox.time_from_ms(@created_at_ms)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
# @return [Time, nil]
|
|
25
25
|
def last_used_at
|
|
26
|
-
|
|
26
|
+
Microsandbox.time_from_ms(@last_used_at_ms)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def inspect
|