Skip to main content

proc_cli/core/
port.rs

1//! Port discovery and management
2//!
3//! Provides cross-platform utilities for discovering which processes
4//! are listening on network ports.
5
6use crate::core::{escape_control, Process};
7use crate::error::{ProcError, Result};
8use serde::{Deserialize, Serialize};
9#[cfg(target_os = "linux")]
10use std::path::Path;
11// lsof on macOS, sockstat on FreeBSD and NetBSD, netstat and tasklist on
12// Windows; Linux reads /proc instead
13#[cfg(not(target_os = "linux"))]
14use std::process::Command;
15
16/// Network protocol
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Protocol {
20    /// Transmission Control Protocol - reliable, ordered delivery
21    Tcp,
22    /// User Datagram Protocol - fast, connectionless delivery
23    Udp,
24}
25
26/// Information about a listening port
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PortInfo {
29    /// Port number
30    pub port: u16,
31    /// Protocol (TCP/UDP)
32    pub protocol: Protocol,
33    /// Process ID using this port
34    pub pid: u32,
35    /// Process name
36    pub process_name: String,
37    /// Bind address (e.g., "0.0.0.0", "127.0.0.1", "::")
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub address: Option<String>,
40}
41
42impl PortInfo {
43    /// Get all listening ports on the system, port ascending and, for one port,
44    /// PID ascending. The system lists them in its own order (lsof's on macOS,
45    /// the kernel's socket table on Linux, netstat's on Windows), so the order
46    /// is fixed here once for everything that reads ports: a process's ports are
47    /// ascending in `on`, `for` and `why`, and the holders of a port are by PID.
48    pub fn get_all_listening() -> Result<Vec<PortInfo>> {
49        #[cfg(target_os = "macos")]
50        let mut ports = Self::get_listening_macos()?;
51        #[cfg(target_os = "linux")]
52        let mut ports = Self::get_listening_linux()?;
53        #[cfg(target_os = "windows")]
54        let mut ports = Self::get_listening_windows()?;
55        #[cfg(any(target_os = "freebsd", target_os = "netbsd"))]
56        let mut ports = Self::get_listening_bsd()?;
57        ports.sort_by_key(|p| (p.port, p.pid));
58        Ok(ports)
59    }
60
61    /// Every process listening on a port, each once, PID ascending (empty when
62    /// nothing listens). One port number can be held by several processes:
63    /// `127.0.0.1` and `[::1]` are different sockets.
64    pub fn holders_of(port: u16) -> Result<Vec<PortInfo>> {
65        Ok(holders_in(Self::get_all_listening()?, port))
66    }
67
68    /// Get the full process info for this port's process
69    pub fn get_process(&self) -> Result<Option<Process>> {
70        Process::find_by_pid(self.pid)
71    }
72
73    /// FreeBSD and NetBSD: `sockstat`, in the base system of both, lists the
74    /// listening sockets with the process that holds each one. NetBSD's has no
75    /// `-P` (protocol) option, so its UDP rows are dropped by the parser, and
76    /// needs `-n` for numeric addresses
77    #[cfg(any(target_os = "freebsd", target_os = "netbsd"))]
78    fn get_listening_bsd() -> Result<Vec<PortInfo>> {
79        #[cfg(target_os = "freebsd")]
80        let args = ["-4", "-6", "-l", "-P", "tcp"];
81        #[cfg(target_os = "netbsd")]
82        let args = ["-4", "-6", "-l", "-n"];
83        let output = crate::ui::interrupt::unblocked_in_child(&mut Command::new("sockstat"))
84            .args(args)
85            .output()
86            .map_err(|e| ProcError::SystemError(format!("Failed to run sockstat: {}", e)))?;
87
88        let mut ports = Self::parse_sockstat_output(
89            &String::from_utf8_lossy(&output.stdout),
90            cfg!(target_os = "netbsd"),
91        );
92        // sockstat cuts names short (FreeBSD keeps 10 characters): name each
93        // holder as the process list does, keeping sockstat's name for one
94        // that is already gone
95        let pids: Vec<u32> = ports.iter().map(|p| p.pid).collect();
96        let names = crate::core::process::names_by_pid(&pids);
97        for port in &mut ports {
98            if let Some(name) = names.get(&port.pid) {
99                port.process_name = name.clone();
100            }
101        }
102        Ok(ports)
103    }
104
105    /// The listening TCP ports in sockstat's output, one entry per port and
106    /// process. `dotted`: the address and port are joined with a dot
107    /// (`127.0.0.1.8080`, NetBSD) rather than a colon (`127.0.0.1:8080`,
108    /// FreeBSD). Compiled on other systems for the tests only
109    #[cfg(any(target_os = "freebsd", target_os = "netbsd", test))]
110    fn parse_sockstat_output(stdout: &str, dotted: bool) -> Vec<PortInfo> {
111        let mut ports = Vec::new();
112        let mut seen = std::collections::HashSet::new();
113        for port_info in stdout
114            .lines()
115            .filter_map(|line| Self::parse_sockstat_line(line, dotted))
116        {
117            // One entry per port and process, as on macOS: a socket a process
118            // holds on both IPv4 and IPv6, or under several descriptors, is one
119            if seen.insert((port_info.port, port_info.pid)) {
120                ports.push(port_info);
121            }
122        }
123        ports
124    }
125
126    /// One sockstat row: `USER COMMAND PID FD PROTO LOCAL FOREIGN`. Read from
127    /// the right, so a command name with a space in it stays whole; the header
128    /// and anything that isn't a listening TCP socket give `None`
129    #[cfg(any(target_os = "freebsd", target_os = "netbsd", test))]
130    fn parse_sockstat_line(line: &str, dotted: bool) -> Option<PortInfo> {
131        let parts: Vec<&str> = line.split_whitespace().collect();
132        let n = parts.len();
133        if n < 7 {
134            return None;
135        }
136        let (proto, local, foreign) = (parts[n - 3], parts[n - 2], parts[n - 1]);
137        if !proto.starts_with("tcp") || !foreign.starts_with('*') {
138            return None;
139        }
140        let pid: u32 = parts[n - 5].parse().ok()?;
141        let process_name = escape_control(&parts[1..n - 5].join(" ")).into_owned();
142        let (address, port) = if dotted {
143            let (host, port) = local.rsplit_once('.')?;
144            split_listen_address(&format!("{host}:{port}"))?
145        } else {
146            split_listen_address(local)?
147        };
148        Some(PortInfo {
149            port,
150            protocol: Protocol::Tcp,
151            pid,
152            process_name,
153            address: Some(address),
154        })
155    }
156
157    #[cfg(target_os = "macos")]
158    fn get_listening_macos() -> Result<Vec<PortInfo>> {
159        // Use lsof on macOS - only TCP LISTEN sockets. `+c 0` prints the whole
160        // command name; by default lsof cuts it to 9 characters
161        let output = crate::ui::interrupt::unblocked_in_child(&mut Command::new("lsof"))
162            .args(["+c", "0", "-iTCP", "-sTCP:LISTEN", "-P", "-n"])
163            .output()
164            .map_err(|e| ProcError::SystemError(format!("Failed to run lsof: {}", e)))?;
165
166        Ok(Self::parse_lsof_output(&String::from_utf8_lossy(
167            &output.stdout,
168        )))
169    }
170
171    /// The listening ports in lsof's output, one entry per port and process
172    /// (compiled on other systems for the tests only)
173    #[cfg(any(target_os = "macos", test))]
174    fn parse_lsof_output(stdout: &str) -> Vec<PortInfo> {
175        let mut ports = Vec::new();
176        let mut seen = std::collections::HashSet::new();
177
178        for line in stdout.lines().skip(1) {
179            // Skip header
180            if let Some(port_info) = Self::parse_lsof_line(line) {
181                // Deduplicate (same port can appear multiple times for IPv4/IPv6)
182                let key = (port_info.port, port_info.pid);
183                if seen.insert(key) {
184                    ports.push(port_info);
185                }
186            }
187        }
188
189        ports
190    }
191
192    /// lsof writes characters it can't print in a column, spaces included, as
193    /// \xNN, so "Activity Monitor" arrives as "Activity\x20Monitor".
194    ///
195    /// Only printable ASCII is decoded. Control characters stay escaped, so a
196    /// process name can't send escape sequences to the terminal, and the name is
197    /// read as bytes, so a "\x" before a multi-byte character can't panic.
198    #[cfg(any(target_os = "macos", test))]
199    fn unescape_lsof(name: &str) -> String {
200        fn hex(b: u8) -> Option<u8> {
201            (b as char).to_digit(16).map(|d| d as u8)
202        }
203        let bytes = name.as_bytes();
204        let mut out = Vec::with_capacity(bytes.len());
205        let mut i = 0;
206        while i < bytes.len() {
207            if let [b'\\', b'x', hi, lo, ..] = bytes[i..] {
208                if let (Some(hi), Some(lo)) = (hex(hi), hex(lo)) {
209                    let decoded = hi << 4 | lo;
210                    if (0x20..=0x7e).contains(&decoded) {
211                        out.push(decoded);
212                        i += 4;
213                        continue;
214                    }
215                }
216            }
217            out.push(bytes[i]);
218            i += 1;
219        }
220        String::from_utf8_lossy(&out).into_owned()
221    }
222
223    #[cfg(any(target_os = "macos", test))]
224    fn parse_lsof_line(line: &str) -> Option<PortInfo> {
225        // lsof output format:
226        // COMMAND  PID USER  FD  TYPE  DEVICE  SIZE/OFF  NODE  NAME
227        // rapportd 643 zee   8u  IPv4  0x...   0t0       TCP   *:52633 (LISTEN)
228        let parts: Vec<&str> = line.split_whitespace().collect();
229        if parts.len() < 9 {
230            return None;
231        }
232
233        let process_name = escape_control(&Self::unescape_lsof(parts[0])).into_owned();
234        let pid: u32 = parts[1].parse().ok()?;
235
236        // Find the NAME column - it's after the NODE (TCP/UDP) column
237        // The NAME looks like "*:3000" or "127.0.0.1:8080" or "*:52633 (LISTEN)"
238        // Find the column that contains a colon and looks like an address:port
239        let name_col = parts.iter().skip(8).find(|p| p.contains(':'))?;
240
241        // Remove any trailing state like "(LISTEN)" by taking just the address:port part
242        let addr_port =
243            name_col.trim_end_matches(|c: char| c == ')' || c.is_alphabetic() || c == '(');
244
245        let (address, port) = split_listen_address(addr_port)?;
246
247        Some(PortInfo {
248            port,
249            protocol: Protocol::Tcp,
250            pid,
251            process_name,
252            address: Some(address),
253        })
254    }
255
256    /// Linux: read the kernel's socket tables directly, as `ss` does, so nothing
257    /// needs to be installed. Listening sockets come from /proc/net/tcp and tcp6,
258    /// and each socket's holders are all the processes with that socket open in
259    /// /proc/<pid>/fd (a master and its workers share one).
260    /// Sockets of other users' processes have no visible owner without root, so
261    /// they are left out, as with `ss -p`.
262    #[cfg(target_os = "linux")]
263    fn get_listening_linux() -> Result<Vec<PortInfo>> {
264        Self::listening_from(Path::new("/proc/net"), Path::new("/proc"))
265    }
266
267    /// `get_listening_linux` reading the socket tables from `net` and the
268    /// processes from `procs`, which are /proc/net and /proc
269    #[cfg(target_os = "linux")]
270    fn listening_from(net: &Path, procs: &Path) -> Result<Vec<PortInfo>> {
271        let mut sockets = Vec::new();
272        let mut read_any = false;
273        for (table, v6) in [("tcp", false), ("tcp6", true)] {
274            // tcp6 is missing when IPv6 is disabled
275            let Ok(text) = std::fs::read_to_string(net.join(table)) else {
276                continue;
277            };
278            read_any = true;
279            sockets.extend(
280                text.lines()
281                    .skip(1)
282                    .filter_map(|line| Self::parse_proc_net_line(line, v6)),
283            );
284        }
285        if !read_any {
286            return Err(ProcError::SystemError(
287                "Can't read /proc/net/tcp to look up ports".to_string(),
288            ));
289        }
290
291        let wanted: std::collections::HashSet<u64> = sockets.iter().map(|s| s.0).collect();
292        let owners = Self::socket_owners(&wanted, procs);
293
294        let mut ports = Vec::new();
295        let mut seen = std::collections::HashSet::new();
296        for (inode, port, address) in sockets {
297            let Some(pids) = owners.get(&inode) else {
298                continue;
299            };
300            // every process with the socket open holds the port (a master and
301            // its forked workers share one listen socket)
302            for &pid in pids {
303                // Same port on IPv4 and IPv6 is listed once, as on macOS
304                if !seen.insert((port, pid)) {
305                    continue;
306                }
307                // any process can set its own comm (prctl), escape sequences included
308                let process_name = std::fs::read_to_string(procs.join(format!("{pid}/comm")))
309                    .map(|name| {
310                        let name = name.strip_suffix('\n').unwrap_or(&name);
311                        escape_control(name).into_owned()
312                    })
313                    .unwrap_or_else(|_| "unknown".to_string());
314                ports.push(PortInfo {
315                    port,
316                    protocol: Protocol::Tcp,
317                    pid,
318                    process_name,
319                    address: Some(address.clone()),
320                });
321            }
322        }
323        Ok(ports)
324    }
325
326    /// Parse a line of /proc/net/tcp or tcp6 into (socket inode, port, address),
327    /// keeping only listening sockets. Addresses are hex in the kernel's byte
328    /// order: 0100007F:1F90 is 127.0.0.1:8080.
329    #[cfg(target_os = "linux")]
330    fn parse_proc_net_line(line: &str, v6: bool) -> Option<(u64, u16, String)> {
331        const LISTEN: &str = "0A";
332        let fields: Vec<&str> = line.split_whitespace().collect();
333        let (local, state, inode) = (fields.get(1)?, fields.get(3)?, fields.get(9)?);
334        if *state != LISTEN {
335            return None;
336        }
337        let (addr_hex, port_hex) = local.split_once(':')?;
338        let port = u16::from_str_radix(port_hex, 16).ok()?;
339        let inode: u64 = inode.parse().ok()?;
340
341        // Each 32-bit word is printed as a number in host byte order
342        let word = |i: usize| -> Option<[u8; 4]> {
343            let hex = addr_hex.get(i * 8..i * 8 + 8)?;
344            Some(u32::from_str_radix(hex, 16).ok()?.to_ne_bytes())
345        };
346        let address = if v6 {
347            if addr_hex.len() != 32 {
348                return None;
349            }
350            let mut bytes = [0u8; 16];
351            for i in 0..4 {
352                bytes[i * 4..i * 4 + 4].copy_from_slice(&word(i)?);
353            }
354            std::net::Ipv6Addr::from(bytes).to_string()
355        } else {
356            if addr_hex.len() != 8 {
357                return None;
358            }
359            std::net::Ipv4Addr::from(word(0)?).to_string()
360        };
361        Some((inode, port, address))
362    }
363
364    /// Map socket inodes to the PIDs that have them open, by reading the
365    /// "socket:[inode]" links in /proc/<pid>/fd. Unreadable processes are skipped.
366    #[cfg(target_os = "linux")]
367    fn socket_owners(
368        wanted: &std::collections::HashSet<u64>,
369        procs: &Path,
370    ) -> std::collections::HashMap<u64, Vec<u32>> {
371        let mut owners = std::collections::HashMap::new();
372        let Ok(procs) = std::fs::read_dir(procs) else {
373            return owners;
374        };
375        for entry in procs.flatten() {
376            let Some(pid) = entry
377                .file_name()
378                .to_str()
379                .and_then(|s| s.parse::<u32>().ok())
380            else {
381                continue;
382            };
383            let Ok(fds) = std::fs::read_dir(entry.path().join("fd")) else {
384                continue;
385            };
386            for fd in fds.flatten() {
387                let Ok(target) = std::fs::read_link(fd.path()) else {
388                    continue;
389                };
390                let inode = target
391                    .to_str()
392                    .and_then(|t| t.strip_prefix("socket:["))
393                    .and_then(|t| t.strip_suffix(']'))
394                    .and_then(|t| t.parse::<u64>().ok());
395                if let Some(inode) = inode.filter(|i| wanted.contains(i)) {
396                    // a process with the socket open on several fds counts once
397                    let pids = owners.entry(inode).or_insert_with(Vec::new);
398                    if !pids.contains(&pid) {
399                        pids.push(pid);
400                    }
401                }
402            }
403        }
404        for pids in owners.values_mut() {
405            pids.sort_unstable();
406        }
407        owners
408    }
409
410    #[cfg(target_os = "windows")]
411    fn get_listening_windows() -> Result<Vec<PortInfo>> {
412        // `netstat -p TCP` lists IPv4 sockets only; IPv6 ones need `-p TCPv6`
413        let v4 = Command::new("netstat")
414            .args(["-ano", "-p", "TCP"])
415            .output()
416            .map_err(|e| ProcError::SystemError(format!("Failed to run netstat: {}", e)))?;
417        // A machine without IPv6 may refuse the second call; that leaves the
418        // IPv4 listeners, not an error
419        let v6 = Command::new("netstat")
420            .args(["-ano", "-p", "TCPv6"])
421            .output()
422            .ok()
423            .filter(|o| o.status.success())
424            .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
425            .unwrap_or_default();
426
427        // One process list names every socket: a `tasklist` per socket made
428        // each reading take seconds, and `proc ports --in` run past a minute
429        let names = Self::process_names_windows();
430        Ok(Self::parse_netstat_output(
431            &[&String::from_utf8_lossy(&v4.stdout), &v6],
432            |pid| names.get(&pid).cloned(),
433        ))
434    }
435
436    /// The listening ports in the outputs of netstat, one per protocol
437    /// (`-p TCP` and `-p TCPv6`); `name_of` says what a PID is called
438    /// (compiled on other systems for the tests only)
439    #[cfg(any(target_os = "windows", test))]
440    fn parse_netstat_output(
441        outputs: &[&str],
442        name_of: impl Fn(u32) -> Option<String>,
443    ) -> Vec<PortInfo> {
444        let mut ports = Vec::new();
445
446        for line in outputs.iter().flat_map(|o| o.lines()) {
447            if line.contains("LISTENING") {
448                if let Some(port_info) = Self::parse_netstat_line(line, &name_of) {
449                    ports.push(port_info);
450                }
451            }
452        }
453
454        ports
455    }
456
457    #[cfg(any(target_os = "windows", test))]
458    fn parse_netstat_line(
459        line: &str,
460        name_of: &impl Fn(u32) -> Option<String>,
461    ) -> Option<PortInfo> {
462        let parts: Vec<&str> = line.split_whitespace().collect();
463        if parts.len() < 5 {
464            return None;
465        }
466
467        // Local address is column 2 (e.g., "0.0.0.0:135" or "[::]:135")
468        let (address, port) = split_listen_address(parts[1])?;
469
470        // PID is the last column
471        let pid: u32 = parts.last()?.parse().ok()?;
472
473        // Get process name from PID
474        let process_name = name_of(pid).unwrap_or_else(|| "unknown".to_string());
475
476        Some(PortInfo {
477            port,
478            protocol: Protocol::Tcp,
479            pid,
480            process_name,
481            address: Some(address),
482        })
483    }
484
485    /// What each running process is called, by PID, as `tasklist` names it
486    /// (`listener.exe`)
487    #[cfg(target_os = "windows")]
488    fn process_names_windows() -> std::collections::HashMap<u32, String> {
489        let mut sys = sysinfo::System::new();
490        sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
491        sys.processes()
492            .iter()
493            .map(|(pid, p)| {
494                let name = p.name().to_string_lossy();
495                (pid.as_u32(), escape_control(&name).into_owned())
496            })
497            .collect()
498    }
499
500    /// Whether the port is open to the network: bound to every address. A port
501    /// with no known address counts as open.
502    pub fn is_exposed(&self) -> bool {
503        self.address
504            .as_ref()
505            .map(|a| a == "0.0.0.0" || a == "::" || a == "*")
506            .unwrap_or(true)
507    }
508
509    /// Whether the port is bound to loopback only
510    pub fn is_local(&self) -> bool {
511        self.address
512            .as_ref()
513            .map(|a| a == "127.0.0.1" || a == "::1")
514            .unwrap_or(false)
515    }
516}
517
518/// The listeners in `listening` that hold `port`, one per process: a process
519/// that listens on both an IPv4 and an IPv6 address is one holder, listed with
520/// the first address
521fn holders_in(listening: Vec<PortInfo>, port: u16) -> Vec<PortInfo> {
522    let mut seen = std::collections::HashSet::new();
523    listening
524        .into_iter()
525        .filter(|p| p.port == port && seen.insert(p.pid))
526        .collect()
527}
528
529/// Split a listening socket's local address, as lsof and netstat print it, into
530/// the bind address and the port. IPv6 addresses come in brackets (`[::1]:631`,
531/// `[fe80::1%eth0]:22`) and are returned without them, as the kernel tables on
532/// Linux are; `*`, every address, is returned as `0.0.0.0`. A scope suffix
533/// (`%eth0`) stays.
534#[cfg(any(
535    target_os = "macos",
536    target_os = "windows",
537    target_os = "freebsd",
538    target_os = "netbsd",
539    test
540))]
541fn split_listen_address(local: &str) -> Option<(String, u16)> {
542    let (host, port) = local.rsplit_once(':')?;
543    let port = port.parse().ok()?;
544    let host = host
545        .strip_prefix('[')
546        .and_then(|h| h.strip_suffix(']'))
547        .unwrap_or(host);
548    let address = if host.is_empty() || host == "*" {
549        "0.0.0.0"
550    } else {
551        host
552    };
553    Some((address.to_string(), port))
554}
555
556/// Parse a port from various formats (":3000", "3000", etc.)
557pub fn parse_port(input: &str) -> Result<u16> {
558    let cleaned = input.trim().trim_start_matches(':');
559    cleaned
560        .parse()
561        .map_err(|_| ProcError::InvalidInput(format!("Invalid port: '{}'", input)))
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_parse_port() {
570        assert_eq!(parse_port(":3000").unwrap(), 3000);
571        assert_eq!(parse_port("3000").unwrap(), 3000);
572        assert_eq!(parse_port("  :8080  ").unwrap(), 8080);
573    }
574
575    #[test]
576    fn test_parse_port_invalid() {
577        assert!(parse_port("abc").is_err());
578        assert!(parse_port("").is_err());
579    }
580
581    #[test]
582    fn test_parse_lsof_line_keeps_the_full_name() {
583        // With `+c 0` lsof prints the whole name, escaping spaces as \x20; the
584        // default 9-character column used to turn python3.14 into "python3.1"
585        let line = "Activity\\x20Monitor  644  zee  8u  IPv4 0x3cc8c443bf3e8f62  0t0  TCP 127.0.0.1:7768 (LISTEN)";
586        let info = PortInfo::parse_lsof_line(line).unwrap();
587        assert_eq!(info.process_name, "Activity Monitor");
588        assert_eq!(info.pid, 644);
589        assert_eq!(info.port, 7768);
590    }
591
592    /// (port, pid, name, address) of each entry, for comparing parses
593    fn rows(ports: &[PortInfo]) -> Vec<(u16, u32, &str, &str)> {
594        ports
595            .iter()
596            .map(|p| {
597                let address = p.address.as_deref().unwrap_or("");
598                (p.port, p.pid, p.process_name.as_str(), address)
599            })
600            .collect()
601    }
602
603    #[test]
604    fn freebsd_sockstat_rows_are_listening_ports() {
605        // `sockstat -4 -6 -l -P tcp` on FreeBSD 15.1, plus a command with a
606        // space in its name
607        let out = "\
608USER COMMAND     PID FD PROTO LOCAL ADDRESS         FOREIGN ADDRESS
609proc nc         2651  3 tcp6  [::1]:45680           *:*
610proc nc         2650  3 tcp4  *:45679               *:*
611proc nc         2649  3 tcp4  127.0.0.1:45678       *:*
612root sshd       2542  6 tcp6  *:22                  *:*
613root sshd       2542  7 tcp4  *:22                  *:*
614proc Web Content 2700 4 tcp46 *:8080                *:*
615";
616        assert_eq!(
617            rows(&PortInfo::parse_sockstat_output(out, false)),
618            vec![
619                (45680, 2651, "nc", "::1"),
620                (45679, 2650, "nc", "0.0.0.0"),
621                (45678, 2649, "nc", "127.0.0.1"),
622                // the IPv4 row of the same port and process adds nothing
623                (22, 2542, "sshd", "0.0.0.0"),
624                (8080, 2700, "Web Content", "0.0.0.0"),
625            ]
626        );
627    }
628
629    #[test]
630    fn netbsd_sockstat_rows_are_listening_tcp_ports() {
631        // `sockstat -4 -6 -l -n` on NetBSD 11.0: a dot before the port, UDP
632        // and raw sockets mixed in (no -P), and a connected socket
633        let out = "\
634USER     COMMAND    PID   FD PROTO  LOCAL ADDRESS         FOREIGN ADDRESS
635root     dhcpcd     545   13 2/0    *.*                   *.*
636_mdnsd   mdnsd      555    3 udp    *.65535               *.*
637proc     nc         3546   3 tcp    *.45679               *.*
638root     sshd       5563   6 tcp6   *.22                  *.*
639root     sshd       5563   7 tcp    *.22                  *.*
640proc     nc         5715   3 tcp6   ::1.45680             *.*
641proc     nc         11181  3 tcp    127.0.0.1.45678       *.*
642proc     nc         11181  4 tcp    127.0.0.1.45678       127.0.0.1.65534
643";
644        assert_eq!(
645            rows(&PortInfo::parse_sockstat_output(out, true)),
646            vec![
647                (45679, 3546, "nc", "0.0.0.0"),
648                (22, 5563, "sshd", "0.0.0.0"),
649                (45680, 5715, "nc", "::1"),
650                (45678, 11181, "nc", "127.0.0.1"),
651            ]
652        );
653    }
654
655    #[test]
656    fn a_sockstat_line_that_is_not_a_listener_is_skipped() {
657        for (line, dotted) in [
658            (
659                "USER COMMAND PID FD PROTO LOCAL ADDRESS FOREIGN ADDRESS",
660                false,
661            ),
662            ("proc nc 2654 3 tcp4 127.0.0.1:58879 127.0.0.1:45678", false),
663            ("proc nc 2654 3 tcp4 127.0.0.1:http *:*", false),
664            ("proc nc x 3 tcp4 127.0.0.1:80 *:*", false),
665            // an unbound socket has no port
666            ("proc nc 12 3 tcp *.* *.*", true),
667            ("short line", false),
668        ] {
669            assert!(
670                PortInfo::parse_sockstat_line(line, dotted).is_none(),
671                "{line}"
672            );
673        }
674    }
675
676    #[test]
677    fn test_split_listen_address() {
678        for (local, address, port) in [
679            ("0.0.0.0:22", "0.0.0.0", 22),
680            ("127.0.0.1:8080", "127.0.0.1", 8080),
681            ("*:80", "0.0.0.0", 80),
682            ("[::]:80", "::", 80),
683            ("[::1]:631", "::1", 631),
684            ("[::ffff:127.0.0.1]:8080", "::ffff:127.0.0.1", 8080),
685            ("[fe80::1%eth0]:22", "fe80::1%eth0", 22),
686            ("127.0.0.53%lo:53", "127.0.0.53%lo", 53),
687            // A bare IPv6 address with the port after the last colon
688            (":::22", "::", 22),
689        ] {
690            assert_eq!(
691                split_listen_address(local),
692                Some((address.to_string(), port)),
693                "{local}"
694            );
695        }
696        for bad in ["[::1]", "[::1]:http", "3000", ""] {
697            assert_eq!(split_listen_address(bad), None, "{bad}");
698        }
699    }
700
701    #[test]
702    fn test_parse_lsof_line_ipv6_has_no_brackets() {
703        // lsof brackets IPv6 addresses; the address is reported without them on
704        // every OS, so `ports --local` matches "::1"
705        let line =
706            "python3.12  4242  zee  3u  IPv6 0x3cc8c443bf3e8f62  0t0  TCP [::1]:8000 (LISTEN)";
707        let info = PortInfo::parse_lsof_line(line).unwrap();
708        assert_eq!(info.address.as_deref(), Some("::1"));
709        assert_eq!(info.port, 8000);
710        assert_eq!(info.pid, 4242);
711        assert!(info.is_local() && !info.is_exposed());
712    }
713
714    #[test]
715    fn test_parse_netstat_line_keeps_the_whole_ipv6_address() {
716        // The address used to be the text between the last two colons, so
717        // [::]:135 came back as "]" and [::1]:8080 as "]" too, and neither
718        // `ports --exposed` nor `ports --local` kept them
719        let any = "  TCP    [::]:135               [::]:0                 LISTENING       1092";
720        let info = PortInfo::parse_netstat_line(any, &|_| None).unwrap();
721        assert_eq!(info.address.as_deref(), Some("::"));
722        assert_eq!(info.port, 135);
723        assert_eq!(info.pid, 1092);
724        assert!(info.is_exposed() && !info.is_local());
725
726        let loopback =
727            "  TCP    [::1]:8080             [::]:0                 LISTENING       4242";
728        let info = PortInfo::parse_netstat_line(loopback, &|_| None).unwrap();
729        assert_eq!(info.address.as_deref(), Some("::1"));
730        assert_eq!(info.port, 8080);
731        assert!(info.is_local() && !info.is_exposed());
732
733        let v4 = "  TCP    127.0.0.1:8081        0.0.0.0:0              LISTENING       4242";
734        let info = PortInfo::parse_netstat_line(v4, &|_| None).unwrap();
735        assert_eq!(info.address.as_deref(), Some("127.0.0.1"));
736        assert!(info.is_local() && !info.is_exposed());
737    }
738
739    #[test]
740    fn exposed_and_local_read_the_address() {
741        let on = |address: Option<&str>| PortInfo {
742            port: 1,
743            protocol: Protocol::Tcp,
744            pid: 1,
745            process_name: "x".to_string(),
746            address: address.map(String::from),
747        };
748        for exposed in ["0.0.0.0", "::", "*"] {
749            assert!(on(Some(exposed)).is_exposed(), "{exposed}");
750            assert!(!on(Some(exposed)).is_local(), "{exposed}");
751        }
752        for local in ["127.0.0.1", "::1"] {
753            assert!(on(Some(local)).is_local(), "{local}");
754            assert!(!on(Some(local)).is_exposed(), "{local}");
755        }
756        // Bound to one address that is neither: in neither group
757        let one = on(Some("192.168.1.5"));
758        assert!(!one.is_exposed() && !one.is_local());
759        // No known address: counts as open, never as local
760        assert!(on(None).is_exposed() && !on(None).is_local());
761    }
762
763    #[test]
764    fn test_unescape_lsof_is_safe_for_any_name() {
765        // Control characters stay escaped, so a process name can't inject
766        // terminal escape sequences (ESC [ 2 J clears the screen)
767        assert_eq!(PortInfo::unescape_lsof("evil\\x1b[2J"), "evil\\x1b[2J");
768        assert_eq!(PortInfo::unescape_lsof("a\\x7fb"), "a\\x7fb");
769        assert_eq!(
770            PortInfo::unescape_lsof("Activity\\x20Monitor"),
771            "Activity Monitor"
772        );
773        // "\x" followed by a multi-byte character used to panic on a byte slice
774        assert_eq!(PortInfo::unescape_lsof("x\\xé1"), "x\\xé1");
775        assert_eq!(PortInfo::unescape_lsof("x\\x"), "x\\x");
776        assert_eq!(PortInfo::unescape_lsof("x\\x4"), "x\\x4");
777    }
778
779    #[cfg(target_os = "linux")]
780    #[test]
781    fn test_parse_proc_net_tcp_lines() {
782        // 127.0.0.1:8080 listening (state 0A), socket inode 123456
783        let v4 = "   0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1000        0 123456 1 0000000000000000 100 0 0 10 0";
784        assert_eq!(
785            PortInfo::parse_proc_net_line(v4, false),
786            Some((123456, 8080, "127.0.0.1".to_string()))
787        );
788        // 0.0.0.0:22
789        let any = "   1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 777 1 0000000000000000 100 0 0 10 0";
790        assert_eq!(
791            PortInfo::parse_proc_net_line(any, false),
792            Some((777, 22, "0.0.0.0".to_string()))
793        );
794        // An established connection (state 01) is not a listening port
795        let established = "   2: 0100007F:1F90 0100007F:D431 01 00000000:00000000 00:00000000 00000000  1000        0 888 1 0000000000000000 20 4 30 10 -1";
796        assert_eq!(PortInfo::parse_proc_net_line(established, false), None);
797        // [::]:80 and [::1]:3000 from /proc/net/tcp6
798        let v6_any = "   0: 00000000000000000000000000000000:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 222 1 0000000000000000 100 0 0 10 0";
799        assert_eq!(
800            PortInfo::parse_proc_net_line(v6_any, true),
801            Some((222, 80, "::".to_string()))
802        );
803        let v6_local = "   1: 00000000000000000000000001000000:0BB8 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000  1000        0 333 1 0000000000000000 100 0 0 10 0";
804        assert_eq!(
805            PortInfo::parse_proc_net_line(v6_local, true),
806            Some((333, 3000, "::1".to_string()))
807        );
808        // Malformed lines are skipped, never a panic
809        assert_eq!(PortInfo::parse_proc_net_line("garbage", false), None);
810        assert_eq!(
811            PortInfo::parse_proc_net_line("  0: ZZ:1F90 0:0 0A", false),
812            None
813        );
814        // An address of the wrong length for its table
815        assert_eq!(PortInfo::parse_proc_net_line(v4, true), None);
816        assert_eq!(PortInfo::parse_proc_net_line(v6_any, false), None);
817    }
818
819    /// A fake /proc: `net/tcp`, `net/tcp6` and one directory per process
820    #[cfg(target_os = "linux")]
821    struct FakeProc {
822        dir: tempfile::TempDir,
823    }
824
825    #[cfg(target_os = "linux")]
826    impl FakeProc {
827        const HEADER: &'static str = "  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode\n";
828
829        fn new() -> Self {
830            let dir = tempfile::tempdir().unwrap();
831            std::fs::create_dir(dir.path().join("net")).unwrap();
832            FakeProc { dir }
833        }
834
835        fn table(&self, name: &str, rows: &[(&str, u64)]) {
836            let mut text = Self::HEADER.to_string();
837            for (local, inode) in rows {
838                text.push_str(&format!(
839                    "   0: {local} 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1000        0 {inode} 1 0000000000000000 100 0 0 10 0\n"
840                ));
841            }
842            std::fs::write(self.dir.path().join("net").join(name), text).unwrap();
843        }
844
845        /// A process with `comm` (if any) and one fd link per target
846        fn process(&self, pid: &str, comm: Option<&str>, fds: &[&str]) {
847            let p = self.dir.path().join(pid);
848            std::fs::create_dir_all(p.join("fd")).unwrap();
849            if let Some(comm) = comm {
850                std::fs::write(p.join("comm"), comm).unwrap();
851            }
852            for (i, target) in fds.iter().enumerate() {
853                std::os::unix::fs::symlink(target, p.join("fd").join(i.to_string())).unwrap();
854            }
855        }
856
857        fn listening(&self) -> Result<Vec<PortInfo>> {
858            PortInfo::listening_from(&self.dir.path().join("net"), self.dir.path())
859        }
860    }
861
862    #[cfg(target_os = "linux")]
863    #[test]
864    fn listening_ports_are_matched_to_the_process_holding_the_socket() {
865        let fake = FakeProc::new();
866        fake.table(
867            "tcp",
868            &[
869                ("0100007F:1F90", 100), // 127.0.0.1:8080, held by 4242
870                ("00000000:0016", 200), // 0.0.0.0:22, held by nobody we can see
871                ("00000000:2328", 300), // 0.0.0.0:9000, held by 4243, which has no comm
872            ],
873        );
874        // the same port on IPv6 is the same listener
875        fake.table("tcp6", &[("00000000000000000000000000000000:1F90", 101)]);
876        fake.process(
877            "4242",
878            Some("myapp\n"),
879            &["socket:[100]", "socket:[101]", "/dev/null", "socket:[x]"],
880        );
881        fake.process("4243", None, &["socket:[300]", "socket:[999]"]);
882        // not a process, and a process whose fds can't be listed
883        std::fs::create_dir(fake.dir.path().join("self")).unwrap();
884        std::fs::create_dir(fake.dir.path().join("4244")).unwrap();
885
886        let mut ports = fake.listening().unwrap();
887        ports.sort_by_key(|p| p.port);
888        let seen: Vec<_> = ports
889            .iter()
890            .map(|p| (p.port, p.pid, p.process_name.as_str(), p.address.as_deref()))
891            .collect();
892        assert_eq!(
893            seen,
894            vec![
895                (8080, 4242, "myapp", Some("127.0.0.1")),
896                (9000, 4243, "unknown", Some("0.0.0.0")),
897            ]
898        );
899    }
900
901    #[cfg(target_os = "linux")]
902    #[test]
903    fn every_process_with_a_listen_socket_open_holds_the_port() {
904        let fake = FakeProc::new();
905        fake.table("tcp", &[("0100007F:1F90", 100)]);
906        // a master and two forked workers share the socket; the master has it
907        // open twice; another process has nothing to do with it
908        fake.process("4242", Some("nginx\n"), &["socket:[100]", "socket:[100]"]);
909        fake.process("4250", Some("nginx\n"), &["socket:[100]"]);
910        fake.process("4251", Some("nginx\n"), &["socket:[100]"]);
911        fake.process("4300", Some("other\n"), &["socket:[555]"]);
912        let ports = fake.listening().unwrap();
913        let mut pids: Vec<u32> = ports.iter().map(|p| p.pid).collect();
914        pids.sort_unstable();
915        assert_eq!(pids, vec![4242, 4250, 4251]);
916        assert!(ports.iter().all(|p| p.port == 8080));
917    }
918
919    #[cfg(target_os = "linux")]
920    #[test]
921    fn a_missing_ipv6_table_is_not_an_error() {
922        let fake = FakeProc::new();
923        fake.table("tcp", &[("0100007F:1F90", 100)]);
924        fake.process("4242", Some("myapp\n"), &["socket:[100]"]);
925        let ports = fake.listening().unwrap();
926        assert_eq!(ports.len(), 1);
927        assert_eq!(ports[0].port, 8080);
928    }
929
930    #[cfg(target_os = "linux")]
931    #[test]
932    fn no_socket_tables_is_an_error() {
933        let fake = FakeProc::new();
934        let err = fake.listening().unwrap_err();
935        assert!(
936            matches!(&err, ProcError::SystemError(m) if m == "Can't read /proc/net/tcp to look up ports"),
937            "{err:?}"
938        );
939    }
940
941    #[cfg(target_os = "linux")]
942    #[test]
943    fn unreadable_processes_leave_no_owners() {
944        let fake = FakeProc::new();
945        fake.table("tcp", &[("0100007F:1F90", 100)]);
946        let missing = fake.dir.path().join("no-such-proc-dir");
947        let ports = PortInfo::listening_from(&fake.dir.path().join("net"), &missing).unwrap();
948        assert!(ports.is_empty());
949    }
950
951    #[test]
952    fn lsof_output_lists_each_listener_once() {
953        let out = "\
954COMMAND  PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
955node     100  zee   20u  IPv4 0xa    0t0      TCP  *:3000 (LISTEN)
956node     100  zee   21u  IPv6 0xb    0t0      TCP  *:3000 (LISTEN)
957node     100  zee   22u  IPv4 0xc    0t0      TCP  127.0.0.1:3001 (LISTEN)
958Activity\\x20Monitor 7 zee 8u IPv6 0xd 0t0 TCP [::1]:3002 (LISTEN)
959short line
960node     abc  zee   23u  IPv4 0xe    0t0      TCP  *:3003 (LISTEN)
961node     101  zee   24u  IPv4 0xf    0t0      TCP  noport (LISTEN)
962node     101  zee   25u  IPv4 0xf    0t0      TCP  *:notaport (LISTEN)
963";
964        let ports = PortInfo::parse_lsof_output(out);
965        let seen: Vec<_> = ports
966            .iter()
967            .map(|p| (p.port, p.pid, p.process_name.as_str(), p.address.as_deref()))
968            .collect();
969        assert_eq!(
970            seen,
971            vec![
972                (3000, 100, "node", Some("0.0.0.0")),
973                (3001, 100, "node", Some("127.0.0.1")),
974                (3002, 7, "Activity Monitor", Some("::1")),
975            ]
976        );
977    }
978
979    #[test]
980    fn netstat_output_lists_the_listening_rows() {
981        let out = "\
982Active Connections
983
984  Proto  Local Address          Foreign Address        State           PID
985  TCP    0.0.0.0:135            0.0.0.0:0              LISTENING       1032
986  TCP    127.0.0.1:8080         0.0.0.0:0              LISTENING       4242
987  TCP    127.0.0.1:50000        127.0.0.1:8080         ESTABLISHED     5000
988  TCP    0.0.0.0:99 LISTENING
989  TCP    0.0.0.0:notaport       0.0.0.0:0              LISTENING       1
990  TCP    0.0.0.0:136            0.0.0.0:0              LISTENING       notapid
991";
992        let name_of = |pid: u32| (pid == 1032).then(|| "svchost.exe".to_string());
993        let ports = PortInfo::parse_netstat_output(&[out], name_of);
994        let seen: Vec<_> = ports
995            .iter()
996            .map(|p| (p.port, p.pid, p.process_name.as_str(), p.address.as_deref()))
997            .collect();
998        assert_eq!(
999            seen,
1000            vec![
1001                (135, 1032, "svchost.exe", Some("0.0.0.0")),
1002                (8080, 4242, "unknown", Some("127.0.0.1")),
1003            ]
1004        );
1005    }
1006
1007    #[test]
1008    fn netstat_lists_ipv4_and_ipv6_listeners_from_both_calls() {
1009        // What `netstat -ano -p TCP` and `-p TCPv6` print: IPv6 rows only come
1010        // from the second call
1011        let v4 = "\
1012Active Connections
1013
1014  Proto  Local Address          Foreign Address        State           PID
1015  TCP    0.0.0.0:135            0.0.0.0:0              LISTENING       1032
1016  TCP    127.0.0.1:8080         0.0.0.0:0              LISTENING       4242
1017";
1018        let v6 = "\
1019Active Connections
1020
1021  Proto  Local Address          Foreign Address        State           PID
1022  TCP    [::]:135               [::]:0                 LISTENING       1032
1023  TCP    [::1]:8080             [::]:0                 LISTENING       4242
1024  TCP    [fe80::1%12]:9000      [::]:0                 LISTENING       4242
1025  TCP    [::1]:50000            [::1]:8080             ESTABLISHED     5000
1026";
1027        let name_of = |pid: u32| (pid == 1032).then(|| "svchost.exe".to_string());
1028        let ports = PortInfo::parse_netstat_output(&[v4, v6], name_of);
1029        let seen: Vec<_> = ports
1030            .iter()
1031            .map(|p| (p.port, p.address.as_deref().unwrap()))
1032            .collect();
1033        assert_eq!(
1034            seen,
1035            vec![
1036                (135, "0.0.0.0"),
1037                (8080, "127.0.0.1"),
1038                (135, "::"),
1039                (8080, "::1"),
1040                (9000, "fe80::1%12"),
1041            ]
1042        );
1043        assert!(ports[2].is_exposed() && ports[3].is_local());
1044
1045        // No IPv6 output (netstat refused the call): the IPv4 listeners remain
1046        let ports = PortInfo::parse_netstat_output(&[v4, ""], |_| None);
1047        assert_eq!(ports.len(), 2);
1048    }
1049
1050    #[test]
1051    fn a_port_held_through_two_sockets_has_two_holders() {
1052        let listener = |port: u16, pid: u32, address: &str| PortInfo {
1053            port,
1054            protocol: Protocol::Tcp,
1055            pid,
1056            process_name: format!("p{pid}"),
1057            address: Some(address.to_string()),
1058        };
1059        let listening = vec![
1060            listener(8080, 10, "127.0.0.1"),
1061            listener(9000, 12, "0.0.0.0"),
1062            listener(8080, 11, "::1"),
1063            // the same process on the IPv6 address too (netstat lists both)
1064            listener(8080, 10, "::1"),
1065        ];
1066        let holders = holders_in(listening.clone(), 8080);
1067        let seen: Vec<_> = holders
1068            .iter()
1069            .map(|p| (p.pid, p.address.as_deref().unwrap()))
1070            .collect();
1071        assert_eq!(seen, vec![(10, "127.0.0.1"), (11, "::1")]);
1072        assert!(holders_in(listening, 7).is_empty());
1073    }
1074
1075    #[test]
1076    fn a_port_gives_its_own_process() {
1077        let me = PortInfo {
1078            port: 1,
1079            protocol: Protocol::Tcp,
1080            pid: std::process::id(),
1081            process_name: "me".to_string(),
1082            address: None,
1083        };
1084        assert_eq!(me.get_process().unwrap().unwrap().pid, std::process::id());
1085    }
1086
1087    #[test]
1088    fn test_get_listening_ports() {
1089        // This test may or may not find ports depending on the system
1090        let result = PortInfo::get_all_listening();
1091        assert!(result.is_ok());
1092    }
1093
1094    #[test]
1095    fn listening_ports_come_by_port_then_pid_and_a_process_has_its_ports_ascending() {
1096        // bound in the order the system hands them out, which is no rule
1097        let held: Vec<std::net::TcpListener> = (0..4)
1098            .map(|_| std::net::TcpListener::bind("127.0.0.1:0").unwrap())
1099            .collect();
1100        let ports = PortInfo::get_all_listening().unwrap();
1101
1102        let keys: Vec<(u16, u32)> = ports.iter().map(|p| (p.port, p.pid)).collect();
1103        assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}");
1104
1105        let me = std::process::id();
1106        let mine: Vec<u16> = ports
1107            .iter()
1108            .filter(|p| p.pid == me)
1109            .map(|p| p.port)
1110            .collect();
1111        assert!(mine.windows(2).all(|w| w[0] <= w[1]), "{mine:?}");
1112        for listener in &held {
1113            let port = listener.local_addr().unwrap().port();
1114            assert!(mine.contains(&port), "{port} is not in {mine:?}");
1115            let holders: Vec<u32> = PortInfo::holders_of(port)
1116                .unwrap()
1117                .iter()
1118                .map(|h| h.pid)
1119                .collect();
1120            assert!(holders.windows(2).all(|w| w[0] < w[1]), "{holders:?}");
1121        }
1122    }
1123}