|
1 | 1 | // sandbox-exec invocation |
2 | 2 | use crate::sandbox::seatbelt::{generate_seatbelt_profile, SandboxParams, SeatbeltError}; |
3 | 3 | use crate::sandbox::trace::TraceSession; |
| 4 | +use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM}; |
| 5 | +use signal_hook::iterator::Signals; |
4 | 6 | use std::fs; |
5 | 7 | use std::io; |
| 8 | +use std::os::unix::process::CommandExt; |
6 | 9 | use std::path::Path; |
7 | 10 | use std::process::{Command, ExitStatus, Stdio}; |
| 11 | +use std::time::Duration; |
8 | 12 | use tempfile::{NamedTempFile, TempDir}; |
9 | 13 |
|
| 14 | +/// Grace period between SIGTERM and SIGKILL when forwarding shutdown signals |
| 15 | +/// to the sandboxed process group. Long enough for typical cleanup (closing |
| 16 | +/// IPC peers, flushing buffers) but short enough that orphaned processes do |
| 17 | +/// not linger after `sx` is signalled. |
| 18 | +const SIGTERM_TO_SIGKILL_GRACE: Duration = Duration::from_secs(2); |
| 19 | + |
10 | 20 | /// Exit codes for sandbox execution |
11 | 21 | pub mod exit_codes { |
12 | 22 | pub const SUCCESS: i32 = 0; |
@@ -171,8 +181,11 @@ SAVEHIST=0 |
171 | 181 | .stdout(Stdio::inherit()) |
172 | 182 | .stderr(Stdio::inherit()); |
173 | 183 |
|
174 | | - // Execute and wait |
175 | | - let status = cmd.spawn()?.wait()?; |
| 184 | + // Execute and wait, forwarding shutdown signals to the entire sandboxed subtree. |
| 185 | + // Without this, sx exits without signalling its sandboxed descendants — IPC |
| 186 | + // children (e.g. Node `--useNodeIpc` workers) get reparented to launchd and |
| 187 | + // accumulate forever. |
| 188 | + let status = spawn_with_signal_forwarding(cmd)?; |
176 | 189 |
|
177 | 190 | // Stop trace session |
178 | 191 | if let Some(ref mut session) = trace_session { |
@@ -217,6 +230,74 @@ pub fn dry_run(params: &SandboxParams) -> Result<String, SeatbeltError> { |
217 | 230 | generate_seatbelt_profile(params) |
218 | 231 | } |
219 | 232 |
|
| 233 | +/// RAII guard that SIGKILLs an entire process group on drop. |
| 234 | +/// Provides panic / early-return safety so the sandbox subtree is never |
| 235 | +/// orphaned if `sx` exits along an unexpected path. |
| 236 | +struct PgidKillGuard { |
| 237 | + pgid: Option<i32>, |
| 238 | +} |
| 239 | + |
| 240 | +impl PgidKillGuard { |
| 241 | + fn new(pgid: i32) -> Self { |
| 242 | + Self { pgid: Some(pgid) } |
| 243 | + } |
| 244 | + |
| 245 | + /// Disarm the guard after a clean exit so we do not signal a dead pgid. |
| 246 | + fn disarm(&mut self) { |
| 247 | + self.pgid = None; |
| 248 | + } |
| 249 | +} |
| 250 | + |
| 251 | +impl Drop for PgidKillGuard { |
| 252 | + fn drop(&mut self) { |
| 253 | + if let Some(pgid) = self.pgid { |
| 254 | + // Best-effort: ignore errors (group may already be gone). |
| 255 | + unsafe { |
| 256 | + libc::kill(-pgid, libc::SIGKILL); |
| 257 | + } |
| 258 | + } |
| 259 | + } |
| 260 | +} |
| 261 | + |
| 262 | +/// Send `sig` to the entire process group identified by `pgid`. |
| 263 | +fn signal_pgroup(pgid: i32, sig: libc::c_int) { |
| 264 | + unsafe { |
| 265 | + libc::kill(-pgid, sig); |
| 266 | + } |
| 267 | +} |
| 268 | + |
| 269 | +/// Spawn `cmd` in its own process group and wait for it, forwarding |
| 270 | +/// SIGINT/SIGTERM/SIGHUP to the sandboxed subtree with a SIGTERM → |
| 271 | +/// grace-period → SIGKILL escalation. |
| 272 | +fn spawn_with_signal_forwarding(mut cmd: Command) -> io::Result<ExitStatus> { |
| 273 | + // Put the child in its own process group so kill(-pgid, ...) reaches every |
| 274 | + // descendant in one syscall and does not loop back to sx itself. |
| 275 | + cmd.process_group(0); |
| 276 | + |
| 277 | + let mut child = cmd.spawn()?; |
| 278 | + let pgid = child.id() as i32; |
| 279 | + let mut kill_guard = PgidKillGuard::new(pgid); |
| 280 | + |
| 281 | + let mut signals = Signals::new([SIGINT, SIGTERM, SIGHUP])?; |
| 282 | + let signal_handle = signals.handle(); |
| 283 | + |
| 284 | + let signal_thread = std::thread::spawn(move || { |
| 285 | + if signals.forever().next().is_some() { |
| 286 | + signal_pgroup(pgid, libc::SIGTERM); |
| 287 | + std::thread::sleep(SIGTERM_TO_SIGKILL_GRACE); |
| 288 | + signal_pgroup(pgid, libc::SIGKILL); |
| 289 | + } |
| 290 | + }); |
| 291 | + |
| 292 | + let status = child.wait()?; |
| 293 | + |
| 294 | + kill_guard.disarm(); |
| 295 | + signal_handle.close(); |
| 296 | + let _ = signal_thread.join(); |
| 297 | + |
| 298 | + Ok(status) |
| 299 | +} |
| 300 | + |
220 | 301 | /// Check if an environment variable name matches any glob-like pattern. |
221 | 302 | /// Supports `*` wildcards: `AWS_*` matches `AWS_SECRET_KEY`, `*_SECRET*` matches `MY_SECRET_VALUE`. |
222 | 303 | fn matches_env_pattern(name: &str, patterns: &[String]) -> bool { |
@@ -363,4 +444,41 @@ mod tests { |
363 | 444 | &["DYLD_*".to_string()] |
364 | 445 | )); |
365 | 446 | } |
| 447 | + |
| 448 | + /// Verifies the foundational mechanism for issue #37: spawning a child via |
| 449 | + /// `Command::process_group(0)` isolates it into its own process group so we |
| 450 | + /// can deliver group-wide signals via `kill(-pgid, ...)`. This is a direct |
| 451 | + /// unit test of the kernel behavior `spawn_with_signal_forwarding` relies |
| 452 | + /// on; it does not require `sandbox-exec` and runs in any environment. |
| 453 | + #[test] |
| 454 | + fn test_process_group_isolation_for_signal_forwarding() { |
| 455 | + let mut cmd = Command::new("/bin/sh"); |
| 456 | + cmd.args(["-c", "sleep 5"]) |
| 457 | + .process_group(0) |
| 458 | + .stdin(Stdio::null()) |
| 459 | + .stdout(Stdio::null()) |
| 460 | + .stderr(Stdio::null()); |
| 461 | + |
| 462 | + let mut child = cmd.spawn().expect("spawn /bin/sh"); |
| 463 | + let child_pid = child.id() as i32; |
| 464 | + |
| 465 | + let child_pgid = unsafe { libc::getpgid(child_pid) }; |
| 466 | + assert_eq!( |
| 467 | + child_pgid, child_pid, |
| 468 | + "child should lead its own process group (pgid == pid)" |
| 469 | + ); |
| 470 | + |
| 471 | + let test_pgid = unsafe { libc::getpgid(0) }; |
| 472 | + assert_ne!( |
| 473 | + test_pgid, child_pgid, |
| 474 | + "child pgid must be isolated from test process pgid" |
| 475 | + ); |
| 476 | + |
| 477 | + // Cleanup: signal the entire group, mirroring what the production |
| 478 | + // signal handler does, then reap the child. |
| 479 | + unsafe { |
| 480 | + libc::kill(-child_pid, libc::SIGTERM); |
| 481 | + } |
| 482 | + let _ = child.wait(); |
| 483 | + } |
366 | 484 | } |
0 commit comments