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