Skip to main content

proc_cli/core/
target.rs

1//! Target resolution - Convert user input to processes
2//!
3//! Targets can be:
4//! - `:port` - Process listening on this port
5//! - `pid` - Process with this PID (numeric)
6//! - `name` - Processes matching this name
7
8use crate::core::port::{parse_port, PortInfo};
9use crate::core::{NameMatch, Process};
10use crate::error::{ProcError, Result};
11
12/// Resolved target type
13#[derive(Debug, Clone)]
14pub enum TargetType {
15    /// Target a process by the port it listens on (e.g., `:3000`)
16    Port(u16),
17    /// Target a process by its process ID (e.g., `1234`)
18    Pid(u32),
19    /// Target processes by name (e.g., `node`): the whole name, or with `--cmd`
20    /// any part of the name or command line
21    Name(String),
22}
23
24/// clap value parser for target arguments: a target that starts with ':' must be
25/// a port from 1 to 65535. Without this, ":70000" fell through to a name search
26/// and reported "not found" instead of a typo.
27pub fn validate_target_arg(s: &str) -> std::result::Result<String, String> {
28    let targets = parse_targets(s);
29    // "" or "," names nothing; without this, `watch ""` watched every process
30    if targets.is_empty() {
31        return Err("a target can't be empty: give a PID, :port or name".to_string());
32    }
33    for target in targets {
34        if let Some(port) = target.strip_prefix(':') {
35            if !matches!(port.parse::<u16>(), Ok(p) if p > 0) {
36                return Err(format!(
37                    "'{target}' is not a port; ports go from :1 to :65535"
38                ));
39            }
40        }
41    }
42    Ok(s.to_string())
43}
44
45/// Parse a target string and determine its type
46pub fn parse_target(target: &str) -> TargetType {
47    let target = target.trim();
48
49    // Explicit port prefix
50    if target.starts_with(':') {
51        if let Ok(port) = parse_port(target) {
52            return TargetType::Port(port);
53        }
54    }
55
56    // Pure number - treat as PID
57    if let Ok(pid) = target.parse::<u32>() {
58        return TargetType::Pid(pid);
59    }
60
61    // Otherwise it's a name
62    TargetType::Name(target.to_string())
63}
64
65/// Resolve a target to processes; `mode` says how a name is compared
66pub fn resolve_target(target: &str, mode: NameMatch) -> Result<Vec<Process>> {
67    match parse_target(target) {
68        TargetType::Port(port) => resolve_port(port),
69        TargetType::Pid(pid) => resolve_pid(pid),
70        TargetType::Name(name) => Process::find_by_name(&name, mode),
71    }
72}
73
74/// Resolve a port to every process listening on it
75fn resolve_port(port: u16) -> Result<Vec<Process>> {
76    processes_on_port(port, PortInfo::holders_of(port)?, Process::find_by_pid)
77}
78
79/// The processes behind `listeners`, what was found listening on `port`, in
80/// that order; `find` looks a process up by PID. A holder can end between the
81/// two lookups and is left out, unless that leaves nobody.
82fn processes_on_port(
83    port: u16,
84    listeners: Vec<PortInfo>,
85    mut find: impl FnMut(u32) -> Result<Option<Process>>,
86) -> Result<Vec<Process>> {
87    let Some(first) = listeners.first().map(|l| l.pid) else {
88        return Err(ProcError::PortNotFound(port));
89    };
90    let mut found = Vec::new();
91    for listener in &listeners {
92        if let Some(proc) = find(listener.pid)? {
93            found.push(proc);
94        }
95    }
96    if found.is_empty() {
97        Err(ProcError::ProcessGone(first))
98    } else {
99        Ok(found)
100    }
101}
102
103/// Resolve PID to process
104fn resolve_pid(pid: u32) -> Result<Vec<Process>> {
105    match Process::find_by_pid(pid)? {
106        Some(proc) => Ok(vec![proc]),
107        None => Err(ProcError::ProcessNotFound(pid.to_string())),
108    }
109}
110
111/// Find all ports a process is listening on
112pub fn find_ports_for_pid(pid: u32) -> Result<Vec<PortInfo>> {
113    let all_ports = PortInfo::get_all_listening()?;
114    Ok(all_ports.into_iter().filter(|p| p.pid == pid).collect())
115}
116
117/// Each process with the ports it listens on, from one reading of the port
118/// table. A reading runs programs on Windows (two `netstat`s, and a
119/// `tasklist` for each socket), so reading it once per process made `proc
120/// for` on a few processes run past a minute there.
121pub fn with_ports(processes: Vec<Process>) -> Result<Vec<(Process, Vec<PortInfo>)>> {
122    let mut listening = None;
123    let mut results = Vec::with_capacity(processes.len());
124    for proc in processes {
125        let all = match &mut listening {
126            Some(all) => all,
127            unread => unread.insert(PortInfo::get_all_listening()?),
128        };
129        let ports = all.iter().filter(|p| p.pid == proc.pid).cloned().collect();
130        results.push((proc, ports));
131    }
132    Ok(results)
133}
134
135/// Split comma-separated targets into individual target strings
136///
137/// Examples:
138///   ":3000,:8080" -> [":3000", ":8080"]
139///   "node,python" -> ["node", "python"]
140///   ":3000, 1234, node" -> [":3000", "1234", "node"]
141pub fn parse_targets(targets_str: &str) -> Vec<String> {
142    targets_str
143        .split(',')
144        .map(|s| s.trim().to_string())
145        .filter(|s| !s.is_empty())
146        .collect()
147}
148
149/// Split every target argument the user typed into individual targets
150///
151/// A command takes its targets as separate arguments, and a value may still be
152/// a comma-separated list, so each value goes through [`parse_targets`].
153///
154/// Examples:
155///   ["123", ":3000,:8080", "node"] -> ["123", ":3000", ":8080", "node"]
156pub fn flatten_targets(values: &[String]) -> Vec<String> {
157    values.iter().flat_map(|v| parse_targets(v)).collect()
158}
159
160/// The commands whose positional arguments are targets that [`flatten_targets`]
161/// splits on commas, with the clap id of that argument. `by`, `tree`, `in` and
162/// `for` take one value that commas never split, so they are not here.
163const COMMA_SPLIT_ARGS: &[(&str, &str)] = &[
164    ("on", "target"),
165    ("info", "targets"),
166    ("list", "name"),
167    ("kill", "target"),
168    ("stop", "target"),
169    ("watch", "target"),
170    ("unstick", "target"),
171    ("freeze", "target"),
172    ("thaw", "target"),
173    ("why", "target"),
174    ("free", "target"),
175    ("wait", "target"),
176];
177
178/// A word as a shell would need it typed: quoted when it holds anything but
179/// plain characters, so the notice's example can be pasted as it stands.
180fn shell_word(word: &str) -> String {
181    let plain = |c: char| c.is_ascii_alphanumeric() || ":._-/@%+=".contains(c);
182    if !word.is_empty() && word.chars().all(plain) {
183        word.to_string()
184    } else {
185        format!("'{}'", word.replace('\'', "'\\''"))
186    }
187}
188
189/// The deprecation notice for comma-separated targets, if the command line
190/// used any, or `None`.
191///
192/// Only the target arguments count (see `COMMA_SPLIT_ARGS`): a comma in a
193/// `--by` or `--in` value, or in the one value of `by`, `in` or `for`, is just
194/// text. Nothing is returned under `--json`, whose output must not change.
195/// The notice is one string however many commas there are, and its example is
196/// the user's own targets, split and joined by spaces.
197pub fn comma_notice(matches: &clap::ArgMatches) -> Option<String> {
198    let (command, sub) = matches.subcommand()?;
199    let (_, id) = COMMA_SPLIT_ARGS.iter().find(|(name, _)| *name == command)?;
200    if sub.get_flag("json") {
201        return None;
202    }
203    let typed: Vec<String> = sub.get_many::<String>(id)?.cloned().collect();
204    if !typed.iter().any(|value| value.contains(',')) {
205        return None;
206    }
207    let targets: Vec<String> = flatten_targets(&typed)
208        .iter()
209        .map(|target| shell_word(target))
210        .collect();
211    Some(format!(
212        "comma-separated targets are deprecated and will be removed in a future major \
213         release; pass them as separate arguments: proc {command} {}",
214        targets.join(" ")
215    ))
216}
217
218/// What resolving several targets came to
219#[derive(Debug, Default)]
220pub struct Resolved {
221    /// The processes found, each once however many targets name it
222    pub processes: Vec<Process>,
223    /// The targets that matched nothing
224    pub not_found: Vec<String>,
225    /// For a name that matched nothing, but is in some command lines: what
226    /// `--cmd` would find
227    pub hints: Vec<String>,
228}
229
230impl Resolved {
231    /// The warning for the targets that matched nothing, if there were any
232    pub fn warning(&self) -> Option<String> {
233        if self.not_found.is_empty() {
234            return None;
235        }
236        let mut warning = format!("Not found: {}", self.not_found.join(", "));
237        for hint in &self.hints {
238            warning.push_str("\n  ");
239            warning.push_str(hint);
240        }
241        Some(warning)
242    }
243}
244
245/// Resolve multiple targets, deduplicating by PID
246pub fn resolve_targets(targets: &[String], mode: NameMatch) -> Resolved {
247    resolve_targets_impl(targets, false, mode)
248}
249
250/// Resolve multiple targets, excluding the current process (self)
251///
252/// Use this for destructive commands (kill, stop) to avoid proc killing itself
253/// when the target pattern matches proc's own command line arguments.
254pub fn resolve_targets_excluding_self(targets: &[String], mode: NameMatch) -> Resolved {
255    resolve_targets_impl(targets, true, mode)
256}
257
258fn resolve_targets_impl(targets: &[String], exclude_self: bool, mode: NameMatch) -> Resolved {
259    use std::collections::HashSet;
260
261    let mut resolved = Resolved::default();
262    let mut seen_pids = HashSet::new();
263    let self_pid = std::process::id();
264
265    for target in targets {
266        match resolve_target(target, mode) {
267            Ok(processes) => {
268                for proc in processes {
269                    // Skip self if requested (for destructive commands)
270                    if exclude_self && proc.pid == self_pid {
271                        continue;
272                    }
273                    if seen_pids.insert(proc.pid) {
274                        resolved.processes.push(proc);
275                    }
276                }
277            }
278            Err(error) => {
279                resolved.not_found.push(target.clone());
280                resolved.hints.extend(error.command_line_hint());
281            }
282        }
283    }
284
285    resolved
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn with_ports_gives_each_process_its_own_ports() {
294        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
295        let port = listener.local_addr().unwrap().port();
296        let me = Process::find_by_pid(std::process::id()).unwrap().unwrap();
297        let mut child = crate::test_support::idle_child();
298        let other = crate::test_support::record_of(&child);
299        // the first process reads the table, the second one uses that reading
300        let results = with_ports(vec![me, other]).unwrap();
301        crate::test_support::end(&mut child);
302        assert!(results[0].1.iter().any(|p| p.port == port), "{results:?}");
303        assert!(results[1].1.is_empty(), "{results:?}");
304    }
305
306    #[test]
307    fn test_parse_targets_single() {
308        assert_eq!(parse_targets(":3000"), vec![":3000"]);
309        assert_eq!(parse_targets("node"), vec!["node"]);
310        assert_eq!(parse_targets("1234"), vec!["1234"]);
311    }
312
313    #[test]
314    fn test_parse_targets_multiple() {
315        assert_eq!(parse_targets(":3000,:8080"), vec![":3000", ":8080"]);
316        assert_eq!(parse_targets("node,python"), vec!["node", "python"]);
317        assert_eq!(
318            parse_targets(":3000,1234,node"),
319            vec![":3000", "1234", "node"]
320        );
321    }
322
323    #[test]
324    fn test_parse_targets_with_whitespace() {
325        assert_eq!(
326            parse_targets(":3000, :8080, :9000"),
327            vec![":3000", ":8080", ":9000"]
328        );
329        assert_eq!(parse_targets(" node , python "), vec!["node", "python"]);
330    }
331
332    #[test]
333    fn separate_arguments_and_comma_lists_flatten_to_one_list_in_order() {
334        let typed = |values: &[&str]| -> Vec<String> {
335            flatten_targets(&values.iter().map(|v| v.to_string()).collect::<Vec<_>>())
336        };
337        assert_eq!(typed(&["123", "456"]), vec!["123", "456"]);
338        assert_eq!(
339            typed(&["123", ":3000,:8080", "node"]),
340            vec!["123", ":3000", ":8080", "node"]
341        );
342        assert_eq!(typed(&["a,b", ",", " c "]), vec!["a", "b", "c"]);
343        // a repeated target stays repeated: the PID dedupe happens when resolving
344        assert_eq!(typed(&["1", "1,1"]), vec!["1", "1", "1"]);
345        assert!(typed(&[]).is_empty());
346    }
347
348    #[test]
349    fn the_commands_that_split_on_commas_are_the_ones_with_a_target_list() {
350        use clap::CommandFactory;
351        let cli = crate::cli::Cli::command();
352        for sub in cli.get_subcommands() {
353            let name = sub.get_name();
354            let listed = COMMA_SPLIT_ARGS.iter().find(|(n, _)| *n == name);
355            // a list of targets: a positional that takes any number of values
356            let lists: Vec<&str> = sub
357                .get_positionals()
358                .filter(|a| a.get_num_args().is_some_and(|n| n.max_values() > 1))
359                .map(|a| a.get_id().as_str())
360                .collect();
361            match listed {
362                Some((_, id)) => assert_eq!(lists, vec![*id], "{name}"),
363                None => assert!(lists.is_empty(), "{name} takes a list but isn't listed"),
364            }
365        }
366    }
367
368    #[test]
369    fn shell_words_are_quoted_only_when_they_need_to_be() {
370        assert_eq!(shell_word(":3000"), ":3000");
371        assert_eq!(shell_word("node"), "node");
372        assert_eq!(shell_word("my app"), "'my app'");
373        assert_eq!(shell_word("it's"), r"'it'\''s'");
374        assert_eq!(shell_word(""), "''");
375    }
376
377    #[test]
378    fn test_parse_targets_empty_entries() {
379        assert_eq!(parse_targets(":3000,,,:8080"), vec![":3000", ":8080"]);
380        assert_eq!(parse_targets(",,node,,"), vec!["node"]);
381    }
382
383    #[test]
384    fn test_parse_target_port() {
385        assert!(matches!(parse_target(":3000"), TargetType::Port(3000)));
386        assert!(matches!(parse_target(":8080"), TargetType::Port(8080)));
387    }
388
389    #[test]
390    fn test_parse_target_pid() {
391        assert!(matches!(parse_target("1234"), TargetType::Pid(1234)));
392        assert!(matches!(parse_target("99999"), TargetType::Pid(99999)));
393    }
394
395    #[test]
396    fn a_colon_target_that_is_not_a_port_is_a_name() {
397        for target in [":abc", ":70000", ":"] {
398            assert!(
399                matches!(parse_target(target), TargetType::Name(n) if n == target),
400                "{target}"
401            );
402        }
403    }
404
405    #[test]
406    fn test_parse_target_name() {
407        assert!(matches!(parse_target("node"), TargetType::Name(_)));
408        assert!(matches!(parse_target("my-process"), TargetType::Name(_)));
409    }
410
411    #[test]
412    fn only_a_colon_target_has_to_be_a_port() {
413        // names and PIDs are left alone, alone or in a list with ports
414        assert_eq!(validate_target_arg("node").unwrap(), "node");
415        assert_eq!(validate_target_arg("1234").unwrap(), "1234");
416        assert_eq!(validate_target_arg(":3000, node").unwrap(), ":3000, node");
417        for bad in [":70000", ":0", ":abc", "node,:70000"] {
418            let err = validate_target_arg(bad).unwrap_err();
419            assert!(err.contains("is not a port"), "{bad}: {err}");
420        }
421    }
422
423    fn listener(pid: u32) -> PortInfo {
424        PortInfo {
425            port: 4000,
426            protocol: crate::core::Protocol::Tcp,
427            pid,
428            process_name: "x".to_string(),
429            address: None,
430        }
431    }
432
433    fn own_process() -> Process {
434        Process::find_by_pid(std::process::id()).unwrap().unwrap()
435    }
436
437    #[test]
438    fn a_port_resolves_to_the_process_listening_on_it() {
439        let found = processes_on_port(4000, vec![listener(7)], |pid| {
440            assert_eq!(pid, 7);
441            Ok(Some(own_process()))
442        })
443        .unwrap();
444        assert_eq!(found.len(), 1);
445
446        let none = processes_on_port(4000, Vec::new(), |_| panic!("no listener, no lookup"));
447        assert!(
448            matches!(none, Err(ProcError::PortNotFound(4000))),
449            "{none:?}"
450        );
451
452        // the process ended after the port was listed
453        let gone = processes_on_port(4000, vec![listener(7)], |_| Ok(None));
454        assert!(matches!(gone, Err(ProcError::ProcessGone(7))), "{gone:?}");
455
456        let failed = processes_on_port(4000, vec![listener(7)], |_| {
457            Err(ProcError::SystemError("no".to_string()))
458        });
459        assert!(
460            matches!(failed, Err(ProcError::SystemError(_))),
461            "{failed:?}"
462        );
463    }
464
465    #[test]
466    fn a_port_resolves_to_every_process_listening_on_it() {
467        let mut asked = Vec::new();
468        let found = processes_on_port(4000, vec![listener(7), listener(8), listener(9)], |pid| {
469            asked.push(pid);
470            // 8 ended after the port was listed; the others are there
471            Ok((pid != 8).then(own_process))
472        })
473        .unwrap();
474        assert_eq!(asked, vec![7, 8, 9]);
475        assert_eq!(found.len(), 2, "the holder that ended is left out");
476
477        // every holder gone: the first one is named
478        let gone = processes_on_port(4000, vec![listener(7), listener(8)], |_| Ok(None));
479        assert!(matches!(gone, Err(ProcError::ProcessGone(7))), "{gone:?}");
480    }
481
482    #[test]
483    fn targets_resolve_once_each_and_never_to_ourselves_when_asked() {
484        let me = std::process::id().to_string();
485        let targets = vec![me.clone(), me.clone(), "99999999".to_string()];
486
487        // listed twice, found once; the made-up PID is not found
488        let resolved = resolve_targets(&targets, NameMatch::Exact);
489        assert_eq!(resolved.processes.len(), 1);
490        assert_eq!(resolved.processes[0].pid, std::process::id());
491        assert_eq!(resolved.not_found, vec!["99999999".to_string()]);
492        assert_eq!(
493            resolved.warning().unwrap(),
494            "Not found: 99999999",
495            "a PID has no --cmd hint"
496        );
497
498        let resolved = resolve_targets_excluding_self(&targets, NameMatch::Exact);
499        assert!(resolved.processes.is_empty());
500        assert_eq!(resolved.not_found, vec!["99999999".to_string()]);
501    }
502
503    #[test]
504    fn nothing_missing_means_no_warning() {
505        let me = std::process::id().to_string();
506        let resolved = resolve_targets(&[me], NameMatch::Exact);
507        assert!(resolved.not_found.is_empty());
508        assert_eq!(resolved.warning(), None);
509    }
510}