mxc 0.2.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2015327035e657f04421e583c3d740dd2701cfd232415f850cdd39be9ab48aa6
4
- data.tar.gz: e4210df59ad071f399fdd4c398965611e88d4644a554891871bee09d64366aba
3
+ metadata.gz: fccd6da3c4cef2c5422949e1a28b2c38fad41a709a33db744e95c255e32c72fa
4
+ data.tar.gz: 165b13bffc2f44b45468a215647b054413acb392aed297d8f9325a8e9f38d707
5
5
  SHA512:
6
- metadata.gz: d5f2756cdde6caf1fca9a2fcd294b551e469908248e8ac3a2429b8542101694fe7ddf858c4d398b21ce7056c2a6368cdb9ed42bbf55238e12ba3c9c17faca92e
7
- data.tar.gz: f365aa9d30f0d02c510f238e4d3c0250463df4ea9ece76b49bfa7de0f78c9831ae98159cc8a3cb67bf053a7aeca0c6188e93d21033af66a1be634713b5313a57
6
+ metadata.gz: c8296fcb1dacf6434405b6e821a88f69871dc3877f05c3fa04e9140017f564aa773f43276812e9954a09335022732e20b4b88159ce63d4eca6ff58d5cea9281a
7
+ data.tar.gz: 9908ebc2252e66cf4d06e4d23c07fd2975943d60aa4b629c0e6b1127f0c690acbc2eab8a5955ed7bb6ec71fc803d30a82d3fb5aefe398f65a9720eb210ae897e
data/Cargo.lock CHANGED
@@ -200,6 +200,26 @@ version = "1.19.0"
200
200
  source = "registry+https://github.com/rust-lang/crates.io-index"
201
201
  checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
202
202
 
203
+ [[package]]
204
+ name = "enumflags2"
205
+ version = "0.7.12"
206
+ source = "registry+https://github.com/rust-lang/crates.io-index"
207
+ checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
208
+ dependencies = [
209
+ "enumflags2_derive",
210
+ ]
211
+
212
+ [[package]]
213
+ name = "enumflags2_derive"
214
+ version = "0.7.12"
215
+ source = "registry+https://github.com/rust-lang/crates.io-index"
216
+ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
217
+ dependencies = [
218
+ "proc-macro2",
219
+ "quote",
220
+ "syn 2.0.119",
221
+ ]
222
+
203
223
  [[package]]
204
224
  name = "errno"
205
225
  version = "0.3.14"
@@ -456,6 +476,17 @@ dependencies = [
456
476
  "wasm-bindgen",
457
477
  ]
458
478
 
479
+ [[package]]
480
+ name = "landlock"
481
+ version = "0.4.7"
482
+ source = "registry+https://github.com/rust-lang/crates.io-index"
483
+ checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1"
484
+ dependencies = [
485
+ "enumflags2",
486
+ "libc",
487
+ "thiserror",
488
+ ]
489
+
459
490
  [[package]]
460
491
  name = "lazy_static"
461
492
  version = "1.5.1"
