Skip to content

Commit 9379033

Browse files
bee-sanAutumn Skerritt
andauthored
Avoid formatting discarded scan errors (#950)
* perf(scanner): defer TCP error diagnostics * bench(scanner): check open-only TCP and UDP paths * fix(scanner): Import HashSet in tests after main merge --------- Co-authored-by: Autumn Skerritt <skerraut@amazon.com>
1 parent f337528 commit 9379033

5 files changed

Lines changed: 197 additions & 19 deletions

File tree

‎.github/scan-benchmark/scan_bench.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def ports(ranges: tuple[tuple[int, int], ...]) -> str:
196196

197197
scenarios = [
198198
Scenario("tcp-1-port", "TCP, 1 open port", lo, ports=(TCP_PORTS[0],), repeat=3),
199+
Scenario("tcp-open", "TCP, open listeners only", lo, ports=TCP_PORTS, repeat=3),
199200
Scenario("tcp-sweep", f"TCP, {ports(sweep)}, default batch", lo, sweep),
200201
Scenario(
201202
"tcp-sweep-excluded",
@@ -236,6 +237,12 @@ def ports(ranges: tuple[tuple[int, int], ...]) -> str:
236237
scenarios.append(
237238
Scenario("udp-sweep", f"UDP, {ports(sweep)}", lo, sweep, timeout_ms=500, udp=True)
238239
)
240+
scenarios.append(
241+
Scenario(
242+
"udp-open", "UDP, echo responders only", lo, ports=UDP_PORTS,
243+
timeout_ms=500, udp=True, repeat=9,
244+
)
245+
)
239246
if delayed:
240247
scenarios.append(
241248
Scenario(

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ anyhow = "1.0.40"
4343
text_placeholder = { version = "0.5", features = ["struct_context"] }
4444
once_cell = "1.21.4"
4545

46+
[target.'cfg(unix)'.dependencies]
47+
libc = "0.2"
48+
49+
[target.'cfg(windows)'.dependencies]
50+
windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock"] }
51+
4652
[dev-dependencies]
4753
parameterized = "2.0.0"
4854
criterion = { version = "0.8", features = ["html_reports"] }

‎src/scanner/errors.rs‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
use std::{collections::HashSet, fmt, io, net::IpAddr};
2+
3+
/// Keep the OS error and its target intact until diagnostics need formatting.
4+
#[derive(Debug)]
5+
struct ScanError {
6+
ip: IpAddr,
7+
error: io::Error,
8+
}
9+
10+
impl fmt::Display for ScanError {
11+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
12+
write!(f, "{} {}", self.error, self.ip)
13+
}
14+
}
15+
16+
impl std::error::Error for ScanError {}
17+
18+
/// Preserve the normal I/O result layout; only diagnostics need target context.
19+
pub(super) fn diagnostic_error(error: io::Error, ip: IpAddr, enabled: bool) -> io::Error {
20+
if enabled {
21+
io::Error::other(ScanError { ip, error })
22+
} else {
23+
error
24+
}
25+
}
26+
27+
pub(super) struct ScanErrors {
28+
enabled: bool,
29+
limit: usize,
30+
messages: HashSet<String>,
31+
}
32+
33+
impl ScanErrors {
34+
pub(super) fn new(enabled: bool, limit: usize) -> Self {
35+
Self {
36+
enabled,
37+
limit,
38+
messages: HashSet::new(),
39+
}
40+
}
41+
42+
pub(super) fn record(&mut self, failure: io::Error) {
43+
if self.enabled && self.messages.len() < self.limit {
44+
self.messages.insert(failure.to_string());
45+
}
46+
}
47+
48+
pub(super) fn messages(&self) -> &HashSet<String> {
49+
&self.messages
50+
}
51+
}
52+
53+
/// Detect descriptor exhaustion without allocating or interpreting OS messages.
54+
#[cfg(unix)]
55+
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
56+
matches!(error.raw_os_error(), Some(libc::EMFILE | libc::ENFILE))
57+
}
58+
59+
#[cfg(windows)]
60+
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
61+
error.raw_os_error() == Some(windows_sys::Win32::Networking::WinSock::WSAEMFILE)
62+
}
63+
64+
// Retain the previous fallback for targets without Unix or Winsock error codes.
65+
#[cfg(not(any(unix, windows)))]
66+
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
67+
error
68+
.to_string()
69+
.to_lowercase()
70+
.contains("too many open files")
71+
}
72+
73+
#[cfg(test)]
74+
mod tests {
75+
use super::*;
76+
use std::{fmt, net::SocketAddr};
77+
78+
#[derive(Debug)]
79+
struct MustNotFormat;
80+
81+
impl fmt::Display for MustNotFormat {
82+
fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
83+
panic!("discarded diagnostics must not format errors")
84+
}
85+
}
86+
87+
impl std::error::Error for MustNotFormat {}
88+
89+
fn failure(error: io::Error) -> io::Error {
90+
diagnostic_error(error, "127.0.0.1".parse().unwrap(), true)
91+
}
92+
93+
#[test]
94+
fn disabled_diagnostics_do_not_format_errors() {
95+
let mut errors = ScanErrors::new(false, 1000);
96+
errors.record(diagnostic_error(
97+
io::Error::other(MustNotFormat),
98+
"127.0.0.1".parse().unwrap(),
99+
false,
100+
));
101+
assert!(errors.messages().is_empty());
102+
}
103+
104+
#[test]
105+
fn disabled_diagnostics_preserve_the_original_os_error() {
106+
let original = io::Error::from_raw_os_error(123);
107+
let error = diagnostic_error(original, "127.0.0.1".parse().unwrap(), false);
108+
assert_eq!(error.raw_os_error(), Some(123));
109+
}
110+
111+
#[test]
112+
fn full_diagnostics_do_not_format_more_errors() {
113+
let mut errors = ScanErrors::new(true, 1);
114+
errors.record(failure(io::ErrorKind::ConnectionRefused.into()));
115+
errors.record(failure(io::Error::other(MustNotFormat)));
116+
assert_eq!(errors.messages().len(), 1);
117+
}
118+
119+
#[test]
120+
fn enabled_diagnostics_keep_error_details_and_deduplicate_per_ip() {
121+
let mut errors = ScanErrors::new(true, 1000);
122+
for port in [80, 443] {
123+
errors.record(diagnostic_error(
124+
io::Error::other("test socket error"),
125+
SocketAddr::from(([127, 0, 0, 1], port)).ip(),
126+
true,
127+
));
128+
}
129+
assert_eq!(errors.messages().len(), 1);
130+
assert!(errors.messages().contains("test socket error 127.0.0.1"));
131+
}
132+
133+
#[cfg(unix)]
134+
#[test]
135+
fn detects_unix_descriptor_limits_by_code() {
136+
for code in [libc::EMFILE, libc::ENFILE] {
137+
assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error(
138+
code
139+
)));
140+
}
141+
assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error(
142+
libc::ECONNREFUSED
143+
)));
144+
}
145+
146+
#[cfg(windows)]
147+
#[test]
148+
fn detects_winsock_descriptor_limits_by_code() {
149+
use windows_sys::Win32::Networking::WinSock::{WSAECONNREFUSED, WSAEMFILE};
150+
assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error(
151+
WSAEMFILE
152+
)));
153+
assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error(
154+
WSAECONNREFUSED
155+
)));
156+
}
157+
158+
#[cfg(any(unix, windows))]
159+
#[test]
160+
fn descriptor_check_never_formats_an_error() {
161+
assert!(!is_descriptor_exhaustion(&io::Error::other(MustNotFormat)));
162+
}
163+
}

