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