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 = crate::ui::interrupt::unblocked_in_child(&mut 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        // One process list names every socket: a `tasklist` per socket made
341        // each reading take seconds, and `proc ports --in` run past a minute
342        let names = Self::process_names_windows();
343        Ok(Self::parse_netstat_output(
344            &[&String::from_utf8_lossy(&v4.stdout), &v6],
345            |pid| names.get(&pid).cloned(),
346        ))
347    }
348
349    /// The listening ports in the outputs of netstat, one per protocol
350    /// (`-p TCP` and `-p TCPv6`); `name_of` says what a PID is called
351    /// (compiled on other systems for the tests only)
352    #[cfg(any(target_os = "windows", test))]
353    fn parse_netstat_output(
354        outputs: &[&str],
355        name_of: impl Fn(u32) -> Option<String>,
356    ) -> Vec<PortInfo> {
357        let mut ports = Vec::new();
358
359        for line in outputs.iter().flat_map(|o| o.lines()) {
360            if line.contains("LISTENING") {
361                if let Some(port_info) = Self::parse_netstat_line(line, &name_of) {
362                    ports.push(port_info);
363                }
364            }
365        }
366
367        ports
368    }
369
370    #[cfg(any(target_os = "windows", test))]
371    fn parse_netstat_line(
372        line: &str,
373        name_of: &impl Fn(u32) -> Option<String>,
374    ) -> Option<PortInfo> {
375        let parts: Vec<&str> = line.split_whitespace().collect();
376        if parts.len() < 5 {
377            return None;
378        }
379
380        // Local address is column 2 (e.g., "0.0.0.0:135" or "[::]:135")
381        let (address, port) = split_listen_address(parts[1])?;
382
383        // PID is the last column
384        let pid: u32 = parts.last()?.parse().ok()?;
385
386        // Get process name from PID
387        let process_name = name_of(pid).unwrap_or_else(|| "unknown".to_string());
388
389        Some(PortInfo {
390            port,
391            protocol: Protocol::Tcp,
392            pid,
393            process_name,
394            address: Some(address),
395        })
396    }
397
398    /// What each running process is called, by PID, as `tasklist` names it
399    /// (`listener.exe`)
400    #[cfg(target_os = "windows")]
401    fn process_names_windows() -> std::collections::HashMap<u32, String> {
402        let mut sys = sysinfo::System::new();
403        sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
404        sys.processes()
405            .iter()
406            .map(|(pid, p)| {
407                let name = p.name().to_string_lossy();
408                (pid.as_u32(), escape_control(&name).into_owned())
409            })
410            .collect()
411    }
412
413    /// Whether the port is open to the network: bound to every address. A port
414    /// with no known address counts as open.
415    pub fn is_exposed(&self) -> bool {
416        self.address
417            .as_ref()
418            .map(|a| a == "0.0.0.0" || a == "::" || a == "*")
419            .unwrap_or(true)
420    }
421
422    /// Whether the port is bound to loopback only
423    pub fn is_local(&self) -> bool {
424        self.address
425            .as_ref()
426            .map(|a| a == "127.0.0.1" || a == "::1")
427            .unwrap_or(false)
428    }
429}
430
431/// The listeners in `listening` that hold `port`, one per process: a process
432/// that listens on both an IPv4 and an IPv6 address is one holder, listed with
433/// the first address
434fn holders_in(listening: Vec<PortInfo>, port: u16) -> Vec<PortInfo> {
435    let mut seen = std::collections::HashSet::new();
436    listening
437        .into_iter()
438        .filter(|p| p.port == port && seen.insert(p.pid))
439        .collect()
440}
441
442/// Split a listening socket's local address, as lsof and netstat print it, into
443/// the bind address and the port. IPv6 addresses come in brackets (`[::1]:631`,
444/// `[fe80::1%eth0]:22`) and are returned without them, as the kernel tables on
445/// Linux are; `*`, every address, is returned as `0.0.0.0`. A scope suffix
446/// (`%eth0`) stays.
447#[cfg(any(target_os = "macos", target_os = "windows", test))]
448fn split_listen_address(local: &str) -> Option<(String, u16)> {
449    let (host, port) = local.rsplit_once(':')?;
450    let port = port.parse().ok()?;
451    let host = host
452        .strip_prefix('[')
453        .and_then(|h| h.strip_suffix(']'))
454        .unwrap_or(host);
455    let address = if host.is_empty() || host == "*" {
456        "0.0.0.0"
457    } else {
458        host
459    };
460    Some((address.to_string(), port))
461}
462
463/// Parse a port from various formats (":3000", "3000", etc.)
464pub fn parse_port(input: &str) -> Result<u16> {
465    let cleaned = input.trim().trim_start_matches(':');
466    cleaned
467        .parse()
468        .map_err(|_| ProcError::InvalidInput(format!("Invalid port: '{}'", input)))
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn test_parse_port() {
477        assert_eq!(parse_port(":3000").unwrap(), 3000);
478        assert_eq!(parse_port("3000").unwrap(), 3000);
479        assert_eq!(parse_port("  :8080  ").unwrap(), 8080);
480    }
481
482    #[test]
483    fn test_parse_port_invalid() {
484        assert!(parse_port("abc").is_err());
485        assert!(parse_port("").is_err());
486    }
487
488    #[test]
489    fn test_parse_lsof_line_keeps_the_full_name() {
490        // With `+c 0` lsof prints the whole name, escaping spaces as \x20; the
491        // default 9-character column used to turn python3.14 into "python3.1"
492        let line = "Activity\\x20Monitor  644  zee  8u  IPv4 0x3cc8c443bf3e8f62  0t0  TCP 127.0.0.1:7768 (LISTEN)";
493        let info = PortInfo::parse_lsof_line(line).unwrap();
494        assert_eq!(info.process_name, "Activity Monitor");
495        assert_eq!(info.pid, 644);
496        assert_eq!(info.port, 7768);
497    }
498
499    #[test]
500    fn test_split_listen_address() {
501        for (local, address, port) in [
502            ("0.0.0.0:22", "0.0.0.0", 22),
503            ("127.0.0.1:8080", "127.0.0.1", 8080),
504            ("*:80", "0.0.0.0", 80),
505            ("[::]:80", "::", 80),
506            ("[::1]:631", "::1", 631),
507            ("[::ffff:127.0.0.1]:8080", "::ffff:127.0.0.1", 8080),
508            ("[fe80::1%eth0]:22", "fe80::1%eth0", 22),
509            ("127.0.0.53%lo:53", "127.0.0.53%lo", 53),
510            // A bare IPv6 address with the port after the last colon
511            (":::22", "::", 22),
512        ] {
513            assert_eq!(
514                split_listen_address(local),
515                Some((address.to_string(), port)),
516                "{local}"
517            );
518        }
519        for bad in ["[::1]", "[::1]:http", "3000", ""] {
520            assert_eq!(split_listen_address(bad), None, "{bad}");
521        }
522    }
523
524    #[test]
525    fn test_parse_lsof_line_ipv6_has_no_brackets() {
526        // lsof brackets IPv6 addresses; the address is reported without them on
527        // every OS, so `ports --local` matches "::1"
528        let line =
529            "python3.12  4242  zee  3u  IPv6 0x3cc8c443bf3e8f62  0t0  TCP [::1]:8000 (LISTEN)";
530        let info = PortInfo::parse_lsof_line(line).unwrap();
531        assert_eq!(info.address.as_deref(), Some("::1"));
532        assert_eq!(info.port, 8000);
533        assert_eq!(info.pid, 4242);
534        assert!(info.is_local() && !info.is_exposed());
535    }
536
537    #[test]
538    fn test_parse_netstat_line_keeps_the_whole_ipv6_address() {
539        // The address used to be the text between the last two colons, so
540        // [::]:135 came back as "]" and [::1]:8080 as "]" too, and neither
541        // `ports --exposed` nor `ports --local` kept them
542        let any = "  TCP    [::]:135               [::]:0                 LISTENING       1092";
543        let info = PortInfo::parse_netstat_line(any, &|_| None).unwrap();
544        assert_eq!(info.address.as_deref(), Some("::"));
545        assert_eq!(info.port, 135);
546        assert_eq!(info.pid, 1092);
547        assert!(info.is_exposed() && !info.is_local());
548
549        let loopback =
550            "  TCP    [::1]:8080             [::]:0                 LISTENING       4242";
551        let info = PortInfo::parse_netstat_line(loopback, &|_| None).unwrap();
552        assert_eq!(info.address.as_deref(), Some("::1"));
553        assert_eq!(info.port, 8080);
554        assert!(info.is_local() && !info.is_exposed());
555
556        let v4 = "  TCP    127.0.0.1:8081        0.0.0.0:0              LISTENING       4242";
557        let info = PortInfo::parse_netstat_line(v4, &|_| None).unwrap();
558        assert_eq!(info.address.as_deref(), Some("127.0.0.1"));
559        assert!(info.is_local() && !info.is_exposed());
560    }
561
562    #[test]
563    fn exposed_and_local_read_the_address() {
564        let on = |address: Option<&str>| PortInfo {
565            port: 1,
566            protocol: Protocol::Tcp,
567            pid: 1,
568            process_name: "x".to_string(),
569            address: address.map(String::from),
570        };
571        for exposed in ["0.0.0.0", "::", "*"] {
572            assert!(on(Some(exposed)).is_exposed(), "{exposed}");
573            assert!(!on(Some(exposed)).is_local(), "{exposed}");
574        }
575        for local in ["127.0.0.1", "::1"] {
576            assert!(on(Some(local)).is_local(), "{local}");
577            assert!(!on(Some(local)).is_exposed(), "{local}");
578        }
579        // Bound to one address that is neither: in neither group
580        let one = on(Some("192.168.1.5"));
581        assert!(!one.is_exposed() && !one.is_local());
582        // No known address: counts as open, never as local
583        assert!(on(None).is_exposed() && !on(None).is_local());
584    }
585
586    #[test]
587    fn test_unescape_lsof_is_safe_for_any_name() {
588        // Control characters stay escaped, so a process name can't inject
589        // terminal escape sequences (ESC [ 2 J clears the screen)
590        assert_eq!(PortInfo::unescape_lsof("evil\\x1b[2J"), "evil\\x1b[2J");
591        assert_eq!(PortInfo::unescape_lsof("a\\x7fb"), "a\\x7fb");
592        assert_eq!(
593            PortInfo::unescape_lsof("Activity\\x20Monitor"),
594            "Activity Monitor"
595        );
596        // "\x" followed by a multi-byte character used to panic on a byte slice
597        assert_eq!(PortInfo::unescape_lsof("x\\xé1"), "x\\xé1");
598        assert_eq!(PortInfo::unescape_lsof("x\\x"), "x\\x");
599        assert_eq!(PortInfo::unescape_lsof("x\\x4"), "x\\x4");
600    }
601
602    #[cfg(target_os = "linux")]
603    #[test]
604    fn test_parse_proc_net_tcp_lines() {
605        // 127.0.0.1:8080 listening (state 0A), socket inode 123456
606        let v4 = "   0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1000        0 123456 1 0000000000000000 100 0 0 10 0";
607        assert_eq!(
608            PortInfo::parse_proc_net_line(v4, false),
609            Some((123456, 8080, "127.0.0.1".to_string()))
610        );
611        // 0.0.0.0:22
612        let any = "   1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 777 1 0000000000000000 100 0 0 10 0";
613        assert_eq!(
614            PortInfo::parse_proc_net_line(any, false),
615            Some((777, 22, "0.0.0.0".to_string()))
616        );
617        // An established connection (state 01) is not a listening port
618        let established = "   2: 0100007F:1F90 0100007F:D431 01 00000000:00000000 00:00000000 00000000  1000        0 888 1 0000000000000000 20 4 30 10 -1";
619        assert_eq!(PortInfo::parse_proc_net_line(established, false), None);
620        // [::]:80 and [::1]:3000 from /proc/net/tcp6
621        let v6_any = "   0: 00000000000000000000000000000000:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 222 1 0000000000000000 100 0 0 10 0";
622        assert_eq!(
623            PortInfo::parse_proc_net_line(v6_any, true),
624            Some((222, 80, "::".to_string()))
625        );
626        let v6_local = "   1: 00000000000000000000000001000000:0BB8 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000  1000        0 333 1 0000000000000000 100 0 0 10 0";
627        assert_eq!(
628            PortInfo::parse_proc_net_line(v6_local, true),
629            Some((333, 3000, "::1".to_string()))
630        );
631        // Malformed lines are skipped, never a panic
632        assert_eq!(PortInfo::parse_proc_net_line("garbage", false), None);
633        assert_eq!(
634            PortInfo::parse_proc_net_line("  0: ZZ:1F90 0:0 0A", false),
635            None
636        );
637        // An address of the wrong length for its table
638        assert_eq!(PortInfo::parse_proc_net_line(v4, true), None);
639        assert_eq!(PortInfo::parse_proc_net_line(v6_any, false), None);
640    }
641
642    /// A fake /proc: `net/tcp`, `net/tcp6` and one directory per process
643    #[cfg(target_os = "linux")]
644    struct FakeProc {
645        dir: tempfile::TempDir,
646    }
647
648    #[cfg(target_os = "linux")]
649    impl FakeProc {
650        const HEADER: &'static str = "  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode\n";
651
652        fn new() -> Self {
653            let dir = tempfile::tempdir().unwrap();
654            std::fs::create_dir(dir.path().join("net")).unwrap();
655            FakeProc { dir }
656        }
657
658        fn table(&self, name: &str, rows: &[(&str, u64)]) {
659            let mut text = Self::HEADER.to_string();
660            for (local, inode) in rows {
661                text.push_str(&format!(
662                    "   0: {local} 00000000:0000 0A 00000000:00000000 00:00000000 00000000  1000        0 {inode} 1 0000000000000000 100 0 0 10 0\n"
663                ));
664            }
665            std::fs::write(self.dir.path().join("net").join(name), text).unwrap();
666        }
667
668        /// A process with `comm` (if any) and one fd link per target
669        fn process(&self, pid: &str, comm: Option<&str>, fds: &[&str]) {
670            let p = self.dir.path().join(pid);
671            std::fs::create_dir_all(p.join("fd")).unwrap();
672            if let Some(comm) = comm {
673                std::fs::write(p.join("comm"), comm).unwrap();
674            }
675            for (i, target) in fds.iter().enumerate() {
676                std::os::unix::fs::symlink(target, p.join("fd").join(i.to_string())).unwrap();
677            }
678        }
679
680        fn listening(&self) -> Result<Vec<PortInfo>> {
681            PortInfo::listening_from(&self.dir.path().join("net"), self.dir.path())
682        }
683    }
684
685    #[cfg(target_os = "linux")]
686    #[test]
687    fn listening_ports_are_matched_to_the_process_holding_the_socket() {
688        let fake = FakeProc::new();
689        fake.table(
690            "tcp",
691            &[
692                ("0100007F:1F90", 100), // 127.0.0.1:8080, held by 4242
693                ("00000000:0016", 200), // 0.0.0.0:22, held by nobody we can see
694                ("00000000:2328", 300), // 0.0.0.0:9000, held by 4243, which has no comm
695            ],
696        );
697        // the same port on IPv6 is the same listener
698        fake.table("tcp6", &[("00000000000000000000000000000000:1F90", 101)]);
699        fake.process(
700            "4242",
701            Some("myapp\n"),
702            &["socket:[100]", "socket:[101]", "/dev/null", "socket:[x]"],
703        );
704        fake.process("4243", None, &["socket:[300]", "socket:[999]"]);
705        // not a process, and a process whose fds can't be listed
706        std::fs::create_dir(fake.dir.path().join("self")).unwrap();
707        std::fs::create_dir(fake.dir.path().join("4244")).unwrap();
708
709        let mut ports = fake.listening().unwrap();
710        ports.sort_by_key(|p| p.port);
711        let seen: Vec<_> = ports
712            .iter()
713            .map(|p| (p.port, p.pid, p.process_name.as_str(), p.address.as_deref()))
714            .collect();
715        assert_eq!(
716            seen,
717            vec![
718                (8080, 4242, "myapp", Some("127.0.0.1")),
719                (9000, 4243, "unknown", Some("0.0.0.0")),
720            ]
721        );
722    }
723
724    #[cfg(target_os = "linux")]
725    #[test]
726    fn every_process_with_a_listen_socket_open_holds_the_port() {
727        let fake = FakeProc::new();
728        fake.table("tcp", &[("0100007F:1F90", 100)]);
729        // a master and two forked workers share the socket; the master has it
730        // open twice; another process has nothing to do with it
731        fake.process("4242", Some("nginx\n"), &["socket:[100]", "socket:[100]"]);
732        fake.process("4250", Some("nginx\n"), &["socket:[100]"]);
733        fake.process("4251", Some("nginx\n"), &["socket:[100]"]);
734        fake.process("4300", Some("other\n"), &["socket:[555]"]);
735        let ports = fake.listening().unwrap();
736        let mut pids: Vec<u32> = ports.iter().map(|p| p.pid).collect();
737        pids.sort_unstable();
738        assert_eq!(pids, vec![4242, 4250, 4251]);
739        assert!(ports.iter().all(|p| p.port == 8080));
740    }
741
742    #[cfg(target_os = "linux")]
743    #[test]
744    fn a_missing_ipv6_table_is_not_an_error() {
745        let fake = FakeProc::new();
746        fake.table("tcp", &[("0100007F:1F90", 100)]);
747        fake.process("4242", Some("myapp\n"), &["socket:[100]"]);
748        let ports = fake.listening().unwrap();
749        assert_eq!(ports.len(), 1);
750        assert_eq!(ports[0].port, 8080);
751    }
752
753    #[cfg(target_os = "linux")]
754    #[test]
755    fn no_socket_tables_is_an_error() {
756        let fake = FakeProc::new();
757        let err = fake.listening().unwrap_err();
758        assert!(
759            matches!(&err, ProcError::SystemError(m) if m == "Can't read /proc/net/tcp to look up ports"),
760            "{err:?}"
761        );
762    }
763
764    #[cfg(target_os = "linux")]
765    #[test]
766    fn unreadable_processes_leave_no_owners() {
767        let fake = FakeProc::new();
768        fake.table("tcp", &[("0100007F:1F90", 100)]);
769        let missing = fake.dir.path().join("no-such-proc-dir");
770        let ports = PortInfo::listening_from(&fake.dir.path().join("net"), &missing).unwrap();
771        assert!(ports.is_empty());
772    }
773
774    #[test]
775    fn lsof_output_lists_each_listener_once() {
776        let out = "\
777COMMAND  PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
778node     100  zee   20u  IPv4 0xa    0t0      TCP  *:3000 (LISTEN)
779node     100  zee   21u  IPv6 0xb    0t0      TCP  *:3000 (LISTEN)
780node     100  zee   22u  IPv4 0xc    0t0      TCP  127.0.0.1:3001 (LISTEN)
781Activity\\x20Monitor 7 zee 8u IPv6 0xd 0t0 TCP [::1]:3002 (LISTEN)
782short line
783node     abc  zee   23u  IPv4 0xe    0t0      TCP  *:3003 (LISTEN)
784node     101  zee   24u  IPv4 0xf    0t0      TCP  noport (LISTEN)
785node     101  zee   25u  IPv4 0xf    0t0      TCP  *:notaport (LISTEN)
786";
787        let ports = PortInfo::parse_lsof_output(out);
788        let seen: Vec<_> = ports
789            .iter()
790            .map(|p| (p.port, p.pid, p.process_name.as_str(), p.address.as_deref()))
791            .collect();
792        assert_eq!(
793            seen,
794            vec![
795                (3000, 100, "node", Some("0.0.0.0")),
796                (3001, 100, "node", Some("127.0.0.1")),
797                (3002, 7, "Activity Monitor", Some("::1")),
798            ]
799        );
800    }
801
802    #[test]
803    fn netstat_output_lists_the_listening_rows() {
804        let out = "\
805Active Connections
806
807  Proto  Local Address          Foreign Address        State           PID
808  TCP    0.0.0.0:135            0.0.0.0:0              LISTENING       1032
809  TCP    127.0.0.1:8080         0.0.0.0:0              LISTENING       4242
810  TCP    127.0.0.1:50000        127.0.0.1:8080         ESTABLISHED     5000
811  TCP    0.0.0.0:99 LISTENING
812  TCP    0.0.0.0:notaport       0.0.0.0:0              LISTENING       1
813  TCP    0.0.0.0:136            0.0.0.0:0              LISTENING       notapid
814";
815        let name_of = |pid: u32| (pid == 1032).then(|| "svchost.exe".to_string());
816        let ports = PortInfo::parse_netstat_output(&[out], name_of);
817        let seen: Vec<_> = ports
818            .iter()
819            .map(|p| (p.port, p.pid, p.process_name.as_str(), p.address.as_deref()))
820            .collect();
821        assert_eq!(
822            seen,
823            vec![
824                (135, 1032, "svchost.exe", Some("0.0.0.0")),
825                (8080, 4242, "unknown", Some("127.0.0.1")),
826            ]
827        );
828    }
829
830    #[test]
831    fn netstat_lists_ipv4_and_ipv6_listeners_from_both_calls() {
832        // What `netstat -ano -p TCP` and `-p TCPv6` print: IPv6 rows only come
833        // from the second call
834        let v4 = "\
835Active Connections
836
837  Proto  Local Address          Foreign Address        State           PID
838  TCP    0.0.0.0:135            0.0.0.0:0              LISTENING       1032
839  TCP    127.0.0.1:8080         0.0.0.0:0              LISTENING       4242
840";
841        let v6 = "\
842Active Connections
843
844  Proto  Local Address          Foreign Address        State           PID
845  TCP    [::]:135               [::]:0                 LISTENING       1032
846  TCP    [::1]:8080             [::]:0                 LISTENING       4242
847  TCP    [fe80::1%12]:9000      [::]:0                 LISTENING       4242
848  TCP    [::1]:50000            [::1]:8080             ESTABLISHED     5000
849";
850        let name_of = |pid: u32| (pid == 1032).then(|| "svchost.exe".to_string());
851        let ports = PortInfo::parse_netstat_output(&[v4, v6], name_of);
852        let seen: Vec<_> = ports
853            .iter()
854            .map(|p| (p.port, p.address.as_deref().unwrap()))
855            .collect();
856        assert_eq!(
857            seen,
858            vec![
859                (135, "0.0.0.0"),
860                (8080, "127.0.0.1"),
861                (135, "::"),
862                (8080, "::1"),
863                (9000, "fe80::1%12"),
864            ]
865        );
866        assert!(ports[2].is_exposed() && ports[3].is_local());
867
868        // No IPv6 output (netstat refused the call): the IPv4 listeners remain
869        let ports = PortInfo::parse_netstat_output(&[v4, ""], |_| None);
870        assert_eq!(ports.len(), 2);
871    }
872
873    #[test]
874    fn a_port_held_through_two_sockets_has_two_holders() {
875        let listener = |port: u16, pid: u32, address: &str| PortInfo {
876            port,
877            protocol: Protocol::Tcp,
878            pid,
879            process_name: format!("p{pid}"),
880            address: Some(address.to_string()),
881        };
882        let listening = vec![
883            listener(8080, 10, "127.0.0.1"),
884            listener(9000, 12, "0.0.0.0"),
885            listener(8080, 11, "::1"),
886            // the same process on the IPv6 address too (netstat lists both)
887            listener(8080, 10, "::1"),
888        ];
889        let holders = holders_in(listening.clone(), 8080);
890        let seen: Vec<_> = holders
891            .iter()
892            .map(|p| (p.pid, p.address.as_deref().unwrap()))
893            .collect();
894        assert_eq!(seen, vec![(10, "127.0.0.1"), (11, "::1")]);
895        assert!(holders_in(listening, 7).is_empty());
896    }
897
898    #[test]
899    fn a_port_gives_its_own_process() {
900        let me = PortInfo {
901            port: 1,
902            protocol: Protocol::Tcp,
903            pid: std::process::id(),
904            process_name: "me".to_string(),
905            address: None,
906        };
907        assert_eq!(me.get_process().unwrap().unwrap().pid, std::process::id());
908    }
909
910    #[test]
911    fn test_get_listening_ports() {
912        // This test may or may not find ports depending on the system
913        let result = PortInfo::get_all_listening();
914        assert!(result.is_ok());
915    }
916
917    #[test]
918    fn listening_ports_come_by_port_then_pid_and_a_process_has_its_ports_ascending() {
919        // bound in the order the system hands them out, which is no rule
920        let held: Vec<std::net::TcpListener> = (0..4)
921            .map(|_| std::net::TcpListener::bind("127.0.0.1:0").unwrap())
922            .collect();
923        let ports = PortInfo::get_all_listening().unwrap();
924
925        let keys: Vec<(u16, u32)> = ports.iter().map(|p| (p.port, p.pid)).collect();
926        assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}");
927
928        let me = std::process::id();
929        let mine: Vec<u16> = ports
930            .iter()
931            .filter(|p| p.pid == me)
932            .map(|p| p.port)
933            .collect();
934        assert!(mine.windows(2).all(|w| w[0] <= w[1]), "{mine:?}");
935        for listener in &held {
936            let port = listener.local_addr().unwrap().port();
937            assert!(mine.contains(&port), "{port} is not in {mine:?}");
938            let holders: Vec<u32> = PortInfo::holders_of(port)
939                .unwrap()
940                .iter()
941                .map(|h| h.pid)
942                .collect();
943            assert!(holders.windows(2).all(|w| w[0] < w[1]), "{holders:?}");
944        }
945    }
946}