mxc 0.1.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: e26fd36fda75fa52f16b61ca42dd734abae8f53bb5f1f67d7c4535547829585d
4
- data.tar.gz: 4f4e49c572372e0571ce7d1d792ac3da2a5d5b92ff7d6621ebf93e70cd77024b
3
+ metadata.gz: 988895e92e027354db083acc4b7fefd8b48d6ac8a5e4bfed22a734f4c020dd3d
4
+ data.tar.gz: 4d014ede1e4b3c35551304d1b92fdf49889587b009bb0fb0df39c96fb8718926
5
5
  SHA512:
6
- metadata.gz: f68d4d670b204c0e24d1200a8cbbd6f4bc9167988d3419e45208c65bd2fa9ee36566d5d3d3ccc0cbeb6ac7b1e805cf4e2f8697ce280bf4bda96f5ab269ea9533
7
- data.tar.gz: dab3f9cd45ad45018b857598ca69133ea75f262576a729176036dfef762d5d419133c243da36da96a0cea29e0aa5daea9e165a0ef53448f22834b111a8fdeca2
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,19 +593,19 @@ dependencies = [
562
593
 
563
594
  [[package]]
564
595
  name = "mxc"
565
- version = "0.1.0"
596
+ version = "0.3.0"
566
597
  dependencies = [
567
598
  "magnus",
568
599
  "mxc-sdk",
569
600
  "rb-sys",
570
601
  "rb-sys-env",
602
+ "serde_json",
571
603
  ]
572
604
 
573
605
  [[package]]
574
606
  name = "mxc-sdk"
575
607
  version = "1.0.0"
576
- source = "registry+https://github.com/rust-lang/crates.io-index"
577
- checksum = "e4866ca589a68edc8e931eba58eed9253ad1ce670e974aedc4830a1d94bca6cd"
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
@@ -5,8 +5,9 @@ This gem is an unofficial Ruby binding to MXC. It is not affiliated with or
5
5
  endorsed by Microsoft.
6
6
 
7
7
  The native extension compiles and links the mxc-sdk crate
8
- (https://github.com/microsoft/mxc, https://crates.io/crates/mxc-sdk),
9
- which is distributed under the MIT License:
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
+ and a Landlock backend, which is distributed under the MIT License:
10
11
 
11
12
  MIT License
12
13
 
data/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ---
16
16
 
17
- Your app runs code it did not write: scripts a model generated, skills users uploaded, build steps from someone else. MXC for Ruby runs them in a sandbox that decides what they can read, write, and reach, and how long they get.
17
+ Your app runs code it did not write: scripts a model generated, skills users uploaded, build steps from someone else. MXC for Ruby runs them in a sandbox that decides what they can read, write, and reach, how long they get, and how much CPU and memory they can use.
18
18
 
19
19
  ```ruby
20
20
  result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
@@ -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.
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
@@ -68,6 +70,14 @@ result = sandbox.run("pip download --no-deps -d /tmp/wheels requests")
68
70
  result.blocked # => hosts the allow-list refused
69
71
  ```
70
72
 
73
+ Limit what a command can use, and lower its priority so your app stays responsive:
74
+
75
+ ```ruby
76
+ result = sandbox.with_limits(cpu_time: 2.minutes, memory: 2.gigabytes).with_priority(:low).run("python3 build.py")
77
+ result.cpu_time # => 3.42
78
+ result.limit_exceeded # => nil, or :cpu_time when it ran out
79
+ ```
80
+
71
81
  Stream output as it arrives:
72
82
 
73
83
  ```ruby
@@ -92,6 +102,7 @@ class SkillSandbox < MXC::Sandbox
92
102
  writable "tmp/skills"
93
103
  network false
94
104
  timeout 30
105
+ limits cpu_time: 60, memory: 1.gigabyte
95
106
  end
96
107
 
97
108
  SkillSandbox.new.run("python3 app/skills/build.py")
@@ -108,15 +119,16 @@ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
108
119
 
109
120
  ## Platforms
110
121
 
111
- | Platform | Backend | 0.1.0 |
122
+ | Platform | Backend | 0.3.0 |
112
123
  | --- | --- | --- |
113
- | Linux | Bubblewrap | Supported and tested |
114
- | macOS | Seatbelt | Supported and tested |
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 |
126
+ | macOS | Seatbelt | Supported and tested, without memory or process limits |
115
127
  | Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
116
128
 
117
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.
118
130
 
119
- 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.
120
132
 
121
133
  ## Documentation
122
134
 
data/ext/mxc/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "mxc"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  edition = "2021"
5
5
  authors = ["Carmine Paolino <carmine@paolino.me>"]
6
6
  license = "MIT"
@@ -11,8 +11,11 @@ crate-type = ["cdylib"]
11
11
 
12
12
  [dependencies]
13
13
  magnus = "0.8.2"
14
- mxc-sdk = "=1.0.0"
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" }
15
17
  rb-sys = { version = "0.9.130", features = ["stable-api-compiled-fallback"] }
18
+ serde_json = "1"
16
19
 
17
20
  [build-dependencies]
18
21
  rb-sys-env = "0.2.2"
data/ext/mxc/src/lib.rs CHANGED
@@ -9,15 +9,27 @@ use magnus::{
9
9
  use mxc_sdk::v1::{
10
10
  self,
11
11
  configs::{ProcessContainerConfig, SeatbeltConfig},
12
- Containment, ContainerRequest, FilesystemPolicy, MxcProcess, NetworkAction,
13
- NetworkEgressPolicy, NetworkPolicy, NetworkRuntimeConfig, StreamCloser, WaitResult,
12
+ Containment, ContainerRequest, ExecutionMetadata, FilesystemPolicy, MxcProcess,
13
+ NetworkAction, NetworkEgressPolicy, NetworkPolicy, NetworkRuntimeConfig, ResourceLimit,
14
+ StreamCloser, WaitResult,
14
15
  };
16
+ use serde_json::{json, Map, Value};
15
17
 
16
18
  mod proxy;
17
19
 
18
20
  use proxy::{Allowlist, EgressProxy};
19
21
 
20
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
+ }
21
33
 
22
34
  fn without_gvl<F, R>(f: F) -> R
23
35
  where
@@ -87,6 +99,162 @@ fn deny_all() -> NetworkEgressPolicy {
87
99
  }
88
100
  }
89
101
 
102
+ /// A request ready to start: the typed request, or, when it carries resource
103
+ /// limits or names Landlock, which MXC's typed V1 request cannot express yet,
104
+ /// the same request as `1.1.0-alpha` JSON.
105
+ enum Request {
106
+ Typed(Box<ContainerRequest>),
107
+ Json(String),
108
+ }
109
+
110
+ impl Request {
111
+ fn spawn(self) -> Result<MxcProcess, v1::Error> {
112
+ match self {
113
+ Request::Typed(req) => v1::spawn(*req, Default::default()),
114
+ Request::Json(json) => mxc_sdk::__ffi::spawn_container_json(&json, false),
115
+ }
116
+ }
117
+ }
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
+
126
+ fn resources(ruby: &Ruby, opts: RHash) -> Result<Option<Value>, Error> {
127
+ let Some(limits) = opt::<RHash>(ruby, opts, "resources")? else {
128
+ return Ok(None);
129
+ };
130
+ let mut resources = Map::new();
131
+ if let Some(ms) = opt::<u64>(ruby, limits, "cpu_time_ms")? {
132
+ resources.insert("cpuTimeMs".into(), ms.into());
133
+ }
134
+ if let Some(mb) = opt::<u64>(ruby, limits, "memory_mb")? {
135
+ resources.insert("memoryMb".into(), mb.into());
136
+ }
137
+ if let Some(count) = opt::<u32>(ruby, limits, "max_processes")? {
138
+ resources.insert("maxProcesses".into(), count.into());
139
+ }
140
+ if let Some(priority) = opt::<String>(ruby, limits, "priority")? {
141
+ resources.insert("priority".into(), priority.into());
142
+ }
143
+ Ok(Some(Value::Object(resources)))
144
+ }
145
+
146
+ fn action(action: Option<NetworkAction>) -> &'static str {
147
+ match action {
148
+ Some(NetworkAction::Allow) => "allow",
149
+ _ => "deny",
150
+ }
151
+ }
152
+
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> {
161
+ let filesystem = req.filesystem.clone().unwrap_or_default();
162
+ let mut process = json!({
163
+ "commandLine": req.command,
164
+ "timeout": req.timeout_ms.unwrap_or(0),
165
+ });
166
+ if let Some(cwd) = &req.working_directory {
167
+ process["cwd"] = cwd.clone().into();
168
+ }
169
+ if let Some(environment) = &req.environment {
170
+ let env: Vec<String> = environment
171
+ .iter()
172
+ .map(|(k, v)| format!("{k}={v}"))
173
+ .collect();
174
+ process["env"] = env.into();
175
+ }
176
+ let mut document = json!({
177
+ "version": "1.1.0-alpha",
178
+ "containerId": mxc_sdk::mxc_common::id::mint_random_token(),
179
+ "lifecycle": { "destroyOnExit": true, "preservePolicy": false },
180
+ "process": process,
181
+ "filesystem": {
182
+ "readwritePaths": filesystem.readwrite_paths,
183
+ "readonlyPaths": filesystem.readonly_paths,
184
+ "deniedPaths": filesystem.denied_paths,
185
+ },
186
+ });
187
+ if let Some(resources) = resources {
188
+ document["resources"] = resources;
189
+ }
190
+ if let Some(network) = &req.network {
191
+ let default = action(network.egress.as_ref().and_then(|egress| egress.default));
192
+ document["network"] = json!({ "egress": { "default": default } });
193
+ if let Some(proxy) = network
194
+ .runtime_config
195
+ .as_ref()
196
+ .and_then(|runtime| runtime.network_proxy.clone())
197
+ {
198
+ document["runtimeConfig"] = json!({ "networkProxy": proxy });
199
+ }
200
+ }
201
+ document["containment"] = match &req.containment {
202
+ _ if landlock => "landlock".into(),
203
+ Containment::Process => "process".into(),
204
+ Containment::Bubblewrap => "bubblewrap".into(),
205
+ Containment::Seatbelt(_) => {
206
+ document["seatbelt"] = json!({
207
+ "guiAccess": false,
208
+ "nestedPty": true,
209
+ "keychainAccess": false,
210
+ "extraMachLookups": [],
211
+ });
212
+ "seatbelt".into()
213
+ }
214
+ Containment::ProcessContainer(_) => {
215
+ document["processContainer"] = json!({ "leastPrivilege": false, "capabilities": [] });
216
+ "processcontainer".into()
217
+ }
218
+ _ => {
219
+ return Err(arg_error(
220
+ ruby,
221
+ "unsupported backend for resource limits".into(),
222
+ ))
223
+ }
224
+ };
225
+ Ok(document.to_string())
226
+ }
227
+
228
+ fn prepare(
229
+ ruby: &Ruby,
230
+ command: String,
231
+ opts: RHash,
232
+ ) -> Result<(Request, Option<EgressProxy>), Error> {
233
+ let (req, egress_proxy) = build_request(ruby, command, opts)?;
234
+ let landlock = landlock(ruby, opts)?;
235
+ let request = match resources(ruby, opts)? {
236
+ None if !landlock => Request::Typed(Box::new(req)),
237
+ resources => Request::Json(request_json(ruby, &req, resources, landlock)?),
238
+ };
239
+ Ok((request, egress_proxy))
240
+ }
241
+
242
+ fn usage_into(ruby: &Ruby, h: RHash, metadata: Option<&ExecutionMetadata>) -> Result<(), Error> {
243
+ let usage = metadata.and_then(|metadata| metadata.resource_usage);
244
+ let cpu_time =
245
+ usage.map(|usage| (usage.user_cpu_time_us + usage.system_cpu_time_us) as f64 / 1_000_000.0);
246
+ let limit = usage.and_then(|usage| usage.limit_exceeded).map(|limit| {
247
+ ruby.to_symbol(match limit {
248
+ ResourceLimit::CpuTime => "cpu_time",
249
+ ResourceLimit::Memory => "memory",
250
+ ResourceLimit::MaxProcesses => "processes",
251
+ })
252
+ });
253
+ h.aset(ruby.to_symbol("cpu_time"), cpu_time)?;
254
+ h.aset(ruby.to_symbol("limit_exceeded"), limit)?;
255
+ Ok(())
256
+ }
257
+
90
258
  fn build_request(
91
259
  ruby: &Ruby,
92
260
  command: String,
@@ -143,6 +311,8 @@ fn build_request(
143
311
  req.containment = match backend.name()?.as_ref() {
144
312
  "auto" => Containment::Process,
145
313
  "bubblewrap" => Containment::Bubblewrap,
314
+ // The typed request has no Landlock; `request_json` names it.
315
+ "landlock" => Containment::Process,
146
316
  "seatbelt" => Containment::Seatbelt(SeatbeltConfig::default()),
147
317
  "process_container" => {
148
318
  Containment::ProcessContainer(ProcessContainerConfig::default())
@@ -153,19 +323,62 @@ fn build_request(
153
323
  Ok((req, egress_proxy))
154
324
  }
155
325
 
156
- 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> {
157
330
  let h = ruby.hash_new();
158
- match outcome {
159
- WaitResult::Exited(code) => {
160
- h.aset(ruby.to_symbol("timed_out"), false)?;
161
- 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)?;
162
352
  }
163
- WaitResult::TimedOut => {
164
- h.aset(ruby.to_symbol("timed_out"), true)?;
165
- 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();
166
379
  }
167
380
  }
168
- Ok(h)
381
+ Ok(((outcome, warnings, metadata), drained(stdout)?, drained(stderr)?))
169
382
  }
170
383
 
171
384
  fn snake_case(name: &str) -> String {
@@ -202,18 +415,21 @@ fn backends(ruby: &Ruby) -> Result<RArray, Error> {
202
415
  }
203
416
 
204
417
  fn run(ruby: &Ruby, command: String, opts: RHash) -> Result<RHash, Error> {
205
- let (req, mut egress_proxy) = build_request(ruby, command, opts)?;
206
- let result = without_gvl(move || v1::run(req, Default::default()));
418
+ let (req, mut egress_proxy) = prepare(ruby, command, opts)?;
419
+ let result = without_gvl(move || req.spawn().map(capture));
207
420
  let blocked = egress_proxy.as_mut().map(|p| {
208
421
  p.stop();
209
422
  p.blocked()
210
423
  });
211
- let r = result.map_err(|e| mxc_error(ruby, e))?;
212
- let h = outcome_hash(ruby, r.outcome)?;
213
- h.aset(ruby.to_symbol("stdout"), ruby.str_from_slice(&r.stdout))?;
214
- h.aset(ruby.to_symbol("stderr"), ruby.str_from_slice(&r.stderr))?;
215
- 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)?;
216
431
  h.aset(ruby.to_symbol("blocked"), blocked.unwrap_or_default())?;
432
+ usage_into(ruby, h, metadata.as_ref())?;
217
433
  Ok(h)
218
434
  }
219
435
 
@@ -233,10 +449,9 @@ struct Process {
233
449
  }
234
450
 
235
451
  fn spawn(ruby: &Ruby, command: String, opts: RHash) -> Result<Process, Error> {
236
- let (req, egress_proxy) = build_request(ruby, command, opts)?;
237
- let timeout = req.timeout_ms.map(|ms| Duration::from_millis(ms.into()));
238
- let mut child = without_gvl(move || v1::spawn(req, Default::default()))
239
- .map_err(|e| mxc_error(ruby, e))?;
452
+ let timeout = opt::<u32>(ruby, opts, "timeout_ms")?.map(|ms| Duration::from_millis(ms.into()));
453
+ let (req, egress_proxy) = prepare(ruby, command, opts)?;
454
+ let mut child = without_gvl(move || req.spawn()).map_err(|e| mxc_error(ruby, e))?;
240
455
  Ok(Process {
241
456
  pid: child.id(),
242
457
  deadline: timeout.map(|t| Instant::now() + t),
@@ -278,6 +493,21 @@ impl Process {
278
493
  self.pid
279
494
  }
280
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
+
281
511
  fn read_stdout(ruby: &Ruby, rb_self: &Self, max: usize) -> Result<Option<RString>, Error> {
282
512
  read_from(ruby, &rb_self.stdout, max)
283
513
  }
@@ -315,7 +545,7 @@ impl Process {
315
545
 
316
546
  // MxcProcess::kill and ::wait both need the handle exclusively, so waiting
317
547
  // polls and releases the lock between polls to let kill in.
318
- fn await_exit(&self) -> std::io::Result<(WaitResult, Vec<String>)> {
548
+ fn await_exit(&self) -> std::io::Result<Outcome> {
319
549
  let mut nap = Duration::from_millis(1);
320
550
  loop {
321
551
  {
@@ -324,11 +554,12 @@ impl Process {
324
554
  .as_mut()
325
555
  .ok_or_else(|| std::io::Error::other("the process was already waited for"))?;
326
556
  let expired = self.deadline.is_some_and(|d| Instant::now() >= d);
327
- if expired || child.try_wait()?.is_some() {
328
- let outcome = child.wait()?;
557
+ if expired || !matches!(child.try_wait(), Ok(None)) {
558
+ let outcome = child.wait();
329
559
  let warnings = child.warnings();
560
+ let metadata = child.output_metadata().cloned();
330
561
  guard.take();
331
- return Ok((outcome, warnings));
562
+ return Ok((outcome, warnings, metadata));
332
563
  }
333
564
  }
334
565
  let mut sleep = nap;
@@ -349,8 +580,11 @@ impl Process {
349
580
  }
350
581
  None => Vec::new(),
351
582
  };
352
- let (outcome, warnings) = res.map_err(|e| io_error(ruby, e))?;
353
- if !matches!(outcome, WaitResult::Exited(_)) {
583
+ let (outcome, warnings, metadata) = res.map_err(|e| io_error(ruby, e))?;
584
+ if outcome.is_err() {
585
+ without_gvl(|| drain_until(|| rb_self.drained()));
586
+ }
587
+ if !matches!(outcome, Ok(WaitResult::Exited(_))) {
354
588
  if let Some(c) = &rb_self.stdout_closer {
355
589
  c.close();
356
590
  }
@@ -361,6 +595,7 @@ impl Process {
361
595
  let h = outcome_hash(ruby, outcome)?;
362
596
  h.aset(ruby.to_symbol("warnings"), warnings)?;
363
597
  h.aset(ruby.to_symbol("blocked"), blocked)?;
598
+ usage_into(ruby, h, metadata.as_ref())?;
364
599
  Ok(h)
365
600
  }
366
601
  }
@@ -374,6 +609,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
374
609
  native.define_singleton_method("spawn", function!(spawn, 2))?;
375
610
  let process = native.define_class("Process", ruby.class_object())?;
376
611
  process.define_method("pid", method!(Process::pid, 0))?;
612
+ process.define_method("warnings", method!(Process::warnings, 0))?;
377
613
  process.define_method("read_stdout", method!(Process::read_stdout, 1))?;
378
614
  process.define_method("read_stderr", method!(Process::read_stderr, 1))?;
379
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
@@ -12,15 +12,46 @@ module MXC
12
12
  'a sandbox to a proxy only by also accepting inbound connections to it'
13
13
  WINDOWS_DENIED = 'Leave the paths out of readable and writable instead, or run on a Windows build where ' \
14
14
  'MXC.supports?(:filesystem_denied_paths) is true'
15
+ MACOS_MEMORY = 'Leave memory out of the limits on macOS, which rejects memory rlimits. ' \
16
+ 'Linux and Windows support it'
17
+ PROCESS_LIMIT = 'Leave processes out of the limits. Only Windows can count the processes of one sandbox; ' \
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"
15
22
 
16
23
  module_function
17
24
 
18
- def check!(network:, denied: [])
19
- error = problem(network:, denied:)
25
+ def check!(network:, denied: [], resources: nil, backend: :auto)
26
+ error = problem(network:, denied:, resources:, backend:)
20
27
  raise error if error
21
28
  end
22
29
 
23
- def problem(network:, denied: [], path: ENV.fetch('PATH', ''), tun: 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)
40
+ end
41
+
42
+ def resource_problem(resources)
43
+ return if resources.nil? || windows?
44
+
45
+ if resources[:max_processes]
46
+ UnsupportedError.new("Process limits are not supported on #{linux? ? 'Linux' : 'macOS'}",
47
+ code: :backend_unavailable, remediation: PROCESS_LIMIT)
48
+ elsif resources[:memory_mb] && !linux?
49
+ UnsupportedError.new('Memory limits are not supported on macOS',
50
+ code: :backend_unavailable, remediation: MACOS_MEMORY)
51
+ end
52
+ end
53
+
54
+ def host_problem(network, denied, path, tun)
24
55
  return windows_problem(network, denied) if windows?
25
56
  return unless network && linux?
26
57
 
data/lib/mxc/result.rb CHANGED
@@ -25,7 +25,18 @@ module MXC
25
25
  # Returns the backend that ran the command, such as <tt>:bubblewrap</tt>.
26
26
  attr_reader :backend
27
27
 
28
- def initialize(stdout:, stderr:, exit_code:, timed_out:, warnings:, blocked:, backend:) # :nodoc:
28
+ # Returns the CPU time the command used, user and system, in seconds as a Float. It
29
+ # includes every process the command started and waited for. Returns nil unless the
30
+ # sandbox has limits or a priority.
31
+ attr_reader :cpu_time
32
+
33
+ # Returns the limit the command ran into: <tt>:cpu_time</tt>, <tt>:memory</tt>,
34
+ # <tt>:processes</tt>, or nil. Linux and macOS report <tt>:cpu_time</tt> only, because a
35
+ # program that cannot allocate memory decides itself how to fail.
36
+ attr_reader :limit_exceeded
37
+
38
+ def initialize(stdout:, stderr:, exit_code:, timed_out:, warnings:, blocked:, backend:, # :nodoc:
39
+ cpu_time: nil, limit_exceeded: nil)
29
40
  @stdout = text(stdout)
30
41
  @stderr = text(stderr)
31
42
  @exit_code = exit_code
@@ -33,6 +44,8 @@ module MXC
33
44
  @warnings = warnings.freeze
34
45
  @blocked = blocked.freeze
35
46
  @backend = backend
47
+ @cpu_time = cpu_time
48
+ @limit_exceeded = limit_exceeded
36
49
  end
37
50
 
38
51
  # Returns true when the command exited with status 0 before its timeout.
@@ -47,11 +60,13 @@ module MXC
47
60
 
48
61
  # Returns the result as a Hash.
49
62
  def to_h
50
- { stdout:, stderr:, exit_code:, timed_out: timed_out?, warnings:, blocked:, backend: }
63
+ { stdout:, stderr:, exit_code:, timed_out: timed_out?, warnings:, blocked:, backend:, cpu_time:,
64
+ limit_exceeded: }
51
65
  end
52
66
 
53
67
  def inspect # :nodoc:
54
- "#<MXC::Result exit_code=#{exit_code.inspect} timed_out=#{timed_out?} backend=#{backend.inspect}>"
68
+ usage = " cpu_time=#{cpu_time.round(3)} limit_exceeded=#{limit_exceeded.inspect}" if cpu_time
69
+ "#<MXC::Result exit_code=#{exit_code.inspect} timed_out=#{timed_out?}#{usage} backend=#{backend.inspect}>"
55
70
  end
56
71
 
57
72
  private
data/lib/mxc/sandbox.rb CHANGED
@@ -22,19 +22,26 @@ module MXC
22
22
  # writable "tmp/skills"
23
23
  # network false
24
24
  # timeout 30
25
+ # limits cpu_time: 60, memory: 2 * 1024**3
26
+ # priority :low
25
27
  # end
26
28
  #
27
29
  # SkillSandbox.new.run("python3 app/skills/build.py")
28
30
  class Sandbox
29
- BACKENDS = %i[auto bubblewrap seatbelt process_container].freeze # :nodoc:
30
- OPTIONS = %i[readable writable denied network env directory timeout backend].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 network env directory timeout backend limits priority].freeze # :nodoc:
31
34
  HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
32
35
  WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
33
36
  PROCESSOR_ARCHITECTURE NUMBER_OF_PROCESSORS OS].freeze # :nodoc:
34
37
  BATCH_FILE = /\.(bat|cmd)\z/i # :nodoc:
35
38
  MAX_TIMEOUT_MS = (2**32) - 1 # :nodoc:
39
+ LIMITS = %i[cpu_time memory processes].freeze # :nodoc:
40
+ PRIORITIES = { normal: nil, low: 'low', lowest: 'idle' }.freeze # :nodoc:
41
+ MEGABYTE = 1024 * 1024 # :nodoc:
36
42
  DEFAULTS = {
37
- readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil
43
+ readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil,
44
+ limits: {}, priority: nil
38
45
  }.freeze # :nodoc:
39
46
 
40
47
  class << self
@@ -62,6 +69,12 @@ module MXC
62
69
  # Sets every instance's backend.
63
70
  def backend(name) = configure { |sandbox| sandbox.with_backend(name) }
64
71
 
72
+ # Sets every instance's resource limits. Takes the same arguments as #with_limits.
73
+ def limits(...) = configure { |sandbox| sandbox.with_limits(...) }
74
+
75
+ # Sets every instance's scheduling priority: <tt>:normal</tt>, <tt>:low</tt>, or <tt>:lowest</tt>.
76
+ def priority(level) = configure { |sandbox| sandbox.with_priority(level) }
77
+
65
78
  def defaults # :nodoc:
66
79
  @defaults ||= allocate.tap { |sandbox| sandbox.send(:inherit, self == Sandbox ? nil : superclass.defaults) }
67
80
  end
@@ -95,9 +108,11 @@ module MXC
95
108
 
96
109
  # Returns a new sandbox with the class's defaults, then applies +options+ through
97
110
  # the matching setters: <tt>readable:</tt>, <tt>writable:</tt>, <tt>denied:</tt>,
98
- # <tt>network:</tt>, <tt>env:</tt>, <tt>directory:</tt>, <tt>timeout:</tt> and <tt>backend:</tt>.
111
+ # <tt>network:</tt>, <tt>env:</tt>, <tt>directory:</tt>, <tt>timeout:</tt>, <tt>backend:</tt>,
112
+ # <tt>limits:</tt> and <tt>priority:</tt>.
99
113
  #
100
114
  # MXC::Sandbox.new(readable: "app/skills", network: { allow: ["pypi.org"] }, timeout: 30)
115
+ # MXC::Sandbox.new(limits: { cpu_time: 60, memory: 2 * 1024**3 }, priority: :low)
101
116
  def initialize(**options)
102
117
  unknown = options.keys - OPTIONS
103
118
  raise ArgumentError, "unknown option#{'s' if unknown.size > 1}: #{unknown.join(', ')}" if unknown.any?
@@ -135,6 +150,13 @@ module MXC
135
150
  # Returns the backend, falling back to MXC.config.backend.
136
151
  def backend = @settings[:backend] || MXC.config.backend
137
152
 
153
+ # Returns the resource limits, such as <tt>{ cpu_time: 60, memory: 2147483648 }</tt>.
154
+ # A limit that is not set is absent.
155
+ def limits = @settings[:limits].dup.freeze
156
+
157
+ # Returns the scheduling priority: <tt>:normal</tt>, <tt>:low</tt>, or <tt>:lowest</tt>.
158
+ def priority = @settings[:priority] || :normal
159
+
138
160
  # Adds paths the command can read. Accepts Strings and Pathnames; relative paths
139
161
  # are expanded from the current directory when the command runs.
140
162
  def with_readable(*paths)
@@ -197,13 +219,53 @@ module MXC
197
219
  end
198
220
 
199
221
  # Sets the backend: <tt>:auto</tt> (Bubblewrap on Linux, Seatbelt on macOS,
200
- # ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:seatbelt</tt>, or
201
- # <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.
202
228
  def with_backend(name)
203
229
  @settings[:backend] = name && self.class.backend_name(name)
204
230
  self
205
231
  end
206
232
 
233
+ # Sets resource limits. Each process the command starts gets its own CPU time and memory.
234
+ #
235
+ # <tt>cpu_time:</tt> is the CPU time each process may use, in seconds. It accepts
236
+ # anything that responds to +to_f+, such as <tt>90</tt> or <tt>2.minutes</tt>, and is
237
+ # rounded up to whole seconds on Linux and macOS. A process that uses it up is stopped.
238
+ #
239
+ # <tt>memory:</tt> is the memory each process may allocate, in bytes. It accepts
240
+ # anything that responds to +to_i+, such as <tt>2 * 1024**3</tt> or
241
+ # <tt>2.gigabytes</tt>, and is rounded up to whole megabytes. Allocations beyond it
242
+ # fail. Not available on macOS.
243
+ #
244
+ # <tt>processes:</tt> is how many processes the command may run at the same time.
245
+ # Windows only.
246
+ #
247
+ # A limit given as +nil+ is removed, and <tt>with_limits(nil)</tt> removes them all.
248
+ # Raises ArgumentError for an unknown limit or a value that is not positive.
249
+ #
250
+ # sandbox.with_limits(cpu_time: 2.minutes, memory: 2.gigabytes)
251
+ def with_limits(reset = (keep = true), **limits)
252
+ unless keep
253
+ raise ArgumentError, "pass limits as keywords, or nil to remove them, got #{reset.inspect}" unless reset.nil?
254
+
255
+ @settings[:limits].clear
256
+ end
257
+ limits.each { |name, value| set_limit(name, value) }
258
+ self
259
+ end
260
+
261
+ # Sets the scheduling priority of every process the command starts: <tt>:normal</tt>
262
+ # (the default), <tt>:low</tt> (nice 10 on Linux and macOS, below normal on
263
+ # Windows), or <tt>:lowest</tt> (nice 19, or idle on Windows).
264
+ def with_priority(level)
265
+ @settings[:priority] = level && priority_name(level)
266
+ self
267
+ end
268
+
207
269
  # Runs +command+ and returns its Result once it finishes. Like Kernel#system, one
208
270
  # String runs through the platform shell: <tt>sh -c</tt>, or <tt>cmd.exe /d /s /c</tt>
209
271
  # on Windows. Two or more Strings are a program and its arguments, and each argument
@@ -222,7 +284,7 @@ module MXC
222
284
  return stream(*command, &) if block_given?
223
285
 
224
286
  outcome = StartupFailure.check!(Native.run(command_line(command), request))
225
- Result.new(**outcome, backend: resolved_backend)
287
+ Result.new(**outcome, backend: resolved_backend(outcome[:warnings]))
226
288
  end
227
289
 
228
290
  # Starts +command+ and returns its Process without waiting for it. Takes the command
@@ -230,7 +292,8 @@ module MXC
230
292
  #
231
293
  # sandbox.spawn("python3", "-i", "-q")
232
294
  def spawn(*command)
233
- 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))
234
297
  end
235
298
 
236
299
  # Returns true when this host can run this sandbox, including its network policy.
@@ -241,15 +304,18 @@ module MXC
241
304
  # Returns the Availability of this sandbox on this host, with the reason and
242
305
  # remediation when it cannot run.
243
306
  def availability
244
- problem = Preflight.problem(network:, denied:)
245
- 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
246
311
 
247
312
  Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
248
313
  end
249
314
 
250
315
  def inspect # :nodoc:
316
+ resources = " limits=#{limits.inspect} priority=#{priority.inspect}" if self.resources
251
317
  "#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
252
- "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}>"
318
+ "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}>"
253
319
  end
254
320
 
255
321
  protected
@@ -266,10 +332,42 @@ module MXC
266
332
  case name
267
333
  when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
268
334
  when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
335
+ when :limits then value.nil? ? with_limits(nil) : with_limits(**value)
269
336
  else public_send(:"with_#{name}", value)
270
337
  end
271
338
  end
272
339
 
340
+ def set_limit(name, value)
341
+ unless LIMITS.include?(name)
342
+ raise ArgumentError, "unknown limit #{name.inspect}, expected one of #{LIMITS.map(&:inspect).join(', ')}"
343
+ end
344
+ return @settings[:limits].delete(name) if value.nil?
345
+
346
+ @settings[:limits][name] = positive_limit(name, value)
347
+ end
348
+
349
+ def positive_limit(name, value)
350
+ amount = limit_amount(name, value)
351
+ raise ArgumentError, "#{name} must be a positive number, got #{value.inspect}" unless amount&.positive?
352
+
353
+ value
354
+ end
355
+
356
+ def limit_amount(name, value)
357
+ return (value if value.is_a?(Integer)) if name == :processes
358
+
359
+ conversion = name == :cpu_time ? :to_f : :to_i
360
+ value.public_send(conversion) if value.respond_to?(conversion)
361
+ end
362
+
363
+ def priority_name(level)
364
+ level = level.to_sym
365
+ return level if PRIORITIES.key?(level)
366
+
367
+ raise ArgumentError,
368
+ "unknown priority #{level.inspect}, expected one of #{PRIORITIES.keys.map(&:inspect).join(', ')}"
369
+ end
370
+
273
371
  def add_paths(name, paths)
274
372
  list = @settings[name]
275
373
  paths == [nil] ? list.clear : list.concat(paths.flatten.map { |path| File.path(path) })
@@ -314,15 +412,16 @@ module MXC
314
412
  arguments.map { |argument| WindowsArguments.quote(argument) }.join(' ')
315
413
  end
316
414
 
317
- def resolved_backend
415
+ def resolved_backend(warnings)
318
416
  return backend unless backend == :auto
319
417
  return :process_container if Preflight.windows?
418
+ return :seatbelt unless Preflight.linux?
320
419
 
321
- Preflight.linux? ? :bubblewrap : :seatbelt
420
+ warnings.any?(LANDLOCK_FALLBACK) ? :landlock : :bubblewrap
322
421
  end
323
422
 
324
423
  def request
325
- Preflight.check!(network:, denied:)
424
+ Preflight.check!(network:, denied:, resources:, backend:)
326
425
  {
327
426
  readonly: expand(readable),
328
427
  readwrite: expand(writable),
@@ -332,10 +431,28 @@ module MXC
332
431
  timeout_ms: timeout && [(timeout.to_f * 1000).ceil, MAX_TIMEOUT_MS].min,
333
432
  backend:,
334
433
  network: network == true,
335
- allow: network.is_a?(Hash) ? network[:allow] : nil
434
+ allow: network.is_a?(Hash) ? network[:allow] : nil,
435
+ resources:
336
436
  }
337
437
  end
338
438
 
439
+ protected
440
+
441
+ # The resource limits as MXC takes them, or nil when there are none.
442
+ def resources
443
+ limits = @settings[:limits]
444
+ return if limits.empty? && priority == :normal
445
+
446
+ {
447
+ cpu_time_ms: limits[:cpu_time] && (limits[:cpu_time].to_f * 1000).ceil,
448
+ memory_mb: limits[:memory] && -(-limits[:memory].to_i / MEGABYTE),
449
+ max_processes: limits[:processes],
450
+ priority: PRIORITIES.fetch(priority)
451
+ }.compact
452
+ end
453
+
454
+ private
455
+
339
456
  def expand(paths)
340
457
  paths.map { |path| native_path(File.expand_path(path)) }
341
458
  end
@@ -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.1.0'
5
+ VERSION = '0.3.0'
6
6
  end
data/lib/mxc.rb CHANGED
@@ -19,6 +19,11 @@ require 'mxc/sandbox'
19
19
  # result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
20
20
  # result.stdout
21
21
  module MXC
22
+ RESOURCE_CAPABILITIES = {
23
+ cpu_time_limit: { cpu_time_ms: 1 }, memory_limit: { memory_mb: 1 },
24
+ process_limit: { max_processes: 1 }, priority: { priority: 'low' }
25
+ }.freeze # :nodoc:
26
+
22
27
  @availability = {}
23
28
  @availability_lock = Mutex.new
24
29
 
@@ -82,22 +87,33 @@ module MXC
82
87
  Native.backend_attributes.map { |attributes| Backend.new(**attributes) }
83
88
  end
84
89
 
85
- # 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>.
86
92
  #
87
93
  # +capability+ is <tt>:network</tt> for unrestricted network access,
88
- # <tt>:network_policy</tt> for host allow-lists, or any capability a Backend reports.
94
+ # <tt>:network_policy</tt> for host allow-lists, <tt>:cpu_time_limit</tt>,
95
+ # <tt>:memory_limit</tt>, <tt>:process_limit</tt> and <tt>:priority</tt> for the
96
+ # matching Sandbox#with_limits and Sandbox#with_priority settings, or any capability a
97
+ # Backend reports.
98
+ #
99
+ # MXC.supports?(:memory_limit) # => false on macOS
89
100
  def supports?(capability)
90
101
  return false unless supported?
91
102
 
92
103
  case capability
93
- when :network then Preflight.problem(network: true).nil?
94
- 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?
106
+ when *RESOURCE_CAPABILITIES.keys then Preflight.resource_problem(RESOURCE_CAPABILITIES[capability]).nil?
95
107
  else configured_backends.any? { |backend| backend.supports?(capability) }
96
108
  end
97
109
  end
98
110
 
99
111
  private
100
112
 
113
+ def runs_on
114
+ availability.runs_on
115
+ end
116
+
101
117
  def proxy_enforcement?
102
118
  !Preflight.linux? || configured_backends.any? { |backend| backend.supports?(:proxy_enforcement) }
103
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino