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