diff --git a/.github/scan-benchmark/scan_bench.py b/.github/scan-benchmark/scan_bench.py index 6ddcf9d1..3e660f85 100644 --- a/.github/scan-benchmark/scan_bench.py +++ b/.github/scan-benchmark/scan_bench.py @@ -196,6 +196,7 @@ def ports(ranges: tuple[tuple[int, int], ...]) -> str: scenarios = [ Scenario("tcp-1-port", "TCP, 1 open port", lo, ports=(TCP_PORTS[0],), repeat=3), + Scenario("tcp-open", "TCP, open listeners only", lo, ports=TCP_PORTS, repeat=3), Scenario("tcp-sweep", f"TCP, {ports(sweep)}, default batch", lo, sweep), Scenario( "tcp-sweep-excluded", @@ -236,6 +237,12 @@ def ports(ranges: tuple[tuple[int, int], ...]) -> str: scenarios.append( Scenario("udp-sweep", f"UDP, {ports(sweep)}", lo, sweep, timeout_ms=500, udp=True) ) + scenarios.append( + Scenario( + "udp-open", "UDP, echo responders only", lo, ports=UDP_PORTS, + timeout_ms=500, udp=True, repeat=9, + ) + ) if delayed: scenarios.append( Scenario( diff --git a/Cargo.lock b/Cargo.lock index 3b6cd169..a7204a12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,6 +1434,7 @@ dependencies = [ "gcd", "hickory-resolver", "itertools 0.15.0", + "libc", "log", "once_cell", "parameterized", @@ -1444,6 +1445,7 @@ dependencies = [ "text_placeholder", "tokio", "toml", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 947d7c06..c4553065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,12 @@ anyhow = "1.0.40" text_placeholder = { version = "0.5", features = ["struct_context"] } once_cell = "1.21.4" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock"] } + [dev-dependencies] parameterized = "2.0.0" criterion = { version = "0.8", features = ["html_reports"] } diff --git a/src/scanner/errors.rs b/src/scanner/errors.rs new file mode 100644 index 00000000..1f9d42e9 --- /dev/null +++ b/src/scanner/errors.rs @@ -0,0 +1,163 @@ +use std::{collections::HashSet, fmt, io, net::IpAddr}; + +/// Keep the OS error and its target intact until diagnostics need formatting. +#[derive(Debug)] +struct ScanError { + ip: IpAddr, + error: io::Error, +} + +impl fmt::Display for ScanError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}", self.error, self.ip) + } +} + +impl std::error::Error for ScanError {} + +/// Preserve the normal I/O result layout; only diagnostics need target context. +pub(super) fn diagnostic_error(error: io::Error, ip: IpAddr, enabled: bool) -> io::Error { + if enabled { + io::Error::other(ScanError { ip, error }) + } else { + error + } +} + +pub(super) struct ScanErrors { + enabled: bool, + limit: usize, + messages: HashSet, +} + +impl ScanErrors { + pub(super) fn new(enabled: bool, limit: usize) -> Self { + Self { + enabled, + limit, + messages: HashSet::new(), + } + } + + pub(super) fn record(&mut self, failure: io::Error) { + if self.enabled && self.messages.len() < self.limit { + self.messages.insert(failure.to_string()); + } + } + + pub(super) fn messages(&self) -> &HashSet { + &self.messages + } +} + +/// Detect descriptor exhaustion without allocating or interpreting OS messages. +#[cfg(unix)] +pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool { + matches!(error.raw_os_error(), Some(libc::EMFILE | libc::ENFILE)) +} + +#[cfg(windows)] +pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool { + error.raw_os_error() == Some(windows_sys::Win32::Networking::WinSock::WSAEMFILE) +} + +// Retain the previous fallback for targets without Unix or Winsock error codes. +#[cfg(not(any(unix, windows)))] +pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool { + error + .to_string() + .to_lowercase() + .contains("too many open files") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fmt, net::SocketAddr}; + + #[derive(Debug)] + struct MustNotFormat; + + impl fmt::Display for MustNotFormat { + fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { + panic!("discarded diagnostics must not format errors") + } + } + + impl std::error::Error for MustNotFormat {} + + fn failure(error: io::Error) -> io::Error { + diagnostic_error(error, "127.0.0.1".parse().unwrap(), true) + } + + #[test] + fn disabled_diagnostics_do_not_format_errors() { + let mut errors = ScanErrors::new(false, 1000); + errors.record(diagnostic_error( + io::Error::other(MustNotFormat), + "127.0.0.1".parse().unwrap(), + false, + )); + assert!(errors.messages().is_empty()); + } + + #[test] + fn disabled_diagnostics_preserve_the_original_os_error() { + let original = io::Error::from_raw_os_error(123); + let error = diagnostic_error(original, "127.0.0.1".parse().unwrap(), false); + assert_eq!(error.raw_os_error(), Some(123)); + } + + #[test] + fn full_diagnostics_do_not_format_more_errors() { + let mut errors = ScanErrors::new(true, 1); + errors.record(failure(io::ErrorKind::ConnectionRefused.into())); + errors.record(failure(io::Error::other(MustNotFormat))); + assert_eq!(errors.messages().len(), 1); + } + + #[test] + fn enabled_diagnostics_keep_error_details_and_deduplicate_per_ip() { + let mut errors = ScanErrors::new(true, 1000); + for port in [80, 443] { + errors.record(diagnostic_error( + io::Error::other("test socket error"), + SocketAddr::from(([127, 0, 0, 1], port)).ip(), + true, + )); + } + assert_eq!(errors.messages().len(), 1); + assert!(errors.messages().contains("test socket error 127.0.0.1")); + } + + #[cfg(unix)] + #[test] + fn detects_unix_descriptor_limits_by_code() { + for code in [libc::EMFILE, libc::ENFILE] { + assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error( + code + ))); + } + assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error( + libc::ECONNREFUSED + ))); + } + + #[cfg(windows)] + #[test] + fn detects_winsock_descriptor_limits_by_code() { + use windows_sys::Win32::Networking::WinSock::{WSAECONNREFUSED, WSAEMFILE}; + assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error( + WSAEMFILE + ))); + assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error( + WSAECONNREFUSED + ))); + } + + #[cfg(any(unix, windows))] + #[test] + fn descriptor_check_never_formats_an_error() { + assert!(!is_descriptor_exhaustion(&io::Error::other(MustNotFormat))); + } +} diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index b7385e09..78e7ff09 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -7,13 +7,16 @@ use log::debug; mod socket_iterator; use socket_iterator::SocketIterator; +mod errors; +use errors::{diagnostic_error, is_descriptor_exhaustion, ScanErrors}; + use colored::Colorize; use futures::stream::{FuturesUnordered, StreamExt}; use std::collections::BTreeMap; use std::future::poll_fn; use std::task::Poll; use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, io, net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr}, num::NonZeroU8, @@ -215,7 +218,8 @@ impl Scanner { async fn scan(&self) -> Vec { let ports = filter_excluded_ports(self.port_strategy.order(), &self.exclude_ports); let mut found_sockets: Vec = Vec::new(); - let mut errors: HashSet = HashSet::new(); + let mut errors = + ScanErrors::new(log::log_enabled!(log::Level::Debug), self.ips.len() * 1000); // Build UDP payload lookup once (only if we are scanning UDP). // This avoids cloning a big map into every spawned future and turns @@ -250,7 +254,7 @@ impl Scanner { } } - debug!("Typical socket connection errors {errors:?}"); + debug!("Typical socket connection errors {:?}", errors.messages()); debug!("Sockets found: {:?}", found_sockets); found_sockets } @@ -271,7 +275,7 @@ impl Scanner { mut sockets: SocketIterator<'_>, udp_payloads: &Option>, found_sockets: &mut Vec, - errors: &mut HashSet, + errors: &mut ScanErrors, ) { let mut ftrs = FuturesUnordered::new(); @@ -319,24 +323,19 @@ impl Scanner { &self, result: io::Result, found_sockets: &mut Vec, - errors: &mut HashSet, + errors: &mut ScanErrors, ) { match result { Ok(status) => found_sockets.push(status), - Err(e) => { - let error_string = e.to_string(); - if errors.len() < self.ips.len() * 1000 { - errors.insert(error_string); - } - } + Err(error) => errors.record(error), } } /// Given a socket, scan it self.tries times. /// Turns the address into a SocketAddr /// Deals with the `` type - /// If it experiences error ErrorKind::Other then too many files are open and it Panics! - /// Else any other error, it returns the error in Result as a string + /// Panics if the OS reports descriptor exhaustion. + /// Other failures retain their I/O error, with target context for debug diagnostics. /// If no errors occur, it returns the port number in Result to signify the port is open. /// This function mainly deals with the logic of Results handling. /// # Example @@ -376,14 +375,14 @@ impl Scanner { return Ok(PortStatus::Closed(socket)); } - let mut error_string = e.to_string(); - - assert!(!error_string.to_lowercase().contains("too many open files"), "Too many open files. Please reduce batch size. The default is 5000. Try -b 2500."); + assert!(!is_descriptor_exhaustion(&e), "Too many open files. Please reduce batch size. The default is 5000. Try -b 2500."); if nr_try == tries { - error_string.push(' '); - error_string.push_str(&socket.ip().to_string()); - return Err(io::Error::other(error_string)); + return Err(diagnostic_error( + e, + socket.ip(), + log::log_enabled!(log::Level::Debug), + )); } } }; @@ -606,6 +605,7 @@ mod tests { use super::*; use crate::input::{PortRanges, ScanOrder}; + use std::collections::HashSet; // These tests never open sockets: they only build a `Scanner` (and its // futures), run a scan that has no socket to scan, or inspect the payload