@@ -562,7 +593,7 @@ dependencies = [
562
593
 
563
594
  [[package]]
564
595
  name = "mxc"
565
- version = "0.2.0"
596
+ version = "0.4.0"
566
597
  dependencies = [
567
598
  "magnus",
568
599
  "mxc-sdk",
@@ -574,7 +605,7 @@ dependencies = [
574
605
  [[package]]
575
606
  name = "mxc-sdk"
576
607
  version = "1.0.0"
577
- source = "git+https://github.com/crmne/microsoft-mxc?rev=21dad14168287bfdbe304266ab30bdbf78b44c32#21dad14168287bfdbe304266ab30bdbf78b44c32"
608
+ source = "git+https://github.com/crmne/microsoft-mxc?rev=d57b0d1aa3a2d552001fa54ef4ac4baaa733c26d#d57b0d1aa3a2d552001fa54ef4ac4baaa733c26d"
578
609
  dependencies = [
579
610
  "anyhow",
580
611
  "base64",
@@ -583,6 +614,7 @@ dependencies = [
583
614
  "cidr",
584
615
  "flatbuffers",
585
616
  "getrandom 0.2.17",
617
+ "landlock",
586
618
  "libc",
587
619
  "nix",
588
620
  "same-file",
data/NOTICE CHANGED
@@ -6,8 +6,8 @@ endorsed by Microsoft.
6
6
 
7
7
  The native extension compiles and links the mxc-sdk crate
8
8
  (https://github.com/microsoft/mxc, https://crates.io/crates/mxc-sdk), from
9
- a fork at https://github.com/crmne/microsoft-mxc that adds resource limits,
10
- which is distributed under the MIT License:
9
+ a fork at https://github.com/crmne/microsoft-mxc that adds resource limits
10
+ and a Landlock backend, which is distributed under the MIT License:
11
11
 
12
12
  MIT License
13
13
 
data/README.md CHANGED
@@ -22,7 +22,7 @@ result.stdout
22
22
  result.success?
23
23
  ```
24
24
 
25
- It is an unofficial Ruby binding to [Microsoft's MXC](https://github.com/microsoft/mxc), the MIT-licensed sandbox library. Not affiliated with or endorsed by Microsoft. Resource limits come from [a fork of MXC](https://github.com/crmne/microsoft-mxc) until they are [part of MXC itself](https://github.com/microsoft/mxc/issues/1498).
25
+ It is an unofficial Ruby binding to [Microsoft's MXC](https://github.com/microsoft/mxc), the MIT-licensed sandbox library. Not affiliated with or endorsed by Microsoft. Resource limits and the Landlock backend come from [a fork of MXC](https://github.com/crmne/microsoft-mxc) until they are part of MXC itself ([resource limits](https://github.com/microsoft/mxc/issues/1498), [Landlock](https://github.com/microsoft/mxc/issues/1494)).
26
26
 
27
27
  ## Installation
28
28
 
@@ -36,6 +36,8 @@ Precompiled gems cover Linux (x86_64 and ARM64, glibc and musl), macOS (Intel an
36
36
  sudo apt-get install bubblewrap
37
37
  ```
38
38
 
39
+ Where Bubblewrap cannot create its namespaces, as in a default Docker container or on Ubuntu with AppArmor's restriction, sandboxes run on Landlock instead (Linux 6.2 or newer), without network access.
40
+
39
41
  Commands that use the network also need `slirp4netns`, `iptables`, util-linux, and `/dev/net/tun`. macOS and Windows need nothing extra.
40
42
 
41
43
  ## Usage
@@ -117,15 +119,16 @@ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
117
119
 
118
120
  ## Platforms
119
121
 
120
- | Platform | Backend | 0.2.0 |
122
+ | Platform | Backend | 0.3.0 |
121
123
  | --- | --- | --- |
122
124
  | Linux | Bubblewrap | Supported and tested, without process limits |
125
+ | Linux 6.2 and later | Landlock, where Bubblewrap cannot run or when asked for | Supported and tested, without network access or process limits |
123
126
  | macOS | Seatbelt | Supported and tested, without memory or process limits |
124
127
  | Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
125
128
 
126
129
  On Windows, a single String runs through `cmd.exe` instead of `sh`. See [Running Commands](https://crmne.github.io/mxc/running-commands/#windows) and [Policies](https://crmne.github.io/mxc/policies/#windows) for what else differs.
127
130
 
128
- Docker blocks the namespaces Bubblewrap needs by default. [Running in Docker](https://crmne.github.io/mxc/running-in-docker/) shows the smallest set of changes that makes it work, with a seccomp profile and a Kamal configuration for a job role.
131
+ Docker blocks the namespaces Bubblewrap needs by default, so a default container runs sandboxes on Landlock. [Running in Docker](https://crmne.github.io/mxc/running-in-docker/) shows what that gives up, and the smallest set of changes that makes Bubblewrap work, with a seccomp profile and a Kamal configuration for a job role.
129
132
 
130
133
  ## Documentation
131
134
 
data/ext/mxc/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "mxc"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  authors = ["Carmine Paolino <carmine@paolino.me>"]
6
6
  license = "MIT"
@@ -11,9 +11,9 @@ crate-type = ["cdylib"]
11
11
 
12
12
  [dependencies]
13
13
  magnus = "0.8.2"
14
- # A fork of mxc-sdk 1.0.0 that adds resource limits (microsoft/mxc#1498),
15
- # until MXC releases them.
16
- mxc-sdk = { git = "https://github.com/crmne/microsoft-mxc", rev = "21dad14168287bfdbe304266ab30bdbf78b44c32" }
14
+ # A fork of mxc-sdk 1.0.0 that adds resource limits (microsoft/mxc#1498) and
15
+ # a Landlock backend (microsoft/mxc#1494), until MXC releases them.
16
+ mxc-sdk = { git = "https://github.com/crmne/microsoft-mxc", rev = "d57b0d1aa3a2d552001fa54ef4ac4baaa733c26d" }
17
17
  rb-sys = { version = "0.9.130", features = ["stable-api-compiled-fallback"] }
18
18
  serde_json = "1"
19
19
 
data/ext/mxc/src/lib.rs CHANGED
@@ -20,6 +20,16 @@ mod proxy;
20
20
  use proxy::{Allowlist, EgressProxy};
21
21
 
22
22
  const MAX_POLL: Duration = Duration::from_millis(50);
23
+ /// How long a sandbox that failed may keep its output streams open before
24
+ /// they are closed. Nothing it started runs on, so they end at once.
25
+ const DRAIN: Duration = Duration::from_secs(1);
26
+
27
+ fn drain_until(drained: impl Fn() -> bool) {
28
+ let deadline = Instant::now() + DRAIN;
29
+ while !drained() && Instant::now() < deadline {
30
+ std::thread::sleep(Duration::from_millis(5));
31
+ }
32
+ }
23
33
 
24
34
  fn without_gvl<F, R>(f: F) -> R
25
35
  where
@@ -90,21 +100,14 @@ fn deny_all() -> NetworkEgressPolicy {
90
100
  }
91
101
 
92
102
  /// A request ready to start: the typed request, or, when it carries resource
93
- /// limits, which MXC's typed V1 request cannot express yet, the same request
94
- /// as `1.1.0-alpha` JSON.
103
+ /// limits or names Landlock, which MXC's typed V1 request cannot express yet,
104
+ /// the same request as `1.1.0-alpha` JSON.
95
105
  enum Request {
96
106
  Typed(Box<ContainerRequest>),
97
107
  Json(String),
98
108
  }
99
109
 
100
110
  impl Request {
101
- fn run(self) -> Result<v1::ExecutionResult, v1::Error> {
102
- match self {
103
- Request::Typed(req) => v1::run(*req, Default::default()),
104
- Request::Json(json) => mxc_sdk::__ffi::run_json(&json, false),
105
- }
106
- }
107
-
108
111
  fn spawn(self) -> Result<MxcProcess, v1::Error> {
109
112
  match self {
110
113
  Request::Typed(req) => v1::spawn(*req, Default::default()),
@@ -113,6 +116,13 @@ impl Request {
113
116
  }
114
117
  }
115
118
 
119
+ fn landlock(ruby: &Ruby, opts: RHash) -> Result<bool, Error> {
120
+ Ok(match opt::<Symbol>(ruby, opts, "backend")? {
121
+ Some(backend) => backend.name()? == "landlock",
122
+ None => false,
123
+ })
124
+ }
125
+
116
126
  fn resources(ruby: &Ruby, opts: RHash) -> Result<Option<Value>, Error> {
117
127
  let Some(limits) = opt::<RHash>(ruby, opts, "resources")? else {
118
128
  return Ok(None);
@@ -140,8 +150,14 @@ fn action(action: Option<NetworkAction>) -> &'static str {
140
150
  }
141
151
  }
142
152
 
143
- // Mirrors the 1.0.0 document MXC's typed V1 request produces, plus `resources`.
144
- fn request_json(ruby: &Ruby, req: &ContainerRequest, resources: Value) -> Result<String, Error> {
153
+ // Mirrors the 1.0.0 document MXC's typed V1 request produces, plus `resources`
154
+ // and the `landlock` containment.
155
+ fn request_json(
156
+ ruby: &Ruby,
157
+ req: &ContainerRequest,
158
+ resources: Option<Value>,
159
+ landlock: bool,
160
+ ) -> Result<String, Error> {
145
161
  let filesystem = req.filesystem.clone().unwrap_or_default();
146
162
  let mut process = json!({
147
163
  "commandLine": req.command,
@@ -167,8 +183,10 @@ fn request_json(ruby: &Ruby, req: &ContainerRequest, resources: Value) -> Result
167
183
  "readonlyPaths": filesystem.readonly_paths,
168
184
  "deniedPaths": filesystem.denied_paths,
169
185
  },
170
- "resources": resources,
171
186
  });
187
+ if let Some(resources) = resources {
188
+ document["resources"] = resources;
189
+ }
172
190
  if let Some(network) = &req.network {
173
191
  let default = action(network.egress.as_ref().and_then(|egress| egress.default));
174
192
  document["network"] = json!({ "egress": { "default": default } });
@@ -181,6 +199,7 @@ fn request_json(ruby: &Ruby, req: &ContainerRequest, resources: Value) -> Result
181
199
  }
182
200
  }
183
201
  document["containment"] = match &req.containment {
202
+ _ if landlock => "landlock".into(),
184
203
  Containment::Process => "process".into(),
185
204
  Containment::Bubblewrap => "bubblewrap".into(),
186
205
  Containment::Seatbelt(_) => {
@@ -212,9 +231,10 @@ fn prepare(
212
231
  opts: RHash,
213
232
  ) -> Result<(Request, Option<EgressProxy>), Error> {
214
233
  let (req, egress_proxy) = build_request(ruby, command, opts)?;
234
+ let landlock = landlock(ruby, opts)?;
215
235
  let request = match resources(ruby, opts)? {
216
- Some(resources) => Request::Json(request_json(ruby, &req, resources)?),
217
- None => Request::Typed(Box::new(req)),
236
+ None if !landlock => Request::Typed(Box::new(req)),
237
+ resources => Request::Json(request_json(ruby, &req, resources, landlock)?),
218
238
  };
219
239
  Ok((request, egress_proxy))
220
240
  }
@@ -291,6 +311,8 @@ fn build_request(
291
311
  req.containment = match backend.name()?.as_ref() {
292
312
  "auto" => Containment::Process,
293
313
  "bubblewrap" => Containment::Bubblewrap,
314
+ // The typed request has no Landlock; `request_json` names it.
315
+ "landlock" => Containment::Process,
294
316
  "seatbelt" => Containment::Seatbelt(SeatbeltConfig::default()),
295
317
  "process_container" => {
296
318
  Containment::ProcessContainer(ProcessContainerConfig::default())
@@ -301,19 +323,62 @@ fn build_request(
301
323
  Ok((req, egress_proxy))
302
324
  }
303
325
 
304
- fn outcome_hash(ruby: &Ruby, outcome: WaitResult) -> Result<RHash, Error> {
326
+ // A wait that fails, such as for a sandbox that never started the command,
327
+ // is reported as `failure` rather than raised, so Ruby reads it alongside
328
+ // what the sandbox printed.
329
+ fn outcome_hash(ruby: &Ruby, outcome: std::io::Result<WaitResult>) -> Result<RHash, Error> {
305
330
  let h = ruby.hash_new();
306
- match outcome {
307
- WaitResult::Exited(code) => {
308
- h.aset(ruby.to_symbol("timed_out"), false)?;
309
- h.aset(ruby.to_symbol("exit_code"), code)?;
331
+ let (timed_out, exit_code) = match outcome {
332
+ Ok(WaitResult::Exited(code)) => (false, Some(code)),
333
+ Ok(WaitResult::TimedOut) => (true, None),
334
+ Err(e) => {
335
+ h.aset(ruby.to_symbol("failure"), e.to_string())?;
336
+ (false, None)
337
+ }
338
+ };
339
+ h.aset(ruby.to_symbol("timed_out"), timed_out)?;
340
+ h.aset(ruby.to_symbol("exit_code"), exit_code)?;
341
+ Ok(h)
342
+ }
343
+
344
+ type Outcome = (std::io::Result<WaitResult>, Vec<String>, Option<ExecutionMetadata>);
345
+ type Drain = std::thread::JoinHandle<std::io::Result<Vec<u8>>>;
346
+
347
+ fn drain(stream: Option<Box<dyn Read + Send>>) -> Drain {
348
+ std::thread::spawn(move || {
349
+ let mut buf = Vec::new();
350
+ if let Some(mut stream) = stream {
351
+ stream.read_to_end(&mut buf)?;
310
352
  }
311
- WaitResult::TimedOut => {
312
- h.aset(ruby.to_symbol("timed_out"), true)?;
313
- h.aset(ruby.to_symbol("exit_code"), ruby.qnil())?;
353
+ Ok(buf)
354
+ })
355
+ }
356
+
357
+ fn drained(handle: Drain) -> std::io::Result<Vec<u8>> {
358
+ handle
359
+ .join()
360
+ .unwrap_or_else(|_| Err(std::io::Error::other("output capture thread panicked")))
361
+ }
362
+
363
+ // MXC's `wait_with_output`, except that a failed wait keeps the output: when
364
+ // Bubblewrap cannot set a sandbox up, its reason is on stderr.
365
+ fn capture(mut child: MxcProcess) -> std::io::Result<(Outcome, Vec<u8>, Vec<u8>)> {
366
+ let closers = [child.stdout_closer(), child.stderr_closer()];
367
+ let stdout = drain(child.take_stdout());
368
+ let stderr = drain(child.take_stderr());
369
+ let outcome = child.wait();
370
+ let warnings = child.warnings();
371
+ let metadata = child.output_metadata().cloned();
372
+ drop(child);
373
+ if outcome.is_err() {
374
+ drain_until(|| stdout.is_finished() && stderr.is_finished());
375
+ }
376
+ if !matches!(outcome, Ok(WaitResult::Exited(_))) {
377
+ for closer in closers.iter().flatten() {
378
+ closer.close();
314
379
  }
315
380
  }
316
- Ok(h)
381
+ Ok(((outcome, warnings, metadata), drained(stdout)?, drained(stderr)?))
317
382
  }
318
383
 
319
384
  fn snake_case(name: &str) -> String {
@@ -351,18 +416,20 @@ fn backends(ruby: &Ruby) -> Result<RArray, Error> {
351
416
 
352
417
  fn run(ruby: &Ruby, command: String, opts: RHash) -> Result<RHash, Error> {
353
418
  let (req, mut egress_proxy) = prepare(ruby, command, opts)?;
354
- let result = without_gvl(move || req.run());
419
+ let result = without_gvl(move || req.spawn().map(capture));
355
420
  let blocked = egress_proxy.as_mut().map(|p| {
356
421
  p.stop();
357
422
  p.blocked()
358
423
  });
359
- let r = result.map_err(|e| mxc_error(ruby, e))?;
360
- let h = outcome_hash(ruby, r.outcome)?;
361
- h.aset(ruby.to_symbol("stdout"), ruby.str_from_slice(&r.stdout))?;
362
- h.aset(ruby.to_symbol("stderr"), ruby.str_from_slice(&r.stderr))?;
363
- h.aset(ruby.to_symbol("warnings"), r.warnings)?;
424
+ let ((outcome, warnings, metadata), stdout, stderr) = result
425
+ .map_err(|e| mxc_error(ruby, e))?
426
+ .map_err(|e| io_error(ruby, e))?;
427
+ let h = outcome_hash(ruby, outcome)?;
428
+ h.aset(ruby.to_symbol("stdout"), ruby.str_from_slice(&stdout))?;
429
+ h.aset(ruby.to_symbol("stderr"), ruby.str_from_slice(&stderr))?;
430
+ h.aset(ruby.to_symbol("warnings"), warnings)?;
364
431
  h.aset(ruby.to_symbol("blocked"), blocked.unwrap_or_default())?;
365
- usage_into(ruby, h, r.output_metadata.as_ref())?;
432
+ usage_into(ruby, h, metadata.as_ref())?;
366
433
  Ok(h)
367
434
  }
368
435
 
@@ -426,6 +493,21 @@ impl Process {
426
493
  self.pid
427
494
  }
428
495
 
496
+ fn warnings(&self) -> Vec<String> {
497
+ match self.child.lock().unwrap().as_ref() {
498
+ Some(child) => child.warnings(),
499
+ None => Vec::new(),
500
+ }
501
+ }
502
+
503
+ // A reader blocked in `read` holds its stream's lock, and one that reached
504
+ // the end leaves `None`.
505
+ fn drained(&self) -> bool {
506
+ [&self.stdout, &self.stderr]
507
+ .iter()
508
+ .all(|stream| matches!(stream.try_lock(), Ok(guard) if guard.is_none()))
509
+ }
510
+
429
511
  fn read_stdout(ruby: &Ruby, rb_self: &Self, max: usize) -> Result<Option<RString>, Error> {
430
512
  read_from(ruby, &rb_self.stdout, max)
431
513
  }
@@ -463,7 +545,7 @@ impl Process {
463
545
 
464
546
  // MxcProcess::kill and ::wait both need the handle exclusively, so waiting
465
547
  // polls and releases the lock between polls to let kill in.
466
- fn await_exit(&self) -> std::io::Result<(WaitResult, Vec<String>, Option<ExecutionMetadata>)> {
548
+ fn await_exit(&self) -> std::io::Result<Outcome> {
467
549
  let mut nap = Duration::from_millis(1);
468
550
  loop {
469
551
  {
@@ -472,8 +554,8 @@ impl Process {
472
554
  .as_mut()
473
555
  .ok_or_else(|| std::io::Error::other("the process was already waited for"))?;
474
556
  let expired = self.deadline.is_some_and(|d| Instant::now() >= d);
475
- if expired || child.try_wait()?.is_some() {
476
- let outcome = child.wait()?;
557
+ if expired || !matches!(child.try_wait(), Ok(None)) {
558
+ let outcome = child.wait();
477
559
  let warnings = child.warnings();
478
560
  let metadata = child.output_metadata().cloned();
479
561
  guard.take();
@@ -499,7 +581,10 @@ impl Process {
499
581
  None => Vec::new(),
500
582
  };
501
583
  let (outcome, warnings, metadata) = res.map_err(|e| io_error(ruby, e))?;
502
- if !matches!(outcome, WaitResult::Exited(_)) {
584
+ if outcome.is_err() {
585
+ without_gvl(|| drain_until(|| rb_self.drained()));
586
+ }
587
+ if !matches!(outcome, Ok(WaitResult::Exited(_))) {
503
588
  if let Some(c) = &rb_self.stdout_closer {
504
589
  c.close();
505
590
  }
@@ -524,6 +609,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
524
609
  native.define_singleton_method("spawn", function!(spawn, 2))?;
525
610
  let process = native.define_class("Process", ruby.class_object())?;
526
611
  process.define_method("pid", method!(Process::pid, 0))?;
612
+ process.define_method("warnings", method!(Process::warnings, 0))?;
527
613
  process.define_method("read_stdout", method!(Process::read_stdout, 1))?;
528
614
  process.define_method("read_stderr", method!(Process::read_stderr, 1))?;
529
615
  process.define_method("write", method!(Process::write, 1))?;
@@ -16,9 +16,13 @@ module MXC
16
16
  # Returns a suggestion for making the host supported, or nil.
17
17
  attr_reader :remediation
18
18
 
19
+ # Returns the backend sandboxes run on, such as <tt>:bubblewrap</tt>, or <tt>:landlock</tt>
20
+ # when <tt>:auto</tt> falls back to it. Returns nil when no sandbox can start.
21
+ attr_reader :runs_on
22
+
19
23
  def self.probe(backend) # :nodoc:
20
24
  result = Sandbox.new(backend:, timeout: 10).run('exit 0')
21
- return new(backend:, supported: true) if result.success?
25
+ return new(backend:, supported: true, runs_on: result.backend) if result.success?
22
26
 
23
27
  outcome = result.timed_out? ? 'timed out' : "exited with #{result.exit_code}"
24
28
  new(backend:, supported: false, reason: "A sandboxed `exit 0` #{outcome}: #{result.stderr.strip}")
@@ -26,8 +30,9 @@ module MXC
26
30
  new(backend:, supported: false, reason: e.message, remediation: e.remediation)
27
31
  end
28
32
 
29
- def initialize(backend:, supported:, reason: nil, remediation: nil) # :nodoc:
33
+ def initialize(backend:, supported:, reason: nil, remediation: nil, runs_on: nil) # :nodoc:
30
34
  @backend = backend
35
+ @runs_on = runs_on
31
36
  @supported = supported
32
37
  @reason = reason
33
38
  @remediation = remediation
@@ -39,7 +44,8 @@ module MXC
39
44
  end
40
45
 
41
46
  def inspect # :nodoc:
42
- "#<MXC::Availability backend=#{backend.inspect} supported=#{supported?} reason=#{reason.inspect}>"
47
+ "#<MXC::Availability backend=#{backend.inspect} supported=#{supported?} runs_on=#{runs_on.inspect} " \
48
+ "reason=#{reason.inspect}>"
43
49
  end
44
50
  end
45
51
  end
@@ -15,7 +15,8 @@ module MXC
15
15
  @timeout = nil
16
16
  end
17
17
 
18
- # Sets the default backend: <tt>:auto</tt>, <tt>:bubblewrap</tt>, or <tt>:seatbelt</tt>.
18
+ # Sets the default backend: <tt>:auto</tt>, <tt>:bubblewrap</tt>, <tt>:landlock</tt>,
19
+ # <tt>:seatbelt</tt>, or <tt>:process_container</tt>.
19
20
  def backend=(name)
20
21
  @backend = Sandbox.backend_name(name)
21
22
  end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+
6
+ module MXC
7
+ # Finds the shared library files programs load, directly or through
8
+ # other libraries, so a sandbox can read them and nothing beside them. Only macOS needs this: Seatbelt
9
+ # sandboxes read the system's libraries but not those a package manager
10
+ # installs, such as Homebrew's, which a program like Ruby may link. The
11
+ # libraries come from the programs' load commands, read with otool from
12
+ # the Command Line Tools. Without them nothing is found, because
13
+ # /usr/bin/otool would offer to install them instead.
14
+ module Libraries # :nodoc:
15
+ OTOOL = '/usr/bin/otool'
16
+ XCODE_SELECT = '/usr/bin/xcode-select'
17
+ SYSTEM = %w[/usr/lib/ /System/ /Library/].freeze
18
+ TOKEN = %r{\A@(loader_path|executable_path|rpath)/}
19
+
20
+ @links = {}
21
+ @lock = Mutex.new
22
+
23
+ class << self
24
+ # Returns the real paths of the library files +binaries+ load outside
25
+ # the system's own, empty where none are needed or otool is missing.
26
+ # +executable+ is the program that loads them, for libraries named
27
+ # relative to it.
28
+ def files(binaries, executable: nil)
29
+ return [] if binaries.empty? || !available?
30
+
31
+ libraries(binaries, executable)
32
+ end
33
+
34
+ # Returns the path of the program +arguments+ start: the first argument, or the first
35
+ # word of a command for the shell, found on +path+ or under +directory+. Returns nil
36
+ # when there is none.
37
+ def program(arguments, path:, directory:)
38
+ name = program_name(arguments)
39
+ return if name.nil?
40
+ return File.expand_path(name, directory || '/') if name.include?('/')
41
+
42
+ path.to_s.split(File::PATH_SEPARATOR).map { |entry| File.join(entry, name) }
43
+ .find { |candidate| File.file?(candidate) && File.executable?(candidate) }
44
+ end
45
+
46
+ # Returns true when this host can list a program's libraries.
47
+ def available?
48
+ return @available if defined?(@available)
49
+
50
+ @available = RUBY_PLATFORM.include?('darwin') && File.executable?(OTOOL) && developer_tools?
51
+ end
52
+
53
+ private
54
+
55
+ def program_name(arguments)
56
+ arguments.one? ? Shellwords.split(arguments.first).first : arguments.first
57
+ rescue ArgumentError
58
+ nil
59
+ end
60
+
61
+ def libraries(binaries, executable)
62
+ roots = binaries.filter_map { |binary| real_path(binary) }
63
+ links_of(roots)
64
+ found = {}
65
+ roots.each { |root| visit(root, executable || root, [], found) }
66
+ found.keys
67
+ end
68
+
69
+ def visit(path, executable, inherited, found)
70
+ dependencies(path, links_of([path]).fetch(path), executable, inherited).each do |library, rpaths|
71
+ next if found.key?(library)
72
+
73
+ found[library] = true
74
+ visit(library, executable, rpaths, found)
75
+ end
76
+ end
77
+
78
+ def developer_tools?
79
+ system(XCODE_SELECT, '-p', out: File::NULL, err: File::NULL) == true
80
+ end
81
+
82
+ def dependencies(path, links, executable, inherited)
83
+ rpaths = links[:rpaths].filter_map { |rpath| expand(rpath, path, executable) } + inherited
84
+ links[:libraries].filter_map do |name|
85
+ next if system?(name)
86
+
87
+ library = locate(name, path, executable, rpaths)
88
+ [library, rpaths] if library && !system?(library)
89
+ end
90
+ end
91
+
92
+ def locate(name, loader, executable, rpaths)
93
+ relative = name.delete_prefix('@rpath/')
94
+ candidates = if relative == name
95
+ [expand(name, loader, executable)]
96
+ else
97
+ rpaths.map { |rpath| File.join(rpath, relative) }
98
+ end
99
+ candidates.compact.lazy.filter_map { |candidate| real_path(candidate) }.first
100
+ end
101
+
102
+ def expand(name, loader, executable)
103
+ token = name[TOKEN, 1]
104
+ return name unless token
105
+ return if token == 'rpath'
106
+
107
+ base = token == 'loader_path' ? loader : executable
108
+ File.expand_path(name.sub(TOKEN, ''), File.dirname(base))
109
+ end
110
+
111
+ def system?(path)
112
+ path.start_with?(*SYSTEM)
113
+ end
114
+
115
+ def real_path(path)
116
+ path = File.realpath(path)
117
+ path if File.file?(path)
118
+ rescue SystemCallError
119
+ nil
120
+ end
121
+
122
+ def links_of(paths)
123
+ @lock.synchronize do
124
+ missing = paths.reject { |path| @links.key?(path) }
125
+ @links.merge!(LoadCommands.parse(otool(missing), missing)) if missing.any?
126
+ @links.slice(*paths)
127
+ end
128
+ end
129
+
130
+ def otool(paths)
131
+ output, = Open3.capture2(OTOOL, '-l', *paths, err: File::NULL)
132
+ output
133
+ rescue SystemCallError
134
+ ''
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # Reads the libraries and run paths of Mach-O files from the load
5
+ # commands <tt>otool -l</tt> lists, with every architecture of a
6
+ # universal file together.
7
+ module LoadCommands # :nodoc:
8
+ LOAD = /\A\s*cmd LC_(?:LOAD_DYLIB|LOAD_WEAK_DYLIB|REEXPORT_DYLIB|LAZY_LOAD_DYLIB|LOAD_UPWARD_DYLIB)\z/
9
+ RPATH = /\A\s*cmd LC_RPATH\z/
10
+ COMMAND = /\A\s*cmd /
11
+ VALUE = /\A\s*(?:name|path) (.+) \(offset \d+\)\z/
12
+ ARCHITECTURE = / \(architecture [^)]+\)\z/
13
+
14
+ class << self
15
+ # Returns, for each of +paths+, the install names of the libraries it
16
+ # loads and its run paths, as <tt>{ libraries: [...], rpaths: [...] }</tt>.
17
+ def parse(output, paths)
18
+ links = paths.to_h { |path| [path, { libraries: [], rpaths: [] }] }
19
+ current = kind = nil
20
+ output.each_line(chomp: true) do |line|
21
+ if links.key?(header = header_path(line))
22
+ current = links[header]
23
+ kind = nil
24
+ elsif current
25
+ kind = record(current, kind, line)
26
+ end
27
+ end
28
+ links
29
+ end
30
+
31
+ private
32
+
33
+ def record(links, kind, line)
34
+ return :libraries if line.match?(LOAD)
35
+ return :rpaths if line.match?(RPATH)
36
+ return if line.match?(COMMAND)
37
+
38
+ value = line[VALUE, 1] if kind
39
+ return kind unless value
40
+
41
+ links[kind] << value
42
+ nil
43
+ end
44
+
45
+ def header_path(line)
46
+ line.delete_suffix(':').sub(ARCHITECTURE, '') if line.end_with?(':') && !line.start_with?(' ', "\t")
47
+ end
48
+ end
49
+ end
50
+ end
data/lib/mxc/native.rb CHANGED
@@ -13,6 +13,9 @@ module MXC
13
13
  MISSING_BUBBLEWRAP = /bwrap\) is not installed/
14
14
  INSTALL_BUBBLEWRAP = 'Install Bubblewrap 0.5.0 or newer (Debian/Ubuntu: apt-get install bubblewrap; ' \
15
15
  'Fedora: dnf install bubblewrap; Arch: pacman -S bubblewrap)'
16
+ LANDLOCK_REFUSAL = /\ALandlock: /
17
+ LANDLOCK_POLICY = 'Change the policy as the message says, or run on Bubblewrap: a sandbox with backend :auto ' \
18
+ 'runs on Landlock only where Bubblewrap cannot create its namespaces'
16
19
 
17
20
  def self.backend_attributes
18
21
  backends.map { |attributes| attributes.merge(name: BACKEND_NAMES.fetch(attributes[:name], attributes[:name])) }
@@ -21,8 +24,12 @@ module MXC
21
24
  def self.error(message, code: nil, remediation: nil, **)
22
25
  if message.match?(MISSING_BUBBLEWRAP)
23
26
  UnsupportedError.new(message, code: :backend_unavailable, remediation: remediation || INSTALL_BUBBLEWRAP, **)
27
+ elsif message.match?(LANDLOCK_REFUSAL)
28
+ UnsupportedError.new(message, code:, remediation: remediation || LANDLOCK_POLICY, **)
29
+ elsif UNSUPPORTED_CODES.include?(code)
30
+ UnsupportedError.new(message, code:, remediation: remediation || StartupFailure.hint(message), **)
24
31
  else
25
- (UNSUPPORTED_CODES.include?(code) ? UnsupportedError : Error).new(message, code:, remediation:, **)
32
+ Error.new(message, code:, remediation:, **)
26
33
  end
27
34
  end
28
35
  end
data/lib/mxc/preflight.rb CHANGED
@@ -16,16 +16,27 @@ module MXC
16
16
  'Linux and Windows support it'
17
17
  PROCESS_LIMIT = 'Leave processes out of the limits. Only Windows can count the processes of one sandbox; ' \
18
18
  'in a container, limit them with docker run --pids-limit'
19
+ LANDLOCK_NETWORK = 'Use network: false, or the :bubblewrap backend on a host that allows unprivileged user ' \
20
+ 'namespaces. Landlock cannot tell one address from another, so it could only allow the ' \
21
+ "whole host network, the host's loopback and inbound connections included"
19
22
 
20
23
  module_function
21
24
 
22
- def check!(network:, denied: [], resources: nil)
23
- error = problem(network:, denied:, resources:)
25
+ def check!(network:, denied: [], resources: nil, backend: :auto)
26
+ error = problem(network:, denied:, resources:, backend:)
24
27
  raise error if error
25
28
  end
26
29
 
27
- def problem(network:, denied: [], resources: nil, path: ENV.fetch('PATH', ''), tun: TUN)
28
- resource_problem(resources) || host_problem(network, denied, path, tun)
30
+ def problem(network:, denied: [], resources: nil, backend: :auto, path: ENV.fetch('PATH', ''), tun: TUN)
31
+ resource_problem(resources) || landlock_problem(network, backend) || host_problem(network, denied, path, tun)
32
+ end
33
+
34
+ def landlock_problem(network, backend)
35
+ return unless network && backend == :landlock
36
+
37
+ kind = network.is_a?(Hash) ? 'Host allow-lists are' : 'Network access is'
38
+ UnsupportedError.new("#{kind} not supported on Landlock", code: :backend_unavailable,
39
+ remediation: LANDLOCK_NETWORK)
29
40
  end
30
41
 
31
42
  def resource_problem(resources)
data/lib/mxc/sandbox.rb CHANGED
@@ -28,8 +28,10 @@ module MXC
28
28
  #
29
29
  # SkillSandbox.new.run("python3 app/skills/build.py")
30
30
  class Sandbox
31
- BACKENDS = %i[auto bubblewrap seatbelt process_container].freeze # :nodoc:
32
- OPTIONS = %i[readable writable denied network env directory timeout backend limits priority].freeze # :nodoc:
31
+ BACKENDS = %i[auto bubblewrap landlock seatbelt process_container].freeze # :nodoc:
32
+ LANDLOCK_FALLBACK = /runs on Landlock instead/ # :nodoc:
33
+ OPTIONS = %i[readable writable denied libraries libraries_of network env directory timeout backend limits
34
+ priority].freeze # :nodoc:
33
35
  HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
34
36
  WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
35
37
  PROCESSOR_ARCHITECTURE NUMBER_OF_PROCESSORS OS].freeze # :nodoc:
@@ -39,8 +41,8 @@ module MXC
39
41
  PRIORITIES = { normal: nil, low: 'low', lowest: 'idle' }.freeze # :nodoc:
40
42
  MEGABYTE = 1024 * 1024 # :nodoc:
41
43
  DEFAULTS = {
42
- readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil,
43
- limits: {}, priority: nil
44
+ readable: [], writable: [], denied: [], libraries: true, libraries_of: [], env: {}, network: false,
45
+ directory: nil, timeout: nil, backend: nil, limits: {}, priority: nil
44
46
  }.freeze # :nodoc:
45
47
 
46
48
  class << self
@@ -53,6 +55,13 @@ module MXC
53
55
  # Adds paths every instance hides, even inside readable or writable paths.
54
56
  def denied(*paths) = configure { |sandbox| sandbox.with_denied(*paths) }
55
57
 
58
+ # Adds programs and libraries whose shared libraries every instance can load.
59
+ # Takes the same arguments as #with_libraries_of.
60
+ def libraries_of(*paths) = configure { |sandbox| sandbox.with_libraries_of(*paths) }
61
+
62
+ # Sets whether every instance can read the libraries its programs load. See #with_libraries.
63
+ def libraries(enabled) = configure { |sandbox| sandbox.with_libraries(enabled) }
64
+
56
65
  # Sets every instance's network access. Takes the same arguments as #with_network.
57
66
  def network(...) = configure { |sandbox| sandbox.with_network(...) }
58
67
 
@@ -134,6 +143,12 @@ module MXC
134
143
  # Returns the paths hidden from the command, as given.
135
144
  def denied = @settings[:denied].dup.freeze
136
145
 
146
+ # Returns the programs and libraries whose shared libraries the command can load, as given.
147
+ def libraries_of = @settings[:libraries_of].dup.freeze
148
+
149
+ # Returns true when the command can read the libraries its program loads, the default.
150
+ def libraries = @settings[:libraries]
151
+
137
152
  # Returns the environment variables set for the command.
138
153
  def env = @settings[:env].dup.freeze
139
154
 
@@ -172,6 +187,32 @@ module MXC
172
187
  add_paths(:denied, paths)
173
188
  end
174
189
 
190
+ # Adds programs and libraries whose shared libraries the command loads, such as the
191
+ # extensions an interpreter opens. On macOS, the command can read those library files,
192
+ # and the libraries they load in turn, when they are not the system's own. The program
193
+ # the command runs needs no entry: #run grants its libraries already. Elsewhere, on a
194
+ # Mac without the Command Line Tools, and with <tt>with_libraries(false)</tt>, this
195
+ # grants nothing.
196
+ #
197
+ # sandbox.with_libraries_of(*Dir[File.join(RbConfig::CONFIG["archdir"], "**", "*.bundle")])
198
+ def with_libraries_of(*paths)
199
+ add_paths(:libraries_of, paths)
200
+ end
201
+
202
+ # Sets whether the command can read the shared libraries its program and #libraries_of
203
+ # load, on macOS. +true+ is the default; +false+ grants no library, so the command reads
204
+ # only the system's and those under #readable. +nil+ resets it to +true+.
205
+ #
206
+ # sandbox.with_libraries(false)
207
+ def with_libraries(enabled)
208
+ unless [true, false, nil].include?(enabled)
209
+ raise ArgumentError, "libraries must be true, false or nil, got #{enabled.inspect}"
210
+ end
211
+
212
+ @settings[:libraries] = enabled != false
213
+ self
214
+ end
215
+
175
216
  # Sets network access. The default, +false+, gives the command no network at all.
176
217
  # +true+ allows any destination. <tt>allow:</tt> takes a list of hosts the command
177
218
  # may reach over HTTP and HTTPS: <tt>"pypi.org"</tt>, <tt>"*.pythonhosted.org"</tt>
@@ -218,8 +259,12 @@ module MXC
218
259
  end
219
260
 
220
261
  # Sets the backend: <tt>:auto</tt> (Bubblewrap on Linux, Seatbelt on macOS,
221
- # ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:seatbelt</tt>, or
222
- # <tt>:process_container</tt>.
262
+ # ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:landlock</tt>, <tt>:seatbelt</tt>,
263
+ # or <tt>:process_container</tt>.
264
+ #
265
+ # On Linux, <tt>:auto</tt> runs on Landlock when Bubblewrap cannot create its namespaces,
266
+ # and says so in Result#warnings. <tt>:bubblewrap</tt> never does. <tt>:landlock</tt>
267
+ # needs Linux 6.2 or newer and runs without network access.
223
268
  def with_backend(name)
224
269
  @settings[:backend] = name && self.class.backend_name(name)
225
270
  self
@@ -268,6 +313,12 @@ module MXC
268
313
  #
269
314
  # With a block, yields each Chunk of output as it arrives.
270
315
  #
316
+ # On macOS, the command can read the shared library files its program links outside
317
+ # the system's own, such as Homebrew's, so programs built against them start. The
318
+ # program is the first argument, or the first word of a command for the shell, found
319
+ # on the +PATH+ of #env. See #with_libraries_of for libraries the program opens later,
320
+ # and #with_libraries to turn this off.
321
+ #
271
322
  # sandbox.run("python3 build.py && ls dist")
272
323
  # sandbox.run("python3", "convert.py", params[:filename])
273
324
  # sandbox.run("python3", "build.py") { |chunk| print chunk.content }
@@ -278,8 +329,9 @@ module MXC
278
329
  def run(*command, &)
279
330
  return stream(*command, &) if block_given?
280
331
 
281
- outcome = StartupFailure.check!(Native.run(command_line(command), request))
282
- Result.new(**outcome, backend: resolved_backend)
332
+ arguments = command_arguments(command)
333
+ outcome = StartupFailure.check!(Native.run(command_line(arguments), request(arguments)))
334
+ Result.new(**outcome, backend: resolved_backend(outcome[:warnings]))
283
335
  end
284
336
 
285
337
  # Starts +command+ and returns its Process without waiting for it. Takes the command
@@ -287,7 +339,9 @@ module MXC
287
339
  #
288
340
  # sandbox.spawn("python3", "-i", "-q")
289
341
  def spawn(*command)
290
- Process.new(Native.spawn(command_line(command), request), backend: resolved_backend)
342
+ arguments = command_arguments(command)
343
+ native = Native.spawn(command_line(arguments), request(arguments))
344
+ Process.new(native, backend: resolved_backend(native.warnings))
291
345
  end
292
346
 
293
347
  # Returns true when this host can run this sandbox, including its network policy.
@@ -298,8 +352,10 @@ module MXC
298
352
  # Returns the Availability of this sandbox on this host, with the reason and
299
353
  # remediation when it cannot run.
300
354
  def availability
301
- problem = Preflight.problem(network:, denied:, resources:)
302
- return MXC.availability(backend:) unless problem
355
+ problem = Preflight.problem(network:, denied:, resources:, backend:)
356
+ host = MXC.availability(backend:) unless problem
357
+ problem ||= Preflight.problem(network:, backend: :landlock) if host&.runs_on == :landlock
358
+ return host unless problem
303
359
 
304
360
  Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
305
361
  end
@@ -307,7 +363,8 @@ module MXC
307
363
  def inspect # :nodoc:
308
364
  resources = " limits=#{limits.inspect} priority=#{priority.inspect}" if self.resources
309
365
  "#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
310
- "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}>"
366
+ "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}" \
367
+ "#{libraries_inspect}>"
311
368
  end
312
369
 
313
370
  protected
@@ -322,7 +379,7 @@ module MXC
322
379
 
323
380
  def apply(name, value)
324
381
  case name
325
- when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
382
+ when :readable, :writable, :denied, :libraries_of then public_send(:"with_#{name}", *Array(value))
326
383
  when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
327
384
  when :limits then value.nil? ? with_limits(nil) : with_limits(**value)
328
385
  else public_send(:"with_#{name}", value)
@@ -376,8 +433,7 @@ module MXC
376
433
  process&.kill unless finished
377
434
  end
378
435
 
379
- def command_line(command)
380
- arguments = command_arguments(command)
436
+ def command_line(arguments)
381
437
  return command_line_for_windows(arguments) if Preflight.windows?
382
438
 
383
439
  arguments.one? ? arguments.first : Shellwords.join(arguments)
@@ -404,17 +460,18 @@ module MXC
404
460
  arguments.map { |argument| WindowsArguments.quote(argument) }.join(' ')
405
461
  end
406
462
 
407
- def resolved_backend
463
+ def resolved_backend(warnings)
408
464
  return backend unless backend == :auto
409
465
  return :process_container if Preflight.windows?
466
+ return :seatbelt unless Preflight.linux?
410
467
 
411
- Preflight.linux? ? :bubblewrap : :seatbelt
468
+ warnings.any?(LANDLOCK_FALLBACK) ? :landlock : :bubblewrap
412
469
  end
413
470
 
414
- def request
415
- Preflight.check!(network:, denied:, resources:)
471
+ def request(arguments)
472
+ Preflight.check!(network:, denied:, resources:, backend:)
416
473
  {
417
- readonly: expand(readable),
474
+ readonly: expand(readable) | library_files(arguments),
418
475
  readwrite: expand(writable),
419
476
  denied: expand(denied) | hidden_resolver_sockets,
420
477
  environment:,
@@ -465,6 +522,17 @@ module MXC
465
522
  system.reject { |name, _| env.keys.any? { |own| own.casecmp?(name) } }.merge(env)
466
523
  end
467
524
 
525
+ def libraries_inspect
526
+ libraries ? (" libraries_of=#{libraries_of.inspect}" if libraries_of.any?) : ' libraries=false'
527
+ end
528
+
529
+ def library_files(arguments)
530
+ return [] unless libraries && Libraries.available?
531
+
532
+ program = Libraries.program(arguments, path: env['PATH'], directory: working_directory)
533
+ Libraries.files([*program, *expand(libraries_of)], executable: program)
534
+ end
535
+
468
536
  def hidden_resolver_sockets
469
537
  return [] if network == true
470
538
 
@@ -7,7 +7,8 @@ module MXC
7
7
  /setting up uid map/i, /unshare.*Operation not permitted/i)
8
8
  PROC = /Can't mount proc|mount proc on/i
9
9
  HOST_PROBLEMS = {
10
- NAMESPACES => 'Unprivileged user namespaces are blocked. On a host, enable kernel.unprivileged_userns_clone=1 ' \
10
+ NAMESPACES => 'Unprivileged user namespaces are blocked. Use backend :auto or :landlock to run on Landlock ' \
11
+ 'instead, or allow them for Bubblewrap: on a host, enable kernel.unprivileged_userns_clone=1 ' \
11
12
  'and, on Ubuntu 23.10 and later, allow bwrap in AppArmor ' \
12
13
  '(kernel.apparmor_restrict_unprivileged_userns). ' \
13
14
  "In Docker, run with a seccomp profile that allows namespaces: #{DOCKER_GUIDE}",
@@ -15,23 +16,27 @@ module MXC
15
16
  "In Docker, see #{DOCKER_GUIDE}"
16
17
  }.freeze
17
18
  CHDIR = /Can't chdir/i
19
+ BWRAP_LINE = /^bwrap: .*$/
18
20
 
19
21
  module_function
20
22
 
21
23
  def detect(outcome)
22
- return unless outcome[:exit_code] == 1 && outcome[:stdout].to_s.empty?
24
+ failure = outcome[:failure] or return
25
+ reason = outcome[:stderr].to_s.scan(BWRAP_LINE).last
26
+ return Error.new(failure, code: :backend_error) unless reason
23
27
 
24
- stderr = outcome[:stderr].to_s
25
- return unless stderr.match?(/\Abwrap: [^\n]*\n?\z/)
26
-
27
- message = "Bubblewrap could not start the sandbox: #{stderr.strip}"
28
- _, hint = HOST_PROBLEMS.find { |pattern, _| stderr.match?(pattern) }
28
+ message = "Bubblewrap could not start the sandbox: #{reason}"
29
+ hint = hint(reason)
29
30
  return UnsupportedError.new(message, code: :backend_unavailable, remediation: hint, operation: 'bwrap') if hint
30
31
 
31
- remediation = 'Make the directory readable or writable in the sandbox' if stderr.match?(CHDIR)
32
+ remediation = 'Make the directory readable or writable in the sandbox' if reason.match?(CHDIR)
32
33
  Error.new(message, code: :backend_error, remediation:, operation: 'bwrap')
33
34
  end
34
35
 
36
+ def hint(message)
37
+ HOST_PROBLEMS.find { |pattern, _| message.match?(pattern) }&.last
38
+ end
39
+
35
40
  def check!(outcome)
36
41
  error = detect(outcome)
37
42
  raise error if error
data/lib/mxc/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module MXC
4
4
  # The gem version.
5
- VERSION = '0.2.0'
5
+ VERSION = '0.4.0'
6
6
  end
data/lib/mxc.rb CHANGED
@@ -12,6 +12,8 @@ require 'mxc/backend'
12
12
  require 'mxc/availability'
13
13
  require 'mxc/process'
14
14
  require 'mxc/windows_arguments'
15
+ require 'mxc/load_commands'
16
+ require 'mxc/libraries'
15
17
  require 'mxc/sandbox'
16
18
 
17
19
  # Runs commands in sandboxes through Microsoft's MXC.
@@ -87,7 +89,8 @@ module MXC
87
89
  Native.backend_attributes.map { |attributes| Backend.new(**attributes) }
88
90
  end
89
91
 
90
- # Returns true when this host supports +capability+ with the configured backend.
92
+ # Returns true when this host supports +capability+ with the configured backend. Where
93
+ # sandboxes run on Landlock, there is no <tt>:network</tt> or <tt>:network_policy</tt>.
91
94
  #
92
95
  # +capability+ is <tt>:network</tt> for unrestricted network access,
93
96
  # <tt>:network_policy</tt> for host allow-lists, <tt>:cpu_time_limit</tt>,
@@ -100,8 +103,8 @@ module MXC
100
103
  return false unless supported?
101
104
 
102
105
  case capability
103
- when :network then Preflight.problem(network: true).nil?
104
- when :network_policy then Preflight.problem(network: { allow: [] }).nil? && proxy_enforcement?
106
+ when :network then Preflight.problem(network: true, backend: runs_on).nil?
107
+ when :network_policy then Preflight.problem(network: { allow: [] }, backend: runs_on).nil? && proxy_enforcement?
105
108
  when *RESOURCE_CAPABILITIES.keys then Preflight.resource_problem(RESOURCE_CAPABILITIES[capability]).nil?
106
109
  else configured_backends.any? { |backend| backend.supports?(capability) }
107
110
  end
@@ -109,6 +112,10 @@ module MXC
109
112
 
110
113
  private
111
114
 
115
+ def runs_on
116
+ availability.runs_on
117
+ end
118
+
112
119
  def proxy_enforcement?
113
120
  !Preflight.linux? || configured_backends.any? { |backend| backend.supports?(:proxy_enforcement) }
114
121
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mxc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
@@ -49,6 +49,8 @@ files:
49
49
  - lib/mxc/chunk.rb
50
50
  - lib/mxc/configuration.rb
51
51
  - lib/mxc/error.rb
52
+ - lib/mxc/libraries.rb
53
+ - lib/mxc/load_commands.rb
52
54
  - lib/mxc/native.rb
53
55
  - lib/mxc/preflight.rb
54
56
  - lib/mxc/process.rb