1use crate::core::{escape_control, Process};
7use crate::error::{ProcError, Result};
8use serde::{Deserialize, Serialize};
9#[cfg(target_os = "linux")]
10use std::path::Path;
11#[cfg(not(target_os = "linux"))]
14use std::process::Command;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Protocol {
20 Tcp,
22 Udp,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PortInfo {
29 pub port: u16,
31 pub protocol: Protocol,
33 pub pid: u32,
35 pub process_name: String,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub address: Option<String>,
40}
41
42impl PortInfo {
43 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 pub fn holders_of(port: u16) -> Result<Vec<PortInfo>> {
65 Ok(holders_in(Self::get_all_listening()?, port))
66 }
67
68 pub fn get_process(&self) -> Result<Option<Process>> {
70 Process::find_by_pid(self.pid)
71 }
72
73 #[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 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 #[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 if seen.insert((port_info.port, port_info.pid)) {
120 ports.push(port_info);
121 }
122 }
123 ports
124 }
125
126 #[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 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 #[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 if let Some(port_info) = Self::parse_lsof_line(line) {
181 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 #[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 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 let name_col = parts.iter().skip(8).find(|p| p.contains(':'))?;
240
241 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 #[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 #[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 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 for &pid in pids {
303 if !seen.insert((port, pid)) {
305 continue;
306 }
307 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 #[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 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 #[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 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 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 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 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 #[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 let (address, port) = split_listen_address(parts[1])?;
469
470 let pid: u32 = parts.last()?.parse().ok()?;
472
473 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 #[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 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 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
518fn 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#[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
556pub 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 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 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 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 (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 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 ("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 (":::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 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 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 let one = on(Some("192.168.1.5"));
758 assert!(!one.is_exposed() && !one.is_local());
759 assert!(on(None).is_exposed() && !on(None).is_local());
761 }
762
763 #[test]
764 fn test_unescape_lsof_is_safe_for_any_name() {
765 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 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 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 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 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 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 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 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 #[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 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), ("00000000:0016", 200), ("00000000:2328", 300), ],
873 );
874 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 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 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 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 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 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 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 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}