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"))]
13use std::process::Command;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Protocol {
19 Tcp,
21 Udp,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PortInfo {
28 pub port: u16,
30 pub protocol: Protocol,
32 pub pid: u32,
34 pub process_name: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub address: Option<String>,
39}
40
41impl PortInfo {
42 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 pub fn holders_of(port: u16) -> Result<Vec<PortInfo>> {
62 Ok(holders_in(Self::get_all_listening()?, port))
63 }
64
65 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 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 #[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 if let Some(port_info) = Self::parse_lsof_line(line) {
94 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 #[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 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 let name_col = parts.iter().skip(8).find(|p| p.contains(':'))?;
153
154 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 #[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 #[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 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 for &pid in pids {
216 if !seen.insert((port, pid)) {
218 continue;
219 }
220 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 #[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 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 #[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 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 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 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 #[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 let (address, port) = split_listen_address(parts[1])?;
379
380 let pid: u32 = parts.last()?.parse().ok()?;
382
383 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 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 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
426fn 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#[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
458pub 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 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 (":::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 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 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 let one = on(Some("192.168.1.5"));
576 assert!(!one.is_exposed() && !one.is_local());
577 assert!(on(None).is_exposed() && !on(None).is_local());
579 }
580
581 #[test]
582 fn test_unescape_lsof_is_safe_for_any_name() {
583 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 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 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 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 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 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 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 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 #[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 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), ("00000000:0016", 200), ("00000000:2328", 300), ],
691 );
692 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 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 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 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 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 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 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 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}