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.
- checksums.yaml +7 -0
- data/Cargo.lock +1500 -0
- data/Cargo.toml +6 -0
- data/LICENSE +21 -0
- data/NOTICE +34 -0
- data/README.md +140 -0
- data/ext/mxc/Cargo.toml +18 -0
- data/ext/mxc/build.rs +28 -0
- data/ext/mxc/extconf.rb +24 -0
- data/ext/mxc/src/lib.rs +384 -0
- data/ext/mxc/src/proxy.rs +480 -0
- data/lib/mxc/availability.rb +45 -0
- data/lib/mxc/backend.rb +36 -0
- data/lib/mxc/chunk.rb +33 -0
- data/lib/mxc/configuration.rb +28 -0
- data/lib/mxc/error.rb +36 -0
- data/lib/mxc/native.rb +29 -0
- data/lib/mxc/preflight.rb +82 -0
- data/lib/mxc/process.rb +100 -0
- data/lib/mxc/result.rb +63 -0
- data/lib/mxc/sandbox.rb +366 -0
- data/lib/mxc/startup_failure.rb +42 -0
- data/lib/mxc/version.rb +6 -0
- data/lib/mxc/windows_arguments.rb +18 -0
- data/lib/mxc.rb +109 -0
- metadata +87 -0
data/Cargo.toml
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carmine Paolino
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/NOTICE
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
MXC for Ruby
|
|
2
|
+
Copyright (c) 2026 Carmine Paolino
|
|
3
|
+
|
|
4
|
+
This gem is an unofficial Ruby binding to MXC. It is not affiliated with or
|
|
5
|
+
endorsed by Microsoft.
|
|
6
|
+
|
|
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:
|
|
10
|
+
|
|
11
|
+
MIT License
|
|
12
|
+
|
|
13
|
+
Copyright (c) Microsoft Corporation.
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
in the Software without restriction, including without limitation the rights
|
|
18
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
furnished to do so, subject to the following conditions:
|
|
21
|
+
|
|
22
|
+
The above copyright notice and this permission notice shall be included in all
|
|
23
|
+
copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
|
32
|
+
|
|
33
|
+
The native extension also links other open source Rust crates, listed with
|
|
34
|
+
their versions in Cargo.lock. Their licenses are available from crates.io.
|
data/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# MXC for Ruby
|
|
4
|
+
|
|
5
|
+
<strong>Run untrusted commands in an OS sandbox, from Ruby</strong>
|
|
6
|
+
|
|
7
|
+
[Documentation](https://crmne.github.io/mxc/) · [Getting Started](https://crmne.github.io/mxc/getting-started/) · [Running in Docker](https://crmne.github.io/mxc/running-in-docker/)
|
|
8
|
+
|
|
9
|
+
[](https://badge.fury.io/rb/mxc)
|
|
10
|
+
[](https://github.com/crmne/mxc/actions/workflows/ci.yml)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
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.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
|
|
21
|
+
result.stdout
|
|
22
|
+
result.success?
|
|
23
|
+
```
|
|
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.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
bundle add mxc
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Precompiled gems cover Linux (x86_64 and ARM64, glibc and musl), macOS (Intel and Apple Silicon), and Windows (x64 and ARM64). On Linux, install Bubblewrap:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
sudo apt-get install bubblewrap
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Commands that use the network also need `slirp4netns`, `iptables`, util-linux, and `/dev/net/tun`. macOS and Windows need nothing extra.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
Run a command. It sees the system's programs read-only, nothing of your home directory or project, and no network:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
MXC.run("python3 -c 'print(6 * 7)'").stdout # => "42\n"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
One String runs through the shell, like `Kernel#system`. Pass the program and its arguments separately when part of the command comes from users, so each one reaches the program unchanged:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
MXC.run("python3", "convert.py", params[:filename])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Build a policy with chainable setters:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
sandbox = MXC.sandbox
|
|
59
|
+
.with_readable("app/skills")
|
|
60
|
+
.with_writable(Rails.root.join("tmp/skills"))
|
|
61
|
+
.with_denied("app/skills/secrets")
|
|
62
|
+
.with_network(allow: ["pypi.org", "*.pythonhosted.org"])
|
|
63
|
+
.with_env("LANG" => "C.UTF-8")
|
|
64
|
+
.with_directory("app/skills")
|
|
65
|
+
.with_timeout(30)
|
|
66
|
+
|
|
67
|
+
result = sandbox.run("pip download --no-deps -d /tmp/wheels requests")
|
|
68
|
+
result.blocked # => hosts the allow-list refused
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Stream output as it arrives:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
sandbox.run("python3 train.py") { |chunk| print chunk.content }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Drive an interactive process:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
process = sandbox.spawn("python3 -i -q")
|
|
81
|
+
process.write("print(6 * 7)\n")
|
|
82
|
+
process.close_write
|
|
83
|
+
process.each { |chunk| print chunk.content }
|
|
84
|
+
process.wait # => #<MXC::Result exit_code=0 timed_out=false backend=:bubblewrap>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Name a policy you use in several places:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class SkillSandbox < MXC::Sandbox
|
|
91
|
+
readable "app/skills"
|
|
92
|
+
writable "tmp/skills"
|
|
93
|
+
network false
|
|
94
|
+
timeout 30
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
SkillSandbox.new.run("python3 app/skills/build.py")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Check the machine before you rely on it:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
MXC.supported? # starts a real sandbox once and caches the answer
|
|
104
|
+
MXC.availability.remediation # what to change when it cannot
|
|
105
|
+
MXC.supports?(:network_policy) # host allow-lists work here
|
|
106
|
+
MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Platforms
|
|
110
|
+
|
|
111
|
+
| Platform | Backend | 0.1.0 |
|
|
112
|
+
| --- | --- | --- |
|
|
113
|
+
| Linux | Bubblewrap | Supported and tested |
|
|
114
|
+
| macOS | Seatbelt | Supported and tested |
|
|
115
|
+
| Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
|
|
116
|
+
|
|
117
|
+
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
|
+
|
|
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.
|
|
120
|
+
|
|
121
|
+
## Documentation
|
|
122
|
+
|
|
123
|
+
* [Getting Started](https://crmne.github.io/mxc/getting-started/)
|
|
124
|
+
* [Running Commands](https://crmne.github.io/mxc/running-commands/)
|
|
125
|
+
* [Policies](https://crmne.github.io/mxc/policies/)
|
|
126
|
+
* [Checking the Host](https://crmne.github.io/mxc/checking-the-host/)
|
|
127
|
+
* [Running in Docker](https://crmne.github.io/mxc/running-in-docker/)
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
bin/setup # bundle install and compile the extension
|
|
133
|
+
bundle exec rake # compile, tests, Rust unit tests, RuboCop
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Tests that start sandboxes skip on machines that cannot. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT. The native extension bundles Microsoft's MXC, also MIT; see [NOTICE](NOTICE).
|
data/ext/mxc/Cargo.toml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "mxc"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
authors = ["Carmine Paolino <carmine@paolino.me>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
publish = false
|
|
8
|
+
|
|
9
|
+
[lib]
|
|
10
|
+
crate-type = ["cdylib"]
|
|
11
|
+
|
|
12
|
+
[dependencies]
|
|
13
|
+
magnus = "0.8.2"
|
|
14
|
+
mxc-sdk = "=1.0.0"
|
|
15
|
+
rb-sys = { version = "0.9.130", features = ["stable-api-compiled-fallback"] }
|
|
16
|
+
|
|
17
|
+
[build-dependencies]
|
|
18
|
+
rb-sys-env = "0.2.2"
|
data/ext/mxc/build.rs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
use std::env;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::PathBuf;
|
|
4
|
+
|
|
5
|
+
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
6
|
+
let _ = rb_sys_env::activate()?;
|
|
7
|
+
|
|
8
|
+
if env::var("CARGO_CFG_TARGET_OS")? == "windows" && env::var("CARGO_CFG_TARGET_ENV")? != "msvc"
|
|
9
|
+
{
|
|
10
|
+
stand_in_for_onecore_apiset()?;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
Ok(())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// tracelogging, which mxc-sdk uses on Windows, links its ETW functions from
|
|
17
|
+
// OneCore_apiset. The MinGW cross-compilers that build the release gems either
|
|
18
|
+
// lack that import library or name it in lowercase, which a linker on Linux
|
|
19
|
+
// does not match. advapi32 exports the same functions, so an empty archive
|
|
20
|
+
// satisfies the -lOneCore_apiset request and advapi32, linked last, provides
|
|
21
|
+
// them.
|
|
22
|
+
fn stand_in_for_onecore_apiset() -> Result<(), Box<dyn std::error::Error>> {
|
|
23
|
+
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
|
|
24
|
+
fs::write(out_dir.join("libOneCore_apiset.a"), b"!<arch>\n")?;
|
|
25
|
+
println!("cargo:rustc-link-search=native={}", out_dir.display());
|
|
26
|
+
println!("cargo:rustc-link-arg=-ladvapi32");
|
|
27
|
+
Ok(())
|
|
28
|
+
}
|
data/ext/mxc/extconf.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mkmf'
|
|
4
|
+
require 'rb_sys/mkmf'
|
|
5
|
+
|
|
6
|
+
create_rust_makefile('mxc/mxc') do |r|
|
|
7
|
+
if Gem.win_platform?
|
|
8
|
+
# RubyInstaller builds Ruby with MinGW, so the extension must use Rust's GNU
|
|
9
|
+
# target even when rustup's default host is MSVC.
|
|
10
|
+
unless ENV.key?('CARGO_BUILD_TARGET') || ENV.key?('RUST_TARGET')
|
|
11
|
+
r.target = RbConfig::CONFIG['host_cpu'] == 'aarch64' ? 'aarch64-pc-windows-gnullvm' : 'x86_64-pc-windows-gnu'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# mxc-sdk's build script compiles version resources for its own Windows
|
|
15
|
+
# executables with rc.exe, which it finds only for MSVC targets. This gem
|
|
16
|
+
# never builds those executables, so a resource compiler that does nothing
|
|
17
|
+
# lets the extension build without the Windows SDK.
|
|
18
|
+
unless ENV.key?('RC_PATH')
|
|
19
|
+
stub = File.expand_path('rc-stub.cmd')
|
|
20
|
+
File.write(stub, "@exit /b 0\r\n")
|
|
21
|
+
r.env = { 'RC_PATH' => stub }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/ext/mxc/src/lib.rs
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
use std::ffi::c_void;
|
|
2
|
+
use std::io::{Read, Write};
|
|
3
|
+
use std::sync::Mutex;
|
|
4
|
+
use std::time::{Duration, Instant};
|
|
5
|
+
|
|
6
|
+
use magnus::{
|
|
7
|
+
function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby, Symbol,
|
|
8
|
+
};
|
|
9
|
+
use mxc_sdk::v1::{
|
|
10
|
+
self,
|
|
11
|
+
configs::{ProcessContainerConfig, SeatbeltConfig},
|
|
12
|
+
Containment, ContainerRequest, FilesystemPolicy, MxcProcess, NetworkAction,
|
|
13
|
+
NetworkEgressPolicy, NetworkPolicy, NetworkRuntimeConfig, StreamCloser, WaitResult,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
mod proxy;
|
|
17
|
+
|
|
18
|
+
use proxy::{Allowlist, EgressProxy};
|
|
19
|
+
|
|
20
|
+
const MAX_POLL: Duration = Duration::from_millis(50);
|
|
21
|
+
|
|
22
|
+
fn without_gvl<F, R>(f: F) -> R
|
|
23
|
+
where
|
|
24
|
+
F: FnOnce() -> R,
|
|
25
|
+
{
|
|
26
|
+
struct Payload<F, R> {
|
|
27
|
+
f: Option<F>,
|
|
28
|
+
r: Option<std::thread::Result<R>>,
|
|
29
|
+
}
|
|
30
|
+
extern "C" fn call<F: FnOnce() -> R, R>(data: *mut c_void) -> *mut c_void {
|
|
31
|
+
let payload = unsafe { &mut *(data as *mut Payload<F, R>) };
|
|
32
|
+
let f = payload.f.take().unwrap();
|
|
33
|
+
payload.r = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)));
|
|
34
|
+
std::ptr::null_mut()
|
|
35
|
+
}
|
|
36
|
+
let mut payload = Payload { f: Some(f), r: None };
|
|
37
|
+
unsafe {
|
|
38
|
+
rb_sys::rb_thread_call_without_gvl(
|
|
39
|
+
Some(call::<F, R>),
|
|
40
|
+
&mut payload as *mut _ as *mut c_void,
|
|
41
|
+
None,
|
|
42
|
+
std::ptr::null_mut(),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
match payload.r.unwrap() {
|
|
46
|
+
Ok(r) => r,
|
|
47
|
+
Err(panic) => std::panic::resume_unwind(panic),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fn native_module(ruby: &Ruby) -> Result<RModule, Error> {
|
|
52
|
+
let mxc: RModule = ruby.class_object().const_get("MXC")?;
|
|
53
|
+
mxc.const_get("Native")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fn mxc_error(ruby: &Ruby, e: v1::Error) -> Error {
|
|
57
|
+
let build = || -> Result<magnus::Exception, Error> {
|
|
58
|
+
let kw = ruby.hash_new();
|
|
59
|
+
kw.aset(ruby.to_symbol("code"), ruby.to_symbol(e.code.as_str()))?;
|
|
60
|
+
kw.aset(ruby.to_symbol("remediation"), e.remediation.clone())?;
|
|
61
|
+
kw.aset(ruby.to_symbol("operation"), e.operation.clone())?;
|
|
62
|
+
kw.aset(ruby.to_symbol("native_code"), e.native_code.clone())?;
|
|
63
|
+
native_module(ruby)?.funcall("error", (e.message.clone(), magnus::KwArgs(kw)))
|
|
64
|
+
};
|
|
65
|
+
match build() {
|
|
66
|
+
Ok(exc) => Error::from(exc),
|
|
67
|
+
Err(err) => err,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn io_error(ruby: &Ruby, e: std::io::Error) -> Error {
|
|
72
|
+
Error::new(ruby.exception_io_error(), e.to_string())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fn arg_error(ruby: &Ruby, message: String) -> Error {
|
|
76
|
+
Error::new(ruby.exception_arg_error(), message)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fn opt<T: magnus::TryConvert>(ruby: &Ruby, h: RHash, key: &str) -> Result<Option<T>, Error> {
|
|
80
|
+
h.lookup::<_, Option<T>>(ruby.to_symbol(key))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fn deny_all() -> NetworkEgressPolicy {
|
|
84
|
+
NetworkEgressPolicy {
|
|
85
|
+
default: Some(NetworkAction::Deny),
|
|
86
|
+
..Default::default()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn build_request(
|
|
91
|
+
ruby: &Ruby,
|
|
92
|
+
command: String,
|
|
93
|
+
opts: RHash,
|
|
94
|
+
) -> Result<(ContainerRequest, Option<EgressProxy>), Error> {
|
|
95
|
+
let mut req = ContainerRequest::new(command);
|
|
96
|
+
req.timeout_ms = opt::<u32>(ruby, opts, "timeout_ms")?;
|
|
97
|
+
req.working_directory = opt::<String>(ruby, opts, "working_directory")?;
|
|
98
|
+
if let Some(env) = opt::<RHash>(ruby, opts, "environment")? {
|
|
99
|
+
let mut pairs = Vec::new();
|
|
100
|
+
env.foreach(|k: String, v: String| {
|
|
101
|
+
pairs.push((k, v));
|
|
102
|
+
Ok(magnus::r_hash::ForEach::Continue)
|
|
103
|
+
})?;
|
|
104
|
+
req.environment = Some(pairs);
|
|
105
|
+
}
|
|
106
|
+
req.filesystem = Some(FilesystemPolicy {
|
|
107
|
+
readonly_paths: opt::<Vec<String>>(ruby, opts, "readonly")?.unwrap_or_default(),
|
|
108
|
+
readwrite_paths: opt::<Vec<String>>(ruby, opts, "readwrite")?.unwrap_or_default(),
|
|
109
|
+
denied_paths: opt::<Vec<String>>(ruby, opts, "denied")?.unwrap_or_default(),
|
|
110
|
+
..Default::default()
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let mut egress_proxy = None;
|
|
114
|
+
let egress = match opt::<Vec<String>>(ruby, opts, "allow")? {
|
|
115
|
+
Some(hosts) => {
|
|
116
|
+
let allowlist = Allowlist::parse(&hosts).map_err(|m| arg_error(ruby, m))?;
|
|
117
|
+
let started = EgressProxy::start(allowlist).map_err(|e| io_error(ruby, e))?;
|
|
118
|
+
let runtime_config = NetworkRuntimeConfig {
|
|
119
|
+
network_proxy: Some(started.url()),
|
|
120
|
+
};
|
|
121
|
+
egress_proxy = Some(started);
|
|
122
|
+
NetworkPolicy {
|
|
123
|
+
egress: Some(deny_all()),
|
|
124
|
+
runtime_config: Some(runtime_config),
|
|
125
|
+
..Default::default()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
None if opt::<bool>(ruby, opts, "network")?.unwrap_or(false) => NetworkPolicy {
|
|
129
|
+
egress: Some(NetworkEgressPolicy {
|
|
130
|
+
default: Some(NetworkAction::Allow),
|
|
131
|
+
..Default::default()
|
|
132
|
+
}),
|
|
133
|
+
..Default::default()
|
|
134
|
+
},
|
|
135
|
+
None => NetworkPolicy {
|
|
136
|
+
egress: Some(deny_all()),
|
|
137
|
+
..Default::default()
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
req.network = Some(egress);
|
|
141
|
+
|
|
142
|
+
if let Some(backend) = opt::<Symbol>(ruby, opts, "backend")? {
|
|
143
|
+
req.containment = match backend.name()?.as_ref() {
|
|
144
|
+
"auto" => Containment::Process,
|
|
145
|
+
"bubblewrap" => Containment::Bubblewrap,
|
|
146
|
+
"seatbelt" => Containment::Seatbelt(SeatbeltConfig::default()),
|
|
147
|
+
"process_container" => {
|
|
148
|
+
Containment::ProcessContainer(ProcessContainerConfig::default())
|
|
149
|
+
}
|
|
150
|
+
other => return Err(arg_error(ruby, format!("unknown backend :{other}"))),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
Ok((req, egress_proxy))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn outcome_hash(ruby: &Ruby, outcome: WaitResult) -> Result<RHash, Error> {
|
|
157
|
+
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)?;
|
|
162
|
+
}
|
|
163
|
+
WaitResult::TimedOut => {
|
|
164
|
+
h.aset(ruby.to_symbol("timed_out"), true)?;
|
|
165
|
+
h.aset(ruby.to_symbol("exit_code"), ruby.qnil())?;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
Ok(h)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
fn snake_case(name: &str) -> String {
|
|
172
|
+
let mut out = String::new();
|
|
173
|
+
for (i, c) in name.chars().enumerate() {
|
|
174
|
+
if c.is_ascii_uppercase() {
|
|
175
|
+
if i > 0 {
|
|
176
|
+
out.push('_');
|
|
177
|
+
}
|
|
178
|
+
out.push(c.to_ascii_lowercase());
|
|
179
|
+
} else {
|
|
180
|
+
out.push(c);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn backends(ruby: &Ruby) -> Result<RArray, Error> {
|
|
187
|
+
let found = without_gvl(v1::available_backends);
|
|
188
|
+
let arr = ruby.ary_new();
|
|
189
|
+
for b in found {
|
|
190
|
+
let h = ruby.hash_new();
|
|
191
|
+
h.aset(ruby.to_symbol("name"), ruby.to_symbol(&b.backend))?;
|
|
192
|
+
h.aset(ruby.to_symbol("tier"), b.tier)?;
|
|
193
|
+
let capabilities = ruby.ary_new();
|
|
194
|
+
for c in &b.capabilities {
|
|
195
|
+
capabilities.push(ruby.to_symbol(snake_case(&format!("{c:?}"))))?;
|
|
196
|
+
}
|
|
197
|
+
h.aset(ruby.to_symbol("capabilities"), capabilities)?;
|
|
198
|
+
h.aset(ruby.to_symbol("warnings"), b.warnings)?;
|
|
199
|
+
arr.push(h)?;
|
|
200
|
+
}
|
|
201
|
+
Ok(arr)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
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()));
|
|
207
|
+
let blocked = egress_proxy.as_mut().map(|p| {
|
|
208
|
+
p.stop();
|
|
209
|
+
p.blocked()
|
|
210
|
+
});
|
|
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)?;
|
|
216
|
+
h.aset(ruby.to_symbol("blocked"), blocked.unwrap_or_default())?;
|
|
217
|
+
Ok(h)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
type Reader = Mutex<Option<Box<dyn Read + Send>>>;
|
|
221
|
+
|
|
222
|
+
#[magnus::wrap(class = "MXC::Native::Process", free_immediately, size)]
|
|
223
|
+
struct Process {
|
|
224
|
+
pid: u32,
|
|
225
|
+
deadline: Option<Instant>,
|
|
226
|
+
child: Mutex<Option<MxcProcess>>,
|
|
227
|
+
stdin: Mutex<Option<Box<dyn Write + Send>>>,
|
|
228
|
+
stdout: Reader,
|
|
229
|
+
stderr: Reader,
|
|
230
|
+
stdout_closer: Option<StreamCloser>,
|
|
231
|
+
stderr_closer: Option<StreamCloser>,
|
|
232
|
+
egress_proxy: Mutex<Option<EgressProxy>>,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
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))?;
|
|
240
|
+
Ok(Process {
|
|
241
|
+
pid: child.id(),
|
|
242
|
+
deadline: timeout.map(|t| Instant::now() + t),
|
|
243
|
+
stdin: Mutex::new(child.take_stdin()),
|
|
244
|
+
stdout: Mutex::new(child.take_stdout()),
|
|
245
|
+
stderr: Mutex::new(child.take_stderr()),
|
|
246
|
+
stdout_closer: child.stdout_closer(),
|
|
247
|
+
stderr_closer: child.stderr_closer(),
|
|
248
|
+
child: Mutex::new(Some(child)),
|
|
249
|
+
egress_proxy: Mutex::new(egress_proxy),
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fn read_from(ruby: &Ruby, stream: &Reader, max: usize) -> Result<Option<RString>, Error> {
|
|
254
|
+
let res = without_gvl(|| {
|
|
255
|
+
let mut guard = stream.lock().unwrap();
|
|
256
|
+
match guard.as_mut() {
|
|
257
|
+
None => Ok(None),
|
|
258
|
+
Some(r) => {
|
|
259
|
+
let mut buf = vec![0u8; max];
|
|
260
|
+
let n = r.read(&mut buf)?;
|
|
261
|
+
buf.truncate(n);
|
|
262
|
+
if n == 0 {
|
|
263
|
+
guard.take();
|
|
264
|
+
}
|
|
265
|
+
Ok(Some(buf))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
match res {
|
|
270
|
+
Ok(Some(buf)) if !buf.is_empty() => Ok(Some(ruby.str_from_slice(&buf))),
|
|
271
|
+
Ok(_) => Ok(None),
|
|
272
|
+
Err(e) => Err(io_error(ruby, e)),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
impl Process {
|
|
277
|
+
fn pid(&self) -> u32 {
|
|
278
|
+
self.pid
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fn read_stdout(ruby: &Ruby, rb_self: &Self, max: usize) -> Result<Option<RString>, Error> {
|
|
282
|
+
read_from(ruby, &rb_self.stdout, max)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
fn read_stderr(ruby: &Ruby, rb_self: &Self, max: usize) -> Result<Option<RString>, Error> {
|
|
286
|
+
read_from(ruby, &rb_self.stderr, max)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
fn write(ruby: &Ruby, rb_self: &Self, data: RString) -> Result<usize, Error> {
|
|
290
|
+
let bytes = unsafe { data.as_slice() }.to_vec();
|
|
291
|
+
let res = without_gvl(|| {
|
|
292
|
+
let mut guard = rb_self.stdin.lock().unwrap();
|
|
293
|
+
match guard.as_mut() {
|
|
294
|
+
None => Err(std::io::Error::other("stdin is closed")),
|
|
295
|
+
Some(w) => w
|
|
296
|
+
.write_all(&bytes)
|
|
297
|
+
.and_then(|_| w.flush())
|
|
298
|
+
.map(|_| bytes.len()),
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
res.map_err(|e| io_error(ruby, e))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
fn close_stdin(&self) {
|
|
305
|
+
self.stdin.lock().unwrap().take();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
fn kill(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
|
|
309
|
+
let mut guard = rb_self.child.lock().unwrap();
|
|
310
|
+
match guard.as_mut() {
|
|
311
|
+
Some(c) => c.kill().map_err(|e| io_error(ruby, e)),
|
|
312
|
+
None => Ok(()),
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// MxcProcess::kill and ::wait both need the handle exclusively, so waiting
|
|
317
|
+
// polls and releases the lock between polls to let kill in.
|
|
318
|
+
fn await_exit(&self) -> std::io::Result<(WaitResult, Vec<String>)> {
|
|
319
|
+
let mut nap = Duration::from_millis(1);
|
|
320
|
+
loop {
|
|
321
|
+
{
|
|
322
|
+
let mut guard = self.child.lock().unwrap();
|
|
323
|
+
let child = guard
|
|
324
|
+
.as_mut()
|
|
325
|
+
.ok_or_else(|| std::io::Error::other("the process was already waited for"))?;
|
|
326
|
+
let expired = self.deadline.is_some_and(|d| Instant::now() >= d);
|
|
327
|
+
if expired || child.try_wait()?.is_some() {
|
|
328
|
+
let outcome = child.wait()?;
|
|
329
|
+
let warnings = child.warnings();
|
|
330
|
+
guard.take();
|
|
331
|
+
return Ok((outcome, warnings));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
let mut sleep = nap;
|
|
335
|
+
if let Some(deadline) = self.deadline {
|
|
336
|
+
sleep = sleep.min(deadline.saturating_duration_since(Instant::now()));
|
|
337
|
+
}
|
|
338
|
+
std::thread::sleep(sleep);
|
|
339
|
+
nap = (nap * 2).min(MAX_POLL);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
fn wait(ruby: &Ruby, rb_self: &Self) -> Result<RHash, Error> {
|
|
344
|
+
let res = without_gvl(|| rb_self.await_exit());
|
|
345
|
+
let blocked = match rb_self.egress_proxy.lock().unwrap().take() {
|
|
346
|
+
Some(mut p) => {
|
|
347
|
+
p.stop();
|
|
348
|
+
p.blocked()
|
|
349
|
+
}
|
|
350
|
+
None => Vec::new(),
|
|
351
|
+
};
|
|
352
|
+
let (outcome, warnings) = res.map_err(|e| io_error(ruby, e))?;
|
|
353
|
+
if !matches!(outcome, WaitResult::Exited(_)) {
|
|
354
|
+
if let Some(c) = &rb_self.stdout_closer {
|
|
355
|
+
c.close();
|
|
356
|
+
}
|
|
357
|
+
if let Some(c) = &rb_self.stderr_closer {
|
|
358
|
+
c.close();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
let h = outcome_hash(ruby, outcome)?;
|
|
362
|
+
h.aset(ruby.to_symbol("warnings"), warnings)?;
|
|
363
|
+
h.aset(ruby.to_symbol("blocked"), blocked)?;
|
|
364
|
+
Ok(h)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#[magnus::init]
|
|
369
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
370
|
+
let mxc = ruby.define_module("MXC")?;
|
|
371
|
+
let native = mxc.define_module("Native")?;
|
|
372
|
+
native.define_singleton_method("backends", function!(backends, 0))?;
|
|
373
|
+
native.define_singleton_method("run", function!(run, 2))?;
|
|
374
|
+
native.define_singleton_method("spawn", function!(spawn, 2))?;
|
|
375
|
+
let process = native.define_class("Process", ruby.class_object())?;
|
|
376
|
+
process.define_method("pid", method!(Process::pid, 0))?;
|
|
377
|
+
process.define_method("read_stdout", method!(Process::read_stdout, 1))?;
|
|
378
|
+
process.define_method("read_stderr", method!(Process::read_stderr, 1))?;
|
|
379
|
+
process.define_method("write", method!(Process::write, 1))?;
|
|
380
|
+
process.define_method("close_stdin", method!(Process::close_stdin, 0))?;
|
|
381
|
+
process.define_method("kill", method!(Process::kill, 0))?;
|
|
382
|
+
process.define_method("wait", method!(Process::wait, 0))?;
|
|
383
|
+
Ok(())
|
|
384
|
+
}
|