1use crate::capability::{CapabilityError, Context, Output, TypedCapability};
36use crate::processes::ProcessSnapshot;
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39use std::time::Duration;
40
41#[cfg(test)]
42use std::process::Command;
43
44#[allow(clippy::arithmetic_side_effects)]
50fn get_process_start_time(pid: u32) -> Option<u64> {
51 let stat_path = format!("/proc/{}/stat", pid);
52 let content = std::fs::read_to_string(&stat_path).ok()?;
53 let last_paren = content.rfind(')')?;
54 let fields: Vec<&str> = content[last_paren + 2..].split_whitespace().collect();
55 fields.get(19)?.parse::<u64>().ok()
56}
57fn get_process_start_time_retry(pid: u32) -> Option<u64> {
58 #[allow(clippy::arithmetic_side_effects)] for attempt in 0..3 {
60 if attempt > 0 {
61 std::thread::sleep(std::time::Duration::from_millis(10 * (1 << attempt)));
62 }
63 if let Some(start_time) = get_process_start_time(pid) {
64 return Some(start_time);
65 }
66 }
67 None
68}
69
70fn get_process_cgroup(pid: u32) -> Option<String> {
74 std::fs::read_to_string(format!("/proc/{}/cgroup", pid)).ok()
75}
76
77fn is_systemd_service(cgroup: &str) -> bool {
79 cgroup.contains("/system.slice/")
80 || cgroup.contains("/init.scope")
81 || cgroup.contains("systemd")
82}
83
84fn protected_pids() -> Vec<u32> {
88 let mut pids = vec![1, 2];
89 let self_pid = std::process::id();
90 pids.push(self_pid);
91
92 if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", self_pid)) {
94 if let Some(ppid_str) = status
95 .lines()
96 .find(|l| l.starts_with("PPid:"))
97 .and_then(|l| l.split_whitespace().nth(1))
98 {
99 if let Ok(ppid) = ppid_str.parse::<u32>() {
100 pids.push(ppid);
101 }
102 }
103 }
104
105 if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", self_pid)) {
107 if let Some(sid_str) = status
108 .lines()
109 .find(|l| l.starts_with("Sid:"))
110 .and_then(|l| l.split_whitespace().nth(1))
111 {
112 if let Ok(sid) = sid_str.parse::<u32>() {
113 if sid != 0 {
114 pids.push(sid);
115 }
116 }
117 }
118 }
119
120 if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", self_pid)) {
122 if let Some(pgid_str) = status
123 .lines()
124 .find(|l| l.starts_with("NSpgid:"))
125 .and_then(|l| l.split_whitespace().nth(1))
126 {
127 if let Ok(pgid) = pgid_str.parse::<u32>() {
128 if pgid != 0 {
129 pids.push(pgid);
130 }
131 }
132 }
133 }
134
135 if let Ok(entries) = std::fs::read_dir("/proc") {
137 for entry in entries.flatten() {
138 if let Ok(name) = entry.file_name().into_string() {
139 if let Ok(pid) = name.parse::<u32>() {
140 if let Some(cgroup) = get_process_cgroup(pid) {
141 if is_systemd_service(&cgroup) {
142 pids.push(pid);
143 }
144 }
145 }
146 }
147 }
148 }
149
150 pids.sort_unstable();
151 pids.dedup();
152 pids
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157#[allow(clippy::exhaustive_structs)] pub struct KillArgs {
159 pub pid: u32,
161 pub signal: Option<i32>,
163}
164
165#[allow(clippy::exhaustive_structs)]
170pub struct Kill;
171
172impl TypedCapability for Kill {
173 type Args = KillArgs;
174
175 fn name(&self) -> &'static str {
176 "Kill"
177 }
178
179 fn description(&self) -> &'static str {
180 "terminate process by PID with PID reuse protection. protected: init (1), kthreadd (2), self, parent, session/group leaders, systemd services. signals: 1-31, 64 (SIGRTMIN)."
181 }
182
183 fn schema(&self) -> Value {
188 serde_json::json!({
189 "type": "object",
190 "properties": {
191 "pid": { "type": "integer", "minimum": 1 },
192 "signal": {
193 "type": "integer",
194 "anyOf": [
195 { "minimum": 1, "maximum": 31 },
196 { "enum": [64] }
197 ]
198 }
199 },
200 "required": ["pid"]
201 })
202 }
203
204 fn execute(
205 &self,
206 args: KillArgs,
207 ctx: &Context,
208 ) -> std::result::Result<Output, CapabilityError> {
209 if let Some(signal) = args.signal {
211 if !(1..=31).contains(&signal) && signal != 64 {
212 return Err(CapabilityError::InvalidArgs(format!(
213 "Invalid signal {}: must be 1-31 or 64 (POSIX signals)",
214 signal
215 )));
216 }
217 }
218
219 let protected = protected_pids();
221 if protected.contains(&args.pid) {
222 return Err(CapabilityError::PermissionDenied(format!(
223 "PID {} is a protected system process (protected: {:?})",
224 args.pid, protected
225 )));
226 }
227
228 if ctx.dry_run {
230 let mut out = Output::ok(format!("DRY RUN: would kill PID {}", args.pid));
232 out.data = Some(serde_json::json!({
233 "pid": args.pid,
234 "killed": false,
235 "dry_run": true,
236 "signal": args.signal.unwrap_or(15),
237 }));
238 return Ok(out);
239 }
240
241 let process_before = ProcessSnapshot::capture();
243 let process_exists = process_before.processes.iter().any(|p| p.pid == args.pid);
244
245 if !process_exists {
246 let mut out = Output::error(
247 format!("Process {} not found", args.pid),
248 "Process not found".into(),
249 );
250 out.data = Some(serde_json::json!({
251 "pid": args.pid,
252 "killed": false,
253 "reason": "Process not found"
254 }));
255 return Ok(out);
256 }
257
258 let process_info: Option<(String, String)> = process_before
260 .processes
261 .iter()
262 .find(|p| p.pid == args.pid)
263 .map(|p| (p.command.clone(), p.user.clone()));
264
265 let start_time_before = get_process_start_time_retry(args.pid);
267
268 let start_time_before_confirm = get_process_start_time_retry(args.pid);
271 if start_time_before != start_time_before_confirm {
272 let mut out = Output::error(
273 format!(
274 "PID {} was reused by a different process (start time changed before kill)",
275 args.pid
276 ),
277 "PID reused between safety checks".into(),
278 );
279 out.data = Some(serde_json::json!({
280 "pid": args.pid,
281 "killed": false,
282 "reason": "PID reused between safety checks",
283 "pid_reused": true,
284 }));
285 return Ok(out);
286 }
287
288 let signal = args.signal.unwrap_or(15);
290
291 #[allow(clippy::cast_possible_wrap)]
295 let kill_result = unsafe { libc::kill(args.pid as libc::pid_t, signal) };
296 let success = kill_result == 0;
297 let stderr_str = if success {
298 String::new()
299 } else {
300 std::io::Error::last_os_error().to_string()
301 };
302
303 std::thread::sleep(Duration::from_millis(500));
305
306 ProcessSnapshot::clear_cache();
308
309 let process_after = ProcessSnapshot::capture();
311
312 let process_still_exists = process_after
314 .processes
315 .iter()
316 .any(|p| p.pid == args.pid && !p.stat.starts_with('Z'));
317 let pid_reused = match (start_time_before, get_process_start_time_retry(args.pid)) {
319 (Some(before_time), Some(after_time)) => before_time != after_time,
320 (None, _) => false,
321 (Some(_), None) => true,
322 };
323
324 let killed_success = success && !process_still_exists && !pid_reused;
325
326 let message = if killed_success {
327 format!("Killed process {} (signal {})", args.pid, signal)
328 } else if pid_reused {
329 format!(
330 "PID {} was reused by a different process (start time changed)",
331 args.pid
332 )
333 } else if !success {
334 format!("Failed to kill process {}: {}", args.pid, stderr_str)
335 } else {
336 format!("Process {} still exists after signal {}", args.pid, signal)
337 };
338
339 let mut out = if killed_success {
340 Output::ok(message)
341 } else {
342 Output::error(
343 message,
344 if success {
345 String::new()
346 } else {
347 stderr_str.clone()
348 },
349 )
350 };
351 out.data = Some(serde_json::json!({
352 "pid": args.pid,
353 "killed": killed_success,
354 "signal": signal,
355 "command": process_info.as_ref().map(|(cmd, _)| cmd),
356 "user": process_info.as_ref().map(|(_, user)| user),
357 "stderr": if success { String::new() } else { stderr_str },
358 "pid_reused": pid_reused,
359 "process_before": {
360 "count": process_before.summary.total_processes,
361 "zombies": process_before.summary.zombie_count
362 },
363 "process_after": {
364 "count": process_after.summary.total_processes,
365 "zombies": process_after.summary.zombie_count
366 }
367 }));
368 Ok(out)
369 }
370}
371
372#[cfg(test)]
373#[allow(clippy::unnecessary_map_or)]
374mod tests {
375 use super::*;
376 use crate::capability::Capability;
377 use std::thread;
378 use std::time::Duration;
379
380 #[test]
381 fn test_kill_schema() {
382 let cap = Kill;
383 let _schema = Capability::schema(&cap);
384 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
387 let pid = child.id();
388
389 let result = get_process_start_time_retry(pid);
390 assert!(
391 result.is_some(),
392 "Should read start time for running process"
393 );
394
395 child.kill().ok();
396 let _ = child.wait();
397
398 let result = get_process_start_time_retry(999999);
400 assert!(result.is_none(), "Non-existent PID should return None");
401 }
402
403 #[test]
404 fn test_kill_protected_pid() {
405 let cap = Kill;
406 let result = Capability::execute(
408 &cap,
409 &serde_json::json!({ "pid": 1 }),
410 &Context {
411 dry_run: false,
412 job_id: "test".into(),
413 working_dir: std::env::current_dir().unwrap(),
414 },
415 );
416
417 assert!(result.is_err());
419 assert!(result
420 .unwrap_err()
421 .to_string()
422 .contains("protected system process"));
423 }
424
425 #[test]
426 fn test_kill_self_protected() {
427 let cap = Kill;
428 let self_pid = std::process::id();
429 let result = Capability::execute(
430 &cap,
431 &serde_json::json!({ "pid": self_pid }),
432 &Context {
433 dry_run: false,
434 job_id: "test".into(),
435 working_dir: std::env::current_dir().unwrap(),
436 },
437 );
438
439 assert!(result.is_err());
440 assert!(result.unwrap_err().to_string().contains("protected"));
441 }
442
443 #[test]
444 fn test_kill_nonexistent() {
445 let cap = Kill;
446 let result = Capability::execute(
448 &cap,
449 &serde_json::json!({ "pid": 999999 }),
450 &Context {
451 dry_run: false,
452 job_id: "test".into(),
453 working_dir: std::env::current_dir().unwrap(),
454 },
455 )
456 .unwrap();
457
458 assert_eq!(result.status, "error");
459 assert!(result.data.as_ref().unwrap()["killed"].as_bool() == Some(false));
460 }
461
462 #[test]
463 fn test_kill_dry_run() {
464 let cap = Kill;
465 let result = Capability::execute(
469 &cap,
470 &serde_json::json!({ "pid": 999998 }),
471 &Context {
472 dry_run: true,
473 job_id: "test".into(),
474 working_dir: std::env::current_dir().unwrap(),
475 },
476 )
477 .unwrap();
478
479 assert_eq!(result.status, "ok");
480 assert!(result.data.as_ref().unwrap()["dry_run"].as_bool() == Some(true));
481 assert!(result.data.as_ref().unwrap()["killed"].as_bool() == Some(false));
482 }
483
484 #[test]
485 fn test_kill_actual_process() {
486 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
488 let pid = child.id();
489
490 thread::sleep(Duration::from_millis(100));
492
493 let pre_check = Command::new("kill").arg("-0").arg(pid.to_string()).output();
495 assert!(
496 pre_check.unwrap().status.success(),
497 "Process should exist before kill"
498 );
499
500 let protected = protected_pids();
505 if protected.contains(&pid) {
506 let _ = child.kill();
507 let _ = child.wait();
508 eprintln!(
509 "SKIP: spawned child PID {pid} is in protected_pids set \
510 ({protected:?}); kill blocked by safety guard. \
511 This is expected in CI containers."
512 );
513 return;
514 }
515
516 ProcessSnapshot::clear_cache();
518
519 let cap = Kill;
521 let result = Capability::execute(
522 &cap,
523 &serde_json::json!({ "pid": pid, "signal": 9 }),
524 &Context {
525 dry_run: false,
526 job_id: "test".into(),
527 working_dir: std::env::current_dir().unwrap(),
528 },
529 )
530 .unwrap();
531
532 assert!(
534 result.data.as_ref().unwrap()["killed"].as_bool() == Some(true),
535 "Kill failed: {:?}",
536 result.data
537 );
538 assert!(
539 result.data.as_ref().unwrap()["signal"].as_i64() == Some(9),
540 "Should use SIGKILL"
541 );
542
543 let _ = child.wait();
545
546 let post_check = Command::new("kill").arg("-0").arg(pid.to_string()).output();
548 let still_alive = post_check.map_or(false, |o| o.status.success());
549 assert!(
550 !still_alive,
551 "Process {} should be dead after kill and reap",
552 pid
553 );
554 }
555
556 #[test]
557 fn test_get_process_start_time() {
558 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
560 let pid = child.id();
561
562 let start_time = get_process_start_time(pid);
563 assert!(
564 start_time.is_some(),
565 "Should be able to read start time for running process"
566 );
567
568 let start_time2 = get_process_start_time(pid);
570 assert_eq!(start_time, start_time2, "Start time should be stable");
571
572 child.kill().ok();
573 let _ = child.wait();
574 }
575
576 #[test]
577 fn test_get_process_start_time_nonexistent() {
578 let result = get_process_start_time(999999);
579 assert!(result.is_none(), "Non-existent PID should return None");
580 }
581
582 #[test]
583 fn test_signal_validation_rejects_negative() {
584 let cap = Kill;
586 let result = Capability::execute(
587 &cap,
588 &serde_json::json!({ "pid": 999998, "signal": -1 }),
589 &Context {
590 dry_run: false,
591 job_id: "test".into(),
592 working_dir: std::env::current_dir().unwrap(),
593 },
594 );
595 assert!(result.is_err());
596 assert!(result.unwrap_err().to_string().contains("Invalid signal"));
597 }
598
599 #[test]
600 fn test_signal_validation_rejects_zero() {
601 let cap = Kill;
603 let result = Capability::execute(
604 &cap,
605 &serde_json::json!({ "pid": 999998, "signal": 0 }),
606 &Context {
607 dry_run: false,
608 job_id: "test".into(),
609 working_dir: std::env::current_dir().unwrap(),
610 },
611 );
612 assert!(result.is_err());
613 assert!(result.unwrap_err().to_string().contains("Invalid signal"));
614 }
615
616 #[test]
617 fn test_signal_validation_rejects_out_of_range() {
618 let cap = Kill;
620 let result = Capability::execute(
621 &cap,
622 &serde_json::json!({ "pid": 999998, "signal": 32 }),
623 &Context {
624 dry_run: false,
625 job_id: "test".into(),
626 working_dir: std::env::current_dir().unwrap(),
627 },
628 );
629 assert!(result.is_err());
630 }
631
632 #[test]
633 fn test_signal_validation_accepts_valid_signals() {
634 let cap = Kill;
635 for sig in [1, 9, 15, 31, 64] {
636 let result = Capability::execute(
637 &cap,
638 &serde_json::json!({ "pid": 999998, "signal": sig }),
639 &Context {
640 dry_run: false,
641 job_id: "test".into(),
642 working_dir: std::env::current_dir().unwrap(),
643 },
644 );
645 if let Err(e) = &result {
648 assert!(
649 !e.to_string().contains("Invalid signal"),
650 "Signal {} should be valid, got: {}",
651 sig,
652 e
653 );
654 }
655 }
656 }
657
658 #[test]
659 fn test_dry_run_hides_process_info() {
660 let cap = Kill;
662 let result = Capability::execute(
663 &cap,
664 &serde_json::json!({ "pid": 999998 }),
665 &Context {
666 dry_run: true,
667 job_id: "test".into(),
668 working_dir: std::env::current_dir().unwrap(),
669 },
670 )
671 .unwrap();
672
673 assert_eq!(result.status, "ok");
674 assert!(result.data.as_ref().unwrap()["dry_run"].as_bool() == Some(true));
675 assert!(
676 result.data.as_ref().unwrap().get("command").is_none(),
677 "dry-run must not expose command"
678 );
679 assert!(
680 result.data.as_ref().unwrap().get("user").is_none(),
681 "dry-run must not expose user"
682 );
683 assert!(
684 result
685 .data
686 .as_ref()
687 .unwrap()
688 .get("process_exists")
689 .is_none(),
690 "dry-run must not expose process_exists"
691 );
692 }
693
694 #[test]
695 fn test_protected_pids_includes_self_and_parent() {
696 let protected = protected_pids();
697 let self_pid = std::process::id();
698 assert!(protected.contains(&1), "PID 1 should be protected");
699 assert!(protected.contains(&2), "PID 2 should be protected");
700 assert!(
701 protected.contains(&self_pid),
702 "self PID should be protected"
703 );
704 }
705
706 #[test]
707 fn test_get_process_start_time_retry() {
708 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
710 let pid = child.id();
711
712 let result = get_process_start_time_retry(pid);
713 assert!(
714 result.is_some(),
715 "Should read start time for running process"
716 );
717
718 child.kill().ok();
719 let _ = child.wait();
720
721 let result = get_process_start_time_retry(999999);
723 assert!(result.is_none(), "Non-existent PID should return None");
724 }
725}