sandbox_run/lib.rs
1//! A sandboxed process execution library for Linux and macOS.
2//!
3//! This crate provides a `Command` API similar to `std::process::Command` but with
4//! the ability to restrict process execution using platform-native sandboxing:
5//!
6//! - **Linux**: Uses [Landlock](https://landlock.io/) (requires kernel 5.13+)
7//! - **macOS**: Uses the native sandbox framework
8//!
9//! The API mirrors systemd's execution environment options where applicable.
10//!
11//! # Example
12//!
13//! ```no_run
14//! use std::path::Path;
15//! use sandbox_run::{Command, Sandbox, ProtectSystem, ProtectHome};
16//!
17//! // Create a sandbox with systemd-style options
18//! let sandbox = Sandbox::new()
19//! .protect_system(ProtectSystem::Strict)
20//! .protect_home(ProtectHome::Yes)
21//! .read_write_paths([Path::new("/tmp/myapp")])
22//! .private_network(true)
23//! .no_new_privileges(true)
24//! .limit_nofile(256)
25//! .build()
26//! .unwrap();
27//!
28//! // Run a command under the sandbox
29//! let status = Command::new("myapp")
30//! .sandbox(sandbox)
31//! .status()
32//! .expect("failed to execute process");
33//! ```
34//!
35//! # Systemd-Compatible Options
36//!
37//! | Sandbox Method | systemd Directive |
38//! |----------------|-------------------|
39//! | `protect_system()` | `ProtectSystem=` |
40//! | `protect_home()` | `ProtectHome=` |
41//! | `read_only_paths()` | `ReadOnlyPaths=` |
42//! | `read_write_paths()` | `ReadWritePaths=` |
43//! | `inaccessible_paths()` | `InaccessiblePaths=` |
44//! | `exec_paths()` | `ExecPaths=` |
45//! | `no_exec_paths()` | `NoExecPaths=` |
46//! | `private_network()` | `PrivateNetwork=` |
47//! | `no_new_privileges()` | `NoNewPrivileges=` |
48//! | `limit_nofile()` | `LimitNOFILE=` |
49//! | `limit_nproc()` | `LimitNPROC=` |
50//! | `limit_core()` | `LimitCORE=` |
51//! | `limit_fsize()` | `LimitFSIZE=` |
52//!
53//! # Platform Notes
54//!
55//! ## Linux
56//!
57//! The Linux implementation uses Landlock, which is an allow-list based sandboxing
58//! mechanism. `ProtectSystem::Strict` works best as it makes the entire filesystem
59//! read-only and you can add exceptions with `read_write_paths()`.
60//!
61//! ## macOS
62//!
63//! The macOS implementation uses the native sandbox framework with generated profiles.
64//! All protection modes work well on macOS.
65
66mod command;
67mod error;
68mod platform;
69mod sandbox;
70
71pub use command::Command;
72pub use error::SandboxError;
73pub use sandbox::{ProtectHome, ProtectSystem, Sandbox, SandboxPolicy};
74
75/// Whether the running kernel enforces Landlock (Linux 5.13+ with
76/// Landlock enabled). Callers that fail open when sandboxing is
77/// unavailable can use this to warn that spawned processes will run
78/// unconfined.
79#[cfg(target_os = "linux")]
80pub use platform::landlock_available;
81
82/// Apply a compiled policy to the **current** process. Intended for
83/// integrations that build their own `pre_exec` closure (e.g. when
84/// running under a custom runtime that owns the child handle, like
85/// `tokio::process::Command`). Must be called in the child after fork
86/// and before exec.
87pub fn apply_sandbox(policy: &SandboxPolicy) -> Result<(), SandboxError> {
88 platform::apply_sandbox(policy)
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use std::path::Path;
95 use std::process::Stdio;
96
97 #[test]
98 fn test_command_without_sandbox() {
99 let output = Command::new("echo")
100 .arg("hello")
101 .stdout(Stdio::piped())
102 .output()
103 .expect("failed to execute echo");
104
105 assert!(output.status.success());
106 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hello");
107 }
108
109 #[test]
110 fn test_sandbox_builder_fluent_api() {
111 let sandbox = Sandbox::new()
112 .protect_system(ProtectSystem::Strict)
113 .protect_home(ProtectHome::Yes)
114 .read_write_paths([Path::new("/tmp")])
115 .private_network(true)
116 .no_new_privileges(true)
117 .limit_nofile(256)
118 .limit_nproc(10)
119 .limit_core(0)
120 .build()
121 .expect("failed to build sandbox");
122
123 // Just verify the sandbox builds successfully
124 let _ = sandbox;
125 }
126
127 #[test]
128 #[cfg(target_os = "macos")]
129 fn test_protect_home_blocks_home_access() {
130 let sandbox = Sandbox::new()
131 .protect_home(ProtectHome::Yes)
132 .build()
133 .expect("failed to build sandbox");
134
135 // Try to list home directory
136 let home = std::env::var("HOME").unwrap_or_else(|_| "/Users".to_string());
137 let output = Command::new("ls")
138 .arg(&home)
139 .sandbox(sandbox)
140 .stderr(Stdio::piped())
141 .output()
142 .expect("failed to execute ls");
143
144 // Should fail because home is inaccessible
145 assert!(!output.status.success());
146 }
147
148 #[test]
149 #[cfg(target_os = "macos")]
150 fn test_private_network_blocks_network() {
151 let sandbox = Sandbox::new()
152 .private_network(true)
153 .build()
154 .expect("failed to build sandbox");
155
156 // Try to make a network connection
157 let output = Command::new("curl")
158 .arg("--connect-timeout")
159 .arg("1")
160 .arg("http://127.0.0.1:1")
161 .sandbox(sandbox)
162 .stderr(Stdio::piped())
163 .output()
164 .expect("failed to execute curl");
165
166 // Should fail because network is blocked
167 // Note: curl may fail with different exit codes, but the sandbox should deny the connection
168 assert!(!output.status.success());
169 }
170
171 #[test]
172 #[cfg(target_os = "macos")]
173 fn test_inaccessible_paths() {
174 // Create a test file
175 let temp_dir = std::env::temp_dir();
176 let test_file = temp_dir.join("sandbox_test_inaccessible.txt");
177 std::fs::write(&test_file, "secret content").expect("failed to write test file");
178
179 let sandbox = Sandbox::new()
180 .inaccessible_paths([&test_file])
181 .build()
182 .expect("failed to build sandbox");
183
184 let output = Command::new("cat")
185 .arg(&test_file)
186 .sandbox(sandbox)
187 .stderr(Stdio::piped())
188 .output()
189 .expect("failed to execute cat");
190
191 // Clean up
192 let _ = std::fs::remove_file(&test_file);
193
194 // Should fail because file is inaccessible
195 assert!(!output.status.success());
196 }
197
198 #[test]
199 #[cfg(target_os = "macos")]
200 fn test_read_only_paths() {
201 let temp_dir = std::env::temp_dir();
202 let test_file = temp_dir.join("sandbox_test_readonly.txt");
203 std::fs::write(&test_file, "initial content").expect("failed to write test file");
204
205 let sandbox = Sandbox::new()
206 .read_only_paths([&temp_dir])
207 .build()
208 .expect("failed to build sandbox");
209
210 // Try to write to the file - should fail
211 let output = Command::new("sh")
212 .arg("-c")
213 .arg(format!("echo 'new content' > {}", test_file.display()))
214 .sandbox(sandbox)
215 .stderr(Stdio::piped())
216 .output()
217 .expect("failed to execute sh");
218
219 // Clean up
220 let _ = std::fs::remove_file(&test_file);
221
222 // Should fail because the path is read-only
223 assert!(!output.status.success());
224 }
225
226 #[test]
227 #[cfg(target_os = "linux")]
228 fn test_protect_system_strict() {
229 // With strict mode, only read_write_paths are writable
230 let sandbox = Sandbox::new()
231 .protect_system(ProtectSystem::Strict)
232 .read_write_paths([Path::new("/tmp")])
233 .build()
234 .expect("failed to build sandbox");
235
236 // Create a temp file to verify /tmp is writable
237 let output = Command::new("sh")
238 .arg("-c")
239 .arg("echo test > /tmp/sandbox_test_strict.txt && cat /tmp/sandbox_test_strict.txt")
240 .sandbox(sandbox)
241 .stdout(Stdio::piped())
242 .stderr(Stdio::piped())
243 .output()
244 .expect("failed to execute sh");
245
246 // Clean up
247 let _ = std::fs::remove_file("/tmp/sandbox_test_strict.txt");
248
249 assert!(
250 output.status.success(),
251 "Command failed: {}",
252 String::from_utf8_lossy(&output.stderr)
253 );
254 }
255}