| 1 | //! macOS Seatbelt (sandbox-exec) profile generation. |
| 2 | //! |
| 3 | //! Seatbelt is Apple's mandatory access control framework that uses the |
| 4 | //! Scheme-based policy language to define what system resources a process |
| 5 | //! can access. This module generates sandbox profiles dynamically based |
| 6 | //! on the configured `SandboxPolicy`. |
| 7 | //! |
| 8 | //! # How it works |
| 9 | //! |
| 10 | //! 1. We generate a Seatbelt policy string in the SBPL format |
| 11 | //! 2. We invoke `/usr/bin/sandbox-exec -p <policy>` to run the command |
| 12 | //! 3. The kernel enforces the policy, blocking unauthorized operations |
| 13 | //! |
| 14 | //! # References |
| 15 | //! |
| 16 | //! - Apple's sandbox(7) man page |
| 17 | //! - <https://reverse.put.as/wp-content/uploads/2011/09/Apple-Sandbox-Guide-v1.0.pdf> |
| 18 | |
| 19 | // Note: cfg(target_os = "macos") is already applied at the module level in mod.rs |
| 20 | |
| 21 | use super::policy::SandboxPolicy; |
| 22 | use std::path::{Path, PathBuf}; |
| 23 | use std::process::Command; |
| 24 | use std::sync::OnceLock; |
| 25 | |
| 26 | /// Path to the sandbox-exec binary on macOS. |
| 27 | pub const SANDBOX_EXEC_PATH: &str = "/usr/bin/sandbox-exec"; |
| 28 | |
| 29 | /// Base seatbelt policy that provides minimal process functionality. |
| 30 | /// |
| 31 | /// This policy: |
| 32 | /// - Denies everything by default |
| 33 | /// - Allows process execution and forking |
| 34 | /// - Allows signals within the same sandbox |
| 35 | /// - Allows reading user preferences (needed by many tools) |
| 36 | /// - Allows basic process introspection |
| 37 | /// - Allows writing to /dev/null |
| 38 | /// - Allows reading sysctl values |
| 39 | /// - Allows POSIX semaphores and pseudo-TTY operations |
| 40 | const SEATBELT_BASE_POLICY: &str = r#" |
| 41 | (version 1) |
| 42 | (deny default) |
| 43 | |
| 44 | ; Core process operations |
| 45 | (allow process-exec) |
| 46 | (allow process-fork) |
| 47 | (allow signal (target same-sandbox)) |
| 48 | (allow process-info* (target same-sandbox)) |
| 49 | |
| 50 | ; User preferences (needed by many CLI tools) |
| 51 | (allow user-preference-read) |
| 52 | |
| 53 | ; Basic I/O to /dev/null |
| 54 | (allow file-write-data |
| 55 | (require-all |
| 56 | (path "/dev/null") |
| 57 | (vnode-type CHARACTER-DEVICE))) |
| 58 | |
| 59 | ; System information |
| 60 | (allow sysctl-read) |
| 61 | |
| 62 | ; IPC primitives |
| 63 | (allow ipc-posix-sem) |
| 64 | (allow ipc-posix-shm-read*) |
| 65 | (allow ipc-posix-shm-write-create) |
| 66 | (allow ipc-posix-shm-write-data) |
| 67 | (allow ipc-posix-shm-write-unlink) |
| 68 | |
| 69 | ; Terminal support (essential for shell commands) |
| 70 | (allow pseudo-tty) |
| 71 | (allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) |
| 72 | (allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+$")) |
| 73 | |
| 74 | ; macOS-specific device access |
| 75 | (allow file-read* (literal "/dev/urandom")) |
| 76 | (allow file-read* (literal "/dev/random")) |
| 77 | (allow file-ioctl (literal "/dev/dtracehelper")) |
| 78 | |
| 79 | ; Mach IPC (needed by many system services) |
| 80 | (allow mach-lookup) |
| 81 | "#; |
| 82 | |
| 83 | /// Network access policy additions. |
| 84 | const SEATBELT_NETWORK_POLICY: &str = r" |
| 85 | ; Network access |
| 86 | (allow network-outbound) |
| 87 | (allow network-inbound) |
| 88 | (allow system-socket) |
| 89 | (allow network-bind) |
| 90 | "; |
| 91 | |
| 92 | /// Check if sandbox-exec is available and permitted on this system. |
| 93 | pub fn is_available() -> bool { |
| 94 | static SEATBELT_AVAILABLE: OnceLock<bool> = OnceLock::new(); |
| 95 | |
| 96 | *SEATBELT_AVAILABLE.get_or_init(|| { |
| 97 | if !Path::new(SANDBOX_EXEC_PATH).exists() { |
| 98 | return false; |
| 99 | } |
| 100 | |
| 101 | let output = Command::new(SANDBOX_EXEC_PATH) |
| 102 | .args(["-p", "(version 1)(allow default)", "--", "/usr/bin/true"]) |
| 103 | .output(); |
| 104 | |
| 105 | match output { |
| 106 | Ok(result) => result.status.success(), |
| 107 | Err(_) => false, |
| 108 | } |
| 109 | }) |
| 110 | } |
| 111 | |
| 112 | /// Create the command-line arguments for sandbox-exec. |
| 113 | /// |
| 114 | /// Returns a Vec of arguments that should be prepended to the command. |
| 115 | /// The format is: `sandbox-exec -p <policy> -D KEY=VALUE ... -- <original command>` |
| 116 | pub fn create_seatbelt_args( |
| 117 | command: Vec<String>, |
| 118 | policy: &SandboxPolicy, |
| 119 | sandbox_cwd: &Path, |
| 120 | ) -> Vec<String> { |
| 121 | let full_policy = generate_policy(policy, sandbox_cwd); |
| 122 | let params = generate_params(policy, sandbox_cwd); |
| 123 | |
| 124 | let mut args = vec!["-p".to_string(), full_policy]; |
| 125 | |
| 126 | // Add parameter definitions for variable substitution |
| 127 | for (key, value) in params { |
| 128 | args.push(format!("-D{}={}", key, value.to_string_lossy())); |
| 129 | } |
| 130 | |
| 131 | // Separator between sandbox-exec args and the actual command |
| 132 | args.push("--".to_string()); |
| 133 | args.extend(command); |
| 134 | |
| 135 | args |
| 136 | } |
| 137 | |
| 138 | /// Generate the complete Seatbelt policy string for the given policy. |
| 139 | fn generate_policy(policy: &SandboxPolicy, cwd: &Path) -> String { |
| 140 | let mut full_policy = SEATBELT_BASE_POLICY.to_string(); |
| 141 | |
| 142 | // Add read access policy |
| 143 | if SandboxPolicy::has_full_disk_read_access() { |
| 144 | full_policy.push_str("\n; Full filesystem read access\n(allow file-read*)"); |
| 145 | } |
| 146 | |
| 147 | // Add write access policy |
| 148 | let file_write_policy = generate_write_policy(policy, cwd); |
| 149 | if !file_write_policy.is_empty() { |
| 150 | full_policy.push_str("\n\n; Write access policy\n"); |
| 151 | full_policy.push_str(&file_write_policy); |
| 152 | } |
| 153 | |
| 154 | // Add network policy if enabled |
| 155 | if policy.has_network_access() { |
| 156 | full_policy.push('\n'); |
| 157 | full_policy.push_str(SEATBELT_NETWORK_POLICY); |
| 158 | } |
| 159 | |
| 160 | // Add Darwin user cache directory access (needed by many macOS tools) |
| 161 | full_policy.push_str("\n\n; Darwin user cache directory\n"); |
| 162 | full_policy |
| 163 | .push_str(r#"(allow file-read* file-write* (subpath (param "DARWIN_USER_CACHE_DIR")))"#); |
| 164 | |
| 165 | // Add common macOS directories that tools often need |
| 166 | full_policy.push_str("\n\n; Common macOS directories\n"); |
| 167 | full_policy.push_str(r#"(allow file-read* (subpath "/usr/lib"))"#); |
| 168 | full_policy.push('\n'); |
| 169 | full_policy.push_str(r#"(allow file-read* (subpath "/usr/share"))"#); |
| 170 | full_policy.push('\n'); |
| 171 | full_policy.push_str(r#"(allow file-read* (subpath "/System/Library"))"#); |
| 172 | full_policy.push('\n'); |
| 173 | full_policy.push_str(r#"(allow file-read* (subpath "/Library/Preferences"))"#); |
| 174 | full_policy.push('\n'); |
| 175 | full_policy.push_str(r#"(allow file-read* (subpath "/private/var/db"))"#); |
| 176 | |
| 177 | // Cargo home (#558): cargo build/test/publish reach into ~/.cargo/registry |
| 178 | // and ~/.cargo/git for crate metadata, downloaded tarballs, and unpacked |
| 179 | // sources. Sandboxed workspace-write was previously rejecting these, |
| 180 | // making `cargo publish` unrunnable from inside the TUI's shell tool. |
| 181 | // Read access is always allowed; write access is granted whenever the |
| 182 | // policy allows any write at all (the registry caches need to be |
| 183 | // mutable for `cargo build` to populate them on a cache miss). Skipped |
| 184 | // entirely when neither `CARGO_HOME` nor `HOME` is set — without one of |
| 185 | // those we have no path to plumb into the policy params. |
| 186 | if resolve_cargo_home().is_some() { |
| 187 | full_policy.push_str("\n\n; Cargo home (~/.cargo) — registry/index/git caches\n"); |
| 188 | full_policy.push_str(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#); |
| 189 | if !matches!(policy, SandboxPolicy::ReadOnly) { |
| 190 | full_policy.push('\n'); |
| 191 | full_policy.push_str(r#"(allow file-write* (subpath (param "CARGO_HOME_REGISTRY")))"#); |
| 192 | full_policy.push('\n'); |
| 193 | full_policy.push_str(r#"(allow file-write* (subpath (param "CARGO_HOME_GIT")))"#); |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | full_policy |
| 198 | } |
| 199 | |
| 200 | /// Resolve the user's cargo home — `CARGO_HOME` if set, else `$HOME/.cargo`. |
| 201 | /// Returns `None` only on hosts where neither env var is set (essentially |
| 202 | /// never on a real macOS user account; can happen in CI containers without |
| 203 | /// `HOME` exported). |
| 204 | fn resolve_cargo_home() -> Option<PathBuf> { |
| 205 | if let Ok(explicit) = std::env::var("CARGO_HOME") |
| 206 | && !explicit.trim().is_empty() |
| 207 | { |
| 208 | return Some(PathBuf::from(explicit)); |
| 209 | } |
| 210 | let home = std::env::var("HOME").ok()?; |
| 211 | Some(PathBuf::from(home).join(".cargo")) |
| 212 | } |
| 213 | |
| 214 | /// Generate the write access portion of the Seatbelt policy. |
| 215 | fn generate_write_policy(policy: &SandboxPolicy, cwd: &Path) -> String { |
| 216 | // Full disk write access |
| 217 | if policy.has_full_disk_write_access() { |
| 218 | return r#"(allow file-write* (regex #"^/"))"#.to_string(); |
| 219 | } |
| 220 | |
| 221 | // Read-only - no write policy needed |
| 222 | if matches!(policy, SandboxPolicy::ReadOnly) { |
| 223 | return String::new(); |
| 224 | } |
| 225 | |
| 226 | // Workspace write - enumerate allowed paths |
| 227 | let writable_roots = policy.get_writable_roots(cwd); |
| 228 | if writable_roots.is_empty() { |
| 229 | return String::new(); |
| 230 | } |
| 231 | |
| 232 | let mut policies = Vec::new(); |
| 233 | |
| 234 | for (index, root) in writable_roots.iter().enumerate() { |
| 235 | let root_param = format!("WRITABLE_ROOT_{index}"); |
| 236 | |
| 237 | if root.read_only_subpaths.is_empty() { |
| 238 | // Simple case: entire subtree is writable |
| 239 | policies.push(format!("(subpath (param \"{root_param}\"))")); |
| 240 | } else { |
| 241 | // Complex case: writable with read-only exceptions |
| 242 | // Use require-all to combine subpath with require-not for each exception |
| 243 | let mut parts = vec![format!("(subpath (param \"{}\"))", root_param)]; |
| 244 | |
| 245 | for (subpath_index, _) in root.read_only_subpaths.iter().enumerate() { |
| 246 | let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"); |
| 247 | parts.push(format!("(require-not (subpath (param \"{ro_param}\")))")); |
| 248 | } |
| 249 | |
| 250 | policies.push(format!("(require-all {})", parts.join(" "))); |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | if policies.is_empty() { |
| 255 | return String::new(); |
| 256 | } |
| 257 | |
| 258 | // Combine all write policies with allow |
| 259 | format!("(allow file-write*\n {})", policies.join("\n ")) |
| 260 | } |
| 261 | |
| 262 | /// Generate parameter definitions for variable substitution in the policy. |
| 263 | /// |
| 264 | /// sandbox-exec allows -DKEY=VALUE to substitute `(param "KEY")` in the policy. |
| 265 | fn generate_params(policy: &SandboxPolicy, cwd: &Path) -> Vec<(String, PathBuf)> { |
| 266 | let mut params = Vec::new(); |
| 267 | |
| 268 | // Add writable root parameters |
| 269 | let writable_roots = policy.get_writable_roots(cwd); |
| 270 | |
| 271 | for (index, root) in writable_roots.iter().enumerate() { |
| 272 | let canonical = root |
| 273 | .root |
| 274 | .canonicalize() |
| 275 | .unwrap_or_else(|_| root.root.clone()); |
| 276 | params.push((format!("WRITABLE_ROOT_{index}"), canonical)); |
| 277 | |
| 278 | // Add parameters for read-only subpaths |
| 279 | for (subpath_index, subpath) in root.read_only_subpaths.iter().enumerate() { |
| 280 | let canonical_subpath = subpath.canonicalize().unwrap_or_else(|_| subpath.clone()); |
| 281 | params.push(( |
| 282 | format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"), |
| 283 | canonical_subpath, |
| 284 | )); |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | // Add Darwin user cache directory |
| 289 | if let Some(cache_dir) = get_darwin_user_cache_dir() { |
| 290 | params.push(("DARWIN_USER_CACHE_DIR".to_string(), cache_dir)); |
| 291 | } else { |
| 292 | // Fallback to a reasonable default |
| 293 | if let Ok(home) = std::env::var("HOME") { |
| 294 | params.push(( |
| 295 | "DARWIN_USER_CACHE_DIR".to_string(), |
| 296 | PathBuf::from(format!("{home}/Library/Caches")), |
| 297 | )); |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | // Cargo home (#558): paired with the policy lines emitted by |
| 302 | // `generate_policy` when `resolve_cargo_home()` succeeds. Both helpers |
| 303 | // use the same fallback chain so the policy text and the -DKEY=VALUE |
| 304 | // params stay in sync — emit one without the other and sandbox-exec |
| 305 | // refuses to load the profile. |
| 306 | if let Some(home) = resolve_cargo_home() { |
| 307 | let canonical_home = home.canonicalize().unwrap_or_else(|_| home.clone()); |
| 308 | params.push(( |
| 309 | "CARGO_HOME_REGISTRY".to_string(), |
| 310 | canonical_home.join("registry"), |
| 311 | )); |
| 312 | params.push(("CARGO_HOME_GIT".to_string(), canonical_home.join("git"))); |
| 313 | params.push(("CARGO_HOME".to_string(), canonical_home)); |
| 314 | } |
| 315 | |
| 316 | params |
| 317 | } |
| 318 | |
| 319 | /// Get the Darwin user cache directory using confstr. |
| 320 | /// |
| 321 | /// This returns the per-user cache directory that macOS assigns, |
| 322 | /// typically something like /var/folders/xx/xxx.../C/ |
| 323 | fn get_darwin_user_cache_dir() -> Option<PathBuf> { |
| 324 | // Use libc to call confstr for _CS_DARWIN_USER_CACHE_DIR |
| 325 | let mut buf = vec![0i8; (libc::PATH_MAX as usize) + 1]; |
| 326 | |
| 327 | // Safety: `buf` is a writable buffer sized to PATH_MAX + 1 for confstr. |
| 328 | let len = |
| 329 | unsafe { libc::confstr(libc::_CS_DARWIN_USER_CACHE_DIR, buf.as_mut_ptr(), buf.len()) }; |
| 330 | |
| 331 | if len == 0 { |
| 332 | return None; |
| 333 | } |
| 334 | |
| 335 | // Convert the C string to a Rust PathBuf |
| 336 | // Safety: confstr guarantees a NUL-terminated string in `buf` when len > 0. |
| 337 | let cstr = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }; |
| 338 | let path_str = cstr.to_str().ok()?; |
| 339 | let path = PathBuf::from(path_str); |
| 340 | |
| 341 | // Try to canonicalize, but return the raw path if that fails |
| 342 | path.canonicalize().ok().or(Some(path)) |
| 343 | } |
| 344 | |
| 345 | /// Detect sandbox denial from command output. |
| 346 | /// |
| 347 | /// Returns true if the output suggests the sandbox blocked an operation. |
| 348 | pub fn detect_denial(exit_code: i32, stderr: &str) -> bool { |
| 349 | if exit_code == 0 { |
| 350 | return false; |
| 351 | } |
| 352 | |
| 353 | // Common sandbox denial messages |
| 354 | let denial_patterns = [ |
| 355 | "Operation not permitted", |
| 356 | "sandbox-exec", |
| 357 | "deny(", |
| 358 | "Sandbox: ", |
| 359 | ]; |
| 360 | |
| 361 | denial_patterns.iter().any(|p| stderr.contains(p)) |
| 362 | } |
| 363 | |
| 364 | #[cfg(test)] |
| 365 | mod tests { |
| 366 | use super::*; |
| 367 | |
| 368 | /// Serializes tests that mutate process-global env vars (HOME, CARGO_HOME) |
| 369 | /// so they don't race with each other or with sibling tests in this |
| 370 | /// crate that read those vars. Mirrors the pattern in main.rs::tests |
| 371 | /// (commit d06eaed0). |
| 372 | static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 373 | |
| 374 | #[test] |
| 375 | fn test_is_available() { |
| 376 | // This test just checks the function doesn't panic |
| 377 | // On macOS it should return true, on other platforms false |
| 378 | let _ = is_available(); |
| 379 | } |
| 380 | |
| 381 | #[test] |
| 382 | fn test_generate_policy_default() { |
| 383 | let policy = SandboxPolicy::default(); |
| 384 | let cwd = Path::new("/tmp/test"); |
| 385 | let result = generate_policy(&policy, cwd); |
| 386 | |
| 387 | assert!(result.contains("(version 1)")); |
| 388 | assert!(result.contains("(deny default)")); |
| 389 | assert!(result.contains("(allow file-read*)")); |
| 390 | assert!(result.contains("file-write*")); |
| 391 | // Default policy has no network |
| 392 | assert!(!result.contains("network-outbound")); |
| 393 | } |
| 394 | |
| 395 | #[test] |
| 396 | fn test_generate_policy_with_network() { |
| 397 | let policy = SandboxPolicy::workspace_with_network(); |
| 398 | let cwd = Path::new("/tmp/test"); |
| 399 | let result = generate_policy(&policy, cwd); |
| 400 | |
| 401 | assert!(result.contains("network-outbound")); |
| 402 | assert!(result.contains("network-inbound")); |
| 403 | } |
| 404 | |
| 405 | #[test] |
| 406 | fn test_generate_policy_read_only() { |
| 407 | let policy = SandboxPolicy::ReadOnly; |
| 408 | let cwd = Path::new("/tmp/test"); |
| 409 | let result = generate_policy(&policy, cwd); |
| 410 | |
| 411 | assert!(result.contains("(allow file-read*)")); |
| 412 | // Should not have workspace write rules |
| 413 | assert!(!result.contains("WRITABLE_ROOT")); |
| 414 | } |
| 415 | |
| 416 | #[test] |
| 417 | fn test_generate_params() { |
| 418 | let policy = SandboxPolicy::default(); |
| 419 | let cwd = Path::new("/tmp/test"); |
| 420 | let params = generate_params(&policy, cwd); |
| 421 | |
| 422 | // Should have at least the cache dir param |
| 423 | assert!(params.iter().any(|(k, _)| k == "DARWIN_USER_CACHE_DIR")); |
| 424 | } |
| 425 | |
| 426 | /// #558: cargo publish reaches into ~/.cargo/registry; the seatbelt has |
| 427 | /// to allow read+write inside it. Both the policy text and the param |
| 428 | /// table must be in sync — emitting one without the other makes |
| 429 | /// sandbox-exec refuse to load the profile. |
| 430 | #[test] |
| 431 | fn test_cargo_home_paths_emitted_in_policy_and_params_when_home_set() { |
| 432 | let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); |
| 433 | |
| 434 | // SAFETY: HOME / CARGO_HOME are process-global. ENV_LOCK serializes |
| 435 | // all tests in this module that mutate them, and we always restore |
| 436 | // the prior value before returning. |
| 437 | let saved_home = std::env::var_os("HOME"); |
| 438 | let saved_cargo = std::env::var_os("CARGO_HOME"); |
| 439 | unsafe { |
| 440 | std::env::set_var("HOME", "/tmp/seatbelt-cargo-test"); |
| 441 | std::env::remove_var("CARGO_HOME"); |
| 442 | } |
| 443 | |
| 444 | let policy = SandboxPolicy::default(); |
| 445 | let cwd = Path::new("/tmp/test"); |
| 446 | |
| 447 | let policy_text = generate_policy(&policy, cwd); |
| 448 | assert!(policy_text.contains(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#)); |
| 449 | assert!(policy_text.contains("CARGO_HOME_REGISTRY")); |
| 450 | assert!(policy_text.contains("CARGO_HOME_GIT")); |
| 451 | |
| 452 | let params = generate_params(&policy, cwd); |
| 453 | assert!(params.iter().any(|(k, _)| k == "CARGO_HOME")); |
| 454 | assert!(params.iter().any(|(k, _)| k == "CARGO_HOME_REGISTRY")); |
| 455 | assert!(params.iter().any(|(k, _)| k == "CARGO_HOME_GIT")); |
| 456 | |
| 457 | // Read-only policy should still emit CARGO_HOME read rule but skip writes. |
| 458 | let read_only_text = generate_policy(&SandboxPolicy::ReadOnly, cwd); |
| 459 | assert!( |
| 460 | read_only_text.contains(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#), |
| 461 | "read-only mode should still allow reading the cargo registry: {read_only_text}" |
| 462 | ); |
| 463 | assert!( |
| 464 | !read_only_text |
| 465 | .contains(r#"(allow file-write* (subpath (param "CARGO_HOME_REGISTRY")))"#), |
| 466 | "read-only mode must NOT grant write access to the cargo registry" |
| 467 | ); |
| 468 | |
| 469 | // Restore. |
| 470 | // SAFETY: restoring the prior value the test stashed at entry. |
| 471 | unsafe { |
| 472 | match saved_home { |
| 473 | Some(v) => std::env::set_var("HOME", v), |
| 474 | None => std::env::remove_var("HOME"), |
| 475 | } |
| 476 | match saved_cargo { |
| 477 | Some(v) => std::env::set_var("CARGO_HOME", v), |
| 478 | None => std::env::remove_var("CARGO_HOME"), |
| 479 | } |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | /// #558: if neither `CARGO_HOME` nor `HOME` is set, the cargo lines and |
| 484 | /// their params must both be omitted — emitting one without the other |
| 485 | /// would crash sandbox-exec on profile load. |
| 486 | #[test] |
| 487 | fn test_cargo_home_skipped_when_no_env() { |
| 488 | let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); |
| 489 | |
| 490 | let saved_home = std::env::var_os("HOME"); |
| 491 | let saved_cargo = std::env::var_os("CARGO_HOME"); |
| 492 | // SAFETY: HOME/CARGO_HOME are process-global; ENV_LOCK serializes |
| 493 | // mutations here and we restore the prior values before returning. |
| 494 | unsafe { |
| 495 | std::env::remove_var("HOME"); |
| 496 | std::env::remove_var("CARGO_HOME"); |
| 497 | } |
| 498 | |
| 499 | let policy = SandboxPolicy::default(); |
| 500 | let cwd = Path::new("/tmp/test"); |
| 501 | let policy_text = generate_policy(&policy, cwd); |
| 502 | let params = generate_params(&policy, cwd); |
| 503 | |
| 504 | assert!(!policy_text.contains("CARGO_HOME")); |
| 505 | assert!(!params.iter().any(|(k, _)| k.starts_with("CARGO_HOME"))); |
| 506 | |
| 507 | // Restore. |
| 508 | // SAFETY: restoring the prior values the test stashed at entry. |
| 509 | unsafe { |
| 510 | match saved_home { |
| 511 | Some(v) => std::env::set_var("HOME", v), |
| 512 | None => std::env::remove_var("HOME"), |
| 513 | } |
| 514 | match saved_cargo { |
| 515 | Some(v) => std::env::set_var("CARGO_HOME", v), |
| 516 | None => std::env::remove_var("CARGO_HOME"), |
| 517 | } |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | #[test] |
| 522 | fn test_create_seatbelt_args() { |
| 523 | let policy = SandboxPolicy::default(); |
| 524 | let cwd = Path::new("/tmp/test"); |
| 525 | let command = vec!["echo".to_string(), "hello".to_string()]; |
| 526 | |
| 527 | let args = create_seatbelt_args(command, &policy, cwd); |
| 528 | |
| 529 | // Should start with -p and the policy |
| 530 | assert_eq!(args[0], "-p"); |
| 531 | assert!(args[1].contains("(version 1)")); |
| 532 | |
| 533 | // Should contain the separator |
| 534 | assert!(args.contains(&"--".to_string())); |
| 535 | |
| 536 | // Should end with the original command |
| 537 | assert!(args.contains(&"echo".to_string())); |
| 538 | assert!(args.contains(&"hello".to_string())); |
| 539 | } |
| 540 | |
| 541 | #[test] |
| 542 | fn test_detect_denial() { |
| 543 | assert!(detect_denial(1, "Operation not permitted")); |
| 544 | assert!(detect_denial(1, "Sandbox: ls denied file-write*")); |
| 545 | assert!(!detect_denial(0, "Operation not permitted")); |
| 546 | assert!(!detect_denial(1, "File not found")); |
| 547 | } |
| 548 | } |
| 549 |