mxc 0.1.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.
@@ -0,0 +1,480 @@
1
+ use std::io::{self, Read, Write};
2
+ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
3
+ use std::sync::atomic::{AtomicBool, Ordering};
4
+ use std::sync::{Arc, Mutex};
5
+ use std::thread::{self, JoinHandle};
6
+ use std::time::Duration;
7
+
8
+ const MAX_HEADER: usize = 16 * 1024;
9
+ const HEADER_TIMEOUT: Duration = Duration::from_secs(30);
10
+ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
11
+ const DEFAULT_PORTS: [u16; 2] = [80, 443];
12
+
13
+ #[derive(Debug, Clone, PartialEq, Eq)]
14
+ enum HostPattern {
15
+ Exact(String),
16
+ Suffix(String),
17
+ }
18
+
19
+ #[derive(Debug, Clone, PartialEq, Eq)]
20
+ struct Rule {
21
+ host: HostPattern,
22
+ ports: Vec<u16>,
23
+ }
24
+
25
+ #[derive(Debug, Clone, Default)]
26
+ pub struct Allowlist {
27
+ rules: Vec<Rule>,
28
+ }
29
+
30
+ fn normalize_host(host: &str) -> String {
31
+ host.trim()
32
+ .trim_start_matches('[')
33
+ .trim_end_matches(']')
34
+ .trim_end_matches('.')
35
+ .to_ascii_lowercase()
36
+ }
37
+
38
+ fn split_host_port(entry: &str) -> (&str, Option<&str>) {
39
+ if let Some(rest) = entry.strip_prefix('[') {
40
+ if let Some(end) = rest.find(']') {
41
+ let port = rest[end + 1..].strip_prefix(':');
42
+ return (&rest[..end], port);
43
+ }
44
+ }
45
+ match entry.rsplit_once(':') {
46
+ Some((host, port)) if !host.contains(':') => (host, Some(port)),
47
+ _ => (entry, None),
48
+ }
49
+ }
50
+
51
+ impl Allowlist {
52
+ pub fn parse(entries: &[String]) -> Result<Self, String> {
53
+ let mut rules = Vec::new();
54
+ for raw in entries {
55
+ let (host, port) = split_host_port(raw.trim());
56
+ let host = normalize_host(host);
57
+ if host.is_empty() || host.contains('/') || host.contains(char::is_whitespace) {
58
+ return Err(format!("invalid allow entry {raw:?}"));
59
+ }
60
+ let ports = match port {
61
+ Some(p) => vec![p
62
+ .parse::<u16>()
63
+ .ok()
64
+ .filter(|p| *p != 0)
65
+ .ok_or_else(|| format!("invalid port in allow entry {raw:?}"))?],
66
+ None => DEFAULT_PORTS.to_vec(),
67
+ };
68
+ let host = match host.strip_prefix("*.") {
69
+ Some(suffix) if !suffix.is_empty() && !suffix.contains('*') => {
70
+ HostPattern::Suffix(suffix.to_string())
71
+ }
72
+ _ if host.contains('*') => {
73
+ return Err(format!(
74
+ "invalid allow entry {raw:?}: only a leading \"*.\" wildcard is supported"
75
+ ))
76
+ }
77
+ _ => HostPattern::Exact(host),
78
+ };
79
+ rules.push(Rule { host, ports });
80
+ }
81
+ Ok(Self { rules })
82
+ }
83
+
84
+ pub fn permits(&self, host: &str, port: u16) -> bool {
85
+ let host = normalize_host(host);
86
+ self.rules.iter().any(|rule| {
87
+ rule.ports.contains(&port)
88
+ && match &rule.host {
89
+ HostPattern::Exact(h) => *h == host,
90
+ HostPattern::Suffix(s) => {
91
+ host.len() > s.len() + 1
92
+ && host.ends_with(s.as_str())
93
+ && host.as_bytes()[host.len() - s.len() - 1] == b'.'
94
+ }
95
+ }
96
+ })
97
+ }
98
+
99
+ fn explicitly_lists_ip(&self, ip: &IpAddr) -> bool {
100
+ let literal = ip.to_string();
101
+ self.rules
102
+ .iter()
103
+ .any(|rule| matches!(&rule.host, HostPattern::Exact(h) if *h == literal))
104
+ }
105
+
106
+ pub fn address_permitted(&self, ip: &IpAddr) -> bool {
107
+ is_public(ip) || self.explicitly_lists_ip(ip)
108
+ }
109
+ }
110
+
111
+ pub fn is_public(ip: &IpAddr) -> bool {
112
+ match ip {
113
+ IpAddr::V4(v4) => is_public_v4(v4),
114
+ IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
115
+ Some(v4) => is_public_v4(&v4),
116
+ None => is_public_v6(v6),
117
+ },
118
+ }
119
+ }
120
+
121
+ fn is_public_v4(ip: &Ipv4Addr) -> bool {
122
+ let o = ip.octets();
123
+ !(ip.is_unspecified()
124
+ || ip.is_loopback()
125
+ || ip.is_private()
126
+ || ip.is_link_local()
127
+ || ip.is_broadcast()
128
+ || ip.is_multicast()
129
+ || ip.is_documentation()
130
+ || o[0] == 0
131
+ || (o[0] == 100 && (o[1] & 0xc0) == 64)
132
+ || (o[0] == 192 && o[1] == 0 && o[2] == 0)
133
+ || (o[0] == 198 && (o[1] & 0xfe) == 18)
134
+ || o[0] >= 240)
135
+ }
136
+
137
+ fn is_public_v6(ip: &Ipv6Addr) -> bool {
138
+ let s = ip.segments();
139
+ !(ip.is_unspecified()
140
+ || ip.is_loopback()
141
+ || ip.is_multicast()
142
+ || (s[0] & 0xfe00) == 0xfc00
143
+ || (s[0] & 0xffc0) == 0xfe80
144
+ || (s[0] == 0x2001 && s[1] == 0x0db8)
145
+ || (s[0] == 0x64 && s[1] == 0xff9b))
146
+ }
147
+
148
+ struct Shared {
149
+ allowlist: Allowlist,
150
+ stopping: AtomicBool,
151
+ connections: Mutex<Vec<TcpStream>>,
152
+ blocked: Mutex<Vec<String>>,
153
+ }
154
+
155
+ pub struct EgressProxy {
156
+ addr: SocketAddr,
157
+ shared: Arc<Shared>,
158
+ acceptor: Option<JoinHandle<()>>,
159
+ }
160
+
161
+ impl EgressProxy {
162
+ pub fn start(allowlist: Allowlist) -> io::Result<Self> {
163
+ let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
164
+ let addr = listener.local_addr()?;
165
+ let shared = Arc::new(Shared {
166
+ allowlist,
167
+ stopping: AtomicBool::new(false),
168
+ connections: Mutex::new(Vec::new()),
169
+ blocked: Mutex::new(Vec::new()),
170
+ });
171
+ let accept_shared = Arc::clone(&shared);
172
+ let acceptor = thread::Builder::new()
173
+ .name("mxc-egress-proxy".into())
174
+ .spawn(move || accept_loop(listener, accept_shared))?;
175
+ Ok(Self {
176
+ addr,
177
+ shared,
178
+ acceptor: Some(acceptor),
179
+ })
180
+ }
181
+
182
+ pub fn url(&self) -> String {
183
+ format!("http://{}", self.addr)
184
+ }
185
+
186
+ pub fn blocked(&self) -> Vec<String> {
187
+ self.shared.blocked.lock().unwrap().clone()
188
+ }
189
+
190
+ pub fn stop(&mut self) {
191
+ if self.shared.stopping.swap(true, Ordering::SeqCst) {
192
+ return;
193
+ }
194
+ let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
195
+ if let Some(handle) = self.acceptor.take() {
196
+ let _ = handle.join();
197
+ }
198
+ for conn in self.shared.connections.lock().unwrap().drain(..) {
199
+ let _ = conn.shutdown(Shutdown::Both);
200
+ }
201
+ }
202
+ }
203
+
204
+ impl Drop for EgressProxy {
205
+ fn drop(&mut self) {
206
+ self.stop();
207
+ }
208
+ }
209
+
210
+ fn accept_loop(listener: TcpListener, shared: Arc<Shared>) {
211
+ for stream in listener.incoming() {
212
+ if shared.stopping.load(Ordering::SeqCst) {
213
+ break;
214
+ }
215
+ let Ok(stream) = stream else { continue };
216
+ let conn_shared = Arc::clone(&shared);
217
+ let _ = thread::Builder::new()
218
+ .name("mxc-egress-conn".into())
219
+ .spawn(move || {
220
+ let _ = handle(stream, &conn_shared);
221
+ });
222
+ }
223
+ }
224
+
225
+ fn track(shared: &Shared, stream: &TcpStream) {
226
+ if let Ok(clone) = stream.try_clone() {
227
+ let mut conns = shared.connections.lock().unwrap();
228
+ conns.retain(|c| c.peer_addr().is_ok());
229
+ conns.push(clone);
230
+ }
231
+ }
232
+
233
+ struct Head {
234
+ method: String,
235
+ target: String,
236
+ version: String,
237
+ headers: Vec<(String, String)>,
238
+ rest: Vec<u8>,
239
+ }
240
+
241
+ fn read_head(stream: &mut TcpStream) -> io::Result<Head> {
242
+ let mut buf = Vec::with_capacity(1024);
243
+ let mut chunk = [0u8; 2048];
244
+ let end = loop {
245
+ if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
246
+ break pos;
247
+ }
248
+ if buf.len() > MAX_HEADER {
249
+ return Err(io::Error::other("request header too large"));
250
+ }
251
+ let n = stream.read(&mut chunk)?;
252
+ if n == 0 {
253
+ return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "closed"));
254
+ }
255
+ buf.extend_from_slice(&chunk[..n]);
256
+ };
257
+ let text = std::str::from_utf8(&buf[..end]).map_err(|_| io::Error::other("bad header"))?;
258
+ let mut lines = text.split("\r\n");
259
+ let mut parts = lines.next().unwrap_or_default().split(' ');
260
+ let method = parts.next().unwrap_or_default().to_string();
261
+ let target = parts.next().unwrap_or_default().to_string();
262
+ let version = parts.next().unwrap_or("HTTP/1.1").to_string();
263
+ let headers = lines
264
+ .filter_map(|l| l.split_once(':'))
265
+ .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
266
+ .collect();
267
+ Ok(Head {
268
+ method,
269
+ target,
270
+ version,
271
+ headers,
272
+ rest: buf[end + 4..].to_vec(),
273
+ })
274
+ }
275
+
276
+ fn respond(stream: &mut TcpStream, status: &str, body: &str) {
277
+ let _ = write!(
278
+ stream,
279
+ "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
280
+ body.len()
281
+ );
282
+ }
283
+
284
+ fn parse_authority(authority: &str, default_port: u16) -> Option<(String, u16)> {
285
+ let (host, port) = split_host_port(authority);
286
+ let port = match port {
287
+ Some(p) => p.parse().ok()?,
288
+ None => default_port,
289
+ };
290
+ let host = normalize_host(host);
291
+ (!host.is_empty()).then_some((host, port))
292
+ }
293
+
294
+ fn parse_absolute_http(target: &str) -> Option<(String, u16, String)> {
295
+ let rest = target
296
+ .get(..7)
297
+ .filter(|s| s.eq_ignore_ascii_case("http://"))
298
+ .map(|_| &target[7..])?;
299
+ let (authority, path) = match rest.find('/') {
300
+ Some(i) => (&rest[..i], &rest[i..]),
301
+ None => (rest, "/"),
302
+ };
303
+ let authority = authority.rsplit('@').next()?;
304
+ let (host, port) = parse_authority(authority, 80)?;
305
+ Some((host, port, path.to_string()))
306
+ }
307
+
308
+ fn dial(shared: &Shared, host: &str, port: u16) -> Result<TcpStream, String> {
309
+ let addrs: Vec<SocketAddr> = (host, port)
310
+ .to_socket_addrs()
311
+ .map_err(|e| format!("cannot resolve {host}: {e}"))?
312
+ .collect();
313
+ let permitted: Vec<&SocketAddr> = addrs
314
+ .iter()
315
+ .filter(|a| shared.allowlist.address_permitted(&a.ip()))
316
+ .collect();
317
+ if permitted.is_empty() {
318
+ return Err(format!(
319
+ "{host} resolves only to non-public addresses ({})",
320
+ addrs.iter().map(|a| a.ip().to_string()).collect::<Vec<_>>().join(", ")
321
+ ));
322
+ }
323
+ let mut last = String::new();
324
+ for addr in permitted {
325
+ match TcpStream::connect_timeout(addr, CONNECT_TIMEOUT) {
326
+ Ok(s) => return Ok(s),
327
+ Err(e) => last = format!("{addr}: {e}"),
328
+ }
329
+ }
330
+ Err(format!("cannot connect to {host}:{port}: {last}"))
331
+ }
332
+
333
+ fn deny(shared: &Shared, stream: &mut TcpStream, host: &str, port: u16, why: &str) {
334
+ shared.blocked.lock().unwrap().push(format!("{host}:{port}"));
335
+ respond(
336
+ stream,
337
+ "403 Forbidden",
338
+ &format!("mxc: egress to {host}:{port} is not allowed ({why})\n"),
339
+ );
340
+ }
341
+
342
+ fn handle(mut client: TcpStream, shared: &Shared) -> io::Result<()> {
343
+ track(shared, &client);
344
+ client.set_read_timeout(Some(HEADER_TIMEOUT))?;
345
+ let head = read_head(&mut client)?;
346
+ client.set_read_timeout(None)?;
347
+
348
+ if head.method.eq_ignore_ascii_case("CONNECT") {
349
+ let Some((host, port)) = parse_authority(&head.target, 443) else {
350
+ respond(&mut client, "400 Bad Request", "mxc: bad CONNECT target\n");
351
+ return Ok(());
352
+ };
353
+ if !shared.allowlist.permits(&host, port) {
354
+ deny(shared, &mut client, &host, port, "not in allow list");
355
+ return Ok(());
356
+ }
357
+ let mut upstream = match dial(shared, &host, port) {
358
+ Ok(s) => s,
359
+ Err(why) => {
360
+ deny(shared, &mut client, &host, port, &why);
361
+ return Ok(());
362
+ }
363
+ };
364
+ track(shared, &upstream);
365
+ client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
366
+ upstream.write_all(&head.rest)?;
367
+ return splice(client, upstream);
368
+ }
369
+
370
+ let Some((host, port, path)) = parse_absolute_http(&head.target) else {
371
+ respond(
372
+ &mut client,
373
+ "400 Bad Request",
374
+ "mxc: expected CONNECT or an absolute http:// URL\n",
375
+ );
376
+ return Ok(());
377
+ };
378
+ if !shared.allowlist.permits(&host, port) {
379
+ deny(shared, &mut client, &host, port, "not in allow list");
380
+ return Ok(());
381
+ }
382
+ let mut upstream = match dial(shared, &host, port) {
383
+ Ok(s) => s,
384
+ Err(why) => {
385
+ deny(shared, &mut client, &host, port, &why);
386
+ return Ok(());
387
+ }
388
+ };
389
+ track(shared, &upstream);
390
+ let mut out = format!("{} {} {}\r\n", head.method, path, head.version);
391
+ for (k, v) in &head.headers {
392
+ let lower = k.to_ascii_lowercase();
393
+ if lower == "proxy-connection" || lower == "proxy-authorization" || lower == "connection" {
394
+ continue;
395
+ }
396
+ out.push_str(&format!("{k}: {v}\r\n"));
397
+ }
398
+ out.push_str("Connection: close\r\n\r\n");
399
+ upstream.write_all(out.as_bytes())?;
400
+ upstream.write_all(&head.rest)?;
401
+ splice(client, upstream)
402
+ }
403
+
404
+ fn splice(client: TcpStream, upstream: TcpStream) -> io::Result<()> {
405
+ let mut c_read = client.try_clone()?;
406
+ let mut u_write = upstream.try_clone()?;
407
+ let up = thread::spawn(move || {
408
+ let _ = io::copy(&mut c_read, &mut u_write);
409
+ let _ = u_write.shutdown(Shutdown::Write);
410
+ });
411
+ let mut u_read = upstream;
412
+ let mut c_write = client;
413
+ let _ = io::copy(&mut u_read, &mut c_write);
414
+ let _ = c_write.shutdown(Shutdown::Write);
415
+ let _ = up.join();
416
+ Ok(())
417
+ }
418
+
419
+ #[cfg(test)]
420
+ mod tests {
421
+ use super::*;
422
+
423
+ fn list(entries: &[&str]) -> Allowlist {
424
+ Allowlist::parse(&entries.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
425
+ }
426
+
427
+ #[test]
428
+ fn exact_hosts_on_default_ports() {
429
+ let l = list(&["PyPI.org."]);
430
+ assert!(l.permits("pypi.org", 443));
431
+ assert!(l.permits("pypi.org", 80));
432
+ assert!(!l.permits("pypi.org", 22));
433
+ assert!(!l.permits("evilpypi.org", 443));
434
+ assert!(!l.permits("files.pypi.org", 443));
435
+ }
436
+
437
+ #[test]
438
+ fn wildcard_matches_subdomains_only() {
439
+ let l = list(&["*.pythonhosted.org"]);
440
+ assert!(l.permits("files.pythonhosted.org", 443));
441
+ assert!(!l.permits("pythonhosted.org", 443));
442
+ assert!(!l.permits("evilpythonhosted.org", 443));
443
+ }
444
+
445
+ #[test]
446
+ fn explicit_port() {
447
+ let l = list(&["example.com:8443", "[2001:db8::1]:443"]);
448
+ assert!(l.permits("example.com", 8443));
449
+ assert!(!l.permits("example.com", 443));
450
+ assert!(l.permits("[2001:db8::1]", 443));
451
+ }
452
+
453
+ #[test]
454
+ fn rejects_bad_entries() {
455
+ for bad in ["", "a b", "10.0.0.0/8", "foo.*.com", "x:0", "x:http"] {
456
+ assert!(Allowlist::parse(&[bad.to_string()]).is_err(), "{bad}");
457
+ }
458
+ }
459
+
460
+ #[test]
461
+ fn private_addresses_are_refused_unless_listed() {
462
+ let l = list(&["internal.example", "10.1.2.3"]);
463
+ for ip in ["127.0.0.1", "10.0.0.5", "169.254.169.254", "::1", "fd00::1", "::ffff:127.0.0.1", "100.64.0.1"] {
464
+ assert!(!l.address_permitted(&ip.parse().unwrap()), "{ip}");
465
+ }
466
+ assert!(l.address_permitted(&"10.1.2.3".parse().unwrap()));
467
+ assert!(l.address_permitted(&"151.101.0.223".parse().unwrap()));
468
+ assert!(l.address_permitted(&"2a04:4e42::223".parse().unwrap()));
469
+ }
470
+
471
+ #[test]
472
+ fn absolute_http_targets() {
473
+ assert_eq!(
474
+ parse_absolute_http("http://user@Example.com:8080/a?b"),
475
+ Some(("example.com".into(), 8080, "/a?b".into()))
476
+ );
477
+ assert_eq!(parse_absolute_http("http://example.com"), Some(("example.com".into(), 80, "/".into())));
478
+ assert_eq!(parse_absolute_http("/relative"), None);
479
+ }
480
+ }
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # Whether this host can start a sandbox, and why not, as returned by MXC.availability.
5
+ #
6
+ # availability = MXC.availability
7
+ # availability.supported? # => false
8
+ # availability.remediation # => "Unprivileged user namespaces are blocked. ..."
9
+ class Availability
10
+ # Returns the backend that was checked.
11
+ attr_reader :backend
12
+
13
+ # Returns why a sandbox cannot start, or nil when it can.
14
+ attr_reader :reason
15
+
16
+ # Returns a suggestion for making the host supported, or nil.
17
+ attr_reader :remediation
18
+
19
+ def self.probe(backend) # :nodoc:
20
+ result = Sandbox.new(backend:, timeout: 10).run('exit 0')
21
+ return new(backend:, supported: true) if result.success?
22
+
23
+ outcome = result.timed_out? ? 'timed out' : "exited with #{result.exit_code}"
24
+ new(backend:, supported: false, reason: "A sandboxed `exit 0` #{outcome}: #{result.stderr.strip}")
25
+ rescue Error => e
26
+ new(backend:, supported: false, reason: e.message, remediation: e.remediation)
27
+ end
28
+
29
+ def initialize(backend:, supported:, reason: nil, remediation: nil) # :nodoc:
30
+ @backend = backend
31
+ @supported = supported
32
+ @reason = reason
33
+ @remediation = remediation
34
+ end
35
+
36
+ # Returns true when a sandbox can start.
37
+ def supported?
38
+ @supported
39
+ end
40
+
41
+ def inspect # :nodoc:
42
+ "#<MXC::Availability backend=#{backend.inspect} supported=#{supported?} reason=#{reason.inspect}>"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # A sandbox backend MXC found on this host, as returned by MXC.backends.
5
+ #
6
+ # MXC.backends.first.name # => :bubblewrap
7
+ class Backend
8
+ # Returns the backend's name, such as <tt>:bubblewrap</tt> or <tt>:seatbelt</tt>.
9
+ attr_reader :name
10
+
11
+ # Returns the isolation tier MXC reports for the backend, or nil when it has none.
12
+ attr_reader :tier
13
+
14
+ # Returns the optional capabilities MXC reports for the backend, as Symbols.
15
+ attr_reader :capabilities
16
+
17
+ # Returns MXC's explanations for capabilities the backend lacks on this host.
18
+ attr_reader :warnings
19
+
20
+ def initialize(name:, tier: nil, capabilities: [], warnings: []) # :nodoc:
21
+ @name = name
22
+ @tier = tier
23
+ @capabilities = capabilities.freeze
24
+ @warnings = warnings.freeze
25
+ end
26
+
27
+ # Returns true when the backend reports +capability+, such as <tt>:proxy_enforcement</tt>.
28
+ def supports?(capability)
29
+ capabilities.include?(capability)
30
+ end
31
+
32
+ def inspect # :nodoc:
33
+ "#<MXC::Backend name=#{name.inspect} tier=#{tier.inspect} capabilities=#{capabilities.inspect}>"
34
+ end
35
+ end
36
+ end
data/lib/mxc/chunk.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # A piece of output from a running sandbox, as passed to the block of Sandbox#run and Process#each.
5
+ #
6
+ # sandbox.run("python3 build.py") { |chunk| print chunk.content }
7
+ class Chunk
8
+ # Returns the stream the output came from: <tt>:stdout</tt> or <tt>:stderr</tt>.
9
+ attr_reader :stream
10
+
11
+ # Returns the output as a String.
12
+ attr_reader :content
13
+
14
+ def initialize(stream:, content:) # :nodoc:
15
+ @stream = stream
16
+ @content = content
17
+ end
18
+
19
+ # Returns true when the chunk came from standard output.
20
+ def stdout?
21
+ stream == :stdout
22
+ end
23
+
24
+ # Returns true when the chunk came from standard error.
25
+ def stderr?
26
+ stream == :stderr
27
+ end
28
+
29
+ def inspect # :nodoc:
30
+ "#<MXC::Chunk stream=#{stream.inspect} content=#{content.inspect}>"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # Global defaults for every sandbox. Change them with MXC.configure.
5
+ class Configuration
6
+ # Returns the default backend, <tt>:auto</tt> unless changed.
7
+ attr_reader :backend
8
+
9
+ # Returns the default timeout in seconds, or nil for none.
10
+ attr_reader :timeout
11
+
12
+ # Returns a Configuration with the built-in defaults.
13
+ def initialize
14
+ @backend = :auto
15
+ @timeout = nil
16
+ end
17
+
18
+ # Sets the default backend: <tt>:auto</tt>, <tt>:bubblewrap</tt>, or <tt>:seatbelt</tt>.
19
+ def backend=(name)
20
+ @backend = Sandbox.backend_name(name)
21
+ end
22
+
23
+ # Sets the default timeout in seconds. Accepts anything that responds to +to_f+, or nil for none.
24
+ def timeout=(seconds)
25
+ @timeout = Sandbox.duration(seconds)
26
+ end
27
+ end
28
+ end
data/lib/mxc/error.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # Raised when MXC cannot start or finish a sandbox.
5
+ #
6
+ # rescue MXC::Error => e
7
+ # e.code # => :backend_error
8
+ # e.remediation # => "Install bubblewrap"
9
+ class Error < StandardError
10
+ # Returns MXC's error code as a Symbol, such as <tt>:backend_unavailable</tt>.
11
+ attr_reader :code
12
+
13
+ # Returns a suggestion for fixing the problem, or nil.
14
+ attr_reader :remediation
15
+
16
+ # Returns the operation that failed, as MXC names it, or nil.
17
+ attr_reader :operation
18
+
19
+ # Returns the operating system's own error code, or nil.
20
+ attr_reader :native_code
21
+
22
+ # Returns a new Error with +message+ and optional details.
23
+ def initialize(message = nil, code: nil, remediation: nil, operation: nil, native_code: nil)
24
+ super(message)
25
+ @code = code
26
+ @remediation = remediation
27
+ @operation = operation
28
+ @native_code = native_code
29
+ end
30
+ end
31
+
32
+ # Raised when this host cannot run the sandbox as configured, such as when Bubblewrap is
33
+ # missing, user namespaces are blocked, or a network policy needs tools that are not installed.
34
+ # Its +remediation+ says what to change.
35
+ class UnsupportedError < Error; end
36
+ end
data/lib/mxc/native.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mxc/#{RUBY_VERSION[/\d+\.\d+/]}/mxc"
5
+ rescue LoadError
6
+ require 'mxc/mxc'
7
+ end
8
+
9
+ module MXC
10
+ module Native # :nodoc:
11
+ UNSUPPORTED_CODES = %i[backend_unavailable unsupported_containment].freeze
12
+ BACKEND_NAMES = { processcontainer: :process_container }.freeze
13
+ MISSING_BUBBLEWRAP = /bwrap\) is not installed/
14
+ INSTALL_BUBBLEWRAP = 'Install Bubblewrap 0.5.0 or newer (Debian/Ubuntu: apt-get install bubblewrap; ' \
15
+ 'Fedora: dnf install bubblewrap; Arch: pacman -S bubblewrap)'
16
+
17
+ def self.backend_attributes
18
+ backends.map { |attributes| attributes.merge(name: BACKEND_NAMES.fetch(attributes[:name], attributes[:name])) }
19
+ end
20
+
21
+ def self.error(message, code: nil, remediation: nil, **)
22
+ if message.match?(MISSING_BUBBLEWRAP)
23
+ UnsupportedError.new(message, code: :backend_unavailable, remediation: remediation || INSTALL_BUBBLEWRAP, **)
24
+ else
25
+ (UNSUPPORTED_CODES.include?(code) ? UnsupportedError : Error).new(message, code:, remediation:, **)
26
+ end
27
+ end
28
+ end
29
+ end