Skip to main content

muxtop_core/
actions.rs

1// System actions: kill and renice process wrappers.
2// All unsafe libc calls are isolated in this module.
3
4use crate::error::CoreError;
5
6/// Safe subset of POSIX signals that muxtop is permitted to send.
7///
8/// The `i32` raw value is only accepted through this enum to prevent
9/// callers from passing arbitrary signal numbers (e.g. SIGKILL to PID 1).
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Signal {
12    /// Graceful termination request.
13    Term,
14    /// Unconditional kill.
15    Kill,
16}
17
18impl Signal {
19    fn as_libc(self) -> i32 {
20        match self {
21            Signal::Term => libc::SIGTERM,
22            Signal::Kill => libc::SIGKILL,
23        }
24    }
25}
26
27/// Send `signal` to the process identified by `pid`.
28///
29/// Maps libc errno values to typed `CoreError` variants:
30/// - ESRCH  → `ProcessNotFound`
31/// - EPERM  → `Permission`
32/// - other  → `Io`
33///
34/// # Safety boundary
35/// PIDs that would overflow `libc::pid_t` (i32) are rejected.
36/// Negative pid values after cast are rejected (pid -1 is a POSIX wildcard
37/// that sends the signal to ALL processes the caller can reach).
38pub fn kill_process(pid: u32, signal: Signal) -> Result<(), CoreError> {
39    // CRITICAL: pid as i32 must be positive. u32 values > i32::MAX wrap to
40    // negative, and kill(-1, sig) sends sig to ALL user processes.
41    let pid_i32 = i32::try_from(pid).map_err(|_| CoreError::ProcessNotFound { pid })?;
42    if pid_i32 <= 0 {
43        return Err(CoreError::ProcessNotFound { pid });
44    }
45
46    let ret = unsafe { libc::kill(pid_i32, signal.as_libc()) };
47    if ret == 0 {
48        return Ok(());
49    }
50
51    let err = std::io::Error::last_os_error();
52    match err.raw_os_error() {
53        Some(code) if code == libc::ESRCH => Err(CoreError::ProcessNotFound { pid }),
54        Some(code) if code == libc::EPERM => Err(CoreError::Permission(format!(
55            "permission denied sending signal {signal:?} to pid {pid}"
56        ))),
57        _ => Err(CoreError::Io(err)),
58    }
59}
60
61/// Change the scheduling priority (nice value) of the process identified by `pid`.
62///
63/// Because `setpriority` returns –1 both on error *and* as a valid success value
64/// when the current priority happens to be –1, errno must be checked explicitly.
65/// This function clears errno before the call and inspects it afterwards.
66///
67/// Maps libc errno values to typed `CoreError` variants:
68/// - ESRCH  → `ProcessNotFound`
69/// - EPERM  → `Permission`
70/// - other  → `Io`
71pub fn renice_process(pid: u32, nice_value: i32) -> Result<(), CoreError> {
72    // Same overflow guard as kill_process — reject PIDs that don't fit in a
73    // positive i32 to avoid silent wrapping to negative id_t values.
74    let pid_i32 = i32::try_from(pid).map_err(|_| CoreError::ProcessNotFound { pid })?;
75    if pid_i32 <= 0 {
76        return Err(CoreError::ProcessNotFound { pid });
77    }
78
79    // Clear errno and call setpriority in a single unsafe block to prevent
80    // any interleaving between the errno clear and the syscall.
81    let ret = unsafe {
82        set_errno_raw(0);
83        libc::setpriority(libc::PRIO_PROCESS, pid_i32 as libc::id_t, nice_value)
84    };
85
86    if ret == 0 {
87        return Ok(());
88    }
89
90    // ret == -1; check whether errno was actually set.
91    let err = std::io::Error::last_os_error();
92    match err.raw_os_error() {
93        Some(0) => Ok(()), // errno was not set — setpriority succeeded with value -1
94        Some(code) if code == libc::ESRCH => Err(CoreError::ProcessNotFound { pid }),
95        Some(code) if code == libc::EPERM => Err(CoreError::Permission(format!(
96            "permission denied changing priority of pid {pid} to {nice_value}"
97        ))),
98        _ => Err(CoreError::Io(err)),
99    }
100}
101
102/// Read the current scheduling priority (nice value) of the process identified by `pid`.
103///
104/// Because `getpriority` returns –1 both on error *and* as a valid success value,
105/// errno must be checked explicitly (same pattern as `renice_process`).
106///
107/// Maps libc errno values to typed `CoreError` variants:
108/// - ESRCH  → `ProcessNotFound`
109/// - EPERM  → `Permission`
110/// - other  → `Io`
111pub fn get_process_priority(pid: u32) -> Result<i32, CoreError> {
112    let pid_i32 = i32::try_from(pid).map_err(|_| CoreError::ProcessNotFound { pid })?;
113    if pid_i32 <= 0 {
114        return Err(CoreError::ProcessNotFound { pid });
115    }
116
117    let ret = unsafe {
118        set_errno_raw(0);
119        libc::getpriority(libc::PRIO_PROCESS, pid_i32 as libc::id_t)
120    };
121
122    let err = std::io::Error::last_os_error();
123    match err.raw_os_error() {
124        Some(0) => Ok(ret), // errno not set — return value is valid (may be -1)
125        Some(code) if code == libc::ESRCH => Err(CoreError::ProcessNotFound { pid }),
126        Some(code) if code == libc::EPERM => Err(CoreError::Permission(format!(
127            "permission denied reading priority of pid {pid}"
128        ))),
129        _ => Err(CoreError::Io(err)),
130    }
131}
132
133/// Raw errno write — must be called inside an existing `unsafe` block.
134///
135/// # Safety
136/// Caller must be in an `unsafe` context.
137unsafe fn set_errno_raw(value: i32) {
138    #[cfg(target_os = "macos")]
139    {
140        unsafe {
141            *libc::__error() = value;
142        }
143    }
144    #[cfg(target_os = "linux")]
145    {
146        unsafe {
147            *libc::__errno_location() = value;
148        }
149    }
150    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
151    {
152        let _ = value;
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    /// Sending SIGTERM to a very large (non-existent) PID must return an error,
161    /// proving the signal dispatch path is reachable via the Signal enum.
162    #[test]
163    fn test_kill_sigterm_nonexistent_pid() {
164        let bad_pid: u32 = i32::MAX as u32 - 1;
165        let result = kill_process(bad_pid, Signal::Term);
166        assert!(result.is_err(), "kill(nonexistent, SIGTERM) must fail");
167    }
168
169    /// Sending SIGKILL to a non-existent PID must also return an error.
170    #[test]
171    fn test_kill_sigkill_nonexistent_pid() {
172        let bad_pid: u32 = i32::MAX as u32 - 1;
173        let result = kill_process(bad_pid, Signal::Kill);
174        assert!(result.is_err(), "kill(nonexistent, SIGKILL) must fail");
175    }
176
177    /// A very large PID should fail safely without sending signals to anyone.
178    #[test]
179    fn test_kill_invalid_pid() {
180        // Use a PID that is large but still a valid positive i32,
181        // avoiding u32::MAX which wraps to -1 (POSIX wildcard: kill ALL processes).
182        let bad_pid: u32 = i32::MAX as u32 - 1; // 2147483646 — almost certainly unused
183        let result = kill_process(bad_pid, Signal::Term);
184        assert!(
185            result.is_err(),
186            "kill(large_pid, SIGTERM) must return an error"
187        );
188        match result.unwrap_err() {
189            CoreError::ProcessNotFound { .. } | CoreError::Permission(_) | CoreError::Io(_) => {}
190            other => panic!("unexpected error variant: {other:?}"),
191        }
192    }
193
194    /// u32::MAX must be rejected before reaching libc (it would become pid -1).
195    #[test]
196    fn test_kill_u32_max_rejected() {
197        let result = kill_process(u32::MAX, Signal::Term);
198        assert!(
199            matches!(result, Err(CoreError::ProcessNotFound { .. })),
200            "u32::MAX must be rejected as ProcessNotFound, got: {result:?}"
201        );
202    }
203
204    /// PID 0 must be rejected (it means "all processes in the caller's process group").
205    #[test]
206    fn test_kill_pid_zero_rejected() {
207        let result = kill_process(0, Signal::Term);
208        assert!(
209            matches!(result, Err(CoreError::ProcessNotFound { .. })),
210            "pid 0 must be rejected, got: {result:?}"
211        );
212    }
213
214    /// Lowering priority (raising nice value) is always permitted for the
215    /// process itself on POSIX systems.
216    #[test]
217    fn test_renice_self() {
218        let pid = std::process::id();
219        let result = renice_process(pid, 10);
220        assert!(
221            result.is_ok(),
222            "renice(self, 10) should succeed: {result:?}"
223        );
224    }
225
226    /// Renicing a very large PID should fail.
227    #[test]
228    fn test_renice_invalid_pid() {
229        let bad_pid: u32 = i32::MAX as u32 - 1;
230        let result = renice_process(bad_pid, 10);
231        assert!(
232            result.is_err(),
233            "renice(large_pid, 10) must return an error"
234        );
235    }
236
237    /// Verify that each error path produces the expected discriminant.
238    #[test]
239    fn test_kill_renice_error_types() {
240        let bad_pid: u32 = i32::MAX as u32 - 1;
241
242        let r = kill_process(bad_pid, Signal::Term);
243        if let Err(e) = r {
244            let is_expected = matches!(
245                e,
246                CoreError::ProcessNotFound { .. } | CoreError::Permission(_) | CoreError::Io(_)
247            );
248            assert!(is_expected, "unexpected error variant: {e:?}");
249        }
250
251        let r2 = renice_process(bad_pid, 0);
252        if let Err(e) = r2 {
253            let is_expected = matches!(
254                e,
255                CoreError::ProcessNotFound { .. } | CoreError::Permission(_) | CoreError::Io(_)
256            );
257            assert!(is_expected, "unexpected error variant: {e:?}");
258        }
259    }
260
261    /// get_process_priority must succeed for our own process.
262    #[test]
263    fn test_get_priority_self() {
264        let pid = std::process::id();
265        let result = get_process_priority(pid);
266        assert!(
267            result.is_ok(),
268            "get_process_priority(self) should succeed: {result:?}"
269        );
270        let nice = result.unwrap();
271        assert!(
272            (-20..=19).contains(&nice),
273            "nice value {nice} out of POSIX range"
274        );
275    }
276
277    /// get_process_priority with pid 0 must be rejected.
278    #[test]
279    fn test_get_priority_pid_zero_rejected() {
280        let result = get_process_priority(0);
281        assert!(
282            matches!(result, Err(CoreError::ProcessNotFound { .. })),
283            "pid 0 must be rejected, got: {result:?}"
284        );
285    }
286}