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 = 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 #[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 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 #[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 let (address, port) = split_listen_address(parts[1])?;
382
383 let pid: u32 = parts.last()?.parse().ok()?;
385
386 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 #[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 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 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
431fn 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#[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
463pub 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 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 (":::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 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 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 let one = on(Some("192.168.1.5"));
581 assert!(!one.is_exposed() && !one.is_local());
582 assert!(on(None).is_exposed() && !on(None).is_local());
584 }
585
586 #[test]
587 fn test_unescape_lsof_is_safe_for_any_name() {
588 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 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 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 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 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 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 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 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 #[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 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), ("00000000:0016", 200), ("00000000:2328", 300), ],
696 );
697 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 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 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 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 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 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 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 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}