1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
8pub struct ProcessIdentity {
9 pub start_marker: u64,
10 pub executable: String,
11}
12
13pub fn run_with_timeout(
25 mut cmd: std::process::Command,
26 timeout: std::time::Duration,
27) -> Option<std::process::Output> {
28 use std::process::Stdio;
29 use std::time::Instant;
30
31 let mut child = cmd
32 .stdin(Stdio::null())
33 .stdout(Stdio::piped())
34 .stderr(Stdio::piped())
35 .spawn()
36 .ok()?;
37
38 let start = Instant::now();
39 loop {
40 match child.try_wait() {
41 Ok(Some(_)) => return child.wait_with_output().ok(),
43 Ok(None) => {
44 if start.elapsed() >= timeout {
45 let _ = child.kill();
46 let _ = child.wait();
47 return None;
48 }
49 std::thread::sleep(std::time::Duration::from_millis(50));
50 }
51 Err(_) => return None,
52 }
53 }
54}
55
56pub use super::detached::{DetachedChild, DetachedStderr, spawn_detached};
60
61pub use lean_ctx_fs::process::is_alive;
62
63pub fn identity(pid: u32) -> Option<ProcessIdentity> {
70 #[cfg(target_os = "macos")]
71 {
72 macos_process_identity(pid)
73 }
74 #[cfg(target_os = "linux")]
75 {
76 linux_process_identity(pid)
77 }
78 #[cfg(windows)]
79 {
80 windows_process_identity(pid)
81 }
82 #[cfg(target_os = "freebsd")]
83 {
84 freebsd_process_identity(pid)
85 }
86 #[cfg(not(any(
87 target_os = "macos",
88 target_os = "linux",
89 target_os = "freebsd",
90 windows
91 )))]
92 {
93 let _ = pid;
94 None
95 }
96}
97
98#[cfg(target_os = "freebsd")]
103fn freebsd_process_identity(pid: u32) -> Option<ProcessIdentity> {
104 let info = freebsd_kinfo(pid)?;
105 let pid = info.ki_pid;
106 let start_marker = u64::try_from(info.ki_start.tv_sec)
107 .ok()?
108 .checked_mul(1_000_000)?
109 .checked_add(u64::try_from(info.ki_start.tv_usec).ok()?)?;
110 freebsd_identity_with_path(pid, start_marker)
111}
112
113#[cfg(target_os = "freebsd")]
114fn freebsd_kinfo(pid: u32) -> Option<libc::kinfo_proc> {
115 let pid = libc::c_int::try_from(pid).ok()?;
116
117 let mut info = std::mem::MaybeUninit::<libc::kinfo_proc>::zeroed();
118 let mut info_len = std::mem::size_of::<libc::kinfo_proc>();
119 let mib = [libc::CTL_KERN, libc::KERN_PROC, libc::KERN_PROC_PID, pid];
120 let rc = unsafe {
123 libc::sysctl(
124 mib.as_ptr(),
125 4,
126 info.as_mut_ptr().cast(),
127 &mut info_len,
128 std::ptr::null(),
129 0,
130 )
131 };
132 if rc != 0 || info_len != std::mem::size_of::<libc::kinfo_proc>() {
133 return None;
134 }
135 let info = unsafe { info.assume_init() };
137 (info.ki_pid == pid).then_some(info)
138}
139
140#[cfg(target_os = "freebsd")]
141fn freebsd_identity_with_path(pid: libc::c_int, start_marker: u64) -> Option<ProcessIdentity> {
142 let mut path = [0_u8; libc::PATH_MAX as usize];
143 let mut path_len = path.len();
144 let mib = [
145 libc::CTL_KERN,
146 libc::KERN_PROC,
147 libc::KERN_PROC_PATHNAME,
148 pid,
149 ];
150 let rc = unsafe {
152 libc::sysctl(
153 mib.as_ptr(),
154 4,
155 path.as_mut_ptr().cast(),
156 &mut path_len,
157 std::ptr::null(),
158 0,
159 )
160 };
161 if rc != 0 || path_len == 0 {
162 return None;
163 }
164 let bytes = path.get(..path_len)?;
166 let bytes = bytes.strip_suffix(&[0]).unwrap_or(bytes);
167 if bytes.is_empty() {
168 return None;
169 }
170 Some(ProcessIdentity {
171 start_marker,
172 executable: String::from_utf8_lossy(bytes).into_owned(),
173 })
174}
175
176pub fn matches_identity(pid: u32, expected: &ProcessIdentity) -> bool {
179 identity(pid).as_ref() == Some(expected)
180}
181
182#[must_use]
191pub fn installed_executable_path(path: &str) -> String {
192 let path = path.strip_suffix(" (deleted)").unwrap_or(path);
193 let name_at = path.rfind(['/', '\\']).map_or(0, |i| i + 1);
194 let (dir, name) = path.split_at(name_at);
195 let lower = name.to_ascii_lowercase();
196 let sidecar = lower
197 .strip_prefix("lean-ctx.old")
198 .and_then(|rest| rest.strip_suffix(".exe"))
199 .is_some_and(is_parked_sidecar_suffix);
200 if sidecar {
201 format!("{dir}lean-ctx.exe")
202 } else {
203 path.to_string()
204 }
205}
206
207fn is_parked_sidecar_suffix(middle: &str) -> bool {
209 if middle.is_empty() {
210 return true;
211 }
212 let Some(rest) = middle.strip_prefix('-') else {
213 return false;
214 };
215 let mut parts = rest.split('-');
216 let digits =
217 |p: Option<&str>| p.is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
218 digits(parts.next()) && digits(parts.next()) && parts.next().is_none()
219}
220
221#[must_use]
225pub fn same_process(expected: &ProcessIdentity, live: &ProcessIdentity) -> bool {
226 if expected.start_marker != live.start_marker {
227 return false;
228 }
229 let a = installed_executable_path(&expected.executable);
230 let b = installed_executable_path(&live.executable);
231 a == b || (cfg!(windows) && a.eq_ignore_ascii_case(&b))
232}
233
234#[must_use]
236pub fn is_same_process(pid: u32, expected: &ProcessIdentity) -> bool {
237 identity(pid).is_some_and(|live| same_process(expected, &live))
238}
239
240#[must_use]
245pub fn owned_by_current_user(pid: u32) -> bool {
246 #[cfg(target_os = "macos")]
247 {
248 let me = unsafe { libc::geteuid() };
250 macos_bsd_info(pid).is_some_and(|info| info.pbi_uid == me)
251 }
252 #[cfg(target_os = "linux")]
253 {
254 let me = unsafe { libc::geteuid() };
256 std::fs::read_to_string(format!("/proc/{pid}/status"))
257 .ok()
258 .and_then(|status| {
259 let line = status.lines().find(|l| l.starts_with("Uid:"))?;
260 line.split_whitespace().nth(2)?.parse::<u32>().ok()
261 })
262 .is_some_and(|euid| euid == me)
263 }
264 #[cfg(target_os = "freebsd")]
265 {
266 let me = unsafe { libc::geteuid() };
268 freebsd_kinfo(pid).is_some_and(|info| info.ki_uid == me)
269 }
270 #[cfg(windows)]
271 {
272 windows_owned_by_current_user(pid)
273 }
274 #[cfg(not(any(
275 target_os = "macos",
276 target_os = "linux",
277 target_os = "freebsd",
278 windows
279 )))]
280 {
281 let _ = pid;
282 false
283 }
284}
285
286#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum StopSignal {
289 Terminate,
292 Kill,
294}
295
296pub fn signal_same_process(pid: u32, expected: &ProcessIdentity, signal: StopSignal) -> bool {
309 if pid == 0 || pid == std::process::id() {
310 return false;
311 }
312 #[cfg(windows)]
313 {
314 let _ = signal;
316 windows_terminate_same_process(pid, expected)
317 }
318 #[cfg(unix)]
319 {
320 let sig = match signal {
321 StopSignal::Terminate => libc::SIGTERM,
322 StopSignal::Kill => libc::SIGKILL,
323 };
324 let Ok(raw_pid) = libc::pid_t::try_from(pid) else {
325 return false;
326 };
327 #[cfg(target_os = "linux")]
328 if let Some(delivered) = linux_pidfd_signal(raw_pid, expected, sig) {
329 return delivered;
330 }
331 is_same_process(pid, expected) && unsafe { libc::kill(raw_pid, sig) } == 0
334 }
335 #[cfg(not(any(unix, windows)))]
336 {
337 let _ = (expected, signal);
338 false
339 }
340}
341
342#[cfg(target_os = "linux")]
344fn linux_pidfd_signal(
345 pid: libc::pid_t,
346 expected: &ProcessIdentity,
347 sig: libc::c_int,
348) -> Option<bool> {
349 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
350
351 let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
354 if fd < 0 {
355 return match std::io::Error::last_os_error().raw_os_error() {
356 Some(libc::ESRCH) => Some(false),
358 _ => None,
360 };
361 }
362 let fd = libc::c_int::try_from(fd).ok()?;
363 let pidfd = unsafe { OwnedFd::from_raw_fd(fd) };
365 if !is_same_process(u32::try_from(pid).ok()?, expected) {
369 return Some(false);
370 }
371 let rc = unsafe {
373 libc::syscall(
374 libc::SYS_pidfd_send_signal,
375 pidfd.as_raw_fd(),
376 sig,
377 std::ptr::null::<libc::siginfo_t>(),
378 0,
379 )
380 };
381 Some(rc == 0)
382}
383
384#[cfg(target_os = "macos")]
385#[repr(C)]
386struct ProcBsdInfo {
387 pbi_flags: u32,
388 pbi_status: u32,
389 pbi_xstatus: u32,
390 pbi_pid: u32,
391 pbi_ppid: u32,
392 pbi_uid: u32,
393 pbi_gid: u32,
394 pbi_ruid: u32,
395 pbi_rgid: u32,
396 pbi_svuid: u32,
397 pbi_svgid: u32,
398 rfu_1: u32,
399 pbi_comm: [std::ffi::c_char; 16],
400 pbi_name: [std::ffi::c_char; 32],
401 pbi_nfiles: u32,
402 pbi_pgid: u32,
403 pbi_pjobc: u32,
404 e_tdev: u32,
405 e_tpgid: u32,
406 pbi_nice: i32,
407 pbi_start_tvsec: u64,
408 pbi_start_tvusec: u64,
409}
410
411#[cfg(target_os = "macos")]
412const PROC_PIDTBSDINFO: std::ffi::c_int = 3;
413
414#[cfg(target_os = "macos")]
415#[link(name = "proc")]
416unsafe extern "C" {
417 fn proc_pidinfo(
418 pid: std::ffi::c_int,
419 flavor: std::ffi::c_int,
420 arg: u64,
421 buffer: *mut std::ffi::c_void,
422 buffersize: std::ffi::c_int,
423 ) -> std::ffi::c_int;
424 fn proc_pidpath(
425 pid: std::ffi::c_int,
426 buffer: *mut std::ffi::c_void,
427 buffersize: u32,
428 ) -> std::ffi::c_int;
429}
430
431#[cfg(target_os = "macos")]
432fn macos_bsd_info(pid: u32) -> Option<ProcBsdInfo> {
433 let mut info = std::mem::MaybeUninit::<ProcBsdInfo>::zeroed();
434 let size = i32::try_from(std::mem::size_of::<ProcBsdInfo>()).ok()?;
435 let read = unsafe {
437 proc_pidinfo(
438 i32::try_from(pid).ok()?,
439 PROC_PIDTBSDINFO,
440 0,
441 info.as_mut_ptr().cast(),
442 size,
443 )
444 };
445 if read != size {
446 return None;
447 }
448 Some(unsafe { info.assume_init() })
450}
451
452#[cfg(target_os = "macos")]
453fn macos_process_identity(pid: u32) -> Option<ProcessIdentity> {
454 let info = macos_bsd_info(pid)?;
455 let mut path = [0_u8; libc::PATH_MAX as usize];
456 let path_len = unsafe {
458 proc_pidpath(
459 i32::try_from(pid).ok()?,
460 path.as_mut_ptr().cast(),
461 u32::try_from(path.len()).ok()?,
462 )
463 };
464 if path_len <= 0 {
465 return None;
466 }
467 let path_len = usize::try_from(path_len).ok()?;
468 let executable = String::from_utf8_lossy(path.get(..path_len)?).into_owned();
469 let start_marker = info
470 .pbi_start_tvsec
471 .checked_mul(1_000_000)?
472 .checked_add(info.pbi_start_tvusec)?;
473 Some(ProcessIdentity {
474 start_marker,
475 executable,
476 })
477}
478
479#[cfg(target_os = "linux")]
480fn linux_process_identity(pid: u32) -> Option<ProcessIdentity> {
481 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
482 let (_, fields) = stat.rsplit_once(')')?;
483 let start_marker = fields.split_whitespace().nth(19)?.parse().ok()?;
484 let executable = std::fs::read_link(format!("/proc/{pid}/exe"))
485 .ok()?
486 .to_string_lossy()
487 .into_owned();
488 Some(ProcessIdentity {
489 start_marker,
490 executable,
491 })
492}
493
494#[cfg(windows)]
496struct ProcessHandle(windows_sys::Win32::Foundation::HANDLE);
497
498#[cfg(windows)]
499impl ProcessHandle {
500 fn open(pid: u32, access: u32) -> Option<Self> {
501 use windows_sys::Win32::System::Threading::OpenProcess;
502 let handle = unsafe { OpenProcess(access, 0, pid) };
504 (!handle.is_null()).then_some(Self(handle))
505 }
506}
507
508#[cfg(windows)]
509impl Drop for ProcessHandle {
510 fn drop(&mut self) {
511 unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) };
513 }
514}
515
516#[cfg(windows)]
517fn windows_process_identity(pid: u32) -> Option<ProcessIdentity> {
518 use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
519 let handle = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION)?;
520 windows_identity_of(&handle)
521}
522
523#[cfg(windows)]
526fn windows_terminate_same_process(pid: u32, expected: &ProcessIdentity) -> bool {
527 use windows_sys::Win32::System::Threading::{
528 PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, TerminateProcess,
529 };
530 let Some(handle) =
531 ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE)
532 else {
533 return false;
534 };
535 if !windows_identity_of(&handle).is_some_and(|live| same_process(expected, &live)) {
536 return false;
537 }
538 unsafe { TerminateProcess(handle.0, 1) != 0 }
540}
541
542#[cfg(windows)]
544pub(crate) fn windows_token_user_sid(
545 process: windows_sys::Win32::Foundation::HANDLE,
546) -> Option<Vec<u8>> {
547 use windows_sys::Win32::Foundation::CloseHandle;
548 use windows_sys::Win32::Security::{
549 GetLengthSid, GetTokenInformation, IsValidSid, TOKEN_QUERY, TOKEN_USER, TokenUser,
550 };
551 use windows_sys::Win32::System::Threading::OpenProcessToken;
552
553 let mut token = std::ptr::null_mut();
554 if unsafe { OpenProcessToken(process, TOKEN_QUERY, &raw mut token) } == 0 {
556 return None;
557 }
558 let sid = (|| {
559 let mut needed = 0_u32;
560 unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &raw mut needed) };
562 if needed < u32::try_from(std::mem::size_of::<TOKEN_USER>()).ok()? || needed > 4096 {
563 return None;
564 }
565 let mut buf = vec![0_u64; (needed as usize).div_ceil(8)];
566 let buf_bytes = buf.len() * 8;
567 let ok = unsafe {
569 GetTokenInformation(
570 token,
571 TokenUser,
572 buf.as_mut_ptr().cast(),
573 needed,
574 &raw mut needed,
575 )
576 };
577 if ok == 0 {
578 return None;
579 }
580 let sid = unsafe { &*buf.as_ptr().cast::<TOKEN_USER>() }.User.Sid;
582 if sid.is_null() || unsafe { IsValidSid(sid) } == 0 {
584 return None;
585 }
586 let len = unsafe { GetLengthSid(sid) } as usize;
588 let (start, base) = (sid as usize, buf.as_ptr() as usize);
589 if start < base || start + len > base + buf_bytes {
590 return None;
591 }
592 Some(unsafe { std::slice::from_raw_parts(sid.cast::<u8>(), len) }.to_vec())
594 })();
595 unsafe { CloseHandle(token) };
597 sid
598}
599
600#[cfg(windows)]
601fn windows_owned_by_current_user(pid: u32) -> bool {
602 use windows_sys::Win32::System::Threading::{
603 GetCurrentProcess, PROCESS_QUERY_LIMITED_INFORMATION,
604 };
605 let Some(handle) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
606 return false;
607 };
608 let mine = windows_token_user_sid(unsafe { GetCurrentProcess() });
610 let theirs = windows_token_user_sid(handle.0);
611 mine.is_some() && mine == theirs
612}
613
614#[cfg(windows)]
615fn windows_identity_of(handle: &ProcessHandle) -> Option<ProcessIdentity> {
616 use std::os::windows::ffi::OsStringExt;
617 use windows_sys::Win32::Foundation::FILETIME;
618 use windows_sys::Win32::System::Threading::{GetProcessTimes, QueryFullProcessImageNameW};
619
620 let handle = handle.0;
621 let mut created = FILETIME::default();
622 let mut exited = FILETIME::default();
623 let mut kernel = FILETIME::default();
624 let mut user = FILETIME::default();
625 let times_ok = unsafe {
627 GetProcessTimes(
628 handle,
629 &raw mut created,
630 &raw mut exited,
631 &raw mut kernel,
632 &raw mut user,
633 )
634 } != 0;
635 let mut path = vec![0_u16; 32_768];
636 let mut path_len = u32::try_from(path.len()).ok()?;
637 let path_ok =
639 unsafe { QueryFullProcessImageNameW(handle, 0, path.as_mut_ptr(), &raw mut path_len) != 0 };
640 if !times_ok || !path_ok {
641 return None;
642 }
643 let executable = std::ffi::OsString::from_wide(path.get(..usize::try_from(path_len).ok()?)?)
644 .to_string_lossy()
645 .into_owned();
646 let start_marker = (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime);
647 Some(ProcessIdentity {
648 start_marker,
649 executable,
650 })
651}
652
653pub fn terminate_gracefully(pid: u32) -> Result<()> {
656 #[cfg(unix)]
657 {
658 let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
661 if ret != 0 {
662 anyhow::bail!(
663 "Failed to send SIGTERM to PID {pid}: {}",
664 std::io::Error::last_os_error()
665 );
666 }
667 Ok(())
668 }
669 #[cfg(windows)]
670 {
671 force_kill(pid)
672 }
673}
674
675pub fn force_kill(pid: u32) -> Result<()> {
677 #[cfg(unix)]
678 {
679 let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
682 if ret != 0 {
683 anyhow::bail!(
684 "Failed to send SIGKILL to PID {pid}: {}",
685 std::io::Error::last_os_error()
686 );
687 }
688 Ok(())
689 }
690 #[cfg(windows)]
691 {
692 use windows_sys::Win32::Foundation::CloseHandle;
693 use windows_sys::Win32::System::Threading::{
694 OpenProcess, PROCESS_TERMINATE, TerminateProcess,
695 };
696
697 unsafe {
700 let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
701 if handle.is_null() {
702 anyhow::bail!(
703 "Failed to open PID {pid} for termination: {}",
704 std::io::Error::last_os_error()
705 );
706 }
707 let ok = TerminateProcess(handle, 1);
708 CloseHandle(handle);
709 if ok == 0 {
710 anyhow::bail!(
711 "Failed to terminate PID {pid}: {}",
712 std::io::Error::last_os_error()
713 );
714 }
715 Ok(())
716 }
717 }
718}
719
720fn protected_self_pids() -> std::collections::HashSet<u32> {
736 let mut protected = std::collections::HashSet::new();
737 protected.insert(std::process::id());
738 #[cfg(unix)]
739 {
740 let mut pid = std::process::id();
741 for _ in 0..16 {
742 let Ok(output) = std::process::Command::new("ps")
743 .args(["-o", "ppid=", "-p", &pid.to_string()])
744 .output()
745 else {
746 break;
747 };
748 let Ok(ppid) = String::from_utf8_lossy(&output.stdout)
749 .trim()
750 .parse::<u32>()
751 else {
752 break;
753 };
754 if ppid <= 1 || !protected.insert(ppid) {
755 break;
756 }
757 pid = ppid;
758 }
759
760 let own_pgid = unsafe { libc::getpgrp() };
762 if own_pgid > 0 {
763 protected.extend(process_group_pids(own_pgid));
764 }
765 }
766 protected
767}
768
769#[cfg(unix)]
770fn process_group_pids(pgid: libc::pid_t) -> Vec<u32> {
771 std::process::Command::new("pgrep")
772 .args(["-g", &pgid.to_string()])
773 .output()
774 .ok()
775 .map(|output| {
776 String::from_utf8_lossy(&output.stdout)
777 .lines()
778 .filter_map(|line| line.trim().parse::<u32>().ok())
779 .collect()
780 })
781 .unwrap_or_default()
782}
783
784pub fn find_pids_by_name(name: &str) -> Vec<u32> {
787 let protected = protected_self_pids();
788 let mut pids = Vec::new();
789
790 #[cfg(unix)]
791 {
792 let uid = unsafe { libc::getuid() }.to_string();
796
797 if let Ok(output) = std::process::Command::new("pgrep")
799 .args(["-U", &uid, "-x", name])
800 .output()
801 {
802 collect_pids(&output.stdout, &protected, &mut pids);
803 }
804
805 if let Ok(output) = std::process::Command::new("pgrep")
808 .args(["-U", &uid])
809 .arg("-f")
810 .arg(format!("/{name}(\\s|$)"))
811 .output()
812 {
813 collect_pids(&output.stdout, &protected, &mut pids);
814 }
815
816 pids.sort_unstable();
817 pids.dedup();
818 }
819
820 #[cfg(windows)]
821 {
822 if let Ok(output) = std::process::Command::new("tasklist")
823 .args([
824 "/FI",
825 &format!("IMAGENAME eq {name}.exe"),
826 "/FO",
827 "CSV",
828 "/NH",
829 ])
830 .output()
831 {
832 let stdout = String::from_utf8_lossy(&output.stdout);
833 for line in stdout.lines() {
834 let parts: Vec<&str> = line.split(',').collect();
835 if parts.len() >= 2 {
836 let pid_str = parts[1].trim().trim_matches('"');
837 if let Ok(pid) = pid_str.parse::<u32>() {
838 if !protected.contains(&pid) {
839 pids.push(pid);
840 }
841 }
842 }
843 }
844 }
845 }
846
847 pids
848}
849
850#[cfg(unix)]
851fn collect_pids(stdout: &[u8], protected: &std::collections::HashSet<u32>, out: &mut Vec<u32>) {
852 let text = String::from_utf8_lossy(stdout);
853 for line in text.lines() {
854 if let Ok(pid) = line.trim().parse::<u32>()
855 && !protected.contains(&pid)
856 {
857 out.push(pid);
858 }
859 }
860}
861
862mod services;
863pub use services::*;
864
865#[cfg(test)]
866mod tests;