‎src/scanner/mod.rs‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ use log::debug;
77
mod socket_iterator;
88
use socket_iterator::SocketIterator;
99

10+
mod errors;
11+
use errors::{diagnostic_error, is_descriptor_exhaustion, ScanErrors};
12+
1013
use colored::Colorize;
1114
use futures::stream::{FuturesUnordered, StreamExt};
1215
use std::collections::BTreeMap;
1316
use std::future::poll_fn;
1417
use std::task::Poll;
1518
use std::{
16-
collections::{HashMap, HashSet},
19+
collections::HashMap,
1720
io,
1821
net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr},
1922
num::NonZeroU8,
@@ -215,7 +218,8 @@ impl Scanner {
215218
async fn scan(&self) -> Vec<PortStatus> {
216219
let ports = filter_excluded_ports(self.port_strategy.order(), &self.exclude_ports);
217220
let mut found_sockets: Vec<PortStatus> = Vec::new();
218-
let mut errors: HashSet<String> = HashSet::new();
221+
let mut errors =
222+
ScanErrors::new(log::log_enabled!(log::Level::Debug), self.ips.len() * 1000);
219223

220224
// Build UDP payload lookup once (only if we are scanning UDP).
221225
// This avoids cloning a big map into every spawned future and turns
@@ -250,7 +254,7 @@ impl Scanner {
250254
}
251255
}
252256

253-
debug!("Typical socket connection errors {errors:?}");
257+
debug!("Typical socket connection errors {:?}", errors.messages());
254258
debug!("Sockets found: {:?}", found_sockets);
255259
found_sockets
256260
}
@@ -271,7 +275,7 @@ impl Scanner {
271275
mut sockets: SocketIterator<'_>,
272276
udp_payloads: &Option<Arc<UdpPayloadLookup>>,
273277
found_sockets: &mut Vec<PortStatus>,
274-
errors: &mut HashSet<String>,
278+
errors: &mut ScanErrors,
275279
) {
276280
let mut ftrs = FuturesUnordered::new();
277281

@@ -319,24 +323,19 @@ impl Scanner {
319323
&self,
320324
result: io::Result<PortStatus>,
321325
found_sockets: &mut Vec<PortStatus>,
322-
errors: &mut HashSet<String>,
326+
errors: &mut ScanErrors,
323327
) {
324328
match result {
325329
Ok(status) => found_sockets.push(status),
326-
Err(e) => {
327-
let error_string = e.to_string();
328-
if errors.len() < self.ips.len() * 1000 {
329-
errors.insert(error_string);
330-
}
331-
}
330+
Err(error) => errors.record(error),
332331
}
333332
}
334333

335334
/// Given a socket, scan it self.tries times.
336335
/// Turns the address into a SocketAddr
337336
/// Deals with the `<result>` type
338-
/// If it experiences error ErrorKind::Other then too many files are open and it Panics!
339-
/// Else any other error, it returns the error in Result as a string
337+
/// Panics if the OS reports descriptor exhaustion.
338+
/// Other failures retain their I/O error, with target context for debug diagnostics.
340339
/// If no errors occur, it returns the port number in Result to signify the port is open.
341340
/// This function mainly deals with the logic of Results handling.
342341
/// # Example
@@ -376,14 +375,14 @@ impl Scanner {
376375
return Ok(PortStatus::Closed(socket));
377376
}
378377

379-
let mut error_string = e.to_string();
380-
381-
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.");
378+
assert!(!is_descriptor_exhaustion(&e), "Too many open files. Please reduce batch size. The default is 5000. Try -b 2500.");
382379

383380
if nr_try == tries {
384-
error_string.push(' ');
385-
error_string.push_str(&socket.ip().to_string());
386-
return Err(io::Error::other(error_string));
381+
return Err(diagnostic_error(
382+
e,
383+
socket.ip(),
384+
log::log_enabled!(log::Level::Debug),
385+
));
387386
}
388387
}
389388
};
@@ -606,6 +605,7 @@ mod tests {
606605
use super::*;
607606

608607
use crate::input::{PortRanges, ScanOrder};
608+
use std::collections::HashSet;
609609

610610
// These tests never open sockets: they only build a `Scanner` (and its
611611
// futures), run a scan that has no socket to scan, or inspect the payload

0 commit comments

Comments
 (0)