Skip to main content

proc_cli/commands/
ports.rs

1//! `proc ports` - List all listening TCP ports
2//!
3//! Examples:
4//!   proc ports              # Show all listening TCP ports
5//!   proc ports --by node     # Only ports of the processes named node
6//!   proc ports --by node --cmd  # ... or with node anywhere in their command line
7//!   proc ports --exposed    # Only network-accessible ports (0.0.0.0, ::)
8//!   proc ports --local      # Only localhost ports (127.0.0.1, ::1)
9//!   proc ports -v           # Show with executable paths
10
11use crate::core::{matches_name, resolve_in_dir, NameMatch, Names, PortInfo, PortSortKey, Process};
12use crate::error::Result;
13use crate::ui::{truncate_string, Printer};
14use clap::Args;
15use colored::*;
16use serde::Serialize;
17use std::collections::{HashMap, HashSet};
18use std::io::Write;
19use std::path::Path;
20
21/// Whether the process is known and its working directory is inside `dir`
22fn cwd_within(proc: Option<&Process>, dir: &Path) -> bool {
23    proc.and_then(|p| p.cwd.as_deref())
24        .is_some_and(|cwd| crate::core::filters::cwd_within(cwd, dir))
25}
26
27/// Whether the process behind a port matches `--by`, the way it does in every
28/// other command. A process that ended since its port was listed is known only
29/// by the name the port was listed with.
30fn port_named(port: &PortInfo, process: Option<&Process>, pattern: &str, mode: NameMatch) -> bool {
31    match process {
32        Some(process) => matches_name(pattern, &process.names(), mode),
33        None => matches_name(
34            pattern,
35            &Names {
36                name: &port.process_name,
37                ..Names::default()
38            },
39            mode,
40        ),
41    }
42}
43
44/// An inclusive port range, START-END, such as 8000-9000
45fn parse_port_range(s: &str) -> std::result::Result<(u16, u16), String> {
46    let usage = || format!("'{s}' is not a range of ports; use START-END, e.g. 8000-9000");
47    let (start, end) = s.split_once('-').ok_or_else(usage)?;
48    let port = |p: &str| p.trim().parse::<u16>().ok().filter(|&p| p > 0);
49    let (start, end) = match (port(start), port(end)) {
50        (Some(start), Some(end)) => (start, end),
51        _ => return Err(format!("{}; ports go from 1 to 65535", usage())),
52    };
53    if start > end {
54        return Err(format!(
55            "'{s}' starts after it ends; did you mean {end}-{start}?"
56        ));
57    }
58    Ok((start, end))
59}
60
61/// List all listening TCP ports
62#[derive(Args, Debug)]
63pub struct PortsCommand {
64    /// Filter by process name
65    #[arg(long = "by", short = 'b')]
66    pub by_name: Option<String>,
67
68    /// Match --by as a substring of the process name or full command line (like pkill -f)
69    #[arg(long)]
70    pub cmd: bool,
71
72    /// Filter by directory; takes the next word as the directory unless it starts with '-'
73    /// (bare --in is the current directory, --in=DIR is explicit)
74    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
75    pub in_dir: Option<String>,
76
77    /// Only show network-exposed ports (0.0.0.0, ::)
78    #[arg(long, short = 'e')]
79    pub exposed: bool,
80
81    /// Only show localhost ports (127.0.0.1, ::1)
82    #[arg(long, short = 'l')]
83    pub local: bool,
84
85    /// Output as JSON
86    #[arg(long, short = 'j')]
87    pub json: bool,
88
89    /// Show verbose output (includes executable path)
90    #[arg(long, short = 'v')]
91    pub verbose: bool,
92
93    /// Sort by: port, pid, name
94    #[arg(long, short = 's', value_enum, default_value_t = PortSortKey::Port)]
95    pub sort: PortSortKey,
96
97    /// Limit the number of results
98    #[arg(long, short = 'n')]
99    pub limit: Option<usize>,
100
101    /// Filter ports by range (e.g., 8000-9000)
102    #[arg(long, short = 'r', value_parser = parse_port_range)]
103    pub range: Option<(u16, u16)>,
104}
105
106impl PortsCommand {
107    /// Executes the ports command, listing all listening TCP ports.
108    pub fn execute(&self) -> Result<()> {
109        let mut ports = PortInfo::get_all_listening()?;
110
111        // Filter by process name if specified
112        if let Some(ref name) = self.by_name {
113            let mode = NameMatch::from_flag(self.cmd);
114            let running: HashMap<u32, Process> = Process::find_all()?
115                .into_iter()
116                .map(|p| (p.pid, p))
117                .collect();
118            ports.retain(|p| port_named(p, running.get(&p.pid), name, mode));
119        }
120
121        // Filter by directory if specified
122        if let Some(dir_path) = resolve_in_dir(&self.in_dir) {
123            ports.retain(|p| {
124                cwd_within(
125                    Process::find_by_pid(p.pid).ok().flatten().as_ref(),
126                    &dir_path,
127                )
128            });
129        }
130
131        // Filter by address exposure
132        if self.exposed {
133            ports.retain(PortInfo::is_exposed);
134        }
135
136        if self.local {
137            ports.retain(PortInfo::is_local);
138        }
139
140        // Filter by port range (e.g., "3000-9000"), validated when parsed
141        if let Some((start, end)) = self.range {
142            ports.retain(|p| p.port >= start && p.port <= end);
143        }
144
145        // Sort ports
146        match self.sort {
147            PortSortKey::Port => ports.sort_by_key(|p| p.port),
148            PortSortKey::Pid => ports.sort_by_key(|p| p.pid),
149            PortSortKey::Name => ports.sort_by(|a, b| {
150                a.process_name
151                    .to_lowercase()
152                    .cmp(&b.process_name.to_lowercase())
153            }),
154        }
155
156        // Apply limit if specified
157        if let Some(limit) = self.limit {
158            ports.truncate(limit);
159        }
160
161        // In verbose mode, fetch process info for paths
162        let process_map: HashMap<u32, Process> = if self.verbose {
163            // One lookup per PID; a process that ended since its port was listed has no entry
164            let pids: HashSet<u32> = ports.iter().map(|p| p.pid).collect();
165            pids.into_iter()
166                .filter_map(|pid| {
167                    let proc = Process::find_by_pid(pid).ok().flatten()?;
168                    Some((pid, proc))
169                })
170                .collect()
171        } else {
172            HashMap::new()
173        };
174
175        if self.json {
176            self.print_json(&ports, &process_map);
177        } else {
178            self.print_human(&mut std::io::stdout(), &ports, &process_map)
179                .expect("failed printing to stdout");
180        }
181
182        Ok(())
183    }
184
185    fn print_human(
186        &self,
187        out: &mut impl Write,
188        ports: &[PortInfo],
189        process_map: &HashMap<u32, Process>,
190    ) -> std::io::Result<()> {
191        if ports.is_empty() {
192            return writeln!(out, "{} No listening ports found", "⚠".yellow().bold());
193        }
194
195        writeln!(
196            out,
197            "{} Found {} listening port{}",
198            "✓".green().bold(),
199            ports.len().to_string().cyan().bold(),
200            if ports.len() == 1 { "" } else { "s" }
201        )?;
202        writeln!(out)?;
203
204        // Header
205        writeln!(
206            out,
207            "{:<8} {:<10} {:<8} {:<20} {:<15}",
208            "PORT".bright_blue().bold(),
209            "PROTO".bright_blue().bold(),
210            "PID".bright_blue().bold(),
211            "PROCESS".bright_blue().bold(),
212            "ADDRESS".bright_blue().bold()
213        )?;
214        writeln!(out, "{}", "─".repeat(65).bright_black())?;
215
216        for port in ports {
217            let addr = port.address.as_deref().unwrap_or("*");
218            let proto = format!("{:?}", port.protocol).to_uppercase();
219
220            writeln!(
221                out,
222                "{:<8} {:<10} {:<8} {:<20} {:<15}",
223                port.port.to_string().cyan().bold(),
224                proto.white(),
225                port.pid.to_string().cyan(),
226                truncate_string(&port.process_name, 19).white(),
227                addr.bright_black()
228            )?;
229
230            // In verbose mode, show path
231            if self.verbose {
232                if let Some(proc) = process_map.get(&port.pid) {
233                    if let Some(ref path) = proc.exe_path {
234                        writeln!(
235                            out,
236                            "         {} {}",
237                            "↳".bright_black(),
238                            truncate_string(path, 55).bright_black()
239                        )?;
240                    }
241                }
242            }
243        }
244        writeln!(out)
245    }
246
247    fn print_json(&self, ports: &[PortInfo], process_map: &HashMap<u32, Process>) {
248        let printer = Printer::from_flags(true, self.verbose);
249
250        let enriched: Vec<PortWithProcess> = ports
251            .iter()
252            .map(|p| PortWithProcess {
253                port: p,
254                exe_path: process_map
255                    .get(&p.pid)
256                    .and_then(|proc| proc.exe_path.as_deref()),
257            })
258            .collect();
259
260        printer.print_json(&PortsOutput {
261            action: "ports",
262            success: true,
263            count: ports.len(),
264            ports: enriched,
265        });
266    }
267}
268
269#[derive(Serialize)]
270struct PortWithProcess<'a> {
271    #[serde(flatten)]
272    port: &'a PortInfo,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    exe_path: Option<&'a str>,
275}
276
277#[derive(Serialize)]
278struct PortsOutput<'a> {
279    action: &'static str,
280    success: bool,
281    count: usize,
282    ports: Vec<PortWithProcess<'a>>,
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::core::{ProcessStatus, Protocol};
289    use crate::test_support::assert_stops_at_a_failed_write;
290
291    fn process(cwd: Option<&str>, exe_path: Option<&str>) -> Process {
292        Process {
293            pid: 1,
294            name: "x".to_string(),
295            exe_path: exe_path.map(String::from),
296            cwd: cwd.map(String::from),
297            command: None,
298            argv0: None,
299            raw: None,
300            cpu_percent: 0.0,
301            memory_mb: 0.0,
302            status: ProcessStatus::Sleeping,
303            user: None,
304            parent_pid: None,
305            start_time: None,
306            uptime_secs: None,
307        }
308    }
309
310    fn listed(name: &str) -> PortInfo {
311        PortInfo {
312            port: 4000,
313            protocol: Protocol::Tcp,
314            pid: 1,
315            process_name: name.to_string(),
316            address: None,
317        }
318    }
319
320    #[test]
321    fn a_port_is_named_by_its_process_else_by_the_name_it_was_listed_with() {
322        let mut node = process(None, Some("/opt/tools/serve"));
323        node.name = "node".to_string();
324        node.command = Some("node app.js --engine deno".to_string());
325        let port = listed("stale-name");
326
327        // the process wins: its name, its executable, not the listing's name
328        assert!(port_named(&port, Some(&node), "NODE", NameMatch::Exact));
329        assert!(port_named(&port, Some(&node), "serve", NameMatch::Exact));
330        assert!(!port_named(
331            &port,
332            Some(&node),
333            "stale-name",
334            NameMatch::Exact
335        ));
336        // part of the name, or the command line, only with --cmd
337        assert!(!port_named(&port, Some(&node), "nod", NameMatch::Exact));
338        assert!(!port_named(&port, Some(&node), "deno", NameMatch::Exact));
339        assert!(port_named(&port, Some(&node), "nod", NameMatch::Command));
340        assert!(port_named(&port, Some(&node), "deno", NameMatch::Command));
341
342        // the process ended since the port was listed: only the listed name is left
343        assert!(port_named(&port, None, "Stale-Name", NameMatch::Exact));
344        assert!(!port_named(&port, None, "stale", NameMatch::Exact));
345        assert!(port_named(&port, None, "stale", NameMatch::Command));
346        assert!(!port_named(&port, None, "node", NameMatch::Command));
347    }
348
349    #[test]
350    fn only_a_known_process_inside_the_directory_passes_in() {
351        let dir = Path::new("/work");
352        assert!(cwd_within(Some(&process(Some("/work/app"), None)), dir));
353        assert!(!cwd_within(Some(&process(Some("/elsewhere"), None)), dir));
354        // No working directory (a zombie has none), or no process at all
355        // (it ended since its port was listed)
356        assert!(!cwd_within(Some(&process(None, None)), dir));
357        assert!(!cwd_within(None, dir));
358    }
359
360    #[test]
361    fn verbose_listing_shows_a_path_only_when_the_process_has_one() {
362        colored::control::set_override(false);
363        let cmd = PortsCommand {
364            by_name: None,
365            cmd: false,
366            in_dir: None,
367            exposed: false,
368            local: false,
369            json: false,
370            verbose: true,
371            sort: PortSortKey::Port,
372            limit: None,
373            range: None,
374        };
375        let port = PortInfo {
376            port: 4000,
377            protocol: Protocol::Tcp,
378            pid: 1,
379            process_name: "x".to_string(),
380            address: None,
381        };
382        let listing = |map: &HashMap<u32, Process>| {
383            let mut out = Vec::new();
384            cmd.print_human(&mut out, std::slice::from_ref(&port), map)
385                .unwrap();
386            String::from_utf8(out).unwrap()
387        };
388
389        // The row is there, with `*` for the missing address, in every case
390        let without_entry = listing(&HashMap::new());
391        let row = without_entry
392            .lines()
393            .find(|l| l.starts_with("4000"))
394            .unwrap();
395        assert!(
396            row.contains("TCP") && row.trim_end().ends_with('*'),
397            "{row}"
398        );
399        assert!(!without_entry.contains('↳'), "{without_entry}");
400
401        let without_path = listing(&HashMap::from([(1, process(None, None))]));
402        assert!(!without_path.contains('↳'), "{without_path}");
403
404        let with_path = listing(&HashMap::from([(1, process(None, Some("/bin/x")))]));
405        assert!(with_path.contains("↳ /bin/x\n"), "{with_path}");
406    }
407
408    #[test]
409    fn an_empty_listing_says_so() {
410        colored::control::set_override(false);
411        let cmd = PortsCommand {
412            by_name: None,
413            cmd: false,
414            in_dir: None,
415            exposed: false,
416            local: false,
417            json: false,
418            verbose: false,
419            sort: PortSortKey::Port,
420            limit: None,
421            range: None,
422        };
423        let mut out = Vec::new();
424        cmd.print_human(&mut out, &[], &HashMap::new()).unwrap();
425        assert_eq!(
426            String::from_utf8(out).unwrap(),
427            "⚠ No listening ports found\n"
428        );
429    }
430
431    #[test]
432    fn a_failed_write_ends_the_listing() {
433        let cmd = PortsCommand {
434            by_name: None,
435            cmd: false,
436            in_dir: None,
437            exposed: false,
438            local: false,
439            json: false,
440            verbose: true,
441            sort: PortSortKey::Port,
442            limit: None,
443            range: None,
444        };
445        let port = PortInfo {
446            port: 4000,
447            protocol: Protocol::Tcp,
448            pid: 1,
449            process_name: "x".to_string(),
450            address: Some("127.0.0.1".to_string()),
451        };
452        let with_path = HashMap::from([(1, process(None, Some("/bin/x")))]);
453        assert_stops_at_a_failed_write(|w| {
454            cmd.print_human(w, std::slice::from_ref(&port), &with_path)
455        });
456        assert_stops_at_a_failed_write(|w| cmd.print_human(w, &[], &HashMap::new()));
457    }
458}