runtimo_core/capabilities/
kill.rs1use crate::capability::{Capability, Context, Output};
29use crate::processes::ProcessSnapshot;
30use crate::{Error, Result};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use std::time::Duration;
34
35#[cfg(test)]
36use std::process::Command;
37
38#[allow(clippy::arithmetic_side_effects)]
44fn get_process_start_time(pid: u32) -> Option<u64> {
45 let stat_path = format!("/proc/{}/stat", pid);
46 let content = std::fs::read_to_string(&stat_path).ok()?;
47 let last_paren = content.rfind(')')?;
48 let fields: Vec<&str> = content[last_paren + 2..].split_whitespace().collect();
49 fields.get(19)?.parse::<u64>().ok()
50}
51fn get_process_start_time_retry(pid: u32) -> Option<u64> {
52 #[allow(clippy::arithmetic_side_effects)] for attempt in 0..3 {
54 if attempt > 0 {
55 std::thread::sleep(std::time::Duration::from_millis(10 * (1 << attempt)));
56 }
57 if let Some(start_time) = get_process_start_time(pid) {
58 return Some(start_time);
59 }
60 }
61 None
62}
63
64fn get_process_cgroup(pid: u32) -> Option<String> {
68 std::fs::read_to_string(format!("/proc/{}/cgroup", pid)).ok()
69}
70
71fn is_systemd_service(cgroup: &str) -> bool {
73 cgroup.contains("/system.slice/")
74 || cgroup.contains("/init.scope")
75 || cgroup.contains("systemd")
76}
77
78fn protected_pids() -> Vec<u32> {
82 let mut pids = vec![1, 2];
83 let self_pid = std::process::id();
84 pids.push(self_pid);
85
86 if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", self_pid)) {
88 if let Some(ppid_str) = status
89 .lines()
90 .find(|l| l.starts_with("PPid:"))
91 .and_then(|l| l.split_whitespace().nth(1))
92 {
93 if let Ok(ppid) = ppid_str.parse::<u32>() {
94 pids.push(ppid);
95 }
96 }
97 }
98
99 if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", self_pid)) {
101 if let Some(sid_str) = status
102 .lines()
103 .find(|l| l.starts_with("Sid:"))
104 .and_then(|l| l.split_whitespace().nth(1))
105 {
106 if let Ok(sid) = sid_str.parse::<u32>() {
107 if sid != 0 {
108 pids.push(sid);
109 }
110 }
111 }
112 }
113
114 if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", self_pid)) {
116 if let Some(pgid_str) = status
117 .lines()
118 .find(|l| l.starts_with("NSpgid:"))
119 .and_then(|l| l.split_whitespace().nth(1))
120 {
121 if let Ok(pgid) = pgid_str.parse::<u32>() {
122 if pgid != 0 {
123 pids.push(pgid);
124 }
125 }
126 }
127 }
128
129 if let Ok(entries) = std::fs::read_dir("/proc") {
131 for entry in entries.flatten() {
132 if let Ok(name) = entry.file_name().into_string() {
133 if let Ok(pid) = name.parse::<u32>() {
134 if let Some(cgroup) = get_process_cgroup(pid) {
135 if is_systemd_service(&cgroup) {
136 pids.push(pid);
137 }
138 }
139 }
140 }
141 }
142 }
143
144 pids.sort_unstable();
145 pids.dedup();
146 pids
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct KillArgs {
152 pub pid: u32,
154 pub signal: Option<i32>,
156}
157
158#[allow(clippy::exhaustive_structs)]
171pub struct Kill;
172
173impl Capability for Kill {
174 fn name(&self) -> &'static str {
175 "Kill"
176 }
177
178 fn description(&self) -> &'static str {
179 "kill PID. Protected: init,kthreadd,self. Custom sig ok."
180 }
181
182 fn schema(&self) -> Value {
187 serde_json::json!({
188 "type": "object",
189 "properties": {
190 "pid": { "type": "integer", "minimum": 1 },
191 "signal": {
192 "type": "integer",
193 "anyOf": [
194 { "minimum": 1, "maximum": 31 },
195 { "enum": [64] }
196 ]
197 }
198 },
199 "required": ["pid"]
200 })
201 }
202
203 fn validate(&self, args: &Value) -> Result<()> {
204 let args: KillArgs = serde_json::from_value(args.clone())
205 .map_err(|e| Error::SchemaValidationFailed(e.to_string()))?;
206
207 if let Some(signal) = args.signal {
209 if !(1..=31).contains(&signal) && signal != 64 {
210 return Err(Error::SchemaValidationFailed(format!(
211 "Invalid signal {}: must be 1-31 or 64 (POSIX signals)",
212 signal
213 )));
214 }
215 }
216
217 Ok(())
218 }
219
220 fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
221 let args: KillArgs = serde_json::from_value(args.clone())
222 .map_err(|e| Error::ExecutionFailed(e.to_string()))?;
223
224 let protected = protected_pids();
226 if protected.contains(&args.pid) {
227 return Err(Error::ExecutionFailed(format!(
228 "PID {} is a protected system process (protected: {:?})",
229 args.pid, protected
230 )));
231 }
232
233 if ctx.dry_run {
235 return Ok(Output {
237 success: true,
238 data: serde_json::json!({
239 "pid": args.pid,
240 "killed": false,
241 "dry_run": true,
242 "signal": args.signal.unwrap_or(15),
243 }),
244 message: Some(format!("DRY RUN: would kill PID {}", args.pid)),
245 });
246 }
247
248 let process_before = ProcessSnapshot::capture();
250 let process_exists = process_before.processes.iter().any(|p| p.pid == args.pid);
251
252 if !process_exists {
253 return Ok(Output {
254 success: false,
255 data: serde_json::json!({
256 "pid": args.pid,
257 "killed": false,
258 "reason": "Process not found"
259 }),
260 message: Some(format!("Process {} not found", args.pid)),
261 });
262 }
263
264 let process_info: Option<(String, String)> = process_before
266 .processes
267 .iter()
268 .find(|p| p.pid == args.pid)
269 .map(|p| (p.command.clone(), p.user.clone()));
270
271 let start_time_before = get_process_start_time_retry(args.pid);
273
274 let signal = args.signal.unwrap_or(15);
276
277 #[allow(clippy::cast_possible_wrap)]
281 let kill_result = unsafe { libc::kill(args.pid as libc::pid_t, signal) };
282 let success = kill_result == 0;
283 let stderr_str = if success {
284 String::new()
285 } else {
286 std::io::Error::last_os_error().to_string()
287 };
288
289 std::thread::sleep(Duration::from_millis(500));
291
292 ProcessSnapshot::clear_cache();
294
295 let process_after = ProcessSnapshot::capture();
297
298 let process_still_exists = process_after
300 .processes
301 .iter()
302 .any(|p| p.pid == args.pid && !p.stat.starts_with('Z'));
303 let pid_reused = match (start_time_before, get_process_start_time_retry(args.pid)) {
305 (Some(before_time), Some(after_time)) => before_time != after_time,
306 (None, _) => false,
307 (Some(_), None) => true,
308 };
309
310 let killed_success = success && !process_still_exists && !pid_reused;
311
312 let message = if killed_success {
313 format!("Killed process {} (signal {})", args.pid, signal)
314 } else if pid_reused {
315 format!(
316 "PID {} was reused by a different process (start time changed)",
317 args.pid
318 )
319 } else if !success {
320 format!("Failed to kill process {}: {}", args.pid, stderr_str)
321 } else {
322 format!("Process {} still exists after signal {}", args.pid, signal)
323 };
324
325 Ok(Output {
326 success: killed_success,
327 data: serde_json::json!({
328 "pid": args.pid,
329 "killed": killed_success,
330 "signal": signal,
331 "command": process_info.as_ref().map(|(cmd, _)| cmd),
332 "user": process_info.as_ref().map(|(_, user)| user),
333 "stderr": if success { String::new() } else { stderr_str },
334 "pid_reused": pid_reused,
335 "process_before": {
336 "count": process_before.summary.total_processes,
337 "zombies": process_before.summary.zombie_count
338 },
339 "process_after": {
340 "count": process_after.summary.total_processes,
341 "zombies": process_after.summary.zombie_count
342 }
343 }),
344 message: Some(message),
345 })
346 }
347}
348
349#[cfg(test)]
350#[allow(clippy::unnecessary_map_or)]
351mod tests {
352 use super::*;
353 use crate::capability::Capability;
354 use std::thread;
355 use std::time::Duration;
356
357 #[test]
358 fn test_kill_schema() {
359 let cap = Kill;
360 let _schema = cap.schema();
361 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
364 let pid = child.id();
365
366 let result = get_process_start_time_retry(pid);
367 assert!(
368 result.is_some(),
369 "Should read start time for running process"
370 );
371
372 child.kill().ok();
373 let _ = child.wait();
374
375 let result = get_process_start_time_retry(999999);
377 assert!(result.is_none(), "Non-existent PID should return None");
378 }
379
380 #[test]
381 fn test_kill_protected_pid() {
382 let cap = Kill;
383 let result = cap.execute(
385 &serde_json::json!({ "pid": 1 }),
386 &Context {
387 dry_run: false,
388 job_id: "test".into(),
389 working_dir: std::env::current_dir().unwrap(),
390 },
391 );
392
393 assert!(result.is_err());
395 assert!(result
396 .unwrap_err()
397 .to_string()
398 .contains("protected system process"));
399 }
400
401 #[test]
402 fn test_kill_self_protected() {
403 let cap = Kill;
404 let self_pid = std::process::id();
405 let result = cap.execute(
406 &serde_json::json!({ "pid": self_pid }),
407 &Context {
408 dry_run: false,
409 job_id: "test".into(),
410 working_dir: std::env::current_dir().unwrap(),
411 },
412 );
413
414 assert!(result.is_err());
415 assert!(result.unwrap_err().to_string().contains("protected"));
416 }
417
418 #[test]
419 fn test_kill_nonexistent() {
420 let cap = Kill;
421 let result = cap
423 .execute(
424 &serde_json::json!({ "pid": 999999 }),
425 &Context {
426 dry_run: false,
427 job_id: "test".into(),
428 working_dir: std::env::current_dir().unwrap(),
429 },
430 )
431 .unwrap();
432
433 assert!(!result.success);
434 assert!(result.data["killed"].as_bool() == Some(false));
435 }
436
437 #[test]
438 fn test_kill_dry_run() {
439 let cap = Kill;
440 let result = cap
444 .execute(
445 &serde_json::json!({ "pid": 999998 }),
446 &Context {
447 dry_run: true,
448 job_id: "test".into(),
449 working_dir: std::env::current_dir().unwrap(),
450 },
451 )
452 .unwrap();
453
454 assert!(result.success);
455 assert!(result.data["dry_run"].as_bool() == Some(true));
456 assert!(result.data["killed"].as_bool() == Some(false));
457 }
458
459 #[test]
460 fn test_kill_actual_process() {
461 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
463 let pid = child.id();
464
465 thread::sleep(Duration::from_millis(100));
467
468 let pre_check = Command::new("kill").arg("-0").arg(pid.to_string()).output();
470 assert!(
471 pre_check.unwrap().status.success(),
472 "Process should exist before kill"
473 );
474
475 ProcessSnapshot::clear_cache();
477
478 let cap = Kill;
480 let result = cap
481 .execute(
482 &serde_json::json!({ "pid": pid, "signal": 9 }),
483 &Context {
484 dry_run: false,
485 job_id: "test".into(),
486 working_dir: std::env::current_dir().unwrap(),
487 },
488 )
489 .unwrap();
490
491 assert!(
493 result.data["killed"].as_bool() == Some(true),
494 "Kill failed: {:?}",
495 result.data
496 );
497 assert!(
498 result.data["signal"].as_i64() == Some(9),
499 "Should use SIGKILL"
500 );
501
502 let _ = child.wait();
504
505 let post_check = Command::new("kill").arg("-0").arg(pid.to_string()).output();
507 let still_alive = post_check.map_or(false, |o| o.status.success());
508 assert!(
509 !still_alive,
510 "Process {} should be dead after kill and reap",
511 pid
512 );
513 }
514
515 #[test]
516 fn test_get_process_start_time() {
517 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
519 let pid = child.id();
520
521 let start_time = get_process_start_time(pid);
522 assert!(
523 start_time.is_some(),
524 "Should be able to read start time for running process"
525 );
526
527 let start_time2 = get_process_start_time(pid);
529 assert_eq!(start_time, start_time2, "Start time should be stable");
530
531 child.kill().ok();
532 let _ = child.wait();
533 }
534
535 #[test]
536 fn test_get_process_start_time_nonexistent() {
537 let result = get_process_start_time(999999);
538 assert!(result.is_none(), "Non-existent PID should return None");
539 }
540
541 #[test]
542 fn test_signal_validation_rejects_negative() {
543 let cap = Kill;
545 let result = cap.validate(&serde_json::json!({ "pid": 999998, "signal": -1 }));
546 assert!(result.is_err());
547 assert!(result.unwrap_err().to_string().contains("Invalid signal"));
548 }
549
550 #[test]
551 fn test_signal_validation_rejects_zero() {
552 let cap = Kill;
554 let result = cap.validate(&serde_json::json!({ "pid": 999998, "signal": 0 }));
555 assert!(result.is_err());
556 assert!(result.unwrap_err().to_string().contains("Invalid signal"));
557 }
558
559 #[test]
560 fn test_signal_validation_rejects_out_of_range() {
561 let cap = Kill;
563 let result = cap.validate(&serde_json::json!({ "pid": 999998, "signal": 32 }));
564 assert!(result.is_err());
565 }
566
567 #[test]
568 fn test_signal_validation_accepts_valid_signals() {
569 let cap = Kill;
570 for sig in [1, 9, 15, 31, 64] {
571 let result = cap.validate(&serde_json::json!({ "pid": 999998, "signal": sig }));
572 assert!(result.is_ok(), "Signal {} should be valid", sig);
573 }
574 }
575
576 #[test]
577 fn test_dry_run_hides_process_info() {
578 let cap = Kill;
580 let result = cap
581 .execute(
582 &serde_json::json!({ "pid": 999998 }),
583 &Context {
584 dry_run: true,
585 job_id: "test".into(),
586 working_dir: std::env::current_dir().unwrap(),
587 },
588 )
589 .unwrap();
590
591 assert!(result.success);
592 assert!(result.data["dry_run"].as_bool() == Some(true));
593 assert!(
594 result.data.get("command").is_none(),
595 "dry-run must not expose command"
596 );
597 assert!(
598 result.data.get("user").is_none(),
599 "dry-run must not expose user"
600 );
601 assert!(
602 result.data.get("process_exists").is_none(),
603 "dry-run must not expose process_exists"
604 );
605 }
606
607 #[test]
608 fn test_protected_pids_includes_self_and_parent() {
609 let protected = protected_pids();
610 let self_pid = std::process::id();
611 assert!(protected.contains(&1), "PID 1 should be protected");
612 assert!(protected.contains(&2), "PID 2 should be protected");
613 assert!(
614 protected.contains(&self_pid),
615 "self PID should be protected"
616 );
617 }
618
619 #[test]
620 fn test_get_process_start_time_retry() {
621 let mut child = Command::new("sleep").arg("60").spawn().unwrap();
623 let pid = child.id();
624
625 let result = get_process_start_time_retry(pid);
626 assert!(
627 result.is_some(),
628 "Should read start time for running process"
629 );
630
631 child.kill().ok();
632 let _ = child.wait();
633
634 let result = get_process_start_time_retry(999999);
636 assert!(result.is_none(), "Non-existent PID should return None");
637 }
638}