Compare commits

...
3 changed files with 281 additions and 11 deletions

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
{
"metadata": {
"base": "67c08a33100362c54b56fde784b0fd234f70fa11",
"candidate": "a05592a8a88b77ad85d961137943dfadfe98b612",
"environment": "Linux-7.2.6-1-cachyos-x86_64-with-glibc2.44",
"rust": "rustc 1.98.1 (48a229cea 2026-09-01) (Arch Linux rust 1:1.98.1-1.1)",
"hosts_bytes": 2760051,
"profile": "release with LTO",
"network_namespace": "unshare --user --map-root-user --net",
"common_args": [
"--no-config",
"--config-path",
"/dev/null",
"--no-banner",
"--scripts",
"none",
"--greppable",
"--ports",
"80",
"--exclude-ports",
"80"
],
"workloads": {
"literal-ipv4": [
"-a",
"127.0.0.1"
],
"mixed-cidrs": [
"-a",
"127.0.0.1,192.0.2.0/30,2001:db8::/126",
"--exclude-addresses",
"192.0.2.1,2001:db8::1"
]
},
"warmups_each": 2,
"samples_each": 20,
"binary_cpus": "0-3",
"harness_cpus": "8-9",
"binary_sha256": {
"base": "4d6b090218ff36249ed95f14ec1a82626e8ea33854f3823f19a6de247b1524b8",
"dns": "a9d6bf949f56c53d18cc85bc9b53b21bd85a63bcdd04ad78992d392c675c77ac"
}
},
"summary": {
"literal-ipv4": {
"base_ms": 168.23068849999999,
"dns_ms": 2.248762,
"samples_each": 20
},
"mixed-cidrs": {
"base_ms": 178.0855925,
"dns_ms": 2.4539600000000004,
"samples_each": 20
}
},
"samples": {
"literal-ipv4": {
"base": [
143.391226,
146.562804,
149.615525,
151.188597,
152.269193,
159.499601,
164.926958,
164.347529,
167.208931,
165.433184,
172.246939,
169.252446,
172.777899,
170.729249,
170.092681,
171.863255,
169.612842,
170.857396,
173.117403,
175.206345
],
"dns": [
2.241793,
1.817341,
2.39956,
2.048986,
3.123401,
1.820017,
3.615094,
2.165312,
2.675535,
2.103261,
2.531392,
2.215141,
2.572939,
2.063803,
2.671431,
2.059846,
2.598858,
2.230916,
2.678259,
2.255731
]
},
"mixed-cidrs": {
"base": [
175.504767,
178.797842,
186.183728,
176.885958,
177.989816,
180.028568,
177.66299,
174.757221,
175.468485,
175.328023,
177.639639,
175.637593,
177.922435,
185.394396,
182.587204,
180.58311,
178.961137,
178.293186,
180.482419,
178.181369
],
"dns": [
3.275193,
2.114074,
2.700771,
2.183317,
2.606538,
2.368046,
2.599702,
2.238105,
2.683892,
2.292249,
2.528366,
2.114478,
2.668921,
2.103271,
3.275514,
2.27974,
3.103311,
2.265298,
2.580743,
2.379554
]
}
},
"invocations": 88
}
+48
View File
@@ -0,0 +1,48 @@
# Lazy DNS initialization benchmark
Compared current Tokio master `67c08a33100362c54b56fde784b0fd234f70fa11` with the lazy-resolver implementation
at `a05592a8a88b77ad85d961137943dfadfe98b612` on 2026-10-02.
## Method
Both binaries are release builds with LTO, using Rust 1.98.1 on an Intel Core
Ultra 7 165U running Linux 7.2.6-1-cachyos. Each workload used twenty measured
runs and two warmups per binary, alternating A/B and B/A order. Binaries used
CPUs 0-3; the harness used CPUs 8-9. No builds ran during measurement.
The harness ran in a private network namespace. Every invocation excluded
its only requested port, so no scan traffic was sent. Configuration and
scripts were disabled. Timing spans process launch through exit, including
the same `taskset` affinity launcher for both builds.
Common arguments:
```sh
--no-config --config-path /dev/null --no-banner --scripts none --greppable \
--ports 80 --exclude-ports 80
```
The literal case adds `-a 127.0.0.1`. The mixed case adds
`-a 127.0.0.1,192.0.2.0/30,2001:db8::/126` and
`--exclude-addresses 192.0.2.1,2001:db8::1`. Both use `RUST_LOG=rustscan=info`.
## Results
| Workload | Master median | Lazy DNS median | Time saved |
| --- | ---: | ---: | ---: |
| Literal IPv4 | 168.23 ms | 2.25 ms | 165.98 ms (98.66%) |
| IPv4/IPv6 CIDRs and exclusions | 178.09 ms | 2.45 ms | 175.63 ms (98.62%) |
All 88 invocations exited successfully with the expected empty scan output.
A separate syscall trace of the optimized literal-IP invocation recorded no
reads of `/etc/hosts` or `/etc/resolv.conf` and no network connects or sends.
This host has a 2,760,051-byte hosts file. Eager Hickory initialization parses
that file even for numeric targets. Systems with smaller hosts files will
save less; this measures CLI startup, not socket throughput. Hostnames that
need fallback DNS still pay resolver initialization once, and parsing order,
file handling, deduplication and exclusions keep their existing behavior.
[Raw samples, setup and binary hashes](lazy-dns-benchmark-results.json) are
included. Network-free regression tests verify that literal IPv4/IPv6 targets,
CIDRs, exclusions, duplicates and empty inputs do not construct the resolver.
+82 -11
View File
@@ -1,4 +1,5 @@
//! Provides functions to parse input IP addresses, CIDRs or files.
use std::cell::LazyCell;
use std::collections::BTreeSet;
use std::fs::{self, File};
use std::io::{prelude::*, BufReader};
@@ -39,12 +40,23 @@ use crate::warning;
/// start your runtime (as the `rustscan` binary does) or from
/// `tokio::task::spawn_blocking`, not directly from async code.
pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
parse_addresses_with_resolver(input, || get_resolver(&input.resolver))
}
fn parse_addresses_with_resolver(
input: &Opts,
create_resolver: impl FnOnce() -> Resolver,
) -> Vec<IpAddr> {
let mut ips: Vec<IpAddr> = Vec::new();
let mut unresolved_addresses: Vec<&str> = Vec::new();
let backup_resolver = get_resolver(&input.resolver);
// Most scans use literal IPs or CIDRs. Defer the resolver's runtime and
// hosts-file loading until a DNS fallback actually needs them, then reuse
// that resolver for the rest of the targets and exclusions.
let backup_resolver = LazyCell::new(create_resolver);
let resolver = || &*backup_resolver;
for address in &input.addresses {
let parsed_ips = parse_address(address, &backup_resolver);
let parsed_ips = parse_address_with_resolver(address, &resolver);
if !parsed_ips.is_empty() {
ips.extend(parsed_ips);
} else {
@@ -66,7 +78,7 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
continue;
}
if let Ok(x) = read_ips_from_file(file_path, &backup_resolver) {
if let Ok(x) = read_ips_from_file(file_path, &resolver) {
ips.extend(x);
} else {
warning!(
@@ -77,7 +89,7 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
}
}
let excluded_cidrs = parse_excluded_networks(&input.exclude_addresses, &backup_resolver);
let excluded_cidrs = parse_excluded_networks_with_resolver(&input.exclude_addresses, &resolver);
// Remove duplicated/excluded IPs.
let mut seen = BTreeSet::new();
@@ -100,6 +112,13 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
/// let ips = parse_address("127.0.0.1", &Resolver::default().unwrap());
/// ```
pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
parse_address_with_resolver(address, &|| resolver)
}
fn parse_address_with_resolver<'a>(
address: &str,
resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpAddr> {
if let Ok(addr) = IpAddr::from_str(address) {
// `address` is an IP string
vec![addr]
@@ -118,14 +137,17 @@ pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
}
/// Uses DNS to get the IPS associated with host
fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr> {
fn resolve_ips_from_host<'a>(
source: &str,
backup_resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpAddr> {
let mut ips: Vec<IpAddr> = Vec::new();
if let Ok(addrs) = source.to_socket_addrs() {
for ip in addrs {
ips.push(ip.ip());
}
} else if let Ok(addrs) = backup_resolver.lookup_ip(source) {
} else if let Ok(addrs) = backup_resolver().lookup_ip(source) {
ips.extend(addrs.iter());
}
@@ -148,6 +170,13 @@ fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr
pub fn parse_excluded_networks(
exclude_addresses: &Option<Vec<String>>,
resolver: &Resolver,
) -> Vec<IpCidr> {
parse_excluded_networks_with_resolver(exclude_addresses, &|| resolver)
}
fn parse_excluded_networks_with_resolver<'a>(
exclude_addresses: &Option<Vec<String>>,
resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpCidr> {
exclude_addresses
.iter()
@@ -157,7 +186,10 @@ pub fn parse_excluded_networks(
}
/// Parses a single address into an IpCidr, handling CIDR notation, IP addresses, and hostnames.
fn parse_single_excluded_address(addr: &str, resolver: &Resolver) -> Vec<IpCidr> {
fn parse_single_excluded_address<'a>(
addr: &str,
resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpCidr> {
if let Ok(cidr) = IpCidr::from_str(addr) {
return vec![cidr];
}
@@ -261,9 +293,9 @@ fn read_resolver_from_file(path: &str) -> Result<Vec<IpAddr>, std::io::Error> {
#[cfg(not(tarpaulin_include))]
/// Parses an input file of IPs and uses those
fn read_ips_from_file(
fn read_ips_from_file<'a>(
ips: &std::path::Path,
backup_resolver: &Resolver,
backup_resolver: &impl Fn() -> &'a Resolver,
) -> Result<Vec<IpAddr>, std::io::Error> {
let file = File::open(ips)?;
let reader = BufReader::new(file);
@@ -272,7 +304,7 @@ fn read_ips_from_file(
for address_line in reader.lines() {
if let Ok(address) = address_line {
ips.extend(parse_address(&address, backup_resolver));
ips.extend(parse_address_with_resolver(&address, backup_resolver));
} else {
debug!("Line in file is not valid");
}
@@ -283,9 +315,48 @@ fn read_ips_from_file(
#[cfg(test)]
mod tests {
use super::{parse_addresses, Opts};
use super::{parse_addresses, parse_addresses_with_resolver, Opts};
use std::net::{IpAddr, Ipv4Addr};
#[test]
fn literal_targets_and_exclusions_do_not_initialize_a_resolver() {
let opts = Opts {
addresses: vec![
"192.0.2.0/30".to_owned(),
"192.0.2.2".to_owned(),
"2001:db8::/126".to_owned(),
"2001:db8::3".to_owned(),
],
exclude_addresses: Some(vec![
"192.0.2.0/31".to_owned(),
"192.0.2.3".to_owned(),
"2001:db8::/127".to_owned(),
"2001:db8::3".to_owned(),
]),
..Default::default()
};
let ips = parse_addresses_with_resolver(&opts, || {
panic!("literal targets and exclusions must not initialize DNS")
});
assert_eq!(
ips,
[
"192.0.2.2".parse::<IpAddr>().unwrap(),
"2001:db8::2".parse::<IpAddr>().unwrap(),
]
);
}
#[test]
fn empty_targets_do_not_initialize_a_resolver() {
assert!(parse_addresses_with_resolver(&Opts::default(), || {
panic!("empty targets must not initialize DNS")
})
.is_empty());
}
#[test]
fn parse_correct_addresses() {
let opts = Opts {