Skip to main content

running_process_core/
sanitized.rs

1//! `Containment::Sanitized` — spawn a child with **no orphaned inheritable
2//! handles** from the parent's table. Only the stdio handles we explicitly
3//! create (NUL on every platform) are passed into the child.
4//!
5//! Motivation: when a process tree has a pipe-redirected ancestor (e.g. a
6//! Python `subprocess.Popen(stdout=PIPE)` several levels up), every
7//! intermediate `CreateProcessW(bInheritHandles=TRUE)` on Windows — and every
8//! `fork`+`exec` of an fd without `FD_CLOEXEC` on Unix — duplicates that
9//! orphaned pipe write-end into the new child. If a daemon ends up at the
10//! bottom of that chain, the original reader at the top never sees EOF.
11//!
12//! Sanitized spawn fixes this by:
13//!
14//! * **Windows**: opening NUL three times for stdin/stdout/stderr, then
15//!   calling `CreateProcessW` with `STARTUPINFOEX` +
16//!   `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` containing **only** those three
17//!   handles. The kernel ignores the "all inheritable handles" rule and
18//!   duplicates exactly the listed handles into the child. Any orphaned
19//!   ancestor pipe stays in the parent.
20//!
21//! * **Unix**: spawning with `Stdio::null()` for the three stdio slots
22//!   (which Rust marks `O_CLOEXEC` internally), and a `pre_exec` closure
23//!   that walks `/dev/fd` (or `/proc/self/fd`) in the forked child and
24//!   closes every fd > 2 before `exec`. Equivalent to what nginx, sshd,
25//!   and other production daemons do.
26//!
27//! Issue: <https://github.com/zackees/running-process/issues/110>.
28
29use std::process::Command;
30
31/// A child spawned via `ContainedProcessGroup::spawn_sanitized`.
32///
33/// Sanitized children always have stdin/stdout/stderr connected to the
34/// platform null device — they are daemon-style processes. They are NOT
35/// assigned to a `ContainedProcessGroup` Job Object on Windows and survive
36/// the group being dropped, matching `Containment::Detached` semantics
37/// (plus the no-orphaned-handles guarantee).
38pub struct SanitizedChild {
39    pid: u32,
40    #[cfg(windows)]
41    handle: windows::OwnedHandle,
42    #[cfg(unix)]
43    child: std::process::Child,
44}
45
46impl SanitizedChild {
47    /// Process ID of the spawned child.
48    pub fn id(&self) -> u32 {
49        self.pid
50    }
51
52    /// Kill the child process. Best-effort — returns the OS error if
53    /// the underlying termination call fails.
54    pub fn kill(&mut self) -> std::io::Result<()> {
55        #[cfg(windows)]
56        {
57            windows::terminate(&self.handle)
58        }
59        #[cfg(unix)]
60        {
61            self.child.kill()
62        }
63    }
64
65    /// Block until the child exits and return its exit code (or signal
66    /// number negated on Unix when the process was terminated by signal).
67    pub fn wait(&mut self) -> std::io::Result<i32> {
68        #[cfg(windows)]
69        {
70            windows::wait(&self.handle)
71        }
72        #[cfg(unix)]
73        {
74            let status = self.child.wait()?;
75            Ok(exit_code(status))
76        }
77    }
78
79    /// Non-blocking variant of [`Self::wait`]. Returns `Ok(None)` while
80    /// the child is still running.
81    pub fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
82        #[cfg(windows)]
83        {
84            windows::try_wait(&self.handle)
85        }
86        #[cfg(unix)]
87        {
88            Ok(self.child.try_wait()?.map(exit_code))
89        }
90    }
91}
92
93#[cfg(unix)]
94fn exit_code(status: std::process::ExitStatus) -> i32 {
95    use std::os::unix::process::ExitStatusExt;
96    status
97        .code()
98        .unwrap_or_else(|| -status.signal().unwrap_or(1))
99}
100
101/// Spawn `command` with sanitized handle inheritance. See the module docs
102/// for the cross-platform behavior.
103pub fn spawn(command: &mut Command) -> std::io::Result<SanitizedChild> {
104    #[cfg(windows)]
105    {
106        windows::spawn(command)
107    }
108    #[cfg(unix)]
109    {
110        unix::spawn(command)
111    }
112}
113
114// ── Windows implementation ──────────────────────────────────────────────────
115
116#[cfg(windows)]
117mod windows {
118    use std::ffi::{OsStr, OsString};
119    use std::os::windows::ffi::OsStrExt;
120    use std::process::Command;
121
122    use winapi::shared::minwindef::{BOOL, DWORD, FALSE, TRUE};
123    use winapi::um::fileapi::{CreateFileW, OPEN_EXISTING};
124    use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE};
125    use winapi::um::minwinbase::SECURITY_ATTRIBUTES;
126    use winapi::um::processthreadsapi::{
127        CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess,
128        InitializeProcThreadAttributeList, TerminateProcess, UpdateProcThreadAttribute,
129        LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION,
130    };
131    use winapi::um::synchapi::WaitForSingleObject;
132    use winapi::um::winbase::{
133        CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, DETACHED_PROCESS,
134        EXTENDED_STARTUPINFO_PRESENT, INFINITE, STARTF_USESTDHANDLES, STARTUPINFOEXW,
135        WAIT_OBJECT_0,
136    };
137    use winapi::um::winnt::{
138        FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE, HANDLE,
139    };
140
141    // PROC_THREAD_ATTRIBUTE_HANDLE_LIST is not exported by winapi 0.3 — derive
142    // it from the `ProcThreadAttributeValue` macro in the Windows SDK headers:
143    //
144    //   #define ProcThreadAttributeHandleList 2
145    //   ProcThreadAttributeValue(N, Thread, Input, Additive) =
146    //       (N & 0x0000FFFF)
147    //     | (Thread   ? 0x00010000 : 0)
148    //     | (Input    ? 0x00020000 : 0)
149    //     | (Additive ? 0x00040000 : 0)
150    //
151    // ProcThreadAttributeHandleList = 2 with Input=TRUE → 0x00020002.
152    const PROC_THREAD_ATTRIBUTE_HANDLE_LIST: usize = 0x00020002;
153    const STILL_ACTIVE: u32 = 259;
154
155    pub struct OwnedHandle(HANDLE);
156
157    impl OwnedHandle {
158        pub fn as_raw(&self) -> HANDLE {
159            self.0
160        }
161    }
162
163    impl Drop for OwnedHandle {
164        fn drop(&mut self) {
165            if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE {
166                unsafe {
167                    CloseHandle(self.0);
168                }
169            }
170        }
171    }
172
173    // HANDLE is *mut c_void; OwnedHandle is the sole owner so sharing it is
174    // safe.  We hand the raw pointer to Windows APIs only via &OwnedHandle.
175    unsafe impl Send for OwnedHandle {}
176    unsafe impl Sync for OwnedHandle {}
177
178    fn open_nul(write: bool) -> std::io::Result<OwnedHandle> {
179        let path: Vec<u16> = OsStr::new("NUL")
180            .encode_wide()
181            .chain(std::iter::once(0))
182            .collect();
183        let mut sa: SECURITY_ATTRIBUTES = unsafe { std::mem::zeroed() };
184        sa.nLength = std::mem::size_of::<SECURITY_ATTRIBUTES>() as DWORD;
185        sa.bInheritHandle = TRUE as BOOL;
186        let access = if write { GENERIC_WRITE } else { GENERIC_READ };
187        let h = unsafe {
188            CreateFileW(
189                path.as_ptr(),
190                access,
191                FILE_SHARE_READ | FILE_SHARE_WRITE,
192                &mut sa as *mut SECURITY_ATTRIBUTES,
193                OPEN_EXISTING,
194                0,
195                std::ptr::null_mut(),
196            )
197        };
198        if h.is_null() || h == INVALID_HANDLE_VALUE {
199            return Err(std::io::Error::last_os_error());
200        }
201        Ok(OwnedHandle(h))
202    }
203
204    pub fn spawn(command: &mut Command) -> std::io::Result<super::SanitizedChild> {
205        // 1. Open NUL three times — fresh inheritable handles for the
206        //    child's stdio slots.  These are the ONLY handles that should
207        //    be passed through.
208        let stdin = open_nul(false)?;
209        let stdout = open_nul(true)?;
210        let stderr = open_nul(true)?;
211
212        // 2. Build the command line and (optional) env block.
213        let mut cmdline = build_command_line(command.get_program(), command.get_args());
214
215        let envs: Vec<(OsString, Option<OsString>)> = command
216            .get_envs()
217            .map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
218            .collect();
219        let env_block = if envs.is_empty() {
220            None
221        } else {
222            Some(build_env_block(envs))
223        };
224
225        let cwd_w: Option<Vec<u16>> = command.get_current_dir().map(|p| {
226            OsStr::new(p)
227                .encode_wide()
228                .chain(std::iter::once(0))
229                .collect()
230        });
231
232        // 3. Initialize the proc-thread attribute list.
233        let mut size: usize = 0;
234        unsafe {
235            // First call: query required size.
236            InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size);
237        }
238        // Must use vec<u8> with the queried size — the struct is opaque.
239        let mut attr_buf: Vec<u8> = vec![0; size];
240        let attr_list = attr_buf.as_mut_ptr() as LPPROC_THREAD_ATTRIBUTE_LIST;
241
242        let ok = unsafe { InitializeProcThreadAttributeList(attr_list, 1, 0, &mut size) };
243        if ok == FALSE {
244            return Err(std::io::Error::last_os_error());
245        }
246
247        let handle_list: [HANDLE; 3] = [stdin.as_raw(), stdout.as_raw(), stderr.as_raw()];
248        let ok = unsafe {
249            UpdateProcThreadAttribute(
250                attr_list,
251                0,
252                PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
253                handle_list.as_ptr() as *mut _,
254                std::mem::size_of::<[HANDLE; 3]>(),
255                std::ptr::null_mut(),
256                std::ptr::null_mut(),
257            )
258        };
259        if ok == FALSE {
260            let err = std::io::Error::last_os_error();
261            unsafe {
262                DeleteProcThreadAttributeList(attr_list);
263            }
264            return Err(err);
265        }
266
267        // 4. Set up STARTUPINFOEX.  The child's stdio slots are the three
268        //    NUL handles.
269        let mut si: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
270        si.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as DWORD;
271        si.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
272        si.StartupInfo.hStdInput = stdin.as_raw();
273        si.StartupInfo.hStdOutput = stdout.as_raw();
274        si.StartupInfo.hStdError = stderr.as_raw();
275        si.lpAttributeList = attr_list;
276
277        let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
278
279        let mut flags: DWORD = EXTENDED_STARTUPINFO_PRESENT
280            | DETACHED_PROCESS
281            | CREATE_NEW_PROCESS_GROUP
282            | CREATE_NO_WINDOW;
283        if env_block.is_some() {
284            flags |= CREATE_UNICODE_ENVIRONMENT;
285        }
286
287        let cwd_ptr = cwd_w
288            .as_ref()
289            .map(|v| v.as_ptr())
290            .unwrap_or(std::ptr::null());
291        let env_ptr = env_block
292            .as_ref()
293            .map(|v| v.as_ptr() as *mut std::ffi::c_void)
294            .unwrap_or(std::ptr::null_mut());
295
296        let ok = unsafe {
297            CreateProcessW(
298                std::ptr::null(),
299                cmdline.as_mut_ptr(),
300                std::ptr::null_mut(),
301                std::ptr::null_mut(),
302                TRUE as BOOL, // bInheritHandles
303                flags,
304                env_ptr,
305                cwd_ptr,
306                &mut si.StartupInfo,
307                &mut pi,
308            )
309        };
310        let err = if ok == FALSE {
311            Some(std::io::Error::last_os_error())
312        } else {
313            None
314        };
315        unsafe {
316            DeleteProcThreadAttributeList(attr_list);
317        }
318        if let Some(err) = err {
319            return Err(err);
320        }
321
322        // We don't need the thread handle.
323        unsafe {
324            CloseHandle(pi.hThread);
325        }
326
327        Ok(super::SanitizedChild {
328            pid: pi.dwProcessId,
329            handle: OwnedHandle(pi.hProcess),
330        })
331    }
332
333    pub fn terminate(handle: &OwnedHandle) -> std::io::Result<()> {
334        let ok = unsafe { TerminateProcess(handle.as_raw(), 1) };
335        if ok == FALSE {
336            return Err(std::io::Error::last_os_error());
337        }
338        Ok(())
339    }
340
341    pub fn wait(handle: &OwnedHandle) -> std::io::Result<i32> {
342        let rc = unsafe { WaitForSingleObject(handle.as_raw(), INFINITE) };
343        if rc != WAIT_OBJECT_0 {
344            return Err(std::io::Error::last_os_error());
345        }
346        let mut code: DWORD = 0;
347        let ok = unsafe { GetExitCodeProcess(handle.as_raw(), &mut code as *mut DWORD) };
348        if ok == FALSE {
349            return Err(std::io::Error::last_os_error());
350        }
351        Ok(code as i32)
352    }
353
354    pub fn try_wait(handle: &OwnedHandle) -> std::io::Result<Option<i32>> {
355        let mut code: DWORD = 0;
356        let ok = unsafe { GetExitCodeProcess(handle.as_raw(), &mut code as *mut DWORD) };
357        if ok == FALSE {
358            return Err(std::io::Error::last_os_error());
359        }
360        if code == STILL_ACTIVE {
361            Ok(None)
362        } else {
363            Ok(Some(code as i32))
364        }
365    }
366
367    fn build_command_line<'a>(program: &OsStr, args: impl Iterator<Item = &'a OsStr>) -> Vec<u16> {
368        let mut s = String::new();
369        s.push_str(&quote(&program.to_string_lossy()));
370        for a in args {
371            s.push(' ');
372            s.push_str(&quote(&a.to_string_lossy()));
373        }
374        OsStr::new(&s)
375            .encode_wide()
376            .chain(std::iter::once(0))
377            .collect()
378    }
379
380    /// MSVCRT argv-parsing rules for quoting a single argument.
381    fn quote(arg: &str) -> String {
382        if !arg.is_empty()
383            && !arg
384                .chars()
385                .any(|c| matches!(c, ' ' | '\t' | '\n' | '\x0b' | '"'))
386        {
387            return arg.to_string();
388        }
389        let mut out = String::from("\"");
390        let chars: Vec<char> = arg.chars().collect();
391        let mut i = 0;
392        while i < chars.len() {
393            let mut nbs = 0;
394            while i < chars.len() && chars[i] == '\\' {
395                nbs += 1;
396                i += 1;
397            }
398            if i == chars.len() {
399                for _ in 0..(nbs * 2) {
400                    out.push('\\');
401                }
402                break;
403            } else if chars[i] == '"' {
404                for _ in 0..(nbs * 2 + 1) {
405                    out.push('\\');
406                }
407                out.push('"');
408            } else {
409                for _ in 0..nbs {
410                    out.push('\\');
411                }
412                out.push(chars[i]);
413            }
414            i += 1;
415        }
416        out.push('"');
417        out
418    }
419
420    fn build_env_block(overrides: Vec<(OsString, Option<OsString>)>) -> Vec<u16> {
421        use std::collections::BTreeMap;
422        // Start from parent env, apply overrides.  Windows env-block keys
423        // are case-insensitive but we preserve original case from the parent.
424        let mut env: BTreeMap<OsString, OsString> = BTreeMap::new();
425        for (k, v) in std::env::vars_os() {
426            env.insert(k, v);
427        }
428        for (k, v) in overrides {
429            match v {
430                Some(val) => {
431                    env.insert(k, val);
432                }
433                None => {
434                    env.remove(&k);
435                }
436            }
437        }
438        let mut block: Vec<u16> = Vec::new();
439        for (k, v) in env {
440            block.extend(k.encode_wide());
441            block.push(b'=' as u16);
442            block.extend(v.encode_wide());
443            block.push(0);
444        }
445        // Double-null terminator.
446        block.push(0);
447        block
448    }
449}
450
451// ── Unix implementation ─────────────────────────────────────────────────────
452
453#[cfg(unix)]
454mod unix {
455    use std::process::Command;
456
457    pub fn spawn(command: &mut Command) -> std::io::Result<super::SanitizedChild> {
458        use std::os::unix::process::CommandExt;
459        use std::process::Stdio;
460
461        // Always run as a daemon — fully detached, no controlling tty.
462        command
463            .stdin(Stdio::null())
464            .stdout(Stdio::null())
465            .stderr(Stdio::null());
466
467        unsafe {
468            command.pre_exec(|| {
469                // Detach from controlling tty / process group.
470                if libc::setsid() == -1 {
471                    // setsid fails when we're already a session leader — not
472                    // fatal for our purposes.  Keep going.
473                }
474                close_extra_fds();
475                Ok(())
476            });
477        }
478
479        let child = command.spawn()?;
480        let pid = child.id();
481        Ok(super::SanitizedChild { pid, child })
482    }
483
484    /// Close every open file descriptor > 2 in the calling process.
485    ///
486    /// Called from the forked child between `fork` and `exec`, which means:
487    ///   * We MUST be async-signal-safe.  No allocator calls, no Rust I/O.
488    ///   * We can call `close`, `open`, `readdir`, `getdents64` etc.
489    ///
490    /// Strategy:
491    ///   1. Try `close_range(3, ~0, 0)` on Linux 5.9+ via direct syscall.
492    ///   2. Fall back to walking `/proc/self/fd` (Linux) or `/dev/fd`
493    ///      (BSD/macOS).
494    ///   3. Final fallback: sysconf loop up to `_SC_OPEN_MAX`.
495    unsafe fn close_extra_fds() {
496        // 1. Try close_range syscall on Linux.
497        #[cfg(target_os = "linux")]
498        {
499            // SYS_close_range = 436 on x86_64/aarch64/most arches.
500            #[cfg(any(
501                target_arch = "x86_64",
502                target_arch = "aarch64",
503                target_arch = "x86",
504                target_arch = "arm",
505                target_arch = "riscv64",
506                target_arch = "powerpc64",
507            ))]
508            {
509                const SYS_CLOSE_RANGE: libc::c_long = 436;
510                let rc = libc::syscall(SYS_CLOSE_RANGE, 3u32, libc::c_uint::MAX, 0u32);
511                if rc == 0 {
512                    return;
513                }
514            }
515        }
516
517        // 2. Walk /dev/fd (works on Linux via /proc symlink and on macOS / BSD).
518        let dir = libc::opendir(c"/dev/fd".as_ptr());
519        if !dir.is_null() {
520            let dir_fd = libc::dirfd(dir);
521            loop {
522                let ent = libc::readdir(dir);
523                if ent.is_null() {
524                    break;
525                }
526                let name_ptr = (*ent).d_name.as_ptr();
527                let mut fd: libc::c_int = 0;
528                let mut p = name_ptr;
529                let mut ok = false;
530                while *p != 0 {
531                    let c = *p as u8;
532                    if !c.is_ascii_digit() {
533                        ok = false;
534                        break;
535                    }
536                    fd = fd * 10 + (c - b'0') as libc::c_int;
537                    p = p.add(1);
538                    ok = true;
539                }
540                if !ok {
541                    continue;
542                }
543                if fd > 2 && fd != dir_fd {
544                    libc::close(fd);
545                }
546            }
547            libc::closedir(dir);
548            return;
549        }
550
551        // 3. Last-resort sysconf loop.
552        let max = libc::sysconf(libc::_SC_OPEN_MAX);
553        let max = if max < 0 { 4096 } else { max as libc::c_int };
554        for fd in 3..max {
555            libc::close(fd);
556        }
557    }
558}
559
560#[cfg(all(test, unix))]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn sanitized_child_unix_holds_child() {
566        // Smoke: just ensure the type wires up.
567        let _ = std::mem::size_of::<SanitizedChild>();
568    }
569}