Skip to main content

winx_code_agent/
os.rs

1//! Narrow operating-system boundary for audited libc and Win32 process calls.
2
3#![allow(unsafe_code)]
4
5#[cfg(target_os = "linux")]
6pub(crate) mod temporary {
7    //! Directory-relative Linux claims for explicitly released managed helpers.
8    #![allow(unsafe_code)]
9
10    use std::ffi::{CString, OsStr};
11    use std::fs::File;
12    use std::io;
13    use std::os::fd::{AsRawFd, FromRawFd};
14    use std::os::unix::ffi::OsStrExt as _;
15    use std::path::{Component, Path};
16
17    fn name(value: &OsStr) -> io::Result<CString> {
18        CString::new(value.as_bytes())
19            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "NUL in path"))
20    }
21
22    fn open_at(parent: &File, leaf: &CString, flags: i32) -> io::Result<File> {
23        // SAFETY: parent and the NUL-terminated name remain alive for this call.
24        let fd = unsafe {
25            libc::openat(
26                parent.as_raw_fd(),
27                leaf.as_ptr(),
28                flags | libc::O_CLOEXEC | libc::O_NOFOLLOW,
29            )
30        };
31        if fd < 0 {
32            return Err(io::Error::last_os_error());
33        }
34        // SAFETY: openat returned a newly owned descriptor.
35        Ok(unsafe { File::from_raw_fd(fd) })
36    }
37
38    pub(crate) fn directory(path: &Path) -> io::Result<File> {
39        if !path.is_absolute() {
40            return Err(io::Error::new(io::ErrorKind::InvalidInput, "absolute directory required"));
41        }
42        let mut dir = File::open("/")?;
43        for component in path.components() {
44            match component {
45                Component::RootDir => {}
46                Component::Normal(part) => {
47                    dir = open_at(&dir, &name(part)?, libc::O_RDONLY | libc::O_DIRECTORY)?;
48                }
49                _ => {
50                    return Err(io::Error::new(
51                        io::ErrorKind::InvalidInput,
52                        "noncanonical directory component",
53                    ))
54                }
55            }
56        }
57        Ok(dir)
58    }
59
60    /// Enumerate a pinned directory through its kernel descriptor alias. The
61    /// caller keeps the descriptor alive for the iterator's complete lifetime.
62    pub(crate) fn directory_entries(directory: &File) -> io::Result<std::fs::ReadDir> {
63        std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))
64    }
65
66    pub(crate) fn directory_child(directory: &File, child: &OsStr) -> io::Result<File> {
67        open_at(directory, &name(child)?, libc::O_RDONLY | libc::O_DIRECTORY)
68    }
69
70    pub(crate) struct Opened {
71        pub(crate) file: File,
72        parent: File,
73        leaf: CString,
74    }
75
76    pub(crate) fn open(path: &Path) -> io::Result<Opened> {
77        let parent = directory(
78            path.parent()
79                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "parent required"))?,
80        )?;
81        let leaf =
82            name(path.file_name().ok_or_else(|| {
83                io::Error::new(io::ErrorKind::InvalidInput, "filename required")
84            })?)?;
85        let file = open_at(&parent, &leaf, libc::O_RDONLY | libc::O_NONBLOCK)?;
86        if !file.metadata()?.is_file() {
87            return Err(io::Error::new(io::ErrorKind::InvalidInput, "regular file required"));
88        }
89        Ok(Opened { file, parent, leaf })
90    }
91
92    fn rename_noreplace(from: &File, old: &CString, to: &File, new: &CString) -> io::Result<()> {
93        // SAFETY: borrowed descriptors and CString pointers remain valid. RENAME_NOREPLACE
94        // never overwrites a concurrently installed destination.
95        if unsafe {
96            libc::renameat2(
97                from.as_raw_fd(),
98                old.as_ptr(),
99                to.as_raw_fd(),
100                new.as_ptr(),
101                libc::RENAME_NOREPLACE,
102            )
103        } == 0
104        {
105            Ok(())
106        } else {
107            Err(io::Error::last_os_error())
108        }
109    }
110
111    fn unlink(parent: &File, leaf: &CString, flags: i32) -> io::Result<()> {
112        // SAFETY: directory descriptor and NUL-terminated name are valid for this call.
113        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), flags) } == 0 {
114            Ok(())
115        } else {
116            Err(io::Error::last_os_error())
117        }
118    }
119
120    pub(crate) struct Claim {
121        parent: File,
122        original: CString,
123        quarantine: File,
124        quarantine_name: CString,
125        captured_name: CString,
126        captured: bool,
127    }
128
129    impl Opened {
130        pub(crate) fn claim(&self) -> io::Result<Claim> {
131            let parent = self.parent.try_clone()?;
132            let quarantine_name =
133                CString::new(format!(".winx-release-{:032x}", rand::random::<u128>()))
134                    .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
135            // SAFETY: parent and the generated single component are valid; a collision
136            // fails without opening or modifying the pre-existing directory.
137            if unsafe { libc::mkdirat(self.parent.as_raw_fd(), quarantine_name.as_ptr(), 0o700) }
138                != 0
139            {
140                return Err(io::Error::last_os_error());
141            }
142            let quarantine =
143                match open_at(&self.parent, &quarantine_name, libc::O_RDONLY | libc::O_DIRECTORY) {
144                    Ok(directory) => directory,
145                    Err(error) => {
146                        let _ = unlink(&self.parent, &quarantine_name, libc::AT_REMOVEDIR);
147                        return Err(error);
148                    }
149                };
150            let mut claim = Claim {
151                parent,
152                original: self.leaf.clone(),
153                quarantine,
154                quarantine_name,
155                captured_name: c"artifact".to_owned(),
156                captured: false,
157            };
158            rename_noreplace(&self.parent, &self.leaf, &claim.quarantine, &claim.captured_name)?;
159            claim.captured = true;
160            Ok(claim)
161        }
162    }
163
164    impl Claim {
165        pub(crate) fn open_captured(&self) -> io::Result<File> {
166            open_at(&self.quarantine, &self.captured_name, libc::O_RDONLY | libc::O_NONBLOCK)
167        }
168
169        pub(crate) fn recovery_name(&self) -> String {
170            format!("{}/artifact", self.quarantine_name.to_string_lossy())
171        }
172
173        pub(crate) fn remove(mut self) -> io::Result<()> {
174            unlink(&self.quarantine, &self.captured_name, 0)?;
175            self.captured = false;
176            Ok(())
177        }
178    }
179
180    impl Drop for Claim {
181        fn drop(&mut self) {
182            if self.captured {
183                // A failed recheck preserves the captured object. Restore only if
184                // its original name is vacant; otherwise retain it in quarantine.
185                if rename_noreplace(
186                    &self.quarantine,
187                    &self.captured_name,
188                    &self.parent,
189                    &self.original,
190                )
191                .is_err()
192                {
193                    return;
194                }
195            }
196            let _ = unlink(&self.parent, &self.quarantine_name, libc::AT_REMOVEDIR);
197        }
198    }
199
200    #[cfg(test)]
201    mod tests {
202        use super::*;
203        #[test]
204        fn restoration_never_overwrites_a_concurrent_original_name() -> io::Result<()> {
205            let directory = tempfile::tempdir()?;
206            let root = directory.path().canonicalize()?;
207            let path = root.join("helper");
208            std::fs::write(&path, "captured")?;
209            let opened = open(&path)?;
210            let claim = opened.claim()?;
211            let recovery = root.join(claim.recovery_name());
212            std::fs::write(&path, "replacement")?;
213            drop(claim);
214            assert_eq!(std::fs::read_to_string(&path)?, "replacement");
215            assert_eq!(std::fs::read_to_string(recovery)?, "captured");
216            Ok(())
217        }
218    }
219}
220
221#[cfg(unix)]
222pub(crate) mod unix {
223    use std::io;
224
225    pub(crate) fn effective_uid() -> u32 {
226        // SAFETY: geteuid has no preconditions and reads process credentials only.
227        unsafe { libc::geteuid() }
228    }
229
230    /// Take an exclusive, non-blocking advisory lock on `file`. Returns
231    /// `Ok(false)` when another process holds it. The lock lives as long as
232    /// the file descriptor, so callers keep the `File` open to stay locked.
233    pub(crate) fn try_lock_exclusive(file: &std::fs::File) -> io::Result<bool> {
234        use std::os::unix::io::AsRawFd as _;
235        // SAFETY: the descriptor belongs to a live `File` borrowed for the call.
236        if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
237            return Ok(true);
238        }
239        let error = io::Error::last_os_error();
240        if error.kind() == io::ErrorKind::WouldBlock {
241            return Ok(false);
242        }
243        Err(error)
244    }
245
246    /// Return whether a positive process ID still names a live process. EPERM
247    /// means the process exists but is not signalable by this caller.
248    pub(crate) fn process_exists(pid: u32) -> bool {
249        let Ok(pid) = i32::try_from(pid) else { return false };
250        if pid <= 1 {
251            return false;
252        }
253        // SAFETY: signal 0 performs permission/existence checking only and uses
254        // no pointers.
255        if unsafe { libc::kill(pid, 0) } == 0 {
256            return true;
257        }
258        io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
259    }
260
261    /// Signal one process. A process that has already exited is treated as a
262    /// successful cleanup operation.
263    pub(crate) fn signal_process(pid: u32, signal: i32) -> io::Result<()> {
264        let pid = i32::try_from(pid)
265            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "pid does not fit pid_t"))?;
266        signal_raw(pid, signal)
267    }
268
269    pub(crate) fn signal_raw(pid: i32, signal: i32) -> io::Result<()> {
270        // SAFETY: kill(2) takes integer process/signal identifiers and no pointers.
271        if unsafe { libc::kill(pid, signal) } == 0 {
272            return Ok(());
273        }
274        let error = io::Error::last_os_error();
275        if error.raw_os_error() == Some(libc::ESRCH) {
276            Ok(())
277        } else {
278            Err(error)
279        }
280    }
281
282    /// Resolve a process group only when the child is its own group leader.
283    pub(crate) fn owned_process_group(pid: u32) -> Option<i32> {
284        let pid = i32::try_from(pid).ok()?;
285        if pid <= 1 {
286            return None;
287        }
288        // SAFETY: getpgid reads kernel metadata for an integer pid and returns -1
289        // on error; no memory is accessed through pointers.
290        let group = unsafe { libc::getpgid(pid) };
291        (group == pid).then_some(group)
292    }
293
294    pub(crate) fn signal_group(group: i32, signal: i32) -> io::Result<()> {
295        if group <= 1 {
296            return Err(io::Error::new(
297                io::ErrorKind::InvalidInput,
298                "process group must be greater than 1",
299            ));
300        }
301        signal_raw(-group, signal)
302    }
303
304    /// Configure a child command to become its own session immediately after
305    /// fork and before exec.
306    pub(crate) fn configure_detached(command: &mut std::process::Command) {
307        use std::os::unix::process::CommandExt as _;
308        // SAFETY: the hook invokes only the async-signal-safe `create_session`
309        // helper and touches no allocator-backed state between fork and exec.
310        unsafe {
311            command.pre_exec(create_session);
312        }
313    }
314
315    /// Async-signal-safe child setup hook used immediately after fork and before
316    /// exec by `Command::pre_exec`.
317    fn create_session() -> io::Result<()> {
318        // SAFETY: setsid has no pointer arguments and is async-signal-safe.
319        if unsafe { libc::setsid() } == -1 {
320            Err(io::Error::last_os_error())
321        } else {
322            Ok(())
323        }
324    }
325
326    #[cfg(test)]
327    mod tests {
328        #[test]
329        fn current_process_exists_and_reserved_ids_are_rejected() {
330            assert!(super::process_exists(std::process::id()));
331            assert!(!super::process_exists(0));
332            assert!(!super::process_exists(1));
333        }
334    }
335}
336
337/// Whether a process with `pid` is alive, on every supported platform.
338/// Process-wide hardening applied at the top of every binary's `main`.
339/// On Windows it narrows the DLL search path (see
340/// `windows::restrict_dll_search_path`); Unix has nothing equivalent to do.
341pub fn harden_process() {
342    #[cfg(windows)]
343    windows::restrict_dll_search_path();
344}
345
346pub(crate) fn process_exists(pid: u32) -> bool {
347    #[cfg(unix)]
348    {
349        unix::process_exists(pid)
350    }
351    #[cfg(windows)]
352    {
353        windows::process_exists(pid)
354    }
355    #[cfg(not(any(unix, windows)))]
356    {
357        let _ = pid;
358        false
359    }
360}
361
362#[cfg(windows)]
363pub(crate) mod windows {
364    use std::ffi::c_void;
365    use std::io;
366
367    use windows_sys::Win32::Foundation::{
368        CloseHandle, LocalFree, SetHandleInformation, ERROR_ACCESS_DENIED, HANDLE,
369        HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, STILL_ACTIVE,
370    };
371    use windows_sys::Win32::Security::Authorization::{
372        ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
373        SDDL_REVISION_1,
374    };
375    use windows_sys::Win32::Security::{
376        GetTokenInformation, TokenElevation, TokenUser, SECURITY_ATTRIBUTES, TOKEN_ELEVATION,
377        TOKEN_QUERY, TOKEN_USER,
378    };
379    use windows_sys::Win32::System::Console::{
380        GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
381    };
382    use windows_sys::Win32::System::JobObjects::{
383        AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
384        SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
385        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
386    };
387    use windows_sys::Win32::System::LibraryLoader::{
388        SetDefaultDllDirectories, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS,
389    };
390    use windows_sys::Win32::System::Threading::{
391        GetCurrentProcess, GetExitCodeProcess, OpenProcess, OpenProcessToken, TerminateProcess,
392        CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, DETACHED_PROCESS,
393        PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
394    };
395
396    /// Kernel handle closed on drop.
397    struct OwnedHandle(HANDLE);
398
399    // SAFETY: a Win32 handle is a process-wide token, not thread-affine memory;
400    // it may be used and closed from any thread.
401    unsafe impl Send for OwnedHandle {}
402    // SAFETY: the job/process calls made through the handle are thread-safe.
403    unsafe impl Sync for OwnedHandle {}
404
405    impl Drop for OwnedHandle {
406        fn drop(&mut self) {
407            // SAFETY: the handle was returned by a successful Win32 call and is
408            // closed exactly once here.
409            unsafe {
410                CloseHandle(self.0);
411            }
412        }
413    }
414
415    fn open_process(pid: u32, access: u32) -> io::Result<OwnedHandle> {
416        // SAFETY: OpenProcess takes integer arguments only and returns a null
417        // handle on failure, which is checked before use.
418        let handle = unsafe { OpenProcess(access, 0, pid) };
419        if handle.is_null() {
420            Err(io::Error::last_os_error())
421        } else {
422            Ok(OwnedHandle(handle))
423        }
424    }
425
426    /// Return whether `pid` names a live process. Access denied means the
427    /// process exists but belongs to another user, matching the Unix `EPERM`
428    /// rule.
429    pub(crate) fn process_exists(pid: u32) -> bool {
430        if pid == 0 {
431            return false;
432        }
433        match open_process(pid, PROCESS_QUERY_LIMITED_INFORMATION) {
434            Ok(handle) => {
435                let mut code = 0u32;
436                // SAFETY: `code` outlives the call and the handle is valid.
437                let queried =
438                    unsafe { GetExitCodeProcess(handle.0, std::ptr::from_mut(&mut code)) } != 0;
439                queried && code == STILL_ACTIVE as u32
440            }
441            Err(error) => error.raw_os_error() == Some(ERROR_ACCESS_DENIED.cast_signed()),
442        }
443    }
444
445    /// Forcefully end one process. A process that already exited counts as
446    /// success, like the Unix `ESRCH` rule.
447    pub(crate) fn terminate_process(pid: u32) -> io::Result<()> {
448        let handle = match open_process(pid, PROCESS_TERMINATE) {
449            Ok(handle) => handle,
450            Err(error) if !process_exists(pid) => {
451                let _ = error;
452                return Ok(());
453            }
454            Err(error) => return Err(error),
455        };
456        // SAFETY: valid handle with PROCESS_TERMINATE; integer exit code.
457        if unsafe { TerminateProcess(handle.0, 1) } == 0 {
458            let error = io::Error::last_os_error();
459            if process_exists(pid) {
460                return Err(error);
461            }
462        }
463        Ok(())
464    }
465
466    /// Restrict implicit DLL loading to the application directory and
467    /// System32.
468    ///
469    /// `portable-pty` probes a bare `conpty.dll` through the default search
470    /// order, which includes the current directory and every `PATH` entry, so
471    /// a planted DLL would be loaded into the process that owns the shells.
472    /// After this call, a bare name resolves only beside the executable or in
473    /// System32 and otherwise falls back to the `ConPTY` exports already in
474    /// `kernel32.dll`. Failure is logged and ignored: the process still works,
475    /// just without the narrower search path.
476    pub(crate) fn restrict_dll_search_path() {
477        // SAFETY: the flag is a documented constant; the call has no pointer
478        // arguments and only changes this process's loader policy.
479        if unsafe { SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) } == 0 {
480            tracing::warn!(
481                error = %io::Error::last_os_error(),
482                "could not restrict the DLL search path"
483            );
484        }
485    }
486
487    /// Owner-only security descriptor for the daemon named pipes.
488    ///
489    /// The default pipe DACL lets other local users open the pipe read-only
490    /// and lets lower-integrity processes of the same user connect, which
491    /// would hand a sandboxed process a shell. This descriptor grants full
492    /// access to SYSTEM and read/write to the current account SID only, and a
493    /// mandatory integrity label refuses readers and writers below the
494    /// daemon's own level (`High` when the daemon runs elevated, otherwise
495    /// `Medium`), so an unelevated client cannot drive an elevated daemon.
496    pub(crate) struct PipeSecurity {
497        descriptor: *mut c_void,
498        attributes: SECURITY_ATTRIBUTES,
499    }
500
501    // SAFETY: the descriptor is a LocalAlloc block that is only read after
502    // construction; nothing about it is thread-affine.
503    unsafe impl Send for PipeSecurity {}
504    // SAFETY: see the `Send` impl; shared references only read the block.
505    unsafe impl Sync for PipeSecurity {}
506
507    impl Drop for PipeSecurity {
508        fn drop(&mut self) {
509            // SAFETY: the pointer came from
510            // ConvertStringSecurityDescriptorToSecurityDescriptorW, whose
511            // contract is a single LocalFree.
512            unsafe {
513                LocalFree(self.descriptor);
514            }
515        }
516    }
517
518    impl PipeSecurity {
519        pub(crate) fn owner_only() -> io::Result<Self> {
520            let sddl = owner_only_sddl()?;
521            let descriptor = parse_sddl(&sddl)?;
522            let attributes = SECURITY_ATTRIBUTES {
523                nLength: u32::try_from(std::mem::size_of::<SECURITY_ATTRIBUTES>())
524                    .map_err(|_| io::Error::other("security attributes too large"))?,
525                lpSecurityDescriptor: descriptor,
526                bInheritHandle: 0,
527            };
528            Ok(Self { descriptor, attributes })
529        }
530
531        /// Create one server instance of `name` carrying this descriptor.
532        pub(crate) fn create_pipe_instance(
533            &self,
534            options: &tokio::net::windows::named_pipe::ServerOptions,
535            name: &str,
536        ) -> io::Result<tokio::net::windows::named_pipe::NamedPipeServer> {
537            let attributes: *const SECURITY_ATTRIBUTES = &raw const self.attributes;
538            // SAFETY: the attributes block and its descriptor outlive the
539            // call, and the descriptor was produced by the SDDL parser.
540            unsafe {
541                options.create_with_security_attributes_raw(name, attributes.cast_mut().cast())
542            }
543        }
544    }
545
546    /// Build the SDDL string for [`PipeSecurity::owner_only`].
547    fn owner_only_sddl() -> io::Result<String> {
548        let token = current_process_token()?;
549        let sid = token_user_sid(&token)?;
550        let integrity = if token_is_elevated(&token)? { "HI" } else { "ME" };
551        Ok(format!("D:P(A;;GA;;;SY)(A;;GRGW;;;{sid})S:(ML;;NRNW;;;{integrity})"))
552    }
553
554    fn current_process_token() -> io::Result<OwnedHandle> {
555        let mut token: HANDLE = std::ptr::null_mut();
556        // SAFETY: the pseudo handle from GetCurrentProcess is always valid and
557        // `token` is a live out-pointer; a zero return is checked.
558        if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) } == 0 {
559            return Err(io::Error::last_os_error());
560        }
561        Ok(OwnedHandle(token))
562    }
563
564    fn token_user_sid(token: &OwnedHandle) -> io::Result<String> {
565        let mut needed = 0u32;
566        // SAFETY: a null buffer with zero length is the documented way to ask
567        // for the required size; the result is deliberately ignored.
568        unsafe {
569            GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &raw mut needed);
570        }
571        if needed == 0 {
572            return Err(io::Error::last_os_error());
573        }
574        // `usize` storage keeps TOKEN_USER aligned and its trailing SID alive.
575        let mut buffer = vec![0usize; (needed as usize).div_ceil(std::mem::size_of::<usize>())];
576        // SAFETY: the buffer is at least `needed` bytes and suitably aligned.
577        if unsafe {
578            GetTokenInformation(
579                token.0,
580                TokenUser,
581                buffer.as_mut_ptr().cast(),
582                needed,
583                &raw mut needed,
584            )
585        } == 0
586        {
587            return Err(io::Error::last_os_error());
588        }
589        // SAFETY: GetTokenInformation initialized a TOKEN_USER at the start of
590        // the buffer, and the SID it points to lives inside the same buffer.
591        let user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
592        let mut sid_text: *mut u16 = std::ptr::null_mut();
593        // SAFETY: the SID pointer is valid while `buffer` lives; the out
594        // pointer receives a LocalAlloc string freed below.
595        if unsafe { ConvertSidToStringSidW(user.User.Sid, &raw mut sid_text) } == 0 {
596            return Err(io::Error::last_os_error());
597        }
598        let sid = wide_to_string(sid_text);
599        // SAFETY: ConvertSidToStringSidW documents a single LocalFree.
600        unsafe {
601            LocalFree(sid_text.cast());
602        }
603        Ok(sid)
604    }
605
606    fn token_is_elevated(token: &OwnedHandle) -> io::Result<bool> {
607        let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 };
608        let mut needed = 0u32;
609        let size = u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>())
610            .map_err(|_| io::Error::other("token elevation block too large"))?;
611        // SAFETY: the out-pointer references a live, correctly sized struct.
612        if unsafe {
613            GetTokenInformation(
614                token.0,
615                TokenElevation,
616                (&raw mut elevation).cast(),
617                size,
618                &raw mut needed,
619            )
620        } == 0
621        {
622            return Err(io::Error::last_os_error());
623        }
624        Ok(elevation.TokenIsElevated != 0)
625    }
626
627    /// Parse an SDDL string into a self-relative security descriptor that the
628    /// caller must release with `LocalFree`.
629    fn parse_sddl(sddl: &str) -> io::Result<*mut c_void> {
630        let wide: Vec<u16> = sddl.encode_utf16().chain(std::iter::once(0)).collect();
631        let mut descriptor: *mut c_void = std::ptr::null_mut();
632        // SAFETY: `wide` is NUL-terminated and outlives the call; the out
633        // pointer receives a LocalAlloc block; a zero return is checked.
634        if unsafe {
635            ConvertStringSecurityDescriptorToSecurityDescriptorW(
636                wide.as_ptr(),
637                SDDL_REVISION_1,
638                &raw mut descriptor,
639                std::ptr::null_mut(),
640            )
641        } == 0
642        {
643            return Err(io::Error::last_os_error());
644        }
645        Ok(descriptor)
646    }
647
648    fn wide_to_string(text: *const u16) -> String {
649        let mut len = 0usize;
650        // SAFETY: the string is NUL-terminated by its producer; the loop stops
651        // at the terminator without reading past it.
652        while unsafe { *text.add(len) } != 0 {
653            len += 1;
654        }
655        // SAFETY: `len` characters were just read successfully.
656        String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, len) })
657    }
658
659    /// Stop this process's standard handles from being inherited by children.
660    ///
661    /// `std::process::Command` calls `CreateProcessW` with `bInheritHandles`
662    /// set, so every inheritable handle leaks into the child even when its own
663    /// stdio is `Stdio::null()`. A detached daemon that inherits the pipes an
664    /// MCP client or a shell gave this process keeps their write ends open,
665    /// and the parent waiting for EOF hangs until the daemon exits. Children
666    /// that should inherit stdio still do: `Stdio::inherit` re-duplicates the
667    /// handle as inheritable at spawn time.
668    pub(crate) fn disable_std_handle_inheritance() {
669        for id in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
670            // SAFETY: GetStdHandle takes a constant and returns a handle or a
671            // sentinel; the sentinels are filtered before the handle is used.
672            let handle = unsafe { GetStdHandle(id) };
673            if handle.is_null() || handle == INVALID_HANDLE_VALUE {
674                continue;
675            }
676            // SAFETY: the handle is one this process owns; clearing a flag on
677            // it is not destructive, and a failure is deliberately ignored
678            // (a console handle without the flag reports an error).
679            unsafe {
680                SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0);
681            }
682        }
683    }
684
685    /// Detach a child from this console, process group, and, when the job
686    /// permits it, from the job object that may kill this process's children
687    /// (MCP clients commonly wrap servers in such a job). Also stops the
688    /// child from inheriting this process's stdio pipes.
689    pub(crate) fn configure_detached(command: &mut std::process::Command, breakaway: bool) {
690        use std::os::windows::process::CommandExt as _;
691        disable_std_handle_inheritance();
692        let mut flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW;
693        if breakaway {
694            flags |= CREATE_BREAKAWAY_FROM_JOB;
695        }
696        command.creation_flags(flags);
697    }
698
699    /// Job object that kills every assigned process, and their descendants,
700    /// when the last handle closes.
701    pub(crate) struct ProcessTreeJob(OwnedHandle);
702
703    impl ProcessTreeJob {
704        pub(crate) fn new() -> io::Result<Self> {
705            // SAFETY: null attributes and name request an anonymous job with
706            // default security; a null result is checked before use.
707            let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
708            if job.is_null() {
709                return Err(io::Error::last_os_error());
710            }
711            let job = OwnedHandle(job);
712            // SAFETY: a zeroed limit block is a valid all-defaults value for
713            // this POD struct; only the kill-on-close flag is set.
714            let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
715            limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
716            let size = u32::try_from(std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
717                .map_err(|_| io::Error::other("job limit block too large"))?;
718            // SAFETY: the pointer references a live, correctly sized struct.
719            let configured = unsafe {
720                SetInformationJobObject(
721                    job.0,
722                    JobObjectExtendedLimitInformation,
723                    std::ptr::from_ref(&limits).cast::<c_void>(),
724                    size,
725                )
726            };
727            if configured == 0 {
728                return Err(io::Error::last_os_error());
729            }
730            Ok(Self(job))
731        }
732
733        pub(crate) fn assign(&self, pid: u32) -> io::Result<()> {
734            let process = open_process(pid, PROCESS_SET_QUOTA | PROCESS_TERMINATE)?;
735            // SAFETY: both handles are valid for the duration of the call.
736            if unsafe { AssignProcessToJobObject(self.0 .0, process.0) } == 0 {
737                return Err(io::Error::last_os_error());
738            }
739            Ok(())
740        }
741
742        pub(crate) fn terminate(&self) {
743            // SAFETY: valid job handle; integer exit code.
744            unsafe {
745                TerminateJobObject(self.0 .0, 1);
746            }
747        }
748    }
749
750    #[cfg(test)]
751    mod tests {
752        #![allow(clippy::expect_used)]
753
754        #[test]
755        fn current_process_exists_and_pid_zero_does_not() {
756            assert!(super::process_exists(std::process::id()));
757            assert!(!super::process_exists(0));
758        }
759
760        #[test]
761        fn job_object_can_be_created_and_owns_a_child() {
762            let job = super::ProcessTreeJob::new().expect("job object");
763            let mut child = std::process::Command::new("cmd.exe")
764                .args(["/c", "ping -n 30 127.0.0.1 >nul"])
765                .spawn()
766                .expect("spawn child");
767            job.assign(child.id()).expect("assign child to job");
768            job.terminate();
769            let status = child.wait().expect("child reaped after job termination");
770            assert!(!status.success());
771            assert!(!super::process_exists(child.id()));
772        }
773
774        #[test]
775        fn owner_only_sddl_names_the_current_user_and_an_integrity_label() {
776            let sddl = super::owner_only_sddl().expect("sddl");
777            assert!(sddl.starts_with("D:P(A;;GA;;;SY)(A;;GRGW;;;S-1-"), "{sddl}");
778            assert!(
779                sddl.ends_with("S:(ML;;NRNW;;;ME)") || sddl.ends_with("S:(ML;;NRNW;;;HI)"),
780                "{sddl}"
781            );
782            let security = super::PipeSecurity::owner_only().expect("descriptor");
783            assert!(!security.descriptor.is_null());
784        }
785
786        #[test]
787        fn dll_search_path_restriction_is_accepted() {
788            super::restrict_dll_search_path();
789        }
790
791        #[test]
792        fn std_handles_stop_being_inheritable() {
793            use windows_sys::Win32::Foundation::{
794                GetHandleInformation, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE,
795            };
796            use windows_sys::Win32::System::Console::{
797                GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
798            };
799
800            super::disable_std_handle_inheritance();
801            for id in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
802                // SAFETY: constant argument; sentinels are filtered below.
803                let handle = unsafe { GetStdHandle(id) };
804                if handle.is_null() || handle == INVALID_HANDLE_VALUE {
805                    continue;
806                }
807                let mut flags = 0u32;
808                // SAFETY: valid handle and a live out-pointer for the flags.
809                let queried =
810                    unsafe { GetHandleInformation(handle, std::ptr::from_mut(&mut flags)) };
811                if queried == 0 {
812                    // Console handles on some hosts reject the query; nothing to assert.
813                    continue;
814                }
815                assert_eq!(flags & HANDLE_FLAG_INHERIT, 0, "std handle {id} still inheritable");
816            }
817        }
818    }
819}
820
821/// Open a regular file without following its final path component. Callers must
822/// establish the parent directory's trust separately and bound their reads.
823#[cfg(unix)]
824pub(crate) fn open_read_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
825    use std::os::unix::fs::OpenOptionsExt as _;
826    let file = std::fs::OpenOptions::new()
827        .read(true)
828        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
829        .open(path)?;
830    if !file.metadata()?.is_file() {
831        return Err(std::io::Error::new(
832            std::io::ErrorKind::InvalidInput,
833            "expected a regular file",
834        ));
835    }
836    Ok(file)
837}
838
839#[cfg(windows)]
840pub(crate) fn open_read_nofollow(path: &std::path::Path) -> std::io::Result<std::fs::File> {
841    use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
842    // Win32 flags are kept here to avoid exposing platform mechanics to callers.
843    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
844    const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
845    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
846    let file = std::fs::OpenOptions::new()
847        .read(true)
848        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
849        .open(path)?;
850    let metadata = file.metadata()?;
851    if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
852        return Err(std::io::Error::new(
853            std::io::ErrorKind::InvalidInput,
854            "expected a regular file without a reparse point",
855        ));
856    }
857    Ok(file)
858}
859
860#[cfg(not(any(unix, windows)))]
861pub(crate) fn open_read_nofollow(_path: &std::path::Path) -> std::io::Result<std::fs::File> {
862    Err(std::io::Error::new(
863        std::io::ErrorKind::Unsupported,
864        "safe no-follow record reads are unsupported on this platform",
865    ))
866}