mxc 0.2.0 → 0.3.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: 988895e92e027354db083acc4b7fefd8b48d6ac8a5e4bfed22a734f4c020dd3d
4
+ data.tar.gz: 4d014ede1e4b3c35551304d1b92fdf49889587b009bb0fb0df39c96fb8718926
5
5
  SHA512:
6
- metadata.gz: d5f2756cdde6caf1fca9a2fcd294b551e469908248e8ac3a2429b8542101694fe7ddf858c4d398b21ce7056c2a6368cdb9ed42bbf55238e12ba3c9c17faca92e
7
- data.tar.gz: f365aa9d30f0d02c510f238e4d3c0250463df4ea9ece76b49bfa7de0f78c9831ae98159cc8a3cb67bf053a7aeca0c6188e93d21033af66a1be634713b5313a57
6
+ metadata.gz: f9330fd9f89ad26e9ce2494969b6734afb4fdb3f6e4a8f3712421e1d5f28d0e4efbc40965520a6ed3c0211b7ccf2c915fd74b05daed107ef02168e9b4b5f18f1
7
+ data.tar.gz: 44a17cc95d03e7a24b2fe2cc71fb32137c2983f01c155dbd2a9e1fb0051bd9193e3c22754acdbe050881ec0a3f64f0fcb4b2e131d09c13e4ea02038fc2fa4ed0
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.3.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.3.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
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,7 +28,8 @@ 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:
31
+ BACKENDS = %i[auto bubblewrap landlock seatbelt process_container].freeze # :nodoc:
32
+ LANDLOCK_FALLBACK = /runs on Landlock instead/ # :nodoc:
32
33
  OPTIONS = %i[readable writable denied network env directory timeout backend limits priority].freeze # :nodoc:
33
34
  HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
34
35
  WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
@@ -218,8 +219,12 @@ module MXC
218
219
  end
219
220
 
220
221
  # 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>.
222
+ # ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:landlock</tt>, <tt>:seatbelt</tt>,
223
+ # or <tt>:process_container</tt>.
224
+ #
225
+ # On Linux, <tt>:auto</tt> runs on Landlock when Bubblewrap cannot create its namespaces,
226
+ # and says so in Result#warnings. <tt>:bubblewrap</tt> never does. <tt>:landlock</tt>
227
+ # needs Linux 6.2 or newer and runs without network access.
223
228
  def with_backend(name)
224
229
  @settings[:backend] = name && self.class.backend_name(name)
225
230
  self
@@ -279,7 +284,7 @@ module MXC
279
284
  return stream(*command, &) if block_given?
280
285
 
281
286
  outcome = StartupFailure.check!(Native.run(command_line(command), request))
282
- Result.new(**outcome, backend: resolved_backend)
287
+ Result.new(**outcome, backend: resolved_backend(outcome[:warnings]))
283
288
  end
284
289
 
285
290
  # Starts +command+ and returns its Process without waiting for it. Takes the command
@@ -287,7 +292,8 @@ module MXC
287
292
  #
288
293
  # sandbox.spawn("python3", "-i", "-q")
289
294
  def spawn(*command)
290
- Process.new(Native.spawn(command_line(command), request), backend: resolved_backend)
295
+ native = Native.spawn(command_line(command), request)
296
+ Process.new(native, backend: resolved_backend(native.warnings))
291
297
  end
292
298
 
293
299
  # Returns true when this host can run this sandbox, including its network policy.
@@ -298,8 +304,10 @@ module MXC
298
304
  # Returns the Availability of this sandbox on this host, with the reason and
299
305
  # remediation when it cannot run.
300
306
  def availability
301
- problem = Preflight.problem(network:, denied:, resources:)
302
- return MXC.availability(backend:) unless problem
307
+ problem = Preflight.problem(network:, denied:, resources:, backend:)
308
+ host = MXC.availability(backend:) unless problem
309
+ problem ||= Preflight.problem(network:, backend: :landlock) if host&.runs_on == :landlock
310
+ return host unless problem
303
311
 
304
312
  Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
305
313
  end
@@ -404,15 +412,16 @@ module MXC
404
412
  arguments.map { |argument| WindowsArguments.quote(argument) }.join(' ')
405
413
  end
406
414
 
407
- def resolved_backend
415
+ def resolved_backend(warnings)
408
416
  return backend unless backend == :auto
409
417
  return :process_container if Preflight.windows?
418
+ return :seatbelt unless Preflight.linux?
410
419
 
411
- Preflight.linux? ? :bubblewrap : :seatbelt
420
+ warnings.any?(LANDLOCK_FALLBACK) ? :landlock : :bubblewrap
412
421
  end
413
422
 
414
423
  def request
415
- Preflight.check!(network:, denied:, resources:)
424
+ Preflight.check!(network:, denied:, resources:, backend:)
416
425
  {
417
426
  readonly: expand(readable),
418
427
  readwrite: expand(writable),
@@ -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.3.0'
6
6
  end
data/lib/mxc.rb CHANGED
@@ -87,7 +87,8 @@ module MXC
87
87
  Native.backend_attributes.map { |attributes| Backend.new(**attributes) }
88
88
  end
89
89
 
90
- # Returns true when this host supports +capability+ with the configured backend.
90
+ # Returns true when this host supports +capability+ with the configured backend. Where
91
+ # sandboxes run on Landlock, there is no <tt>:network</tt> or <tt>:network_policy</tt>.
91
92
  #
92
93
  # +capability+ is <tt>:network</tt> for unrestricted network access,
93
94
  # <tt>:network_policy</tt> for host allow-lists, <tt>:cpu_time_limit</tt>,
@@ -100,8 +101,8 @@ module MXC
100
101
  return false unless supported?
101
102
 
102
103
  case capability
103
- when :network then Preflight.problem(network: true).nil?
104
- when :network_policy then Preflight.problem(network: { allow: [] }).nil? && proxy_enforcement?
104
+ when :network then Preflight.problem(network: true, backend: runs_on).nil?
105
+ when :network_policy then Preflight.problem(network: { allow: [] }, backend: runs_on).nil? && proxy_enforcement?
105
106
  when *RESOURCE_CAPABILITIES.keys then Preflight.resource_problem(RESOURCE_CAPABILITIES[capability]).nil?
106
107
  else configured_backends.any? { |backend| backend.supports?(capability) }
107
108
  end
@@ -109,6 +110,10 @@ module MXC
109
110
 
110
111
  private
111
112
 
113
+ def runs_on
114
+ availability.runs_on
115
+ end
116
+
112
117
  def proxy_enforcement?
113
118
  !Preflight.linux? || configured_backends.any? { |backend| backend.supports?(:proxy_enforcement) }
114
119
  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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino