Compare commits

..
25 changed files with 1050 additions and 7536 deletions

No files matched your search

+3 -3
View File
@@ -3,15 +3,15 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
interval: "daily"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "monthly"
interval: "daily"
- package-ecosystem: cargo
directory: "/"
schedule:
interval: monthly
interval: daily
open-pull-requests-limit: 10
ignore:
- dependency-name: anyhow
@@ -1,57 +0,0 @@
"""Provision TCP memory for repeated scans on a disposable macOS CI runner."""
import ctypes
import json
import os
import subprocess
class Statistics(ctypes.Structure):
# XNU's bsd/sys/mem_acct_private.h, struct memacct_statistics.
_fields_ = [("peak", ctypes.c_uint64), ("allocated", ctypes.c_int64),
("softlimit", ctypes.c_uint64), ("hardlimit", ctypes.c_uint64),
("name", ctypes.c_char * 16)]
def tcp_memory(new_limit: int | None = None) -> dict:
"""Read the TCP accounting object, optionally raising its hard limit."""
libc = ctypes.CDLL(None, use_errno=True)
libc.sysctlnametomib.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_size_t)]
libc.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint,
ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t),
ctypes.c_void_p, ctypes.c_size_t]
mib = (ctypes.c_int * 24)()
count = ctypes.c_size_t(22)
if libc.sysctlnametomib(b"kern.memacct", mib, ctypes.byref(count)):
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
def query(operation: int, index: int, output, replacement=None):
mib[count.value], mib[count.value + 1] = operation, index
length = ctypes.c_size_t(ctypes.sizeof(output))
if libc.sysctl(mib, count.value + 2, ctypes.byref(output), ctypes.byref(length),
ctypes.byref(replacement) if replacement is not None else None,
ctypes.sizeof(replacement) if replacement is not None else 0):
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
return length.value
names = ctypes.create_string_buffer(8 * 16)
length = query(5, 0, names) # MEM_ACCT_SUBSYSTEMS
index = next(i for i in range(length // 16)
if names.raw[i * 16:(i + 1) * 16].rstrip(b"\0") == b"TCP")
stats = Statistics()
query(6, index, stats) # MEM_ACCT_ALL_SUBSYSTEM_STATISTICS
previous_limit = stats.hardlimit
if new_limit is not None and 0 < previous_limit < new_limit:
query(3, index, ctypes.c_uint64(), ctypes.c_uint64(new_limit))
query(6, index, stats)
return {"previous_hardlimit": previous_limit,
**{name: getattr(stats, name) for name in ("peak", "allocated", "softlimit", "hardlimit")}}
if __name__ == "__main__":
# The default TCP budget is 1/32 of physical memory. Repeated 10,000-socket
# batches hit ENOBUFS on Darwin 25.6 even on master. Give both builds 1/8
# before timing; retain every listener check and performance threshold.
memory = int(subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True))
print(json.dumps(tcp_memory(memory // 8)))
-984
View File
@@ -1,984 +0,0 @@
#!/usr/bin/env python3
"""Compare RustScan builds by scanning listeners on the loopback interface.
CI-only tooling for ``.github/workflows/scan-benchmark.yml``; it is not part
of the test suite. It port-scans 127.0.0.1, so only run it on a disposable CI
runner, never on a developer machine or a shared host.
``run`` starts ``serve`` (TCP listeners and UDP responders on 127.0.0.1) in a
separate process, runs every scenario with every build (interleaving the
builds so that drift on the runner affects them all alike), checks that all
builds report the same open ports, and writes the raw measurements as JSON
plus a Markdown summary of the medians. On Linux the workflow also sets up a
host behind an artificial delay (a network namespace on the runner, with its
own ``serve``), so that answers arrive after a round trip, as they do from a
remote host, instead of while the probe is being sent.
Measured per run:
* scan time: RustScan's own "Portscan" timer (``RUST_LOG=rustscan=info``),
reported as throughput (sockets per second);
* wall time of the whole process, and the time until the first ``Open`` line
appears on stdout (latency to the first result);
* peak RSS and CPU time (user + system) of the process;
* peak number of open file descriptors (Windows: handles) and of threads,
sampled every few milliseconds while the scan runs.
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import re
import selectors
import signal
import socket
import statistics
import subprocess
import sys
import threading
import time
from dataclasses import asdict, dataclass, field
LOOPBACK = "127.0.0.1"
# Ports `serve` listens on. They stay below 32768, i.e. below the ephemeral
# port ranges of Linux (32768-60999), macOS and Windows (49152-65535): a scan
# of a port inside the ephemeral range can occasionally connect a socket to
# itself and report a bogus open port.
TCP_PORTS = tuple(range(1024, 32768, 101)) # 315 open TCP ports
UDP_PORTS = tuple(range(1031, 32768, 997)) # 32 open UDP ports
SWEEP = (1, 32767)
SAMPLE_INTERVAL = 0.005 # seconds between fd/thread samples
RUN_TIMEOUT = 300 # seconds before a run is killed and counted as failed
# --------------------------------------------------------------------------
# Listener process
# --------------------------------------------------------------------------
def serve(addresses: list[str]) -> int:
"""Accepts (and closes) TCP connections and answers every UDP datagram."""
selector = selectors.DefaultSelector()
bound: dict[str, dict[str, list[int]]] = {"tcp": {}, "udp": {}, "busy": {}}
for address in addresses:
for kind, ports in (("tcp", TCP_PORTS), ("udp", UDP_PORTS)):
sock_type = socket.SOCK_STREAM if kind == "tcp" else socket.SOCK_DGRAM
for port in ports:
sock = socket.socket(socket.AF_INET, sock_type)
if os.name != "nt":
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((address, port))
except OSError:
sock.close()
bound["busy"].setdefault(f"{kind} {address}", []).append(port)
continue
if kind == "tcp":
sock.listen(1024)
sock.setblocking(False)
selector.register(sock, selectors.EVENT_READ, kind)
bound[kind].setdefault(address, []).append(port)
print(json.dumps(bound), flush=True)
while True:
for key, _ in selector.select(timeout=1.0):
sock = key.fileobj
if key.data == "tcp":
while True:
try:
conn, _ = sock.accept()
except OSError: # BlockingIOError once the queue is empty
break
conn.close()
else:
while True:
try:
_, peer = sock.recvfrom(4096)
except ConnectionResetError:
# Windows reports an earlier ICMP error here; skip it.
continue
except OSError: # BlockingIOError once the queue is empty
break
try:
sock.sendto(b"rustscan-benchmark\n", peer)
except OSError:
pass
def start_server(addresses: list[str]) -> tuple[subprocess.Popen, dict]:
args = [arg for address in addresses for arg in ("--address", address)]
proc = subprocess.Popen(
[sys.executable, os.path.abspath(__file__), "serve", *args],
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
text=True,
)
line = proc.stdout.readline()
if not line:
raise SystemExit("the listener process exited before it was ready")
return proc, json.loads(line)
# --------------------------------------------------------------------------
# Scenarios
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Scenario:
name: str
title: str
addresses: tuple[str, ...]
ranges: tuple[tuple[int, int], ...] = ()
ports: tuple[int, ...] = ()
batch: int = 4500
timeout_ms: int = 1500
udp: bool = False
repeat: int = 1 # extra samples for very short scenarios
excluded_ports: tuple[int, ...] = ()
@property
def sockets(self) -> int:
span = sum(end - start + 1 for start, end in self.ranges)
excluded = sum(self.covers(port) for port in set(self.excluded_ports))
return len(self.addresses) * (len(self.ports) + span - excluded)
def covers(self, port: int) -> bool:
return port in self.ports or any(s <= port <= e for s, e in self.ranges)
def args(self) -> list[str]:
args = ["-a", ",".join(self.addresses)]
if self.ports:
args += ["-p", ",".join(map(str, self.ports))]
else:
args += ["-r", ",".join(f"{s}-{e}" for s, e in self.ranges)]
args += ["-b", str(self.batch), "-t", str(self.timeout_ms)]
if self.udp:
args.append("--udp")
if self.excluded_ports:
args += ["--exclude-ports", ",".join(map(str, self.excluded_ports))]
return args
def expected(self, listeners: dict) -> set[str]:
by_address = listeners["udp" if self.udp else "tcp"]
return {
f"{address}:{port}"
for address in self.addresses
for port in by_address.get(address, [])
if self.covers(port) and port not in self.excluded_ports
}
def describe(self) -> str:
hosts = f"{len(self.addresses)} addresses" if len(self.addresses) > 1 else self.addresses[0]
return f"{self.title}: `{' '.join(self.args()[2:])}` on {hosts}"
def build_scenarios(
system: str, filtered: tuple[int, int] | None, delayed: str | None, delay_ms: int
) -> list[Scenario]:
lo = (LOOPBACK,)
if system == "Windows":
# Windows retries a refused connection for about a second, so every
# closed port there takes as long as a filtered one: sweep fewer.
sweep, small_sweep = ((1, 8191),), ((1, 2047),)
else:
sweep = small_sweep = (SWEEP,)
def ports(ranges: tuple[tuple[int, int], ...]) -> str:
return f"{sum(e - s + 1 for s, e in ranges):,} ports"
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",
f"TCP, {ports(sweep)}, 1,024 excluded ports",
lo,
sweep,
excluded_ports=tuple(range(1024, 2048)),
),
Scenario(
"tcp-sweep-b500", f"TCP, {ports(small_sweep)}, small batch", lo, small_sweep, batch=500
),
Scenario("tcp-sweep-b10000", f"TCP, {ports(sweep)}, large batch", lo, sweep, batch=10000),
]
if system == "Linux":
# Linux answers on all of 127.0.0.0/8; other systems only on 127.0.0.1.
hosts = tuple(f"127.0.0.{i}" for i in range(1, 9))
scenarios.append(
Scenario("tcp-8-hosts", "TCP, 8 hosts x 4,096 ports", hosts, ((1, 4096),))
)
if delayed:
scenarios.append(
Scenario("tcp-delayed", f"TCP, {ports(sweep)}, {delay_ms} ms away", (delayed,), sweep)
)
if filtered:
dropped = (filtered,)
scenarios += [
Scenario(
"tcp-filtered", "TCP, 2,000 filtered ports", lo, dropped, batch=500, timeout_ms=250
),
Scenario(
"tcp-mixed",
f"TCP, {ports(sweep)} + 2,000 filtered ports",
lo,
sweep + dropped,
timeout_ms=500,
),
]
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(
"udp-delayed",
f"UDP, {ports(sweep)}, {delay_ms} ms away",
(delayed,),
sweep,
timeout_ms=500,
udp=True,
)
)
if filtered:
scenarios.append(
Scenario(
"udp-filtered",
"UDP, 2,000 filtered ports",
lo,
(filtered,),
batch=500,
timeout_ms=250,
udp=True,
)
)
return scenarios
# --------------------------------------------------------------------------
# Running one scan
# --------------------------------------------------------------------------
@dataclass
class RunResult:
build: str
scenario: str
sockets: int = 0
ok: bool = True
error: str = ""
returncode: int | None = None
wall_s: float | None = None
scan_s: float | None = None
first_open_s: float | None = None
rss_kb: int | None = None
cpu_s: float | None = None
peak_fds: int | None = None
peak_threads: int | None = None
open: list[str] = field(default_factory=list)
@property
def rate(self) -> float | None:
return self.sockets / self.scan_s if self.scan_s else None
SCAN_TIME = re.compile(r"Portscan\s*\|\s*([0-9.]+)\s*s")
OPEN_LINE = re.compile(r"^Open (\S+)$")
SUMMARY_LINE = re.compile(r"^(\S+) -> \[([0-9,]*)\]$")
class Sampler(threading.Thread):
"""Samples the open descriptors and the threads of a running process."""
def __init__(self, proc: subprocess.Popen):
super().__init__(daemon=True)
self.proc = proc
self.done = threading.Event()
self.peak_fds: int | None = None
self.peak_threads: int | None = None
self.system = platform.system()
self.libproc = darwin_libproc() if self.system == "Darwin" else None
def sample(self) -> tuple[int | None, int | None]:
pid = self.proc.pid
if self.system == "Linux":
fds = len(os.listdir(f"/proc/{pid}/fd"))
threads = None
with open(f"/proc/{pid}/status", encoding="ascii", errors="replace") as status:
for line in status:
if line.startswith("Threads:"):
threads = int(line.split()[1])
break
return fds, threads
if self.libproc is not None:
return darwin_fds_and_threads(self.libproc, pid)
if self.system == "Windows":
return windows_handle_count(self.proc), None
return None, None
def run(self) -> None:
while not self.done.is_set():
try:
fds, threads = self.sample()
except (OSError, ValueError):
return # the process is gone
if fds is not None:
self.peak_fds = max(self.peak_fds or 0, fds)
if threads is not None:
self.peak_threads = max(self.peak_threads or 0, threads)
self.done.wait(SAMPLE_INTERVAL)
def darwin_libproc():
import ctypes
try:
libproc = ctypes.CDLL("/usr/lib/libproc.dylib")
except OSError:
return None
libproc.proc_pidinfo.argtypes = [
ctypes.c_int,
ctypes.c_int,
ctypes.c_uint64,
ctypes.c_void_p,
ctypes.c_int,
]
libproc.proc_pidinfo.restype = ctypes.c_int
return libproc
def darwin_fds_and_threads(libproc, pid: int) -> tuple[int | None, int | None]:
import ctypes
proc_pidlistfds, proc_pidtaskinfo = 1, 4
size = libproc.proc_pidinfo(pid, proc_pidlistfds, 0, None, 0)
if size <= 0:
raise OSError("process is gone")
buf = ctypes.create_string_buffer(size)
used = libproc.proc_pidinfo(pid, proc_pidlistfds, 0, ctypes.addressof(buf), size)
if used <= 0:
raise OSError("process is gone")
fds = used // 8 # sizeof(struct proc_fdinfo)
class TaskInfo(ctypes.Structure): # struct proc_taskinfo
_fields_ = [
*((name, ctypes.c_uint64) for name in ("vsize", "rsize", "user", "system", "tu", "ts")),
*(
(name, ctypes.c_int32)
for name in (
"policy",
"faults",
"pageins",
"cow_faults",
"messages_sent",
"messages_received",
"syscalls_mach",
"syscalls_unix",
"csw",
"threadnum",
"numrunning",
"priority",
)
),
]
info = TaskInfo()
got = libproc.proc_pidinfo(
pid, proc_pidtaskinfo, 0, ctypes.addressof(info), ctypes.sizeof(info)
)
return fds, (info.threadnum if got == ctypes.sizeof(info) else None)
def windows_handle_count(proc: subprocess.Popen) -> int | None:
import ctypes
from ctypes import wintypes
count = wintypes.DWORD()
handle = wintypes.HANDLE(int(proc._handle)) # Popen keeps the handle open
if not ctypes.windll.kernel32.GetProcessHandleCount(handle, ctypes.byref(count)):
raise OSError("process is gone")
return count.value
def windows_memory_and_cpu(proc: subprocess.Popen) -> tuple[int | None, float | None]:
import ctypes
from ctypes import wintypes
class Counters(ctypes.Structure): # PROCESS_MEMORY_COUNTERS
_fields_ = [
("cb", wintypes.DWORD),
("PageFaultCount", wintypes.DWORD),
("PeakWorkingSetSize", ctypes.c_size_t),
("WorkingSetSize", ctypes.c_size_t),
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
("QuotaPagedPoolUsage", ctypes.c_size_t),
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
("PagefileUsage", ctypes.c_size_t),
("PeakPagefileUsage", ctypes.c_size_t),
]
handle = wintypes.HANDLE(int(proc._handle))
counters = Counters()
counters.cb = ctypes.sizeof(counters)
rss_kb = None
if ctypes.windll.psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb):
rss_kb = counters.PeakWorkingSetSize // 1024
times = [wintypes.FILETIME() for _ in range(4)] # creation, exit, kernel, user
cpu_s = None
if ctypes.windll.kernel32.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)):
ticks = sum((t.dwHighDateTime << 32) | t.dwLowDateTime for t in times[2:])
cpu_s = ticks / 1e7 # 100 ns units
return rss_kb, cpu_s
def run_once(name: str, binary: str, scenario: Scenario) -> RunResult:
result = RunResult(build=name, scenario=scenario.name, sockets=scenario.sockets)
cmd = [
binary,
*scenario.args(),
"--scripts",
"none",
"--accessible",
"--no-banner",
"--no-config",
]
env = dict(os.environ, RUST_LOG="rustscan=info")
stdout_lines: list[str] = []
stderr_chunks: list[bytes] = []
start = time.perf_counter()
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, env=env
)
def read_stdout() -> None:
for raw in proc.stdout:
line = raw.decode("utf-8", "replace").rstrip("\r\n")
if result.first_open_s is None and line.startswith("Open "):
result.first_open_s = time.perf_counter() - start
stdout_lines.append(line)
readers = [
threading.Thread(target=read_stdout, daemon=True),
threading.Thread(target=lambda: stderr_chunks.append(proc.stderr.read()), daemon=True),
]
for reader in readers:
reader.start()
sampler = Sampler(proc)
sampler.start()
# On Unix the child is reaped with os.wait4 below, so the watchdog must
# not go through Popen (which would try to reap it as well).
kill = proc.kill if os.name == "nt" else lambda: os.kill(proc.pid, signal.SIGKILL)
watchdog = threading.Timer(RUN_TIMEOUT, kill)
watchdog.start()
try:
if hasattr(os, "wait4"):
_, status, usage = os.wait4(proc.pid, 0)
result.wall_s = time.perf_counter() - start
proc.returncode = os.waitstatus_to_exitcode(status)
maxrss = usage.ru_maxrss # KiB on Linux, bytes on macOS
result.rss_kb = maxrss // 1024 if platform.system() == "Darwin" else maxrss
result.cpu_s = usage.ru_utime + usage.ru_stime
else:
proc.wait()
result.wall_s = time.perf_counter() - start
result.rss_kb, result.cpu_s = windows_memory_and_cpu(proc)
finally:
watchdog.cancel()
sampler.done.set()
sampler.join()
for reader in readers:
reader.join()
result.returncode = proc.returncode
result.peak_fds = sampler.peak_fds
result.peak_threads = sampler.peak_threads
stderr = b"".join(stderr_chunks).decode("utf-8", "replace")
opened: set[str] = set()
summarised: set[str] = set()
for line in stdout_lines:
if match := OPEN_LINE.match(line):
opened.add(match.group(1))
elif match := SUMMARY_LINE.match(line):
summarised.update(f"{match.group(1)}:{p}" for p in match.group(2).split(",") if p)
result.open = sorted(opened)
if scan_time := SCAN_TIME.search(stderr):
result.scan_s = float(scan_time.group(1))
problems = []
if proc.returncode != 0:
problems.append(f"exit code {proc.returncode}")
if result.scan_s is None:
problems.append("no 'Portscan' timer in the log")
if summarised != opened:
problems.append(f"'Open' lines and the summary disagree: {sorted(summarised ^ opened)[:5]}")
if problems:
result.ok = False
tail = "\n".join(stderr.strip().splitlines()[-15:])
result.error = "; ".join(problems) + (f"\nstderr tail:\n{tail}" if tail else "")
return result
# --------------------------------------------------------------------------
# Environment
# --------------------------------------------------------------------------
def sysctl(*names: str) -> list[str]:
return subprocess.run(
["sysctl", "-n", *names], capture_output=True, text=True, check=True
).stdout.split("\n")
def ephemeral_range() -> tuple[int, int] | None:
system = platform.system()
try:
if system == "Linux":
with open("/proc/sys/net/ipv4/ip_local_port_range", encoding="ascii") as f:
low, high = map(int, f.read().split())
return low, high
if system == "Darwin":
first, last = sysctl("net.inet.ip.portrange.first", "net.inet.ip.portrange.last")[:2]
return int(first), int(last)
except (OSError, ValueError, subprocess.CalledProcessError):
return None
return (49152, 65535) if system == "Windows" else None
def diagnose_tcp(binary: str, scenario: Scenario, listeners: dict) -> dict:
"""Untimed failure capture on a disposable macOS runner, using debug logs."""
from macos_tcp_memory import tcp_memory
def states() -> dict[str, int]:
snapshot = subprocess.run(
["netstat", "-an", "-p", "tcp"], capture_output=True, text=True, check=True
)
counts: dict[str, int] = {}
for line in snapshot.stdout.splitlines():
if line.startswith("tcp"):
state = line.split()[-1]
counts[state] = counts.get(state, 0) + 1
return counts
before = states()
memory_before = tcp_memory()
probe = subprocess.run(
[binary, *scenario.args(), "--scripts", "none", "--accessible", "--no-banner", "--no-config"],
env=dict(os.environ, RUST_LOG="rustscan=info,rustscan::scanner=debug"),
capture_output=True, text=True, check=True,
)
opened = {
match.group(1) for line in probe.stdout.splitlines()
if (match := OPEN_LINE.match(line))
}
return {
"before": before, "after": states(),
"memory_before": memory_before, "memory_after": tcp_memory(),
"missing": sorted(scenario.expected(listeners) - opened),
"errors": [line for line in probe.stderr.splitlines()
if "Typical socket connection errors" in line],
}
def environment() -> dict[str, object]:
system = platform.system()
info: dict[str, object] = {
"system": system,
"release": platform.release(),
"machine": platform.machine(),
"cpus": os.cpu_count(),
"cpu": platform.processor(),
"python": platform.python_version(),
"ephemeral_ports": ephemeral_range(),
}
try:
if system == "Linux":
with open("/proc/cpuinfo", encoding="ascii", errors="replace") as f:
for line in f:
if line.lower().startswith(("model name", "cpu model")):
info["cpu"] = line.split(":", 1)[1].strip()
break
elif system == "Darwin":
info["cpu"] = sysctl("machdep.cpu.brand_string")[0].strip()
except (OSError, subprocess.CalledProcessError):
pass
try:
import resource
info["open_file_limit"] = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
except (ImportError, OSError, ValueError):
pass
return info
# --------------------------------------------------------------------------
# Analysis and reporting
# --------------------------------------------------------------------------
Results = dict[str, dict[str, list[RunResult]]]
# key, title, formatter, higher is better
METRICS = (
("rate", "Throughput (sockets/s, from RustScan's scan timer)", lambda v: f"{v:,.0f}", True),
("wall_s", "Wall time of the whole process", lambda v: f"{v * 1000:,.0f} ms", False),
("first_open_s", "Time to the first open port", lambda v: f"{v * 1000:,.1f} ms", False),
("rss_kb", "Peak RSS", lambda v: f"{v / 1024:,.1f} MiB", False),
("cpu_s", "CPU time (user + system)", lambda v: f"{v * 1000:,.0f} ms", False),
("peak_fds", "Peak open descriptors (sampled; Windows: handles)", lambda v: f"{v:,.0f}", False),
("peak_threads", "Peak threads (sampled)", lambda v: f"{v:,.0f}", False),
)
FLAG_WORSE = 10.0 # percent
def metric_values(runs: list[RunResult], key: str) -> list[float]:
return [float(getattr(r, key)) for r in runs if r.ok and getattr(r, key) is not None]
def median(values: list[float]) -> float | None:
return statistics.median(values) if values else None
def worse_by(value: float, base: float, higher_is_better: bool) -> float:
"""How much worse `value` is than `base`, in percent (negative: better)."""
pct = (value / base - 1.0) * 100.0
return -pct if higher_is_better else pct
def check_open_ports(
results: Results, scenarios: list[Scenario], listeners: dict, baseline: str
) -> tuple[list[str], list[str], list[str]]:
"""Returns (failed runs, open-port mismatches, notes).
No build may miss more listeners than the baseline: none at all if the
baseline found every listener in every run, or barely more if it did not
either (a runner too slow for the scenario's timeout). Ports that are
open beyond the listeners (services already running on the runner) must
match what the baseline found: a build may not miss a port the baseline
found in every run, nor report one the baseline never found.
"""
failures, mismatches, notes = [], [], []
for sc in scenarios:
expected = sc.expected(listeners)
per_build = results[sc.name]
def missed(runs: list[RunResult]) -> int:
return sum(len(expected - set(r.open)) for r in runs if r.ok)
base_missed = missed(per_build[baseline])
allowed = base_missed + max(3, base_missed // 4) if base_missed else 0
if base_missed:
notes.append(
f"`{sc.name}`: `{baseline}` itself missed {base_missed} of "
f"{len(expected) * len(per_build[baseline])} listener answers"
)
base_extra = [set(r.open) - expected for r in per_build[baseline] if r.ok]
always = set.intersection(*base_extra) if base_extra else set()
ever = set.union(*base_extra) if base_extra else set()
for name, runs in per_build.items():
for index, res in enumerate(runs, start=1):
if not res.ok:
failures.append(f"`{sc.name}` `{name}` run {index}: {res.error.splitlines()[0]}")
if name == baseline:
continue
if (count := missed(runs)) > allowed:
mismatches.append(
f"`{sc.name}` `{name}`: missed {count} listener answers "
f"(`{baseline}`: {base_missed})"
)
for index, res in enumerate(runs, start=1):
if not res.ok:
continue
found = set(res.open)
where = f"`{sc.name}` `{name}` run {index}"
if lost := always - found:
mismatches.append(f"{where}: missed ports `{baseline}` always found {sorted(lost)[:5]}")
if bogus := (found - expected) - ever:
mismatches.append(f"{where}: reported ports `{baseline}` never found {sorted(bogus)[:5]}")
return failures, mismatches, notes
def gate(
results: Results,
scenarios: list[Scenario],
baseline: str,
candidate: str,
max_slowdown: float,
max_memory: float,
) -> list[str]:
"""Scenarios where `candidate` is clearly worse than `baseline`."""
problems = []
for sc in scenarios:
checks = [("wall_s", False, max_slowdown, 0.002), ("rss_kb", False, max_memory, 1024)]
if sc.sockets >= 1000: # throughput is meaningless for a handful of sockets
checks.append(("rate", True, max_slowdown, 0))
for key, higher, limit, floor in checks:
base = median(metric_values(results[sc.name][baseline], key))
value = median(metric_values(results[sc.name][candidate], key))
if not base or value is None or abs(value - base) <= floor:
continue
if (worse := worse_by(value, base, higher)) > limit:
problems.append(f"{sc.name}: {key} {worse:+.1f}% worse than {baseline} (limit {limit}%)")
return problems
def render_markdown(report: dict, results: Results, scenarios: list[Scenario]) -> str:
builds = report["builds"]
baseline = report["baseline"]
env = report["environment"]
out = [f"### Scan benchmark: {env['system']} {env['machine']}", ""]
out.append(
"; ".join(f"`{b['name']}`: {b['label']}" for b in builds)
+ f". {report['runs']} interleaved runs per build and scenario (`tcp-1-port`: x3), medians."
f" Runner: {env.get('cpu') or '?'}, {env['cpus']} CPUs, {env['system']} {env['release']},"
f" open-file limit {env.get('open_file_limit', 'n/a')}."
)
out.append("")
problems = report["failures"] + report["mismatches"]
tcp = sum(len(ports) for ports in report["listeners"]["tcp"].values())
udp = sum(len(ports) for ports in report["listeners"]["udp"].values())
if not problems:
out.append(
f"✅ Every run of every build exited cleanly; no build missed more of the {tcp} TCP "
f"and {udp} UDP listeners than `{baseline}`, or disagreed with it about other ports."
)
else:
out.append(f"❌ {len(report['failures'])} failed runs, {len(report['mismatches'])} open-port mismatches:")
out += [f"- {p}" for p in problems[:25]]
if report["notes"]:
out.append("")
out += [f"- ⚠️ {note}" for note in report["notes"]]
out.append("")
out.append("| Scenario | What is scanned |")
out.append("|---|---|")
out += [f"| `{sc.name}` | {sc.describe()} ({sc.sockets:,} sockets) |" for sc in scenarios]
out.append("")
out.append(f"Changes are relative to `{baseline}`; ❗ marks a median more than {FLAG_WORSE:.0f}% worse.")
out.append("")
for key, title, fmt, higher in METRICS:
rows = []
for sc in scenarios:
base_value = median(metric_values(results[sc.name][baseline], key))
cells = []
for build in builds:
value = median(metric_values(results[sc.name][build["name"]], key))
if value is None:
cells.append("–")
continue
cell = fmt(value)
if build["name"] != baseline and base_value:
pct = (value / base_value - 1.0) * 100.0
flag = " ❗" if worse_by(value, base_value, higher) > FLAG_WORSE else ""
cell += f" ({pct:+.1f}%{flag})"
cells.append(cell)
if any(c != "–" for c in cells):
rows.append(f"| `{sc.name}` | " + " | ".join(cells) + " |")
out.append(f"**{title}**")
out.append("")
if rows:
out.append("| Scenario | " + " | ".join(f"`{b['name']}`" for b in builds) + " |")
out.append("|---|" + "---:|" * len(builds))
out += rows
else:
out.append("_Not measured on this platform._")
out.append("")
return "\n".join(out)
# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def parse_pairs(values: list[str], option: str) -> dict[str, str]:
pairs = {}
for value in values:
name, sep, rest = value.partition("=")
if not sep or not name or not rest:
raise SystemExit(f"--{option} expects NAME=VALUE, got {value!r}")
pairs[name] = rest
return pairs
def run(args: argparse.Namespace) -> int:
builds = parse_pairs(args.build, "build")
labels = parse_pairs(args.label, "label")
baseline = args.baseline or next(iter(builds))
if baseline not in builds:
raise SystemExit(f"unknown baseline build {baseline!r}")
for name, path in builds.items():
if not os.path.isfile(path):
raise SystemExit(f"build {name!r}: {path} does not exist")
if args.gate and args.gate not in builds:
raise SystemExit(f"unknown --gate build {args.gate!r}")
if args.delayed_host and not args.delayed_listeners:
raise SystemExit("--delayed-host needs --delayed-listeners")
filtered = None
if args.filtered:
low, _, high = args.filtered.partition("-")
filtered = (int(low), int(high))
scenarios = build_scenarios(platform.system(), filtered, args.delayed_host, args.delay_ms)
if args.only:
wanted = set(args.only.split(","))
scenarios = [sc for sc in scenarios if sc.name in wanted]
if ephemeral := ephemeral_range():
low, high = ephemeral
for sc in scenarios:
for start, end in sc.ranges:
if start <= high and end >= low:
raise SystemExit(
f"scenario {sc.name} scans {start}-{end}, which overlaps the ephemeral "
f"port range {low}-{high}: a scan could connect a socket to itself"
)
server, listeners = start_server([LOOPBACK])
if args.delayed_host:
# The workflow runs a second `serve` on the delayed host (inside a
# network namespace) and stores its first line in this file.
with open(args.delayed_listeners, encoding="utf-8") as f:
far = json.loads(f.readline())
for kind in ("tcp", "udp"):
listeners[kind][args.delayed_host] = far[kind].get(args.delayed_host, [])
for kind in ("tcp", "udp"):
counts = {address: len(ports) for address, ports in listeners[kind].items()}
print(f"{kind} listeners: {counts}", flush=True)
if listeners["busy"]:
print(f"already in use: {listeners['busy']}", flush=True)
names = list(builds)
results: Results = {sc.name: {name: [] for name in names} for sc in scenarios}
diagnostics = []
try:
warmup = next((sc for sc in scenarios if sc.name == "tcp-sweep"), scenarios[0])
for name in names:
run_once(name, builds[name], warmup)
rounds = args.runs * max(sc.repeat for sc in scenarios)
for index in range(rounds):
shift = index % len(names)
order = names[shift:] + names[:shift]
for sc in scenarios:
if index >= args.runs * sc.repeat:
continue
for name in order:
res = run_once(name, builds[name], sc)
results[sc.name][name].append(res)
print(
f"[{index + 1}/{rounds}] {sc.name:<17} {name:<14} ok={res.ok} "
f"scan={res.scan_s} wall={res.wall_s} first={res.first_open_s} "
f"rss={res.rss_kb} cpu={res.cpu_s} fds={res.peak_fds} "
f"threads={res.peak_threads} open={len(res.open)}",
flush=True,
)
if not res.ok:
print(f" {res.error}", flush=True)
if platform.system() == "Darwin" and not diagnostics and any(
sc.expected(listeners) - set(results[sc.name][name][-1].open) for name in names
):
# A failed pair cannot support a performance claim. Capture
# its OS errors immediately, outside both measured scans.
for name in names:
record = {"build": name, "scenario": sc.name,
**diagnose_tcp(builds[name], sc, listeners)}
diagnostics.append(record)
print("TCP diagnosis: " + json.dumps(record), flush=True)
finally:
server.kill()
server.wait()
failures, mismatches, notes = check_open_ports(results, scenarios, listeners, baseline)
report = {
"environment": environment(),
"baseline": baseline,
"builds": [{"name": n, "label": labels.get(n, n), "path": builds[n]} for n in names],
"runs": args.runs,
"listeners": listeners,
"scenarios": [asdict(sc) | {"sockets": sc.sockets} for sc in scenarios],
"results": {
sc: {n: [asdict(r) | {"rate": r.rate} for r in runs] for n, runs in per.items()}
for sc, per in results.items()
},
"failures": failures,
"mismatches": mismatches,
"notes": notes,
"diagnostics": diagnostics,
}
markdown = render_markdown(report, results, scenarios)
if args.json:
with open(args.json, "w", encoding="utf-8") as f:
json.dump(report, f, indent=1)
if args.markdown:
with open(args.markdown, "w", encoding="utf-8") as f:
f.write(markdown + "\n")
print(markdown)
status = 1 if failures or mismatches else 0
if args.gate:
for problem in gate(results, scenarios, baseline, args.gate, args.max_slowdown, args.max_memory):
print(f"::error::performance regression: {problem}", flush=True)
status = 1
return status
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
sub = parser.add_subparsers(dest="command", required=True)
listen = sub.add_parser("serve", help="run the loopback listeners (started by `run`)")
listen.add_argument("--address", action="append", default=[], help="address to listen on")
bench = sub.add_parser("run", help="benchmark the given builds")
bench.add_argument("--build", action="append", required=True, metavar="NAME=PATH")
bench.add_argument("--label", action="append", default=[], metavar="NAME=TEXT")
bench.add_argument("--baseline", help="build the others are compared with (default: the first)")
bench.add_argument("--runs", type=int, default=9, help="interleaved runs per build and scenario")
bench.add_argument(
"--filtered", metavar="START-END", help="ports the runner drops; enables filtered scenarios"
)
bench.add_argument(
"--delayed-host",
metavar="ADDRESS",
help="address behind an artificial delay, with its own `serve`; enables delayed scenarios",
)
bench.add_argument(
"--delayed-listeners", metavar="FILE", help="first output line of that host's `serve`"
)
bench.add_argument("--delay-ms", type=int, default=5, help="that delay, for the summary")
bench.add_argument("--only", metavar="NAMES", help="comma-separated scenarios to run")
bench.add_argument("--json", help="write the raw results to this file")
bench.add_argument("--markdown", help="write the Markdown summary to this file")
bench.add_argument("--gate", metavar="NAME", help="fail if this build regresses against the baseline")
bench.add_argument("--max-slowdown", type=float, default=15.0, help="percent, throughput and wall time")
bench.add_argument("--max-memory", type=float, default=25.0, help="percent, peak RSS")
args = parser.parse_args()
# The summary contains emoji; Windows runners default to a legacy code page.
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
return serve(args.address or [LOOPBACK]) if args.command == "serve" else run(args)
if __name__ == "__main__":
sys.exit(main())
-256
View File
@@ -1,256 +0,0 @@
name: Scan benchmark
# Compares the scanning speed and resource use of a change with its base.
#
# Each run builds both versions in release mode and runs
# .github/scan-benchmark/scan_bench.py, which scans listeners it opens on
# 127.0.0.1 inside the runner (plus closed and, on Linux, filtered ports) and
# reports medians over interleaved runs. Nothing outside the runner is
# scanned. The unit tests deliberately never open sockets; this workflow is
# where scanning behaviour and performance are measured.
#
# It only runs for pull requests that touch the scanner, its runtime or its
# dependencies, or when started by hand, so it does not slow down normal CI.
on:
pull_request:
branches: [master]
paths:
- "src/scanner/**"
- "src/main.rs"
- "src/lib.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/scan-benchmark/**"
- ".github/workflows/scan-benchmark.yml"
workflow_dispatch:
inputs:
base:
description: "Branch, tag or commit to compare against"
default: "master"
runs:
description: "Interleaved runs per build and scenario"
default: "9"
extra:
description: "More builds to compare, as space-separated NAME=REF pairs"
default: ""
permissions:
contents: read
concurrency:
group: scan-benchmark-${{ github.ref }}
cancel-in-progress: true
jobs:
benchmark:
name: Benchmark (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
defaults:
run:
shell: bash
env:
# Ports the Linux runners drop, to measure filtered ports (timeouts).
# They lie above Linux's ephemeral port range (32768-60999).
FILTERED_PORTS: "61000-62999"
# The Linux runners' host behind a 5 ms delay (see "Set up a host 5 ms away").
DELAYED_HOST: "10.200.0.2"
RUNS: ${{ inputs.runs || '9' }}
EXTRA: ${{ inputs.extra }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Check the benchmark script
run: python -m py_compile .github/scan-benchmark/scan_bench.py
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0
with:
toolchain: stable
# build.rs formats the generated payload table with rustfmt.
components: rustfmt
# Caching is handled by Swatinem/rust-cache below.
cache: false
rustflags: ""
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Build the change
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
exe=""
if [ "$RUNNER_OS" = Windows ]; then exe=".exe"; fi
echo "EXE=$exe" >> "$GITHUB_ENV"
mkdir -p "$RUNNER_TEMP/bin"
cargo build --release --locked
cp "target/release/rustscan$exe" "$RUNNER_TEMP/bin/head$exe"
if [ -n "$PR_NUMBER" ]; then
label="#$PR_NUMBER at ${PR_HEAD::7}, merged into its base"
else
label="$(git log -1 --format='%h %s' HEAD)"
fi
echo "HEAD_LABEL=$label" >> "$GITHUB_ENV"
- name: Build the base and any extra builds
env:
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
INPUT_BASE: ${{ inputs.base }}
run: |
build_ref() {
local name="$1" ref="$2"
if [[ ! "$name" =~ ^[A-Za-z0-9._+-]+$ ]]; then
echo "::error::invalid build name '$name'"
exit 1
fi
git fetch --no-tags --depth=1 origin "$ref"
git worktree add --detach "$RUNNER_TEMP/src-$name" FETCH_HEAD
cargo build --release --locked \
--manifest-path "$RUNNER_TEMP/src-$name/Cargo.toml" --target-dir target
cp "target/release/rustscan$EXE" "$RUNNER_TEMP/bin/$name$EXE"
echo "$name=$(git log -1 --format='%h %s' FETCH_HEAD)" >> "$RUNNER_TEMP/labels"
}
if [ "$EVENT" = pull_request ]; then base="$PR_BASE"; else base="${INPUT_BASE:-master}"; fi
build_ref base "$base"
for spec in $EXTRA; do
build_ref "${spec%%=*}" "${spec#*=}"
done
cat "$RUNNER_TEMP/labels"
- name: Drop traffic to the filtered ports
if: runner.os == 'Linux'
run: |
if ! command -v iptables >/dev/null; then
echo "::warning::iptables is not available; skipping the filtered-port scenarios"
exit 0
fi
for proto in tcp udp; do
sudo iptables -I INPUT -i lo -p "$proto" --dport "${FILTERED_PORTS/-/:}" -j DROP
done
sudo iptables -S INPUT
echo "FILTERED_OK=1" >> "$GITHUB_ENV"
- name: Set up a host 5 ms away
if: runner.os == 'Linux'
run: |
# A network namespace on the runner, reached over a veth pair whose
# outbound side delays every packet. Its answers (SYN-ACKs, resets,
# ICMP errors, UDP replies) arrive a round trip after the probe, as
# from a remote host, instead of while the probe is being sent.
skip() {
echo "::warning::$1; skipping the delayed-host scenarios"
exit 0
}
sudo ip netns add far || skip "cannot create a network namespace"
sudo ip link add rs-near type veth peer name rs-far || skip "cannot create a veth pair"
sudo ip link set rs-far netns far || skip "cannot move the veth end"
sudo ip addr add 10.200.0.1/30 dev rs-near || skip "cannot address the veth pair"
sudo ip link set rs-near up || skip "cannot bring up the veth pair"
sudo ip netns exec far ip addr add "$DELAYED_HOST/30" dev rs-far || skip "cannot address the namespace"
sudo ip netns exec far ip link set rs-far up || skip "cannot bring up the namespace"
sudo ip netns exec far ip link set lo up || true
# Answer every probe to a closed UDP port, like the loopback
# interface does (ICMP errors are rate limited everywhere else).
for setting in icmp_ratelimit=0 icmp_msgs_per_sec=100000000 icmp_msgs_burst=100000000; do
sudo ip netns exec far sysctl -qw "net.ipv4.$setting" || true
done
sudo tc qdisc add dev rs-near root netem delay 5ms limit 1000000 || skip "netem is not available"
tc qdisc show dev rs-near
sudo ip netns exec far "$(command -v python)" .github/scan-benchmark/scan_bench.py \
serve --address "$DELAYED_HOST" > "$RUNNER_TEMP/far.json" 2> "$RUNNER_TEMP/far.log" &
for _ in $(seq 100); do
if [ -s "$RUNNER_TEMP/far.json" ]; then break; fi
sleep 0.1
done
if [ ! -s "$RUNNER_TEMP/far.json" ]; then
cat "$RUNNER_TEMP/far.log" || true
skip "the delayed host's listeners did not start"
fi
head -c 200 "$RUNNER_TEMP/far.json"; echo
echo "DELAYED_OK=1" >> "$GITHUB_ENV"
- name: Provision macOS TCP memory for repeated socket batches
if: runner.os == 'macOS'
run: |
# XNU's TCP accounting hard limit otherwise makes both builds miss
# listeners with ENOBUFS during repeated sweeps. Apply one budget to
# both builds before timing; keep all input sizes and gates intact.
sudo "$(command -v python)" .github/scan-benchmark/macos_tcp_memory.py
- name: Describe the runner
continue-on-error: true
run: |
uname -a
if [ "$RUNNER_OS" = Linux ]; then
nproc
grep -m1 'model name' /proc/cpuinfo || true
sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse net.core.somaxconn || true
ss -Hltnu | sort -k5 | head -n 50 || true
elif [ "$RUNNER_OS" = macOS ]; then
for oid in hw.ncpu machdep.cpu.brand_string kern.maxfilesperproc \
net.inet.ip.portrange.first net.inet.ip.portrange.last \
kern.ipc.maxsockbuf kern.ipc.nmbclusters; do
sysctl "$oid" || true
done
fi
- name: Run the benchmark
run: |
# Allow the largest batch size without RustScan shrinking it to fit
# the descriptor limit (macOS defaults to 256, and caps processes at
# kern.maxfilesperproc, 10240 on its runners).
if [ "$RUNNER_OS" != Windows ]; then
for limit in 65536 24576 10240; do
ulimit -n "$limit" 2>/dev/null && break
done
echo "open-file limit: $(ulimit -n)"
fi
# Fail on wrong results, or if the change is clearly slower or
# bigger than its base (see --max-slowdown/--max-memory).
args=(--runs "$RUNS" --gate head --json bench-results.json --markdown bench-results.md)
if [ -n "${FILTERED_OK:-}" ]; then args+=(--filtered "$FILTERED_PORTS"); fi
if [ -n "${DELAYED_OK:-}" ]; then
args+=(--delayed-host "$DELAYED_HOST" --delayed-listeners "$RUNNER_TEMP/far.json")
fi
args+=(--build "base=$RUNNER_TEMP/bin/base$EXE")
args+=(--build "head=$RUNNER_TEMP/bin/head$EXE" --label "head=$HEAD_LABEL")
while IFS= read -r line; do
name="${line%%=*}"
args+=(--label "$line")
if [ "$name" != base ]; then
args+=(--build "$name=$RUNNER_TEMP/bin/$name$EXE")
fi
done < "$RUNNER_TEMP/labels"
python .github/scan-benchmark/scan_bench.py run "${args[@]}"
- name: Publish the summary
if: always()
run: |
if [ -f bench-results.md ]; then cat bench-results.md >> "$GITHUB_STEP_SUMMARY"; fi
- name: Upload the results
if: always()
uses: actions/upload-artifact@v7
with:
name: scan-benchmark-${{ matrix.os }}
path: |
bench-results.json
bench-results.md
if-no-files-found: ignore
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
toolchain: ${{ matrix.rust }}
components: clippy, rustfmt
Generated
+189 -29
View File
@@ -103,6 +103,24 @@ version = "1.0.96"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4"
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-trait"
version = "0.1.86"
@@ -279,7 +297,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -288,6 +306,15 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb474a9c3219a8254ead020421ecf1b90427f29b55f6aae9a2471fa62c126ef"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -387,7 +414,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -455,7 +482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -481,6 +508,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
@@ -503,12 +531,44 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -530,6 +590,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
@@ -615,6 +676,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hickory-proto"
version = "0.24.4"
@@ -933,9 +1000,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.191"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libredox"
@@ -977,9 +1044,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.35"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru-cache"
@@ -1004,13 +1071,13 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "mio"
version = "1.0.3"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1090,6 +1157,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.3"
@@ -1159,6 +1232,20 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.11.0"
@@ -1382,7 +1469,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1423,6 +1510,7 @@ dependencies = [
"ansi_term",
"anstream",
"anyhow",
"async-io",
"cidr-utils",
"clap",
"colored",
@@ -1434,7 +1522,6 @@ dependencies = [
"gcd",
"hickory-resolver",
"itertools 0.15.0",
"libc",
"log",
"once_cell",
"parameterized",
@@ -1445,7 +1532,6 @@ dependencies = [
"text_placeholder",
"tokio",
"toml",
"windows-sys 0.61.2",
]
[[package]]
@@ -1535,9 +1621,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "1.1.2"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
@@ -1580,7 +1666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1635,7 +1721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1726,9 +1812,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
@@ -1750,9 +1836,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.8+spec-1.1.0"
version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap 2.13.0",
"serde_core",
@@ -1765,27 +1851,27 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.5+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
@@ -2035,7 +2121,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2077,6 +2163,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -2110,13 +2205,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -2129,6 +2241,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -2141,6 +2259,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -2153,12 +2277,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -2171,6 +2307,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -2183,6 +2325,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -2195,6 +2343,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -2207,6 +2361,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "1.0.0"
+4 -11
View File
@@ -19,11 +19,9 @@ exclude = [
[dependencies]
clap = { version = "4.6.1", features = ["derive", "wrap_help"] }
colored = "3.1.1"
# Async runtime for the scanner. hickory-resolver already depends on tokio, so
# this adds no crates; only the features the scanner itself uses are listed.
tokio = { version = "1.44.2", features = ["rt", "net", "time"] }
# Only `FuturesUnordered`/`StreamExt` are used; the executor is not needed.
futures = { version = "0.3", default-features = false, features = ["std"] }
tokio = { version = "1.53.1", features = ["rt", "net", "time"] }
futures = "0.3"
async-io = "2.4.0"
rlimit = "0.11.0"
log = "0.4.32"
env_logger = "0.11.10"
@@ -43,13 +41,8 @@ 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.61", features = ["Win32_Networking_WinSock"] }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["test-util"] }
parameterized = "2.0.0"
criterion = { version = "0.8", features = ["html_reports"] }
+52 -45
View File
@@ -1,13 +1,19 @@
use criterion::{criterion_group, criterion_main, Criterion};
use futures::{stream::FuturesUnordered, StreamExt};
use rustscan::generated::get_parsed_data;
use rustscan::input::{Opts, PortRanges, ScanOrder};
use rustscan::port_strategy::PortStrategy;
use rustscan::scanner::{build_udp_payload_lookup, Scanner};
use rustscan::scanner::build_udp_payload_lookup;
use std::collections::BTreeMap;
use std::hint::black_box;
use std::io;
use std::net::IpAddr;
use std::time::Duration;
// Benchmark the production timeout path with simulated I/O operations.
#[path = "../src/scanner/timeout.rs"]
mod timeout;
fn bench_address() {
let _addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
}
@@ -47,7 +53,52 @@ fn old_payload_for_port(udp_map: &'static BTreeMap<Vec<u16>, Vec<u8>>, port: u16
payload
}
// Exercise the scanner's bounded scheduling pattern without opening sockets.
// Ready operations model immediate replies; pending operations expire instead.
async fn simulated_operation(expires: bool) -> io::Result<()> {
timeout::io_timeout(Duration::from_millis(10), async {
if expires {
std::future::pending::<()>().await;
}
Ok(())
})
.await
}
async fn simulated_batch(expires: bool) {
const OPERATIONS: usize = 4096;
const BATCH_SIZE: usize = 256;
let mut pending = FuturesUnordered::new();
for _ in 0..BATCH_SIZE {
pending.push(simulated_operation(expires));
}
let mut remaining = OPERATIONS - BATCH_SIZE;
while let Some(result) = pending.next().await {
let _ = black_box(result);
if remaining > 0 {
pending.push(simulated_operation(expires));
remaining -= 1;
}
}
}
fn criterion_benchmark(c: &mut Criterion) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut runtime_group = c.benchmark_group("runtime scheduling");
runtime_group.sample_size(20);
runtime_group.warm_up_time(Duration::from_secs(1));
runtime_group.measurement_time(Duration::from_secs(5));
runtime_group.bench_function("ready operations", |b| {
b.iter(|| runtime.block_on(simulated_batch(false)));
});
runtime_group.bench_function("expired operations", |b| {
b.iter(|| runtime.block_on(simulated_batch(true)));
});
runtime_group.finish();
// Benching helper functions
c.bench_function("parse address", |b| b.iter(bench_address));
@@ -60,50 +111,6 @@ fn criterion_benchmark(c: &mut Criterion) {
});
address_group.finish();
// Exercise production port preparation without opening a socket. The
// scanner has no target addresses, and runtime construction is not timed.
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut preparation = c.benchmark_group("port preparation");
preparation.sample_size(20);
preparation.warm_up_time(Duration::from_millis(500));
preparation.measurement_time(Duration::from_secs(2));
for (port_count, excluded_count) in [
(1, 1),
(16, 4),
(64, 1),
(64, 4096),
(4096, 0),
(4096, 64),
(4096, 4096),
(65535, 0),
(65535, 4),
(65535, 64),
(65535, 1024),
(65535, 4096),
] {
let scanner = Scanner::new(
&[],
500,
Duration::from_millis(100),
1,
true,
PortStrategy::Manual((1..=port_count).collect()),
true,
(0..excluded_count)
.map(|i| (i * 13 % 65536) as u16)
.collect(),
false,
);
preparation.bench_function(
format!("{port_count} ports, {excluded_count} exclusions"),
|b| b.iter(|| black_box(runtime.block_on(black_box(&scanner).run_with_status()))),
);
}
preparation.finish();
// UDP payload lookup micro-benchmark: compares the old linear scan of the
// payload map with the precomputed port -> payload lookup. No sockets.
let udp_map = get_parsed_data();
-9
View File
@@ -72,12 +72,3 @@ Automated tests and benchmarks must run without network traffic or external scri
execution. Keep coverage focused on parsing, configuration, port ordering, socket
address iteration, and timing helpers. Do not add tests that run TCP/UDP scans
(including localhost), resolve hostnames, or launch Nmap or other scripts.
Scanning behaviour and speed are measured separately, by the opt-in "Scan benchmark"
GitHub Actions workflow (`.github/workflows/scan-benchmark.yml`). It runs for pull
requests that touch the scanner, its runtime or its dependencies, and can be started
by hand from the Actions tab. It builds the change and its base in release mode,
scans listeners that it opens on 127.0.0.1 inside the runner, checks that both
builds find the same open ports, and adds a comparison table to the job summary. Its
script (`.github/scan-benchmark/scan_bench.py`) port-scans the loopback interface,
so it is meant for disposable CI runners only.
-151
View File
@@ -1,151 +0,0 @@
{
"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
@@ -1,48 +0,0 @@
# 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.
File diff suppressed because it is too large. Load diff
-114
View File
@@ -1,114 +0,0 @@
# Port exclusion preparation benchmark
Compared Tokio master `b7119f7f3eb1601e81126ef3572699ed7b3b7c0b` with
implementation `270561d553688acbd99f61c460cdfa7c76c1423e` on 2026-10-02.
## Method
Release builds with LTO, Rust 1.98.1, Intel Core Ultra 7 165U, Linux
7.2.6-1-cachyos. Both versions use the same lockfile and identical
`benches/benchmark_helpers.rs`; copy the final benchmark harness into the
baseline worktree before building it. The benchmark runs the production
`Scanner::run_with_status` with **zero target addresses**, so it opens no
sockets and does no DNS or scripting. It includes generating/cloning the
port list, filtering and empty scan setup. Scanner construction and the
current-thread Tokio runtime construction are outside the timer.
Manual ports are `1..=port_count`; exclusions are
`(i * 13 % 65536) as u16` for `i` in `0..excluded_count`. Each case has two
independent twenty-sample Criterion runs per build, a 500 ms warmup and a
2 s target measurement. Cases run in A/B then B/A order on CPU 0. No builds
or other local checks ran during timing. CPU frequency was not fixed on
this shared laptop, so the table retains both repetitions rather than
selecting one result. Values are Criterion's mean point estimates.
Build command for each worktree:
```sh
cargo bench --locked --bench benchmark_helpers --no-run
```
The saved benchmark executables are then run serially, for each case:
```sh
CARGO_TARGET_DIR=/tmp/preparation-results taskset -c 0 /path/to/benchmark_helpers \
--bench 'port preparation/65535 ports, 4096 exclusions' --save-baseline base-a
```
Repeat with the candidate binary as `head-a`, then the candidate as
`head-b` and the baseline as `base-b`. To run the group normally without
comparing builds:
```sh
cargo bench --locked --bench benchmark_helpers -- 'port preparation'
```
## Results
Times are microseconds; A and B are separate matched repetitions.
| Ports | Exclusions | Master µs A / B | Optimized µs A / B | Time saved A / B |
| ---: | ---: | ---: | ---: | ---: |
| 1 | 1 | 0.289 / 0.286 | 0.261 / 0.271 | 9.7% / 5.2% |
| 16 | 4 | 0.260 / 0.268 | 0.232 / 0.237 | 10.6% / 11.6% |
| 64 | 1 | 0.433 / 0.437 | 0.237 / 0.239 | 45.2% / 45.4% |
| 64 | 4,096 | 6.231 / 10.093 | 2.484 / 3.038 | 60.1% / 69.9% |
| 4,096 | 0 | 10.889 / 10.037 | 0.335 / 0.397 | 96.9% / 96.0% |
| 4,096 | 64 | 20.680 / 20.363 | 2.930 / 3.064 | 85.8% / 85.0% |
| 4,096 | 4,096 | 524.223 / 519.670 | 6.805 / 6.710 | 98.7% / 98.7% |
| 65,535 | 0 | 166.113 / 115.686 | 3.655 / 2.333 | 97.8% / 98.0% |
| 65,535 | 4 | 175.717 / 181.343 | 29.769 / 30.230 | 83.1% / 83.3% |
| 65,535 | 64 | 207.617 / 206.910 | 31.595 / 31.462 | 84.8% / 84.8% |
| 65,535 | 1,024 | 2018.463 / 3271.564 | 33.403 / 33.520 | 98.3% / 99.0% |
| 65,535 | 4,096 | 8811.234 / 8469.706 | 59.853 / 63.211 | 99.3% / 99.3% |
All twelve cases improved in both repetitions. This measures **preparation,
not end-to-end network scan speed**: many scans are dominated by socket I/O,
timeouts and reporting. In particular, the default no-exclusion saving is
only a fraction of a millisecond.
## Implementation and correctness
With no exclusions, keep the already generated vector. For fewer than 64
ports, filter in place with linear membership checks; this avoids bitmap
setup and a second allocation. Larger lists use an 8 KiB bitmap covering
all 65,536 values representable by `u16`, then retain ports in place. This
changes the large-list filtering cost from ports × exclusions to ports +
exclusions, while preserving order, duplicate ports and boundary values.
The helper is kept out of line: release assembly showed an inlined bitmap
reserved 8,424 bytes in every async poll, whereas the final poll frame uses
344 bytes and the helper reserves its bitmap only during preparation.
Exploration found that in-place linear membership slows large cases. A
first production candidate also slowed 64 ports with 4,096 exclusions;
using the bitmap for that case corrected the regression before the final
matched runs. The cutoff is a performance choice, not a limit on input.
One focused network-free regression test compares the implementation with
an independent membership oracle for empty inputs, endpoints 0/65535,
duplicate ports/exclusions, all ports, and a frozen random order.
`cargo test --locked` passes 74 tests with one pre-existing ignored doctest.
All-target Clippy, formatting, documentation and diff checks pass. The
CI-only scan harness adds an excluded-port sweep using the existing
listeners, checking expected results and using the actual attempted socket
count for throughput.
[Raw Criterion samples, confidence intervals, revisions and binary hashes](port-exclusion-benchmark-results.json)
are included. Hosted correctness/performance results are linked in the PR.
## macOS runner setup
Repeated hosted sweeps on Darwin 25.6 returned `ENOBUFS` (error 55) on
both master and the candidate, missing fixture listeners. Such runs are
invalid for performance claims. The workflow provisions the TCP memory
budget once before either build is measured, raising it from the default
1/32 to 1/8 of physical memory when necessary, and prints the actual limit
and allocation counters. It retains every scenario, repeat, listener
comparison and performance threshold. An immediate untimed diagnostic
scan records errors and kernel counters if a measured pair misses a
listener; that failed dataset is not used to claim a speedup.
This uses XNU's [TCP budget initialization](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/netinet/tcp_subr.c)
and [memory accounting interface](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/mem_acct_private.h).
It changes the disposable benchmark environment, not RustScan's behavior
or the operating system configuration on users' machines.
+49
View File
@@ -0,0 +1,49 @@
--- a/benches/benchmark_helpers.rs
+++ b/benches/benchmark_helpers.rs
@@ -1,0 +2 @@
+use futures::{stream::FuturesUnordered, StreamExt};
@@ -7,0 +9 @@
+use std::io;
@@ -49,0 +52,29 @@
+// Exercise the scanner's bounded scheduling pattern without opening sockets.
+// Ready operations model immediate replies; pending operations expire instead.
+async fn simulated_operation(expires: bool) -> io::Result<()> {
+ async_std::io::timeout(Duration::from_millis(10), async {
+ if expires {
+ std::future::pending::<()>().await;
+ }
+ Ok(())
+ })
+ .await
+}
+
+async fn simulated_batch(expires: bool) {
+ const OPERATIONS: usize = 4096;
+ const BATCH_SIZE: usize = 256;
+ let mut pending = FuturesUnordered::new();
+ for _ in 0..BATCH_SIZE {
+ pending.push(simulated_operation(expires));
+ }
+ let mut remaining = OPERATIONS - BATCH_SIZE;
+ while let Some(result) = pending.next().await {
+ let _ = black_box(result);
+ if remaining > 0 {
+ pending.push(simulated_operation(expires));
+ remaining -= 1;
+ }
+ }
+}
+
@@ -50,0 +82,12 @@
+ let mut runtime_group = c.benchmark_group("runtime scheduling");
+ runtime_group.sample_size(20);
+ runtime_group.warm_up_time(Duration::from_secs(1));
+ runtime_group.measurement_time(Duration::from_secs(5));
+ runtime_group.bench_function("ready operations", |b| {
+ b.iter(|| futures::executor::block_on(simulated_batch(false)));
+ });
+ runtime_group.bench_function("expired operations", |b| {
+ b.iter(|| futures::executor::block_on(simulated_batch(true)));
+ });
+ runtime_group.finish();
+
+25
View File
@@ -0,0 +1,25 @@
{"metadata": {"base": "cfb864590161d1c8a003811dcf4846ceb8c8284f", "head": "42427e88c1a6587d0a8b198a73bac4553bb4ad72", "kernel": "7.2.6-1-cachyos", "scanner_cpus": "0-3", "server_cpus": [4, 5, 6, 7], "trials": 12, "warmups": 2, "binary_sha256": {"async-std": "00c7f0d91bab260ac622540e86083175afacfd1677647d2b8e96e56ba4f60732", "tokio": "669627834d8a32f60e2167e78a9518dcede7562831adb85fb61a9d94871494b8"}, "date": "2026-10-01", "cpu": "Intel Core Ultra 7 165U", "rustc": "1.98.1", "rlimit_nofile_soft": 65536, "server_observed_counts": {"tcp": 58702, "udp_echo": 14336, "udp_silent": 17920}, "firewall_dropped_tcp_packets": 21504, "sample_order": "First two entries per series are warmups; remaining twelve are measured runs. Runtime execution order alternates for each pair.", "build_profile": "release with LTO", "network": "private namespace; loopback only"},
"cases": [
{"case": "tcp_closed_65535_b256", "arguments": ["--addresses", "127.0.0.2", "--range", "1-65535", "--batch-size", "256", "--timeout", "1500", "--tries", "1"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "tcp_closed_65535_b256", "async-std": {"n": 12, "scan_median_ms": 565.61545, "scan_min_ms": 542.8159, "scan_max_ms": 597.2202, "wall_median_ms": 678.4058859998368}, "tokio": {"n": 12, "scan_median_ms": 377.77217499999995, "scan_min_ms": 365.76328, "scan_max_ms": 391.70304, "wall_median_ms": 490.91583049948895}, "paired_ratio_median": 0.6691842486934787, "paired_ratio_bootstrap_95_percent": [0.651478199869526, 0.680316018080831]}, "samples": {"async-std": {"scan_ms": [547.71084, 546.87524, 573.7815, 542.8159, 557.4494, 551.8315, 552.46323, 547.43415, 579.65297, 576.02394, 577.4498, 548.5023, 580.08724, 597.2202], "wall_ms": [663.019741, 658.9046, 687.255539, 653.559305, 669.556233, 660.832612, 664.688112, 665.16539, 692.104642, 692.894896, 699.378062, 663.848877, 693.821868, 715.319005]}, "tokio": {"scan_ms": [386.10637, 366.50577, 388.7874, 388.9165, 367.81788, 391.70304, 375.97436, 365.76328, 379.11865, 386.067, 374.6031, 373.03102, 376.4257, 384.8664], "wall_ms": [498.365617, 480.96794, 508.885514, 507.263527, 481.161202, 502.921307, 490.034017, 479.758393, 491.797644, 497.79691, 489.335471, 487.71927, 489.31152, 502.222538]}}},
{"case": "tcp_closed_65535_b4500", "arguments": ["--addresses", "127.0.0.2", "--range", "1-65535", "--batch-size", "4500", "--timeout", "1500", "--tries", "1"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "tcp_closed_65535_b4500", "async-std": {"n": 12, "scan_median_ms": 769.2585799999999, "scan_min_ms": 751.53786, "scan_max_ms": 794.6415, "wall_median_ms": 886.4860560001944}, "tokio": {"n": 12, "scan_median_ms": 571.338355, "scan_min_ms": 554.6383000000001, "scan_max_ms": 612.74534, "wall_median_ms": 686.1564379996707}, "paired_ratio_median": 0.7392118097843652, "paired_ratio_bootstrap_95_percent": [0.7270143421681834, 0.7653997249648308]}, "samples": {"async-std": {"scan_ms": [775.69896, 781.10725, 767.2349, 769.74416, 755.10293, 775.59453, 783.22995, 767.6211, 776.608, 751.53786, 752.1119, 779.8275, 768.773, 794.6415], "wall_ms": [900.142351, 901.448905, 882.951503, 885.785811, 871.963026, 891.82683, 895.949848, 883.562564, 891.482204, 863.927796, 866.721748, 894.248947, 887.186301, 909.792057]}, "tokio": {"scan_ms": [579.2819, 566.00755, 582.7605, 559.8762, 566.8316, 554.6383, 576.05165, 563.19356, 612.74534, 586.5669, 580.0588, 566.6812, 571.15334, 571.52337], "wall_ms": [692.862157, 680.198058, 698.352173, 671.65832, 680.533892, 668.544743, 688.044384, 674.552564, 735.085916, 699.754035, 695.691597, 680.953486, 687.596672, 684.716204]}}},
{"case": "tcp_mixed_4096_b256", "arguments": ["--addresses", "127.0.0.1", "--range", "20000-24095", "--batch-size", "256", "--timeout", "1500", "--tries", "1"], "expected_open_count": 512, "expected_closed_count": 0, "summary": {"case": "tcp_mixed_4096_b256", "async-std": {"n": 12, "scan_median_ms": 68.141467, "scan_min_ms": 55.01162, "scan_max_ms": 74.22786, "wall_median_ms": 187.83275400073762}, "tokio": {"n": 12, "scan_median_ms": 27.07619, "scan_min_ms": 26.30396, "scan_max_ms": 29.205604, "wall_median_ms": 141.96964999973716}, "paired_ratio_median": 0.3978116368627497, "paired_ratio_bootstrap_95_percent": [0.37823381704207826, 0.43222201838556734]}, "samples": {"async-std": {"scan_ms": [63.719936, 66.61555, 55.01162, 70.96551, 73.14818, 73.35321, 63.210845, 67.237474, 63.8246, 74.22786, 72.827265, 69.04546, 66.88673, 56.79349], "wall_ms": [180.925745, 189.033784, 169.724796, 182.401319, 193.146419, 198.574615, 187.32776, 187.759146, 189.552171, 193.552151, 187.906362, 186.211485, 188.919732, 170.810584]}, "tokio": {"scan_ms": [26.11342, 28.618133, 26.30396, 27.60356, 26.624788, 26.957022, 27.126616, 27.040524, 27.782789, 29.205604, 26.756745, 27.10681, 27.859924, 27.04557], "wall_ms": [142.457495, 146.887664, 141.370764, 145.212753, 139.031326, 141.839596, 142.860691, 141.604121, 142.410272, 144.175928, 141.592621, 140.95038, 142.099704, 142.563949]}}},
{"case": "tcp_mixed_4096_b4500", "arguments": ["--addresses", "127.0.0.1", "--range", "20000-24095", "--batch-size", "4500", "--timeout", "1500", "--tries", "1"], "expected_open_count": 512, "expected_closed_count": 0, "summary": {"case": "tcp_mixed_4096_b4500", "async-std": {"n": 12, "scan_median_ms": 105.183213, "scan_min_ms": 92.38623, "scan_max_ms": 118.32085000000001, "wall_median_ms": 223.5699919997387}, "tokio": {"n": 12, "scan_median_ms": 40.7245485, "scan_min_ms": 40.203160000000004, "scan_max_ms": 43.439656, "wall_median_ms": 156.73000700007833}, "paired_ratio_median": 0.4002557620046163, "paired_ratio_bootstrap_95_percent": [0.37097988370736024, 0.41671607494471485]}, "samples": {"async-std": {"scan_ms": [115.14703, 114.446916, 92.38623, 108.52898, 107.40388, 102.962546, 110.30818, 101.29879, 110.973485, 99.98305, 96.58376, 93.3442, 118.32085, 109.31534], "wall_ms": [237.680642, 228.93913, 214.413981, 229.134308, 220.8739, 224.930336, 225.133944, 224.350373, 224.012733, 218.937123, 211.773245, 213.402572, 230.26942, 223.127251]}, "tokio": {"scan_ms": [42.504027, 40.87808, 42.628516, 40.42182, 43.439656, 41.02149, 40.5159, 41.792054, 41.588504, 40.20316, 40.6492, 40.36123, 40.799897, 40.392883], "wall_ms": [164.520424, 153.958011, 168.511547, 151.627343, 157.813913, 158.826988, 155.48564, 159.408084, 159.302398, 157.162484, 155.533542, 153.505071, 156.29753, 155.263059]}}},
{"case": "tcp_open_512_b4500", "arguments": ["--addresses", "127.0.0.1", "--range", "20000-20511", "--batch-size", "4500", "--timeout", "1500", "--tries", "1"], "expected_open_count": 512, "expected_closed_count": 0, "summary": {"case": "tcp_open_512_b4500", "async-std": {"n": 12, "scan_median_ms": 47.70304, "scan_min_ms": 36.67692, "scan_max_ms": 68.96059, "wall_median_ms": 160.65413699971032}, "tokio": {"n": 12, "scan_median_ms": 6.261838, "scan_min_ms": 6.104236, "scan_max_ms": 6.608183, "wall_median_ms": 124.33755050005857}, "paired_ratio_median": 0.13084120838120605, "paired_ratio_bootstrap_95_percent": [0.11244047830149623, 0.1483106271556775]}, "samples": {"async-std": {"scan_ms": [46.98071, 49.201056, 46.035964, 58.204245, 68.96059, 45.698512, 38.846433, 56.73356, 46.20831, 36.67692, 41.455615, 49.29091, 49.19777, 57.64334], "wall_ms": [160.558191, 160.740311, 158.833574, 170.663018, 181.746459, 157.622693, 152.247554, 167.369295, 158.456413, 148.534519, 155.633953, 162.4747, 167.594306, 173.535561]}, "tokio": {"scan_ms": [6.28506, 6.0532, 6.104236, 6.147001, 6.206233, 6.270633, 6.42198, 6.306521, 6.194992, 6.253043, 6.608183, 6.158266, 6.350709, 6.555237], "wall_ms": [125.899195, 127.460307, 125.087796, 116.874444, 118.344468, 117.786915, 125.684343, 129.993995, 123.587305, 127.177718, 128.525335, 130.741019, 116.031822, 120.737224]}}},
{"case": "tcp_ipv6_mixed_4096_b4500", "arguments": ["--addresses", "::1", "--range", "20000-24095", "--batch-size", "4500", "--timeout", "1500", "--tries", "1"], "expected_open_count": 512, "expected_closed_count": 0, "summary": {"case": "tcp_ipv6_mixed_4096_b4500", "async-std": {"n": 12, "scan_median_ms": 106.25599, "scan_min_ms": 92.231296, "scan_max_ms": 119.37266000000001, "wall_median_ms": 225.6745664999471}, "tokio": {"n": 12, "scan_median_ms": 40.983215, "scan_min_ms": 40.500764000000004, "scan_max_ms": 43.351088000000004, "wall_median_ms": 154.43425250032305}, "paired_ratio_median": 0.38505220824311115, "paired_ratio_bootstrap_95_percent": [0.36158756791142943, 0.39815014470090115]}, "samples": {"async-std": {"scan_ms": [103.4242, 101.70391, 113.68274, 102.45315, 105.20947, 100.802965, 105.56823, 113.60494, 116.37136, 106.94375, 119.37266, 112.66027, 104.448915, 92.231296], "wall_ms": [227.238681, 213.929011, 228.832603, 223.209492, 218.101656, 211.377824, 221.963913, 228.139641, 230.733897, 221.140289, 232.238324, 230.332511, 228.886917, 212.248323]}, "tokio": {"scan_ms": [41.84887, 42.669304, 43.351088, 41.374706, 40.902335, 42.38061, 41.326944, 40.948737, 40.565256, 40.500764, 41.106615, 40.864874, 40.992025, 40.974405], "wall_ms": [154.656851, 162.111319, 164.86059, 152.724654, 155.535032, 161.052929, 153.618886, 154.69096, 152.754894, 154.585249, 154.283256, 153.658405, 153.15487, 155.942636]}}},
{"case": "udp_echo_256_b256", "arguments": ["--addresses", "127.0.0.1", "--range", "30000-30255", "--batch-size", "256", "--timeout", "1500", "--tries", "1", "--udp"], "expected_open_count": 256, "expected_closed_count": 0, "summary": {"case": "udp_echo_256_b256", "async-std": {"n": 12, "scan_median_ms": 26.09995, "scan_min_ms": 16.339796, "scan_max_ms": 51.66373, "wall_median_ms": 144.53015349999987}, "tokio": {"n": 12, "scan_median_ms": 3.550261, "scan_min_ms": 3.4090409999999998, "scan_max_ms": 3.760615, "wall_median_ms": 117.10653199997978}, "paired_ratio_median": 0.13508511965414755, "paired_ratio_bootstrap_95_percent": [0.10720311129055016, 0.16944309058040086]}, "samples": {"async-std": {"scan_ms": [21.703886, 37.568197, 16.339796, 19.275581, 25.42451, 26.131544, 26.231969, 26.068356, 40.599234, 21.879036, 51.66373, 29.525625, 37.230626, 20.666992], "wall_ms": [132.136823, 160.093568, 127.916215, 136.567585, 139.767169, 145.03025, 147.152265, 144.030057, 152.23601, 140.657323, 174.678649, 151.764345, 157.714272, 138.801602]}, "tokio": {"scan_ms": [3.58793, 3.647557, 3.512101, 3.733033, 3.409041, 3.46458, 3.760615, 3.547523, 3.589418, 3.47494, 3.5728, 3.552999, 3.502289, 3.721321], "wall_ms": [116.999981, 123.901236, 115.435224, 118.95129, 116.437523, 116.181437, 113.989477, 117.653641, 119.460373, 116.987976, 119.387785, 118.771634, 112.816753, 117.225088]}}},
{"case": "udp_ipv6_echo_256_b256", "arguments": ["--addresses", "::1", "--range", "30000-30255", "--batch-size", "256", "--timeout", "1500", "--tries", "1", "--udp"], "expected_open_count": 256, "expected_closed_count": 0, "summary": {"case": "udp_ipv6_echo_256_b256", "async-std": {"n": 12, "scan_median_ms": 27.625454499999996, "scan_min_ms": 19.875253999999998, "scan_max_ms": 38.867235, "wall_median_ms": 145.4195784995136}, "tokio": {"n": 12, "scan_median_ms": 3.799017, "scan_min_ms": 3.6955, "scan_max_ms": 4.067423, "wall_median_ms": 119.61354899995058}, "paired_ratio_median": 0.13852945122327148, "paired_ratio_bootstrap_95_percent": [0.11635290629059791, 0.15096773740372016]}, "samples": {"async-std": {"scan_ms": [40.33954, 21.779982, 25.549388, 33.147827, 25.456525, 26.251681, 28.999228, 38.867235, 36.46853, 19.875254, 32.311674, 25.559943, 29.844549, 24.700988], "wall_ms": [163.080692, 139.558642, 147.557955, 145.782568, 145.197701, 140.07066, 148.222519, 152.220419, 156.648518, 130.942647, 145.126391, 137.323428, 141.182265, 145.641456]}, "tokio": {"scan_ms": [3.874475, 3.758481, 3.860633, 3.877691, 3.839627, 3.6955, 3.873471, 3.699186, 3.83033, 3.767704, 3.739238, 3.763777, 4.067423, 3.736082], "wall_ms": [116.249398, 116.498595, 115.169205, 119.516884, 123.086973, 115.31348, 123.611914, 113.79081, 114.133146, 120.625814, 119.710214, 126.404842, 121.332105, 115.011208]}}},
{"case": "tcp_filtered_128_t20_retry2_b64", "arguments": ["--addresses", "127.0.0.3", "--range", "40000-40127", "--batch-size", "64", "--timeout", "20", "--tries", "2"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "tcp_filtered_128_t20_retry2_b64", "async-std": {"n": 12, "scan_median_ms": 87.6446925, "scan_min_ms": 83.705105, "scan_max_ms": 95.73294, "wall_median_ms": 203.2048140004008}, "tokio": {"n": 12, "scan_median_ms": 90.200395, "scan_min_ms": 84.00111, "scan_max_ms": 99.7434, "wall_median_ms": 203.24993450003603}, "paired_ratio_median": 1.0119900466846206, "paired_ratio_bootstrap_95_percent": [0.9963617580190984, 1.0629572999350771]}, "samples": {"async-std": {"scan_ms": [87.98372, 91.602646, 86.6592, 92.56119, 83.82087, 83.89964, 91.62219, 83.705105, 92.42998, 88.630185, 85.04144, 95.73294, 90.631686, 86.57442], "wall_ms": [209.289809, 203.418129, 204.967734, 208.14526, 201.807086, 200.596062, 209.949818, 200.093062, 210.158309, 204.013388, 200.23668, 204.49939, 202.39624, 197.121998]}, "tokio": {"scan_ms": [83.77635, 87.813005, 90.50424, 93.56978, 89.589715, 93.88123, 84.00111, 84.11684, 93.56413, 89.66802, 89.89655, 93.36173, 89.5264, 99.7434], "wall_ms": [194.068953, 202.098137, 203.901289, 205.50657, 199.834834, 204.891561, 197.104592, 195.919982, 202.59858, 200.310774, 199.606024, 210.950254, 205.862549, 208.864636]}}},
{"case": "tcp_filtered_128_t1500_b4500", "arguments": ["--addresses", "127.0.0.3", "--range", "40000-40127", "--batch-size", "4500", "--timeout", "1500", "--tries", "1"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "tcp_filtered_128_t1500_b4500", "async-std": {"n": 12, "scan_median_ms": 1519.9909000000002, "scan_min_ms": 1514.0051, "scan_max_ms": 1527.4003, "wall_median_ms": 1625.5414565002866}, "tokio": {"n": 12, "scan_median_ms": 1500.6792500000001, "scan_min_ms": 1500.6533, "scan_max_ms": 1500.7889, "wall_median_ms": 1607.9048350006815}, "paired_ratio_median": 0.9872864815881686, "paired_ratio_bootstrap_95_percent": [0.9860211048523806, 0.9893007737644279]}, "samples": {"async-std": {"scan_ms": [1520.2672, 1516.4726, 1514.8814, 1521.4541, 1526.0279, 1521.2376, 1516.8859, 1514.0051, 1517.0343, 1517.0065, 1527.4003, 1521.6393, 1522.2969, 1518.7442], "wall_ms": [1629.725436, 1624.468006, 1623.793379, 1628.174016, 1633.755173, 1633.153861, 1623.867637, 1620.035128, 1623.419386, 1623.095493, 1633.97451, 1627.215276, 1628.552662, 1623.647248]}, "tokio": {"scan_ms": [1500.7088, 1500.7062, 1500.6834, 1500.7012, 1500.6533, 1500.6555, 1500.6707, 1500.6621, 1500.7889, 1500.6878, 1500.6776, 1500.6809, 1500.7043, 1500.6754], "wall_ms": [1610.089424, 1608.901673, 1607.865974, 1607.1767, 1609.037891, 1613.202192, 1607.943696, 1605.800055, 1608.702953, 1610.989485, 1606.903005, 1606.369366, 1607.172129, 1610.257635]}}},
{"case": "udp_silent_128_t20_retry2_b64", "arguments": ["--addresses", "127.0.0.1", "--range", "31000-31127", "--batch-size", "64", "--timeout", "20", "--tries", "2", "--udp"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "udp_silent_128_t20_retry2_b64", "async-std": {"n": 12, "scan_median_ms": 91.0113, "scan_min_ms": 85.47477, "scan_max_ms": 101.52072500000001, "wall_median_ms": 198.82166349998442}, "tokio": {"n": 12, "scan_median_ms": 82.374075, "scan_min_ms": 82.15854999999999, "scan_max_ms": 82.71911999999999, "wall_median_ms": 193.47493999975995}, "paired_ratio_median": 0.9056145986510931, "paired_ratio_bootstrap_95_percent": [0.8684654796153525, 0.9399090634650837]}, "samples": {"async-std": {"scan_ms": [99.2002, 87.0283, 92.82178, 93.114525, 96.1305, 89.20082, 87.134354, 86.520605, 88.210866, 85.47477, 101.520725, 93.399614, 97.3816, 88.98857], "wall_ms": [206.826011, 195.030786, 201.546836, 202.165958, 203.125042, 195.259398, 194.754565, 194.000578, 195.239916, 193.140741, 209.96905, 200.410742, 205.804614, 197.232585]}, "tokio": {"scan_ms": [82.729965, 82.53965, 82.33637, 82.18716, 82.41128, 82.5791, 82.33687, 82.54546, 82.466274, 82.26455, 82.71912, 82.15855, 82.562424, 82.242526], "wall_ms": [192.803999, 192.725638, 193.896498, 192.528066, 194.065959, 195.141017, 191.801711, 191.701865, 192.781206, 194.752557, 195.949918, 194.693378, 193.053382, 189.984702]}}},
{"case": "udp_silent_128_t1500_b4500", "arguments": ["--addresses", "127.0.0.1", "--range", "31000-31127", "--batch-size", "4500", "--timeout", "1500", "--tries", "1", "--udp"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "udp_silent_128_t1500_b4500", "async-std": {"n": 12, "scan_median_ms": 1518.52745, "scan_min_ms": 1514.0991, "scan_max_ms": 1528.4443, "wall_median_ms": 1626.3375505000113}, "tokio": {"n": 12, "scan_median_ms": 1501.6571000000001, "scan_min_ms": 1501.6054000000001, "scan_max_ms": 1501.781, "wall_median_ms": 1608.1746704999205}, "paired_ratio_median": 0.9888991810266266, "paired_ratio_bootstrap_95_percent": [0.9865208648810749, 0.9898526070883118]}, "samples": {"async-std": {"scan_ms": [1518.4212, 1517.0245, 1518.4689, 1516.9675, 1520.9479, 1523.4239, 1518.586, 1514.0991, 1515.2898, 1517.1113, 1526.8329, 1520.123, 1517.5409, 1528.4443], "wall_ms": [1625.755639, 1625.149282, 1623.676844, 1629.639593, 1629.579045, 1629.622137, 1625.341947, 1622.234258, 1621.903718, 1622.809251, 1632.650784, 1627.333154, 1623.842187, 1634.457294]}, "tokio": {"scan_ms": [1501.6277, 1501.7376, 1501.656, 1501.6828, 1501.6761, 1501.6582, 1501.6851, 1501.6054, 1501.781, 1501.608, 1501.6263, 1501.6633, 1501.641, 1501.6465], "wall_ms": [1609.273123, 1608.924992, 1609.919092, 1607.439902, 1608.738686, 1609.290304, 1607.610655, 1606.815587, 1606.94348, 1606.421318, 1610.999882, 1608.945001, 1608.793034, 1607.310885]}}},
{"case": "tcp_open_16_interval5", "arguments": ["--addresses", "127.0.0.1", "--range", "20000-20015", "--batch-size", "256", "--timeout", "1500", "--tries", "1", "--interval", "5"], "expected_open_count": 16, "expected_closed_count": 0, "summary": {"case": "tcp_open_16_interval5", "async-std": {"n": 12, "scan_median_ms": 76.526898, "scan_min_ms": 76.28906, "scan_max_ms": 76.98857000000001, "wall_median_ms": 184.3007565003063}, "tokio": {"n": 12, "scan_median_ms": 76.198385, "scan_min_ms": 75.96929, "scan_max_ms": 76.34483, "wall_median_ms": 182.87123099980818}, "paired_ratio_median": 0.995816455071374, "paired_ratio_bootstrap_95_percent": [0.9940940322939229, 0.997153398744211]}, "samples": {"async-std": {"scan_ms": [76.22546, 76.634064, 76.59302, 76.39457, 76.645374, 76.3332, 76.62176, 76.28906, 76.41959, 76.36926, 76.4935, 76.560296, 76.98857, 76.76206], "wall_ms": [184.669709, 182.866488, 185.538649, 183.313847, 184.925599, 183.675914, 183.637934, 185.093184, 187.063112, 183.638293, 182.9653, 182.843469, 186.901708, 185.251247]}, "tokio": {"scan_ms": [76.195486, 75.97554, 76.34483, 76.11899, 76.28056, 76.30077, 76.1682, 76.29438, 75.96929, 76.17687, 76.25071, 76.16523, 76.15225, 76.2199], "wall_ms": [184.722406, 182.572365, 187.34923, 182.041604, 183.36494, 182.523404, 183.084177, 182.781318, 182.961144, 182.379863, 182.473341, 182.171679, 187.598076, 184.172442]}}},
{"case": "tcp_filtered_128_t1_b1", "arguments": ["--addresses", "127.0.0.3", "--range", "40000-40127", "--batch-size", "1", "--timeout", "1", "--tries", "1"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "tcp_filtered_128_t1_b1", "async-std": {"n": 12, "scan_median_ms": 130.93322, "scan_min_ms": 130.62957, "scan_max_ms": 131.24298000000002, "wall_median_ms": 239.21898499975214}, "tokio": {"n": 12, "scan_median_ms": 131.0992, "scan_min_ms": 130.88854, "scan_max_ms": 131.27832, "wall_median_ms": 239.95067800024117}, "paired_ratio_median": 1.0005112769246107, "paired_ratio_bootstrap_95_percent": [0.9999663972630607, 1.0021629287391751]}, "samples": {"async-std": {"scan_ms": [130.78079, 131.10988, 130.9561, 130.94509, 130.83921, 131.1865, 130.62957, 131.02207, 130.70644, 130.90175, 130.75319, 131.24298, 130.92135, 131.06887], "wall_ms": [239.781417, 238.586222, 239.454156, 237.931582, 238.284514, 238.520237, 241.34305, 238.878925, 240.314313, 240.056949, 238.664271, 241.762701, 238.983814, 241.307165]}, "tokio": {"scan_ms": [130.6635, 130.95422, 130.92089, 130.88854, 130.99842, 131.1627, 131.0357, 131.17874, 131.2091, 130.93698, 131.27832, 131.25797, 130.9423, 131.16762], "wall_ms": [241.110604, 238.237406, 241.265624, 236.701241, 243.135758, 242.763681, 240.139445, 237.299031, 243.166426, 239.761911, 238.337466, 242.007046, 237.278855, 238.046974]}}},
{"case": "tcp_filtered_128_t5_b1", "arguments": ["--addresses", "127.0.0.3", "--range", "40000-40127", "--batch-size", "1", "--timeout", "5", "--tries", "1"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "tcp_filtered_128_t5_b1", "async-std": {"n": 12, "scan_median_ms": 644.5146500000001, "scan_min_ms": 644.1285, "scan_max_ms": 645.423, "wall_median_ms": 753.1218705003084}, "tokio": {"n": 12, "scan_median_ms": 644.9479249999999, "scan_min_ms": 644.26196, "scan_max_ms": 645.4041000000001, "wall_median_ms": 753.6899540000377}, "paired_ratio_median": 1.0003472826276902, "paired_ratio_bootstrap_95_percent": [0.9997139996239722, 1.0010076863466117]}, "samples": {"async-std": {"scan_ms": [644.68694, 644.6084, 644.4459, 644.1285, 644.51116, 644.7121, 644.44476, 644.48434, 644.4745, 645.2324, 644.8094, 645.423, 644.8345, 644.51814], "wall_ms": [752.617209, 752.072663, 751.723478, 753.035024, 754.164624, 753.567375, 754.457316, 751.579595, 751.879974, 753.208717, 756.413493, 755.272146, 751.549782, 750.185216]}, "tokio": {"scan_ms": [644.9864, 644.3036, 644.2817, 644.75507, 644.70977, 644.51116, 644.26196, 645.1563, 645.34944, 645.04635, 645.124, 644.8495, 645.08367, 645.4041], "wall_ms": [750.909824, 751.864968, 752.487215, 754.880392, 752.804057, 752.172361, 751.845615, 752.622455, 754.163878, 759.625524, 753.21603, 761.649699, 759.674361, 759.360811]}}},
{"case": "udp_silent_128_t1_b1", "arguments": ["--addresses", "127.0.0.1", "--range", "31000-31127", "--batch-size", "1", "--timeout", "1", "--tries", "1", "--udp"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "udp_silent_128_t1_b1", "async-std": {"n": 12, "scan_median_ms": 133.377255, "scan_min_ms": 132.9543, "scan_max_ms": 134.05539, "wall_median_ms": 241.22244349973698}, "tokio": {"n": 12, "scan_median_ms": 133.435925, "scan_min_ms": 132.90279999999998, "scan_max_ms": 133.90249, "wall_median_ms": 241.50215099962224}, "paired_ratio_median": 1.000629542926133, "paired_ratio_bootstrap_95_percent": [0.9983003180595498, 1.0009995969657295]}, "samples": {"async-std": {"scan_ms": [133.02739, 133.06117, 133.60235, 133.16965, 133.40722, 133.34133, 133.79982, 133.50019, 133.34729, 132.9543, 132.97513, 133.86926, 134.05539, 133.24223], "wall_ms": [239.632582, 239.299313, 240.69326, 241.236812, 245.210151, 240.884955, 240.974041, 241.96585, 239.067496, 241.208075, 254.893531, 245.278615, 248.441437, 240.973139]}, "tokio": {"scan_ms": [133.238, 133.49098, 133.4732, 132.9028, 133.5228, 133.4069, 133.90249, 133.85785, 133.1612, 133.06777, 133.46495, 133.47353, 133.05344, 133.39317], "wall_ms": [252.542821, 243.438741, 246.292204, 241.467916, 246.338636, 241.292069, 242.210823, 241.536386, 240.60291, 240.217646, 241.608773, 243.789372, 240.538868, 241.191347]}}},
{"case": "udp_silent_128_t5_b1", "arguments": ["--addresses", "127.0.0.1", "--range", "31000-31127", "--batch-size", "1", "--timeout", "5", "--tries", "1", "--udp"], "expected_open_count": 0, "expected_closed_count": 0, "summary": {"case": "udp_silent_128_t5_b1", "async-std": {"n": 12, "scan_median_ms": 647.45235, "scan_min_ms": 647.13037, "scan_max_ms": 647.9096, "wall_median_ms": 754.5120764998501}, "tokio": {"n": 12, "scan_median_ms": 647.5434499999999, "scan_min_ms": 646.6989, "scan_max_ms": 649.0831999999999, "wall_median_ms": 756.2571695002589}, "paired_ratio_median": 1.0001673335253631, "paired_ratio_bootstrap_95_percent": [0.9995790714638624, 1.0005779645783957]}, "samples": {"async-std": {"scan_ms": [647.3635, 647.20917, 647.3771, 647.33446, 647.413, 647.9096, 647.4971, 647.56674, 647.6953, 647.18795, 647.13037, 647.24445, 647.6764, 647.4917], "wall_ms": [758.592425, 755.975749, 758.079069, 754.578821, 754.257856, 754.087964, 754.511489, 754.512664, 754.525435, 757.162554, 757.284223, 753.779497, 753.794215, 754.096046]}, "tokio": {"scan_ms": [647.42637, 647.2082, 647.5038, 649.0832, 646.6989, 647.32325, 647.8038, 647.9199, 647.7853, 647.5831, 647.7122, 647.13156, 647.30984, 647.31306], "wall_ms": [756.138619, 753.755517, 757.528466, 753.601939, 757.316129, 757.89494, 753.950169, 756.446763, 754.649347, 756.228975, 753.679032, 754.926627, 756.285364, 758.323076]}}},
{"case": "tcp_open_16_interval1", "arguments": ["--addresses", "127.0.0.1", "--range", "20000-20015", "--batch-size", "256", "--timeout", "1500", "--tries", "1", "--interval", "1"], "expected_open_count": 16, "expected_closed_count": 0, "summary": {"case": "tcp_open_16_interval1", "async-std": {"n": 12, "scan_median_ms": 16.380881000000002, "scan_min_ms": 16.150762999999998, "scan_max_ms": 16.9349, "wall_median_ms": 127.33355999989726}, "tokio": {"n": 12, "scan_median_ms": 15.968566, "scan_min_ms": 15.782170000000002, "scan_max_ms": 16.192745000000002, "wall_median_ms": 123.29525399991326}, "paired_ratio_median": 0.9745702232857189, "paired_ratio_bootstrap_95_percent": [0.9638471208044207, 0.9843992590941175]}, "samples": {"async-std": {"scan_ms": [16.742926, 16.30988, 16.436962, 16.418753, 16.9349, 16.31153, 16.568368, 16.311154, 16.377501, 16.749995, 16.150763, 16.382523, 16.379239, 16.34811], "wall_ms": [123.728541, 124.449409, 127.732305, 129.316049, 129.626865, 127.054303, 128.552302, 126.115599, 127.535378, 127.131742, 127.636723, 123.361358, 125.967128, 126.564565]}, "tokio": {"scan_ms": [15.898872, 16.012024, 16.004669, 16.192745, 15.848257, 15.910927, 15.932463, 16.0934, 15.86936, 16.149458, 15.913244, 16.077776, 15.78217, 16.078446], "wall_ms": [123.129233, 124.075349, 123.24542, 125.239692, 121.153835, 122.962107, 123.345088, 123.750072, 123.947659, 123.936385, 123.59239, 122.694261, 122.874058, 122.894589]}}},
{"case": "tcp_open_16_interval20", "arguments": ["--addresses", "127.0.0.1", "--range", "20000-20015", "--batch-size", "256", "--timeout", "1500", "--tries", "1", "--interval", "20"], "expected_open_count": 16, "expected_closed_count": 0, "summary": {"case": "tcp_open_16_interval20", "async-std": {"n": 12, "scan_median_ms": 302.22183499999994, "scan_min_ms": 301.97242, "scan_max_ms": 302.5761, "wall_median_ms": 412.45387100025255}, "tokio": {"n": 12, "scan_median_ms": 301.93652499999996, "scan_min_ms": 301.76678000000004, "scan_max_ms": 302.2357, "wall_median_ms": 411.5444945005038}, "paired_ratio_median": 0.9989881724371461, "paired_ratio_bootstrap_95_percent": [0.9987076082418354, 0.9995525033875622]}, "samples": {"async-std": {"scan_ms": [302.18267, 302.13732, 301.9773, 302.5761, 302.25044, 301.97242, 302.143, 302.19373, 302.2255, 302.00857, 302.36325, 302.21817, 302.42342, 302.41114], "wall_ms": [413.653618, 410.964384, 412.493781, 411.729865, 412.494401, 411.493839, 418.091404, 412.413961, 414.137723, 415.698233, 410.759472, 412.03479, 412.157087, 413.086966]}, "tokio": {"scan_ms": [301.75188, 301.73197, 302.215, 301.85777, 301.83905, 301.76678, 302.06013, 301.8798, 301.8705, 302.0028, 301.99325, 301.77155, 302.2357, 302.11332], "wall_ms": [411.204909, 409.04968, 409.769905, 407.712868, 413.807731, 413.013744, 409.228791, 410.831539, 412.670222, 410.527218, 413.87037, 411.52089, 411.798223, 411.568099]}}}
],
"filtered_tcp_followup": {"purpose": "More samples for the noisy filtered TCP 20 ms result; separate from the 19-case run.", "trials": 40, "warmups": 2, "summary": {"case": "tcp_filtered_128_t20_retry2_b64", "async-std": {"n": 40, "scan_median_ms": 90.806345, "scan_min_ms": 83.67984, "scan_max_ms": 98.48335399999999, "wall_median_ms": 199.7979699995085}, "tokio": {"n": 40, "scan_median_ms": 89.66677, "scan_min_ms": 83.70783, "scan_max_ms": 98.51789, "wall_median_ms": 199.89575449972108}, "paired_ratio_median": 0.9904734730864315, "paired_ratio_bootstrap_95_percent": [0.9584064276305695, 1.0173184206016161]}, "samples": {"async-std": {"scan_ms": [88.323325, 92.4213, 88.936195, 92.37154, 96.92283, 84.850445, 89.15503, 92.10235, 83.80518, 92.24769, 92.32318, 84.8584, 92.456356, 88.23032, 92.04123, 92.18477, 86.79756, 98.483354, 89.1963, 91.340594, 83.67984, 93.583435, 92.54462, 89.31536, 91.27254, 88.60093, 84.68702, 93.62957, 92.30719, 88.04115, 84.974065, 94.37316, 89.16571, 95.54624, 93.07317, 84.02605, 90.34015, 96.3984, 90.19143, 90.06581, 96.61417, 85.11115], "wall_ms": [195.0308, 198.595023, 205.550084, 201.45625, 204.755249, 190.154735, 195.168845, 200.063899, 193.003685, 197.985884, 210.720138, 198.077596, 210.647758, 194.687149, 199.664278, 204.498946, 195.373439, 207.872652, 196.887853, 198.249704, 190.054223, 199.931662, 199.328127, 197.193407, 199.537635, 195.275976, 192.193237, 206.635061, 200.028809, 198.330338, 196.621195, 215.452312, 198.812027, 208.724402, 207.834586, 202.403001, 203.333875, 206.879636, 203.607022, 208.38006, 204.950703, 197.268808]}, "tokio": {"scan_ms": [92.301026, 93.38117, 93.45285, 94.5628, 88.58762, 84.90488, 89.12393, 91.01911, 96.5046, 88.37657, 98.51789, 88.854186, 84.06957, 91.783285, 87.75008, 94.83653, 90.99176, 89.60787, 90.4417, 92.33754, 92.54058, 89.72567, 83.76211, 93.127385, 87.82867, 83.70783, 84.0695, 87.62123, 90.449825, 89.82943, 92.07562, 88.124685, 90.16974, 83.91896, 87.90877, 89.39017, 84.15518, 93.53352, 86.91823, 85.3855, 89.75218, 98.28125], "wall_ms": [198.423459, 203.341894, 201.703401, 202.09388, 195.800999, 193.598472, 195.551461, 200.580659, 206.981415, 194.671571, 206.959222, 195.364517, 193.512965, 206.094437, 199.177046, 200.599854, 201.202883, 199.020153, 201.921695, 199.143086, 201.97124, 201.214532, 192.511818, 205.472348, 197.018051, 190.893376, 206.579472, 194.892288, 199.580795, 198.181881, 206.1589, 195.554125, 199.940732, 203.003288, 197.682916, 201.536251, 205.527831, 212.436077, 194.466474, 196.772168, 199.850777, 205.605438]}}},
"synthetic": {"operations": 4096, "batch_size": 256, "timeout_ms": 10, "samples_per_run": 20, "warmup_seconds": 1, "measurement_seconds": 5, "runtime_construction": "excluded", "ready_affinity": "CPU 0", "ready_order": ["async-std-1", "tokio-1", "tokio-2", "async-std-2"], "expired_affinity": "CPUs 0-3", "binary_sha256": {"async-std": "60e3fc23233d914f36a3d1913202a099440e926febcc36d37d6fcd3b79e7b867", "tokio": "64adfd7f5194bfc9ce7ad7c7875560f43727299b1b714c013a340a7353d57214"}, "ready": {"async-std": {"median_ms": 0.29120075443342, "runs": [{"median_ms": 0.283435356372549, "sample_ms": [0.2825720470588235, 0.28204494705882355, 0.28254624705882353, 0.28159535294117644, 0.2827166776470589, 0.28334777843137254, 0.2822799512605042, 0.28262099705882354, 0.28297270196078433, 0.28314464705882353, 0.2836823540106952, 0.2835229343137255, 0.28407015746606334, 0.2842827201680672, 0.2849620729411765, 0.2847762551470588, 0.285210384083045, 0.2895305209150327, 0.2874756, 0.2877200064705882]}, {"median_ms": 0.29475756493621547, "sample_ms": [0.2928709879518072, 0.2937114819277108, 0.2933189156626506, 0.29493332831325303, 0.2935973734939759, 0.2949348473895582, 0.29569418416523235, 0.295620109939759, 0.29519459571619816, 0.29707792530120486, 0.29418250054764516, 0.294691171686747, 0.2942722882298424, 0.29563811101549053, 0.29616976465863454, 0.2951352620481928, 0.2948239581856839, 0.2945176673360107, 0.29444605960684844, 0.2944720572289157]}]}, "tokio": {"median_ms": 0.302037698125, "runs": [{"median_ms": 0.30038876319444446, "sample_ms": [0.298729525, 0.29950843125000004, 0.3017063583333333, 0.30070625, 0.299885025, 0.3007033645833333, 0.3001008196428571, 0.2997647140625, 0.30044873055555554, 0.3001521925, 0.30016312272727275, 0.30431824270833335, 0.30060469615384616, 0.3011879410714286, 0.3012789341666666, 0.30614084375, 0.30109486838235294, 0.30032879583333333, 0.29896177302631577, 0.299311944375]}, {"median_ms": 0.3030938016369047, "sample_ms": [0.3012269375, 0.30389388125, 0.30436450416666666, 0.30480048125000003, 0.3020644975, 0.3028290625, 0.30420522678571427, 0.302707453125, 0.30225569861111107, 0.30201089875, 0.30262227954545456, 0.30319728541666663, 0.30556625480769234, 0.3029903178571428, 0.30277277, 0.30272753671875, 0.3081278419117647, 0.30388027500000003, 0.3033398302631579, 0.303353878125]}]}}, "expired": {"async-std": {"median_ms": 160.8818555, "sample_ms": [160.938174, 160.915957, 160.831742, 160.9983525, 160.883492, 160.906152, 160.858202, 160.7991255, 160.880219, 160.786231, 160.763265, 160.9403975, 160.8187525, 160.847539, 160.7793675, 160.8122135, 160.891816, 160.9187445, 160.945858, 161.08323]}, "tokio": {"median_ms": 160.96872275, "sample_ms": [161.0623005, 161.067578, 160.9593655, 160.9637835, 161.007304, 160.938726, 160.86018, 160.973662, 160.9531165, 160.9530565, 161.099919, 160.872232, 160.845637, 161.0776695, 160.8679585, 161.043725, 160.911652, 161.115825, 161.00697, 161.0155235]}}}
}
+229
View File
@@ -0,0 +1,229 @@
# Tokio scan benchmark
The runtime migration was compared with its async-std base using real TCP and UDP
sockets on 2026-10-01. This was a one-off, explicitly requested measurement in
an isolated Linux network namespace. No network benchmark was added to the
automated test or Criterion suites.
## Builds and environment
- Baseline: `cfb864590161d1c8a003811dcf4846ceb8c8284f`, async-std 1.13.2.
- Migration: `42427e88c1a6587d0a8b198a73bac4553bb4ad72`, Tokio 1.53.1.
- Both are optimized release binaries with the repository's LTO settings,
built using Rust 1.98.1.
- Intel Core Ultra 7 165U, Linux 7.2.6-1-cachyos, x86_64.
- Scanner processes were restricted to CPUs 0-3 (two physical performance
cores); the responder process used CPUs 4-7. Both runtimes used the same
affinity. The harness used CPUs 8-9.
- File descriptor soft limit: 65,536, so the requested batches of 256 and 4,500
were not reduced by RustScan's limit checks.
- The private namespace contained only loopback, with no external interface.
Targets used literal IPv4/IPv6 addresses; scripts and configuration loading
were disabled. The normal synchronous resolver initialization still occurred.
Binary SHA-256 hashes:
```text
async-std 00c7f0d91bab260ac622540e86083175afacfd1677647d2b8e96e56ba4f60732
tokio 669627834d8a32f60e2167e78a9518dcede7562831adb85fb61a9d94871494b8
```
## Method
Each workload used two warmup runs and twelve measured runs per binary. The
order alternated between async-std/Tokio and Tokio/async-std for each pair.
Each run started a fresh CLI process, matching ordinary command-line use.
The primary measurement is RustScan's `Portscan` timer, read using
`RUST_LOG=info`. It includes scan runtime initialization and cleanup and excludes
address parsing and final output. The wall time measures the entire process,
including startup, address parsing and output capture. Runtime startup was
excluded from the synthetic Criterion measurements below, so those numbers
measure a different workload.
The responder bound 512 TCP listeners, 256 UDP echo sockets and 128 silent UDP
sockets on each of `127.0.0.1` and `::1`. Empty UDP requests received empty replies.
TCP connections were accepted and closed immediately. Silent UDP sockets received
datagrams without replying. A firewall rule inside the private namespace dropped
TCP packets to `127.0.0.3:40000-40127`, producing actual connection timeouts.
No listeners were bound to `127.0.0.2`, used for the all-closed TCP scans.
Every run checked the exact set of reported open ports and rejected duplicates.
A separate check verified closed-port reporting and port exclusions for both
binaries. Each invocation used this common prefix:
```sh
RUST_LOG=info taskset -c 0-3 /path/to/rustscan \
--no-config --config-path /dev/null --no-banner --scripts none \
--greppable --scan-order serial
```
Append the arguments for the workload:
| Workload | Arguments |
| --- | --- |
| TCP closed, 65,535 ports | `-a 127.0.0.2 -r 1-65535 -b 256` or `-b 4500` |
| TCP mixed, 512 open / 4,096 ports | `-a 127.0.0.1 -r 20000-24095 -b 256` or `-b 4500` |
| TCP open, 512 ports | `-a 127.0.0.1 -r 20000-20511 -b 4500` |
| TCP IPv6 mixed, 512 open / 4,096 ports | `-a ::1 -r 20000-24095 -b 4500` |
| UDP echo, 256 ports | `-a 127.0.0.1 -r 30000-30255 -b 256 --udp` |
| UDP IPv6 echo, 256 ports | `-a ::1 -r 30000-30255 -b 256 --udp` |
| TCP filtered, short timeout and retries | `-a 127.0.0.3 -r 40000-40127 -b 64 -t 20 --tries 2` |
| TCP filtered, default timeout | `-a 127.0.0.3 -r 40000-40127 -b 4500 -t 1500` |
| UDP silent, short timeout and retries | `-a 127.0.0.1 -r 31000-31127 -b 64 -t 20 --tries 2 --udp` |
| UDP silent, default timeout | `-a 127.0.0.1 -r 31000-31127 -b 4500 -t 1500 --udp` |
| TCP open, 16 ports with intervals | `-a 127.0.0.1 -r 20000-20015 -b 256 --interval 1` (also 5 and 20 ms) |
| TCP filtered, short serial timeouts | `-a 127.0.0.3 -r 40000-40127 -b 1 -t 1` (also 5 ms) |
| UDP silent, short serial timeouts | `-a 127.0.0.1 -r 31000-31127 -b 1 -t 1 --udp` (also 5 ms) |
All unspecified timeouts use 1,500 ms and one try. The mixed TCP workloads have
listeners on ports 20000-20511 and no listeners on the remaining ports.
## Results
These results cover the revised timeout implementation. The initial migration
added 20.1% to the 5 ms interval workload (76.45 to 91.79 ms). Port intervals and
short deadlines now retain the existing `async_io::Timer`; long deadlines use
Tokio until 32 ms before expiry, then finish with the precise timer. Both phases
preserve the original deadline. This avoids timer rounding on short waits and
avoids registering a second timer for normal early socket replies.
All values below are medians of twelve measured runs. Negative changes mean
less elapsed time. The final column is a bootstrap 95% interval for the median
of paired Tokio/async-std scan-time ratios, expressed as a percentage change.
It uses 5,000 resamples of twelve pairs and does not account for every source
of system noise. It differs from the ratio of the two medians in the Change
column. [Per-run timings and summaries](tokio-benchmark-results.json) include
the warmups and full precision.
| Workload | async-std scan (ms) | Tokio scan (ms) | Change | Paired change, 95% interval |
| --- | ---: | ---: | ---: | ---: |
| TCP closed, 65,535 ports, batch 256 | 565.62 | 377.77 | -33.21% | -34.85% to -31.97% |
| TCP closed, 65,535 ports, batch 4,500 | 769.26 | 571.34 | -25.73% | -27.30% to -23.46% |
| TCP mixed, 4,096 ports, batch 256 | 68.14 | 27.08 | -60.26% | -62.18% to -56.78% |
| TCP mixed, 4,096 ports, batch 4,500 | 105.18 | 40.72 | -61.28% | -62.90% to -58.33% |
| TCP open, 512 ports, batch 4,500 | 47.70 | 6.26 | -86.87% | -88.76% to -85.17% |
| TCP IPv6 mixed, 4,096 ports, batch 4,500 | 106.26 | 40.98 | -61.43% | -63.84% to -60.18% |
| UDP echo, 256 ports, batch 256 | 26.10 | 3.55 | -86.40% | -89.28% to -83.06% |
| UDP IPv6 echo, 256 ports, batch 256 | 27.63 | 3.80 | -86.25% | -88.36% to -84.90% |
| TCP filtered, 20 ms, two tries, batch 64 | 87.64 | 90.20 | +2.92% | -0.36% to +6.30% |
| TCP filtered, 1,500 ms, batch 4,500 | 1519.99 | 1500.68 | -1.27% | -1.40% to -1.07% |
| UDP silent, 20 ms, two tries, batch 64 | 91.01 | 82.37 | -9.49% | -13.15% to -6.01% |
| UDP silent, 1,500 ms, batch 4,500 | 1518.53 | 1501.66 | -1.11% | -1.35% to -1.01% |
| TCP open, 16 ports, 5 ms interval | 76.53 | 76.20 | -0.43% | -0.59% to -0.28% |
| TCP filtered, 1 ms, batch 1 | 130.93 | 131.10 | +0.13% | -0.00% to +0.22% |
| TCP filtered, 5 ms, batch 1 | 644.51 | 644.95 | +0.07% | -0.03% to +0.10% |
| UDP silent, 1 ms, batch 1 | 133.38 | 133.44 | +0.04% | -0.17% to +0.10% |
| UDP silent, 5 ms, batch 1 | 647.45 | 647.54 | +0.01% | -0.04% to +0.06% |
| TCP open, 16 ports, 1 ms interval | 16.38 | 15.97 | -2.52% | -3.62% to -1.56% |
| TCP open, 16 ports, 20 ms interval | 302.22 | 301.94 | -0.09% | -0.13% to -0.04% |
Bulk TCP scans took 25.7-86.9% less scan time, and UDP echo scans took
86.2-86.4% less. All three interval workloads improved; the former 5 ms
regression is gone. Serial 1 ms and 5 ms timeout medians took 0.01-0.13% more
time. Default 1,500 ms timeout scans took about 1.1-1.3% less time.
These measurements support substantial bulk-scan gains, but **do not establish
literally zero regressions**. The noisy filtered TCP case and the remaining
synthetic overhead are detailed below.
### Follow-up on the noisy filtered TCP case
The 20 ms filtered TCP workload had a 2.92% higher median in the main run, with
substantial variation. A separate repeat used the same binaries, setup and
alternating order, with two warmups and **40 measured runs per binary**:
| Measurement | async-std | Tokio | Change |
| --- | ---: | ---: | ---: |
| Scan median | 90.81 ms | 89.67 ms | -1.25% |
| Entire CLI median | 199.80 ms | 199.90 ms | +0.05% |
The paired scan-ratio bootstrap 95% interval was **-4.16% to +1.73%**. The
repeat does not establish a slowdown, but it cannot rule out a small change
in this noisy case. Its 84 runs all returned the expected results. The original
result remains in the table above; the JSON includes both runs.
### Entire CLI process
Address parsing and startup contribute substantial time on this machine, whose
hosts file is approximately 2.7 MB. Total process changes are consequently
smaller than scan-phase changes. These figures include startup, address parsing,
output capture and process exit:
| Workload | async-std wall (ms) | Tokio wall (ms) | Change |
| --- | ---: | ---: | ---: |
| TCP closed, 65,535 ports, batch 256 | 678.41 | 490.92 | -27.64% |
| TCP closed, 65,535 ports, batch 4,500 | 886.49 | 686.16 | -22.60% |
| TCP mixed, 4,096 ports, batch 256 | 187.83 | 141.97 | -24.42% |
| TCP mixed, 4,096 ports, batch 4,500 | 223.57 | 156.73 | -29.90% |
| TCP open, 512 ports, batch 4,500 | 160.65 | 124.34 | -22.61% |
| TCP IPv6 mixed, 4,096 ports, batch 4,500 | 225.67 | 154.43 | -31.57% |
| UDP echo, 256 ports, batch 256 | 144.53 | 117.11 | -18.97% |
| UDP IPv6 echo, 256 ports, batch 256 | 145.42 | 119.61 | -17.75% |
| TCP filtered, 20 ms, two tries, batch 64 | 203.20 | 203.25 | +0.02% |
| TCP filtered, 1,500 ms, batch 4,500 | 1625.54 | 1607.90 | -1.08% |
| UDP silent, 20 ms, two tries, batch 64 | 198.82 | 193.47 | -2.69% |
| UDP silent, 1,500 ms, batch 4,500 | 1626.34 | 1608.17 | -1.12% |
| TCP open, 16 ports, 5 ms interval | 184.30 | 182.87 | -0.78% |
| TCP filtered, 1 ms, batch 1 | 239.22 | 239.95 | +0.31% |
| TCP filtered, 5 ms, batch 1 | 753.12 | 753.69 | +0.08% |
| UDP silent, 1 ms, batch 1 | 241.22 | 241.50 | +0.12% |
| UDP silent, 5 ms, batch 1 | 754.51 | 756.26 | +0.23% |
| TCP open, 16 ports, 1 ms interval | 127.33 | 123.30 | -3.17% |
| TCP open, 16 ports, 20 ms interval | 412.45 | 411.54 | -0.22% |
### Correctness
All 532 runs (456 measured and 76 warmups) returned exactly the expected open
ports, with no duplicates or missing replies. Both binaries also passed the
separate closed-port and exclusion check. Responder counters matched the expected
58,702 accepted TCP connections, 14,336 echoed UDP datagrams and 17,920 silent UDP
datagrams, including retries. The isolated firewall counted 21,504 dropped TCP
packets during the filtered-port workloads.
## Synthetic scheduling comparison
These use simulated operations without sockets, DNS, or scripts. Each iteration
completes 4,096 operations through a 256-operation window. One workload replies
immediately; the other expires every operation after 10 ms. Both builds use the
same loop; the baseline uses `async_std::io::timeout` and its original
`futures::executor::block_on`, while the migration uses the production timeout
helper and Tokio. Runtime construction is outside the measurement.
Each Criterion run has twenty samples, a one-second warmup and a five-second
measurement target. The ready case was repeated on CPU 0 in A/B/B/A order to
reduce core-migration and run-order effects. Its table value is the median of
the forty per-sample batch times for each binary. The expiration case used one
run per binary on CPUs 0-3. No builds or socket benchmarks ran concurrently.
| Simulated workload | async-std median | Tokio median | Change |
| --- | ---: | ---: | ---: |
| Immediately ready operations | 0.29120 ms | 0.30204 ms | +3.72% |
| Every operation expires after 10 ms | 160.88186 ms | 160.96872 ms | +0.05% |
The ready case still costs about **11 microseconds more per 4,096 operations**.
Its two individual run medians were 0.28344/0.29476 ms for async-std and
0.30039/0.30309 ms for Tokio. This remaining overhead is included rather than
claiming that every workload improved. The original 11% synthetic expiration
regression has been reduced to about 0.05% in this comparison.
Reproduce the migration benchmark with:
```sh
cargo bench --locked --bench benchmark_helpers -- 'runtime scheduling'
```
For the baseline, apply [the benchmark adapter](tokio-benchmark-baseline.patch)
with `git apply --unidiff-zero` to a checkout of
`cfb864590161d1c8a003811dcf4846ceb8c8284f`, then run the same benchmark command.
The JSON includes per-sample times and benchmark binary hashes.
## Scope
These results apply to this machine and its loopback stack. They do not establish
throughput across a LAN or WAN, under packet loss, or on macOS or Windows. The
single Python responder can affect reply timing, and the host was an ordinary
interactive system. Both binaries used the same responder and CPU restrictions,
and every expected reply was checked. A finite benchmark cannot guarantee zero
slowdown for every workload or distinguish every sub-percent difference from
system noise.
+104
View File
@@ -0,0 +1,104 @@
# Tokio runtime migration
This implements the runtime migration discussed in [#856](https://github.com/bee-san/RustScan/pull/856)
and [#944](https://github.com/bee-san/RustScan/issues/944), based on master at
`cfb864590161d1c8a003811dcf4846ceb8c8284f`.
## CLI behavior
TCP connections and UDP sockets now use Tokio. The CLI creates a runtime on the calling thread for the
scan. `FuturesUnordered` still polls at most `batch_size` socket attempts at once;
concurrent sockets do not require a thread per socket or a task per socket.
Address parsing and Hickory's synchronous resolver run before entering the scan
runtime. Script execution runs after the runtime has been dropped. Port ordering,
exclusions, retries, UDP payloads, closed-port reporting and CLI options retain
their existing implementations.
Port intervals and short timeouts retain `async_io::Timer`, the timer underneath
async-std, independently of the Tokio socket runtime. Longer timeouts first use
Tokio's timer until 32 ms before the deadline, then use `async_io::Timer` for the
remaining wait. This avoids registering a second timer for ordinary replies
while leaving room for coarse timer rounding. Both phases use the original
deadline, including time spent setting up the socket.
An expired timer maps to `std::io::ErrorKind::TimedOut`. Socket errors retain
their original kind, so TCP refusal reporting and UDP timeout retries continue
to distinguish replies from failures. Successful TCP streams are converted to
standard streams, shut down in both directions, and dropped before another
attempt is polled.
Using Tokio's timers for short waits added approximately one millisecond per
interval in the first benchmark. Retaining the existing timer avoids that extra
rounding while keeping the async runtime responsive.
Dropping a scan cancels its timers; runtime shutdown never has to wait for a
sleeping blocking task. Immediately ready I/O operations avoid timer allocation,
and the time spent polling a pending operation is charged to its original timeout.
## Library callers
`Scanner::new`, `run`, `run_with_status`, `PortStrategy`, and the address helpers
keep their signatures. However, awaiting a scan now requires a Tokio runtime
with both I/O and time enabled. Calling it from `futures::executor::block_on` or
`async_std::task::block_on` alone is no longer sufficient.
Treat this new runtime requirement as a **breaking library change for the next
major release**. This PR does not publish a release or change the package version.
Add Tokio to the embedding application's dependencies:
```toml
tokio = { version = "1.53.1", features = ["rt", "net", "time"] }
```
For a synchronous application, resolve inputs first, then create the runtime:
```rust,ignore
let ips = rustscan::address::parse_addresses(&opts);
let scanner = Scanner::new(/* existing arguments, including &ips */);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let sockets = runtime.block_on(scanner.run());
```
The [crate example](../src/lib.rs) provides a complete, compile-checked example.
Applications already running on Tokio can simply use `scanner.run().await` or
`scanner.run_with_status().await`; do not nest `Runtime::block_on` calls.
Keep the synchronous address helpers outside async runtime execution. If an
existing Tokio application needs to call `parse_addresses`, move owned options
into `tokio::task::spawn_blocking(move || parse_addresses(&opts)).await` and handle
the join result before constructing the scanner. Hickory's synchronous resolver
owns its own runtime and performs blocking lookups.
## Dependencies and validation
The direct Tokio dependency enables only `rt`, `net`, and `time`; `test-util` is
enabled for deterministic tests with a paused clock. Tokio was already present
through Hickory. The existing `futures` dependency continues to provide
`FuturesUnordered`, `StreamExt` and future polling. `async-io` becomes a direct
dependency solely to retain the existing timer implementation and its short-wait
performance; socket I/O uses Tokio. `async-std` and its executor are absent from
the resolved dependency tree.
Regression tests cover successful operations (including zero-byte responses),
preservation of I/O errors, timeout cancellation, immediate results at a zero
deadline, wakeups during the first poll, scan cancellation, and interval timing with
excluded or empty port sets. They use simulated operations and scanners with no
target addresses, following the repository's rule against network tests.
Run the repository checks with `just test`. Run the isolated scheduling
microbenchmarks with:
```sh
cargo bench --locked --bench benchmark_helpers -- 'runtime scheduling'
```
Each benchmark completes 4,096 simulated operations through a 256-operation
window. One case completes immediately; the other expires every operation after
10 ms. Runtime construction is outside the measurement. Criterion uses 20
samples, a one-second warmup and a five-second measurement target. These measure
scheduling and timer costs. The [real TCP/UDP scan comparison](tokio-benchmark.md)
records a separate measurement in an isolated loopback environment, including
full CLI timings and short port intervals. LAN/WAN performance
and network performance on other operating systems remain unmeasured.
+11 -90
View File
@@ -1,5 +1,4 @@
//! 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};
@@ -31,32 +30,13 @@ use crate::warning;
/// ```
///
/// Finally, any duplicates are removed to avoid excessive scans.
///
/// # Blocking
///
/// This function blocks: it reads files and resolves host names, and
/// hickory's synchronous [`Resolver`] runs a Tokio runtime of its own, which
/// Tokio refuses to drop inside an asynchronous context. Call it before you
/// 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();
// 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;
let backup_resolver = get_resolver(&input.resolver);
for address in &input.addresses {
let parsed_ips = parse_address_with_resolver(address, &resolver);
let parsed_ips = parse_address(address, &backup_resolver);
if !parsed_ips.is_empty() {
ips.extend(parsed_ips);
} else {
@@ -78,7 +58,7 @@ fn parse_addresses_with_resolver(
continue;
}
if let Ok(x) = read_ips_from_file(file_path, &resolver) {
if let Ok(x) = read_ips_from_file(file_path, &backup_resolver) {
ips.extend(x);
} else {
warning!(
@@ -89,7 +69,7 @@ fn parse_addresses_with_resolver(
}
}
let excluded_cidrs = parse_excluded_networks_with_resolver(&input.exclude_addresses, &resolver);
let excluded_cidrs = parse_excluded_networks(&input.exclude_addresses, &backup_resolver);
// Remove duplicated/excluded IPs.
let mut seen = BTreeSet::new();
@@ -112,13 +92,6 @@ fn parse_addresses_with_resolver(
/// 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]
@@ -137,17 +110,14 @@ fn parse_address_with_resolver<'a>(
}
/// Uses DNS to get the IPS associated with host
fn resolve_ips_from_host<'a>(
source: &str,
backup_resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpAddr> {
fn resolve_ips_from_host(source: &str, backup_resolver: &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());
}
@@ -170,13 +140,6 @@ fn resolve_ips_from_host<'a>(
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()
@@ -186,10 +149,7 @@ fn parse_excluded_networks_with_resolver<'a>(
}
/// Parses a single address into an IpCidr, handling CIDR notation, IP addresses, and hostnames.
fn parse_single_excluded_address<'a>(
addr: &str,
resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpCidr> {
fn parse_single_excluded_address(addr: &str, resolver: &Resolver) -> Vec<IpCidr> {
if let Ok(cidr) = IpCidr::from_str(addr) {
return vec![cidr];
}
@@ -293,9 +253,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<'a>(
fn read_ips_from_file(
ips: &std::path::Path,
backup_resolver: &impl Fn() -> &'a Resolver,
backup_resolver: &Resolver,
) -> Result<Vec<IpAddr>, std::io::Error> {
let file = File::open(ips)?;
let reader = BufReader::new(file);
@@ -304,7 +264,7 @@ fn read_ips_from_file<'a>(
for address_line in reader.lines() {
if let Ok(address) = address_line {
ips.extend(parse_address_with_resolver(&address, backup_resolver));
ips.extend(parse_address(&address, backup_resolver));
} else {
debug!("Line in file is not valid");
}
@@ -315,48 +275,9 @@ fn read_ips_from_file<'a>(
#[cfg(test)]
mod tests {
use super::{parse_addresses, parse_addresses_with_resolver, Opts};
use super::{parse_addresses, 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 {
+6 -181
View File
@@ -1,9 +1,6 @@
//! Provides a means to read, parse and hold configuration options for scans.
use clap::parser::ValueSource;
use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum};
use clap::{Parser, ValueEnum};
use serde_derive::Deserialize;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
@@ -25,11 +22,8 @@ pub enum ScanOrder {
/// - custom will read the ScriptConfig file and the available scripts in the predefined folders
#[derive(Deserialize, Debug, ValueEnum, Clone, PartialEq, Eq, Copy)]
pub enum ScriptsRequired {
#[serde(alias = "none")]
None,
#[serde(alias = "default")]
Default,
#[serde(alias = "custom")]
Custom,
}
@@ -237,48 +231,18 @@ pub struct Opts {
/// scanning the next one, for slow, low-noise scans. 0 disables the delay.
#[arg(long, default_value = "0", value_name = "MS")]
pub interval: u64,
/// Ids of the options given explicitly on the command line. The
/// configuration file never overrides these (see [`Opts::merge`]).
#[arg(skip)]
pub cli_args: HashSet<String>,
}
#[cfg(not(tarpaulin_include))]
impl Opts {
pub fn read() -> Self {
Self::try_read_from(std::env::args_os()).unwrap_or_else(|e| e.exit())
}
/// Parses `args` like [`Opts::read`] does, returning an error instead of
/// exiting.
fn try_read_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let mut command = Self::command();
let mut matches = command.try_get_matches_from_mut(args)?;
// clap fills in default values, so the parsed struct alone cannot
// tell `-b 4500` apart from no `-b` at all. Record what the user
// actually typed so that `merge` lets it win over the config file.
let cli_args = command
.get_arguments()
.map(|arg| arg.get_id().as_str())
.filter(|id| matches.value_source(id) == Some(ValueSource::CommandLine))
.map(str::to_owned)
.collect();
let mut opts =
Self::from_arg_matches_mut(&mut matches).map_err(|e| e.format(&mut command))?;
opts.cli_args = cli_args;
let mut opts = Opts::parse();
if opts.ports.is_none() && opts.range.is_none() {
opts.range = Some(PortRanges(vec![(LOWEST_PORT_NUMBER, TOP_PORT_NUMBER)]));
}
Ok(opts)
opts
}
/// Validates options whose availability or semantics depend on the
@@ -303,8 +267,7 @@ impl Opts {
}
/// Reads the command line arguments into an Opts struct and merge
/// values found within the user configuration file. Options given on the
/// command line take precedence over the configuration file.
/// values found within the user configuration file.
pub fn merge(&mut self, config: &Config) {
if !self.no_config {
self.merge_required(config);
@@ -317,9 +280,7 @@ impl Opts {
($($field: ident),+) => {
$(
if let Some(e) = &config.$field {
if !self.cli_args.contains(stringify!($field)) {
self.$field = e.clone();
}
self.$field = e.clone();
}
)+
}
@@ -335,7 +296,7 @@ impl Opts {
macro_rules! merge_optional {
($($field: ident),+) => {
$(
if config.$field.is_some() && !self.cli_args.contains(stringify!($field)) {
if config.$field.is_some() {
self.$field = config.$field.clone();
}
)+
@@ -376,7 +337,6 @@ impl Default for Opts {
udp: false,
closed: false,
interval: 0,
cli_args: HashSet::new(),
}
}
}
@@ -499,7 +459,6 @@ pub fn old_default_config_path() -> PathBuf {
mod tests {
use clap::{CommandFactory, Parser};
use parameterized::parameterized;
use std::collections::HashSet;
use super::{Config, Opts, PortRanges, ScanOrder, ScriptsRequired};
@@ -529,55 +488,6 @@ mod tests {
}
}
fn assert_config_script_mode(value: &str, expected: ScriptsRequired) {
let config: Config = toml::from_str(&format!("scripts = \"{value}\""))
.expect("documented script mode must deserialize");
assert_eq!(config.scripts, Some(expected));
}
#[test]
fn config_scripts_lowercase_none() {
assert_config_script_mode("none", ScriptsRequired::None);
}
#[test]
fn config_scripts_lowercase_default() {
assert_config_script_mode("default", ScriptsRequired::Default);
}
#[test]
fn config_scripts_lowercase_custom() {
assert_config_script_mode("custom", ScriptsRequired::Custom);
}
#[test]
fn config_scripts_legacy_and_unknown_controls() {
assert_config_script_mode("None", ScriptsRequired::None);
assert_config_script_mode("Default", ScriptsRequired::Default);
assert_config_script_mode("Custom", ScriptsRequired::Custom);
for invalid in ["disabled", "unknown", ""] {
assert!(toml::from_str::<Config>(&format!("scripts = \"{invalid}\"")).is_err());
}
}
#[test]
fn config_read_accepts_documented_lowercase_script_modes() {
for (value, expected) in [
("none", ScriptsRequired::None),
("default", ScriptsRequired::Default),
("custom", ScriptsRequired::Custom),
] {
let fixture = std::env::temp_dir().join(format!(
"rustscan-config-scripts-{}-{value}.toml",
std::process::id()
));
std::fs::write(&fixture, format!("scripts = \"{value}\"\n")).unwrap();
let config = Config::read(Some(fixture.clone()));
std::fs::remove_file(fixture).unwrap();
assert_eq!(config.scripts, Some(expected));
}
}
#[test]
fn verify_cli() {
Opts::command().debug_assert();
@@ -725,91 +635,6 @@ mod tests {
assert_eq!(opts.resolver, config.resolver);
}
#[test]
fn cli_arguments_override_config() {
// Regression test for #722: the config file used to silently replace
// options given on the command line, e.g. `-r` with its `range`.
let mut opts = Opts::try_read_from([
"rustscan",
"-a",
"192.168.0.1",
"-r",
"8600-8650",
"-b",
"100",
// Passing clap's default value explicitly must still win.
"-t",
"1500",
"--tries",
"3",
"--scan-order",
"serial",
"--scripts",
"none",
"-g",
"--",
"-sV",
])
.unwrap();
let config: Config = toml::from_str(
r#"
addresses = ["127.0.0.1"]
range = { start = 1, end = 100 }
batch_size = 25000
timeout = 1000
tries = 1
scan_order = "Random"
scripts = "Custom"
greppable = false
command = ["-A"]
"#,
)
.unwrap();
opts.merge(&config);
assert_eq!(opts.addresses, vec!["192.168.0.1".to_owned()]);
assert_eq!(opts.range, Some(PortRanges(vec![(8_600, 8_650)])));
assert_eq!(opts.batch_size, 100);
assert_eq!(opts.timeout, 1_500);
assert_eq!(opts.tries, 3);
assert_eq!(opts.scan_order, ScanOrder::Serial);
assert_eq!(opts.scripts, ScriptsRequired::None);
assert!(opts.greppable);
assert_eq!(opts.command, vec!["-sV".to_owned()]);
}
#[test]
fn config_fills_arguments_not_given_on_cli() {
let mut opts = Opts::try_read_from(["rustscan", "-a", "192.168.0.1"]).unwrap();
let config: Config = toml::from_str(
r#"
addresses = ["127.0.0.1"]
range = { start = 1, end = 100 }
batch_size = 25000
timeout = 1000
tries = 3
scan_order = "Random"
greppable = true
command = ["-A"]
"#,
)
.unwrap();
opts.merge(&config);
// Only `-a` was typed; clap defaults such as `-b 4500` must not count.
assert_eq!(opts.cli_args, HashSet::from(["addresses".to_owned()]));
assert_eq!(opts.addresses, vec!["192.168.0.1".to_owned()]);
assert_eq!(opts.range, Some(PortRanges(vec![(1, 100)])));
assert_eq!(opts.batch_size, 25_000);
assert_eq!(opts.timeout, 1_000);
assert_eq!(opts.tries, 3);
assert_eq!(opts.scan_order, ScanOrder::Random);
assert!(opts.greppable);
assert_eq!(opts.command, vec!["-A".to_owned()]);
}
#[test]
fn parses_comma_separated_ranges() {
let opts = Opts::parse_from([
+4 -8
View File
@@ -5,13 +5,11 @@
//!
//! The core scanning behaviour is managed by
//! [`Scanner`](crate::scanner::Scanner) which in turn requires a
//! [`PortStrategy`](crate::port_strategy::PortStrategy).
//! [`PortStrategy`](crate::port_strategy::PortStrategy):
//!
//! The scanner is asynchronous and runs on [Tokio](https://docs.rs/tokio):
//! await [`Scanner::run`](crate::scanner::Scanner::run) from a Tokio runtime
//! with the I/O and time drivers enabled. It never spawns tasks, so a
//! current-thread runtime is enough (see [`Scanner`](crate::scanner::Scanner)
//! for details).
//! Scans require a Tokio runtime with I/O and time enabled. Synchronous address
//! parsing should run before entering the runtime; see the
//! [runtime migration guide](https://github.com/bee-san/RustScan/blob/master/docs/tokio-migration.md).
//!
//! This example is compiled but never executed by the test suite.
//!
@@ -38,8 +36,6 @@
//! false, // is this a UDP scan?
//! );
//!
//! // Inside an async context (e.g. `#[tokio::main]`) this is simply
//! // `scanner.run().await`.
//! let runtime = tokio::runtime::Builder::new_current_thread()
//! .enable_all()
//! .build()
+23 -98
View File
@@ -107,13 +107,14 @@ fn main() {
debug!("Scanner finished building: {scanner:?}");
let mut portscan_bench = NamedTimer::start("Portscan");
let scan_result = match run_scan(&scanner) {
Ok(scan_result) => scan_result,
Err(e) => {
eprintln!("error: could not start the scan: {e}");
std::process::exit(1);
}
};
// Keep synchronous DNS resolution and script execution outside the runtime.
// FuturesUnordered drives the bounded scan concurrently on this thread.
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create the Tokio runtime");
let scan_result = runtime.block_on(scanner.run_with_status());
drop(runtime);
portscan_bench.end();
benchmarks.push(portscan_bench);
@@ -128,7 +129,20 @@ fn main() {
map.entry(socket.ip()).or_default().push(socket.port());
}
if let Some(x) = no_open_ports_warning(&ips, &ports_per_ip, &closed_ports_per_ip, batch_size) {
for ip in ips {
if ports_per_ip.contains_key(&ip) {
continue;
}
// If we got here it means the IP was not found within the HashMap, this
// means the scan couldn't find any open ports for it.
let x = format!("Looks like I didn't find any open ports for {:?}. This is usually caused by a high batch size.
\n*I used {} batch size, consider lowering it with {} or a comfortable number for your system.
\n Alternatively, increase the timeout if your ping is high. Rustscan -t 2000 for 2000 milliseconds (2s) timeout.\n",
ip,
batch_size,
"'rustscan -b <batch_size> -a <ip address>'");
warning!(x, opts.greppable, opts.accessible);
}
@@ -211,35 +225,6 @@ fn main() {
info!("{}", benchmarks.summary());
}
/// Builds the hint printed when the scan found no open ports.
///
/// A host without open ports is normal when another host answered (a subnet
/// sweep for one port): replies got through at this batch size and timeout,
/// so batch size and timeout are unlikely to be the cause. The hint is printed
/// once, and only when no host answered with an open or, under `--closed`, a
/// closed port.
fn no_open_ports_warning(
ips: &[IpAddr],
ports_per_ip: &HashMap<IpAddr, Vec<u16>>,
closed_ports_per_ip: &HashMap<IpAddr, Vec<u16>>,
batch_size: usize,
) -> Option<String> {
if !ports_per_ip.is_empty() || !closed_ports_per_ip.is_empty() {
return None;
}
let target = match ips {
[ip] => format!("for {ip:?}"),
_ => format!("on any of the {} addresses", ips.len()),
};
Some(format!("Looks like I didn't find any open ports {}. This is usually caused by a high batch size.
\n*I used {} batch size, consider lowering it with {} or a comfortable number for your system.
\n Alternatively, increase the timeout if your ping is high. Rustscan -t 2000 for 2000 milliseconds (2s) timeout.\n",
target,
batch_size,
"'rustscan -b <batch_size> -a <ip address>'"))
}
/// Determines the actual batch size used by the scanner.
///
/// Unix systems may reduce the requested batch size according to the process
@@ -257,25 +242,6 @@ fn effective_batch_size(opts: &Opts) -> usize {
}
}
/// Runs the scan to completion on a current-thread Tokio runtime.
///
/// The scanner drives every socket from a single future and never spawns
/// tasks, so one thread is all it can use: a current-thread runtime runs the
/// scan, the I/O driver and the timers on the calling thread, without the
/// worker threads and cross-thread wake-ups of a multi-threaded runtime.
///
/// The runtime only exists for the duration of the scan. Address resolution
/// (blocking DNS lookups and file reads) happens before it is created, so it
/// can never stall the scan, and scripts run after it is gone.
fn run_scan(scanner: &Scanner) -> std::io::Result<Vec<PortStatus>> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()?;
Ok(runtime.block_on(scanner.run_with_status()))
}
/// Prints the opening title of RustScan
#[allow(clippy::items_after_statements, clippy::needless_raw_string_hashes)]
fn print_opening(opts: &Opts) {
@@ -389,48 +355,7 @@ mod tests {
use super::effective_batch_size;
#[cfg(unix)]
use super::{adjust_ulimit_size, infer_batch_size};
use super::{no_open_ports_warning, print_opening, Opts};
use std::collections::HashMap;
use std::net::IpAddr;
fn hosts(n: u8) -> Vec<IpAddr> {
(1..=n).map(|i| IpAddr::from([192, 0, 2, i])).collect()
}
#[test]
fn no_open_ports_silent_when_another_host_answered() {
// Sweeping a subnet for one port: one host has it open, the rest do not.
let ips = hosts(254);
let ports_per_ip = HashMap::from([(ips[0], vec![22])]);
assert!(no_open_ports_warning(&ips, &ports_per_ip, &HashMap::new(), 4500).is_none());
}
#[test]
fn no_open_ports_silent_when_a_host_answered_closed() {
// With --closed, a refused connection also proves replies get through.
let ips = hosts(254);
let closed_ports_per_ip = HashMap::from([(ips[0], vec![443])]);
assert!(no_open_ports_warning(&ips, &HashMap::new(), &closed_ports_per_ip, 4500).is_none());
}
#[test]
fn no_open_ports_warns_once_when_nothing_found() {
let warning = no_open_ports_warning(&hosts(254), &HashMap::new(), &HashMap::new(), 4500);
let warning = warning.expect("one hint when nothing was found");
assert!(warning.contains("on any of the 254 addresses."));
assert!(warning.contains("4500 batch size"));
}
#[test]
fn no_open_ports_names_the_single_host() {
let warning = no_open_ports_warning(&hosts(1), &HashMap::new(), &HashMap::new(), 4500);
let warning = warning.expect("one hint for the single host");
assert!(warning.contains("for 192.0.2.1."));
}
use super::{print_opening, Opts};
#[test]
#[cfg(unix)]
-163
View File
@@ -1,163 +0,0 @@
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<String>,
}
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<String> {
&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)));
}
}
+291 -327
View File
@@ -5,58 +5,23 @@ use crate::tui::println_safe;
use log::debug;
mod socket_iterator;
mod timeout;
use socket_iterator::SocketIterator;
mod errors;
use errors::{diagnostic_error, is_descriptor_exhaustion, ScanErrors};
use timeout::io_timeout;
use colored::Colorize;
use futures::stream::{FuturesUnordered, StreamExt};
use futures::{stream::FuturesUnordered, StreamExt};
use std::collections::BTreeMap;
use std::future::poll_fn;
use std::task::Poll;
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
future::Future,
io,
net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr},
net::{IpAddr, Shutdown, SocketAddr},
num::NonZeroU8,
sync::Arc,
time::Duration,
};
use tokio::io::Interest;
use tokio::net::{TcpStream, UdpSocket};
use tokio::time::{sleep, timeout};
/// How many sockets the scan starts or finishes (counted together) between
/// two polls of the runtime's I/O driver; see `Scanner::scan_sockets`.
///
/// Every round ends with a non-blocking poll of the OS selector, so this
/// keeps the overhead far below 1% while bounding the time between two
/// polls to about a millisecond on Linux.
const WORK_PER_TURN: usize = 128;
/// Keeps the generated order, including duplicate ports, while removing exclusions.
// Keep the bitmap out of the async polling frame, which runs on every wake.
#[inline(never)]
fn filter_excluded_ports(mut ports: Vec<u16>, excluded: &[u16]) -> Vec<u16> {
if excluded.is_empty() {
return ports;
}
// Bitmap setup costs more than membership checks on short port lists.
if ports.len() < 64 {
ports.retain(|port| !excluded.contains(port));
return ports;
}
// The complete u16 port space fits in an 8 KiB bitmap.
let mut excluded_bits = [0_u64; 1024];
for &port in excluded {
excluded_bits[usize::from(port) / 64] |= 1 << (port % 64);
}
ports.retain(|&port| excluded_bits[usize::from(port) / 64] & (1 << (port % 64)) == 0);
ports
}
/// UDP payload lookup: port -> payload bytes
///
@@ -86,19 +51,6 @@ pub fn build_udp_payload_lookup(udp_map: &'static BTreeMap<Vec<u16>, Vec<u8>>) -
/// batch_size is how many ports at a time should be scanned
/// Timeout is the time RustScan should wait before declaring a port closed. As datatype Duration.
/// greppable is whether or not RustScan should print things, or wait until the end to print only the ip and open ports.
///
/// # Runtime
///
/// The futures returned by [`Scanner::run`] and [`Scanner::run_with_status`]
/// use Tokio sockets and timers, so they must be polled from within a
/// [Tokio](https://docs.rs/tokio) runtime that has both the I/O and the time
/// drivers enabled (for example `#[tokio::main]`, or a runtime built with
/// `enable_all()`). Polling them from another executor panics.
///
/// The scan never spawns tasks: every socket is driven from the one future
/// you await, so a current-thread runtime is enough (it is what the
/// `rustscan` binary uses), and the future is `Send` if you prefer to spawn
/// it on a multi-threaded runtime.
#[cfg(not(tarpaulin_include))]
#[derive(Debug)]
pub struct Scanner {
@@ -191,6 +143,8 @@ impl Scanner {
/// Runs scan_range with chunk sizes
/// If you want to run RustScan normally, this is the entry point used
/// Returns all open sockets.
///
/// Must be awaited inside a Tokio runtime with I/O and time enabled.
pub async fn run(&self) -> Vec<SocketAddr> {
self.run_with_status()
.await
@@ -205,21 +159,33 @@ impl Scanner {
/// Like [`Self::run`], but returns the status of every socket that gave a
/// definitive answer: open sockets, plus closed sockets when
/// [`Self::with_closed_ports`] is enabled.
///
/// Must be awaited inside a Tokio runtime with I/O and time enabled.
pub async fn run_with_status(&self) -> Vec<PortStatus> {
// Every in-flight socket is polled from this single future through a
// `FuturesUnordered`. Under Tokio's cooperative budget the future
// would be forced to yield after ~128 sockets made progress, and the
// `FuturesUnordered` would then re-poll every other ready socket just
// to have it return `Pending` again. Opt out; `scan_sockets` yields to
// the runtime by itself, in rounds of `WORK_PER_TURN` sockets.
tokio::task::unconstrained(self.scan()).await
// Tokio's millisecond rounding adds about 1 ms to each short interval.
// This cancellable delay keeps the requested spacing without blocking
// the async runtime or accumulating that rounding on every port.
self.run_with_delay(|interval| async move {
async_io::Timer::after(interval).await;
})
.await
}
async fn scan(&self) -> Vec<PortStatus> {
let ports = filter_excluded_ports(self.port_strategy.order(), &self.exclude_ports);
/// Supplying the delay lets tests verify spacing with a virtual clock.
async fn run_with_delay<D, F>(&self, delay: D) -> Vec<PortStatus>
where
D: Fn(Duration) -> F,
F: Future<Output = ()>,
{
let ports: Vec<u16> = self
.port_strategy
.order()
.iter()
.filter(|&port| !self.exclude_ports.contains(port))
.copied()
.collect();
let mut found_sockets: Vec<PortStatus> = Vec::new();
let mut errors =
ScanErrors::new(log::log_enabled!(log::Level::Debug), self.ips.len() * 1000);
let mut errors: HashSet<String> = HashSet::new();
// Build UDP payload lookup once (only if we are scanning UDP).
// This avoids cloning a big map into every spawned future and turns
@@ -246,7 +212,7 @@ impl Scanner {
// before moving on to the next port.
for (i, port) in ports.iter().enumerate() {
if i > 0 {
sleep(self.interval).await;
delay(self.interval).await;
}
let sockets = SocketIterator::new(&self.ips, std::slice::from_ref(port));
self.scan_sockets(sockets, &udp_payloads, &mut found_sockets, &mut errors)
@@ -254,88 +220,52 @@ impl Scanner {
}
}
debug!("Typical socket connection errors {:?}", errors.messages());
debug!("Typical socket connection errors {errors:?}");
debug!("Sockets found: {:?}", found_sockets);
found_sockets
}
/// Scans every socket yielded by `sockets`, keeping at most `batch_size`
/// connection attempts in flight.
///
/// Starts sockets and handles finished ones in rounds of at most
/// [`WORK_PER_TURN`] of either, and lets the runtime poll for I/O and fire
/// timers between rounds. One poll of the scan future otherwise lasts
/// until no socket is ready, which can take long: starting a whole large
/// batch at once, or a run of UDP probes that all finish straight away.
/// Sockets that finish in the meantime are only noticed once the poll
/// ends, so their results come late, and if the poll outlasts the timeout
/// their timers fire before their answers are seen.
async fn scan_sockets(
&self,
mut sockets: SocketIterator<'_>,
udp_payloads: &Option<Arc<UdpPayloadLookup>>,
found_sockets: &mut Vec<PortStatus>,
errors: &mut ScanErrors,
errors: &mut HashSet<String>,
) {
let mut ftrs = FuturesUnordered::new();
loop {
let mut work = 0;
while work < WORK_PER_TURN {
let mut started = false;
if ftrs.len() < self.batch_size {
if let Some(socket) = sockets.next() {
ftrs.push(self.scan_socket(socket, udp_payloads.clone()));
started = true;
work += 1;
}
}
// Poll once without waiting; this starts the new socket.
match poll_fn(|cx| Poll::Ready(ftrs.poll_next_unpin(cx))).await {
Poll::Ready(Some(result)) => {
self.record(result, found_sockets, errors);
work += 1;
}
// Nothing in flight and nothing left to start.
Poll::Ready(None) => return,
// Nothing has finished; keep starting sockets while there is room.
Poll::Pending if started => {}
Poll::Pending => break,
}
}
if work >= WORK_PER_TURN {
tokio::task::yield_now().await;
for _ in 0..self.batch_size {
if let Some(socket) = sockets.next() {
ftrs.push(self.scan_socket(socket, udp_payloads.clone()));
} else {
// The batch is full (or complete) and every socket in it is
// waiting on the network.
match ftrs.next().await {
Some(result) => self.record(result, found_sockets, errors),
None => return,
}
break;
}
}
}
/// Records the outcome of one socket.
fn record(
&self,
result: io::Result<PortStatus>,
found_sockets: &mut Vec<PortStatus>,
errors: &mut ScanErrors,
) {
match result {
Ok(status) => found_sockets.push(status),
Err(error) => errors.record(error),
while let Some(result) = ftrs.next().await {
if let Some(socket) = sockets.next() {
ftrs.push(self.scan_socket(socket, udp_payloads.clone()));
}
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);
}
}
}
}
}
/// Given a socket, scan it self.tries times.
/// Turns the address into a SocketAddr
/// Deals with the `<result>` type
/// Panics if the OS reports descriptor exhaustion.
/// Other failures retain their I/O error, with target context for debug diagnostics.
/// 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
/// 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
@@ -359,7 +289,10 @@ impl Scanner {
match self.connect(socket).await {
Ok(tcp_stream) => {
debug!("Connection was successful, shutting down stream {}", socket);
if let Err(e) = shutdown_both(tcp_stream) {
if let Err(e) = tcp_stream
.into_std()
.and_then(|stream| stream.shutdown(Shutdown::Both))
{
debug!("Shutdown stream error {}", e);
}
self.fmt_ports(socket);
@@ -375,14 +308,14 @@ impl Scanner {
return Ok(PortStatus::Closed(socket));
}
assert!(!is_descriptor_exhaustion(&e), "Too many open files. Please reduce batch size. The default is 5000. Try -b 2500.");
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.");
if nr_try == tries {
return Err(diagnostic_error(
e,
socket.ip(),
log::log_enabled!(log::Level::Debug),
));
error_string.push(' ');
error_string.push_str(&socket.ip().to_string());
return Err(io::Error::other(error_string));
}
}
};
@@ -429,22 +362,30 @@ impl Scanner {
/// ```
///
async fn connect(&self, socket: SocketAddr) -> io::Result<TcpStream> {
timeout(self.timeout, TcpStream::connect(socket))
.await
.unwrap_or_else(|_elapsed| Err(timed_out()))
io_timeout(self.timeout, TcpStream::connect(socket)).await
}
/// Binds a non-blocking UDP socket on the unspecified address of the
/// target's address family, so we can send and receive packets.
fn udp_bind(socket: SocketAddr) -> io::Result<std::net::UdpSocket> {
/// Binds to a UDP socket so we can send and receive packets
/// # Example
///
/// ```compile_fail
/// # use std::net::{IpAddr, Ipv6Addr, SocketAddr};
/// let port: u16 = 80;
/// // ip is an IpAddr type
/// let ip = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1));
/// let socket = SocketAddr::new(ip, port);
/// scanner.udp_bind(socket);
/// // returns Result which is either Ok(stream) for port is open, or Err for port is closed.
/// // Timeout occurs after self.timeout seconds
/// ```
///
async fn udp_bind(&self, socket: SocketAddr) -> io::Result<UdpSocket> {
let local_addr = match socket {
SocketAddr::V4(_) => SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)),
SocketAddr::V6(_) => SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0)),
SocketAddr::V4(_) => "0.0.0.0:0".parse::<SocketAddr>().unwrap(),
SocketAddr::V6(_) => "[::]:0".parse::<SocketAddr>().unwrap(),
};
let udp_socket = std::net::UdpSocket::bind(local_addr)?;
udp_socket.set_nonblocking(true)?;
Ok(udp_socket)
UdpSocket::bind(local_addr).await
}
/// Performs a UDP scan on the specified socket with a payload and wait duration
@@ -468,59 +409,32 @@ impl Scanner {
payload: &[u8],
wait: Duration,
) -> io::Result<bool> {
let udp_socket = match Self::udp_bind(socket) {
Ok(udp_socket) => udp_socket,
match self.udp_bind(socket).await {
Ok(udp_socket) => {
let mut buf = [0u8; 1024];
udp_socket.connect(socket).await?;
udp_socket.send(payload).await?;
match io_timeout(wait, udp_socket.recv(&mut buf)).await {
Ok(size) => {
debug!("Received {size} bytes");
self.fmt_ports(socket);
Ok(true)
}
Err(e) => {
if e.kind() == io::ErrorKind::TimedOut {
Ok(false)
} else {
Err(e)
}
}
}
}
Err(e) => {
debug!("Error binding UDP socket: {e:?}");
return Err(e);
Err(e)
}
};
let mut buf = [0u8; 1024];
udp_socket.connect(socket)?;
// Send the probe and try the first receive straight away, as
// async-std did. Tokio's readiness-based I/O would first wait for the
// reactor to report the socket ready, costing every probe extra trips
// through the event loop, while the probe can almost always be sent
// immediately and, on the local host, the answer (often an ICMP "port
// unreachable", seen as a refused connection) is usually already
// there when the send returns. The socket is only registered with
// Tokio when we really have to wait.
let sent = match udp_socket.send(payload) {
Ok(_) => true,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => false,
Err(e) => return Err(e),
};
let early = if sent {
udp_socket.recv(&mut buf)
} else {
Err(io::ErrorKind::WouldBlock.into())
};
let received = match early {
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
let udp_socket = UdpSocket::from_std(udp_socket)?;
if !sent {
udp_socket.send(payload).await?;
}
match timeout(wait, recv_or_error(&udp_socket, &mut buf)).await {
Ok(received) => received,
// Nothing came back in time.
Err(_elapsed) => return Ok(false),
}
}
early => early,
};
match received {
Ok(size) => {
debug!("Received {size} bytes");
self.fmt_ports(socket);
Ok(true)
}
Err(e) if e.kind() == io::ErrorKind::TimedOut => Ok(false),
Err(e) => Err(e),
}
}
@@ -547,69 +461,197 @@ impl Scanner {
}
}
/// Shuts down both halves of a connected stream before it is closed, as the
/// async-std implementation did (`TcpStream::shutdown(Shutdown::Both)`).
///
/// Tokio only offers an asynchronous write-side shutdown, so take the socket
/// back from the reactor and shut it down synchronously instead.
fn shutdown_both(stream: TcpStream) -> io::Result<()> {
stream.into_std()?.shutdown(Shutdown::Both)
}
/// Waits for a datagram on a connected UDP socket, or for the error the
/// system queued for it: an ICMP "port unreachable" is reported as a refused
/// connection, which is how closed UDP ports are told apart from filtered
/// ones.
///
/// `UdpSocket::recv` alone does not do this on Linux: a queued ICMP error
/// only raises `EPOLLERR`, which Tokio does not count as readable, so `recv`
/// would sleep until the timeout. async-io treated `EPOLLERR` as readable.
async fn recv_or_error(socket: &UdpSocket, buf: &mut [u8]) -> io::Result<usize> {
loop {
let ready = socket.ready(Interest::READABLE | Interest::ERROR).await?;
if ready.is_readable() {
match socket.try_recv(buf) {
// A spurious wake-up; wait again (unless the socket is closed
// for reading, which would wake us up forever).
Err(e) if e.kind() == io::ErrorKind::WouldBlock && !ready.is_read_closed() => {}
received => return received,
}
}
if ready.is_error() {
// Take (and clear) the queued error. `WouldBlock` when there is
// none makes Tokio clear the error readiness, so this cannot spin.
let queued = socket.try_io(Interest::ERROR, || {
socket
.take_error()?
.map_or_else(|| Err(io::ErrorKind::WouldBlock.into()), Ok)
});
match queued {
Ok(error) => return Err(error),
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
Err(e) => return Err(e),
}
}
}
}
/// The error reported for a connection attempt that hit the timeout; the same
/// kind and message async-std's `io::timeout` used.
fn timed_out() -> io::Error {
io::Error::new(io::ErrorKind::TimedOut, "future timed out")
}
#[cfg(test)]
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
// table generated by build.rs.
// These tests never open sockets: operations are simulated futures, and
// scanners that run have no target addresses.
fn test_runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.start_paused(true)
.build()
.unwrap()
}
#[test]
fn timeout_preserves_success_including_empty_udp_responses() {
test_runtime().block_on(async {
// A zero-byte UDP datagram is still a successful response.
let result = io_timeout(Duration::from_secs(1), async { Ok(0_usize) }).await;
assert_eq!(result.unwrap(), 0);
});
}
#[test]
fn timeout_preserves_io_errors() {
test_runtime().block_on(async {
for kind in [
io::ErrorKind::ConnectionRefused,
io::ErrorKind::PermissionDenied,
io::ErrorKind::TimedOut,
] {
let result = io_timeout(Duration::from_secs(1), async {
Err::<(), _>(io::Error::new(kind, "socket error"))
})
.await;
let error = result.unwrap_err();
assert_eq!(error.kind(), kind);
assert_eq!(error.to_string(), "socket error");
}
});
}
#[test]
fn ready_operation_wins_at_zero_timeout() {
test_runtime().block_on(async {
assert_eq!(
io_timeout(Duration::ZERO, async { Ok(7) }).await.unwrap(),
7
);
});
}
#[test]
fn timeout_preserves_wakes_from_the_first_poll() {
test_runtime().block_on(async {
let mut polled = false;
let operation = std::future::poll_fn(|context| {
if polled {
std::task::Poll::Ready(Ok(7))
} else {
polled = true;
context.waker().wake_by_ref();
std::task::Poll::Pending
}
});
assert_eq!(
io_timeout(Duration::from_secs(1), operation).await.unwrap(),
7
);
});
}
#[test]
fn expired_timeout_cancels_the_operation() {
use std::cell::Cell;
struct DropGuard<'a>(&'a Cell<bool>);
impl Drop for DropGuard<'_> {
fn drop(&mut self) {
self.0.set(true);
}
}
for duration in [Duration::from_millis(5), Duration::from_millis(80)] {
test_runtime().block_on(async {
let dropped = Cell::new(false);
let started = std::time::Instant::now();
let operation = async {
let _guard = DropGuard(&dropped);
std::future::pending::<io::Result<()>>().await
};
let error = io_timeout(duration, operation).await.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::TimedOut);
assert!(started.elapsed() >= duration);
assert!(dropped.get(), "the timed-out operation must be dropped");
});
}
}
#[test]
fn operation_can_complete_before_the_precise_timeout_phase() {
test_runtime().block_on(async {
let result = io_timeout(Duration::from_secs(1), async {
tokio::time::sleep(Duration::from_millis(10)).await;
Ok(7)
})
.await;
assert_eq!(result.unwrap(), 7);
});
}
#[test]
fn interval_waits_only_between_included_ports() {
test_runtime().block_on(async {
let interval = Duration::from_millis(250);
let mut scanner = test_scanner().with_interval(interval);
scanner.ips.clear();
scanner.port_strategy = PortStrategy::Manual(vec![80, 443, 8080, 8443]);
scanner.exclude_ports = vec![443];
let started = tokio::time::Instant::now();
assert!(scanner.run_with_delay(tokio::time::sleep).await.is_empty());
assert_eq!(started.elapsed(), interval * 2);
});
}
#[test]
fn dropping_a_scan_cancels_a_pending_interval() {
use std::cell::Cell;
use std::task::Poll;
struct PendingDelay<'a>(&'a Cell<bool>);
impl Future for PendingDelay<'_> {
type Output = ();
fn poll(self: std::pin::Pin<&mut Self>, _: &mut std::task::Context<'_>) -> Poll<()> {
Poll::Pending
}
}
impl Drop for PendingDelay<'_> {
fn drop(&mut self) {
self.0.set(true);
}
}
test_runtime().block_on(async {
let dropped = Cell::new(false);
let mut scanner = test_scanner().with_interval(Duration::from_secs(3600));
scanner.ips.clear();
scanner.port_strategy = PortStrategy::Manual(vec![80, 443]);
let mut scan = Box::pin(scanner.run_with_delay(|_| PendingDelay(&dropped)));
assert!(futures::poll!(scan.as_mut()).is_pending());
assert!(!dropped.get());
drop(scan);
assert!(dropped.get());
});
}
#[test]
fn zero_interval_and_empty_port_sets_do_not_wait() {
test_runtime().block_on(async {
let mut scanner = test_scanner();
scanner.ips.clear();
scanner.port_strategy = PortStrategy::Manual(vec![80, 443]);
let started = tokio::time::Instant::now();
assert!(scanner.run().await.is_empty());
assert_eq!(started.elapsed(), Duration::ZERO);
scanner = scanner.with_interval(Duration::from_secs(1));
scanner.exclude_ports = vec![80, 443];
assert!(scanner.run().await.is_empty());
assert_eq!(started.elapsed(), Duration::ZERO);
scanner.port_strategy = PortStrategy::Manual(Vec::new());
assert!(scanner.run().await.is_empty());
assert_eq!(started.elapsed(), Duration::ZERO);
});
}
fn test_scanner() -> Scanner {
let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
@@ -627,35 +669,6 @@ mod tests {
)
}
#[test]
fn port_exclusions_preserve_order_duplicates_and_boundaries() {
let inputs = [
Vec::new(),
vec![65535, 0, 80, 443, 80, 65535, 1],
(0..=65535).collect(),
PortStrategy::pick(&Some(PortRanges(vec![(0, 1023)])), None, ScanOrder::Random).order(),
];
let exclusions = [
Vec::new(),
vec![0],
vec![0, 65535, 0, 443],
(0..1024).collect(),
(0..=65535).collect(),
];
for ports in inputs {
for excluded in &exclusions {
let excluded_set: HashSet<_> = excluded.iter().copied().collect();
let expected: Vec<u16> = ports
.iter()
.filter(|&port| !excluded_set.contains(port))
.copied()
.collect();
assert_eq!(filter_excluded_ports(ports.clone(), excluded), expected);
}
}
}
#[test]
fn library_scanner_is_quiet_by_default() {
let scanner = test_scanner();
@@ -692,55 +705,6 @@ mod tests {
assert_eq!(scanner.interval, Duration::from_millis(250));
}
/// Embedders may spawn the scan on a multi-threaded runtime, which needs
/// `Send` futures. The futures are only created here, never polled, so
/// no socket is opened.
#[test]
fn scan_futures_are_send() {
fn assert_send<T: Send>(_: &T) {}
let scanner = test_scanner();
assert_send(&scanner.run());
assert_send(&scanner.run_with_status());
}
/// Drives a scan on the same kind of runtime the CLI builds. Every port
/// is excluded, so the scan has no socket to open and returns at once.
#[test]
fn scan_without_sockets_completes_on_a_current_thread_runtime() {
let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.unwrap();
for interval in [Duration::ZERO, Duration::from_millis(10)] {
let scanner = Scanner::new(
&addrs,
10,
Duration::from_millis(100),
1,
true,
PortStrategy::Manual(vec![1, 2]),
true,
vec![1, 2],
false,
)
.with_interval(interval);
assert!(runtime.block_on(scanner.run_with_status()).is_empty());
}
}
#[test]
fn timeouts_are_reported_as_timed_out_errors() {
let error = timed_out();
assert_eq!(error.kind(), io::ErrorKind::TimedOut);
assert_eq!(error.to_string(), "future timed out");
}
/// Regression test for https://github.com/bee-san/RustScan/issues/933:
/// the SNMP public-walk probe must be the exact 33-byte BER packet, with
/// the literal `public` community string intact. The old hexdigits-only
+57
View File
@@ -0,0 +1,57 @@
use std::{
future::{poll_fn, Future},
io,
task::Poll,
time::{Duration, Instant},
};
/// Preserve I/O errors and cancel pending operations when their deadline expires.
/// Poll once before allocating a timer so immediate replies pay no timer cost.
pub(super) async fn io_timeout<T>(
duration: Duration,
operation: impl Future<Output = io::Result<T>>,
) -> io::Result<T> {
let started = Instant::now();
futures::pin_mut!(operation);
let mut delay = None;
// Most replies arrive well before a normal scan timeout. Keep their timers
// on Tokio's reactor to avoid registering each socket's deadline with a
// second reactor. Leave 32 ms for the precise timer to allow for coarse
// timer rounding (including Windows). Short timeouts use the
// precise timer immediately; neither phase changes the original deadline.
// Allocate this state only for pending I/O. Keeping it out of the outer
// future also keeps FuturesUnordered entries small for immediate results.
poll_fn(|context| {
if let Poll::Ready(result) = operation.as_mut().poll(context) {
return Poll::Ready(result);
}
let delay = delay.get_or_insert_with(|| {
Box::pin(async move {
let Some(deadline) = started.checked_add(duration) else {
return std::future::pending::<()>().await;
};
const PRECISE_WINDOW: Duration = Duration::from_millis(32);
if deadline.saturating_duration_since(Instant::now()) > PRECISE_WINDOW {
tokio::time::sleep_until(tokio::time::Instant::from_std(
deadline - PRECISE_WINDOW,
))
.await;
}
// OS waits avoid rounding every short timeout to the next millisecond.
async_io::Timer::at(deadline).await;
})
});
if delay.as_mut().poll(context).is_ready() {
Poll::Ready(Err(io::Error::new(
io::ErrorKind::TimedOut,
"future timed out",
)))
} else {
Poll::Pending
}
})
.await
}
+2 -121
View File
@@ -143,78 +143,9 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
}
}
verify_script_programs(&scripts_to_run)?;
Ok(scripts_to_run)
}
/// Whether `path/program` is something the shell could run.
#[cfg(not(windows))]
fn program_exists_in(dir: &std::path::Path, program: &str) -> bool {
dir.join(program).is_file()
}
/// Whether `path/program` is something `cmd.exe` could run, which appends
/// entries from `PATHEXT` to a bare name.
#[cfg(windows)]
fn program_exists_in(dir: &std::path::Path, program: &str) -> bool {
if dir.join(program).is_file() {
return true;
}
let extensions = std::env::var("PATHEXT").unwrap_or_default();
extensions
.split(';')
.filter(|extension| !extension.is_empty())
.any(|extension| dir.join(format!("{program}{extension}")).is_file())
}
fn program_is_available(program: &str) -> bool {
match std::env::var_os("PATH") {
Some(path) => std::env::split_paths(&path).any(|dir| program_exists_in(&dir, program)),
None => false,
}
}
/// Check that the program each selected script starts with is available.
///
/// Scripts run through `sh -c`, so a missing binary surfaces only as a bare
/// "Exit code = 127" once the scan has already finished. Report it up front
/// instead, before a long scan has been wasted.
fn verify_script_programs(scripts_to_run: &[ScriptFile]) -> Result<()> {
for script in scripts_to_run {
let call_format = match &script.call_format {
Some(call_format) => call_format,
None => continue,
};
let program = match call_format.split_whitespace().next() {
Some(program) => program,
None => continue,
};
// A leading assignment (`FOO=bar nmap ...`) or an absolute path is not
// a bare program name, and `{{...}}` placeholders are only filled in
// when the script actually runs, so leave those formats alone.
if program.contains('=')
|| program.contains('/')
|| program.contains('$')
|| program.contains("{{")
{
continue;
}
if !program_is_available(program) {
return Err(anyhow!(
"The script `{}` runs `{}`, which is not installed or not in PATH. \
Install it, or skip scripts with `--scripts none`.",
script
.path
.as_ref()
.map_or_else(|| "default".to_string(), |path| path.display().to_string()),
program
));
}
}
Ok(())
}
pub fn parse_scripts(scripts: Vec<PathBuf>) -> Vec<ScriptFile> {
let mut parsed_scripts: Vec<ScriptFile> = Vec::with_capacity(scripts.len());
for script in scripts {
@@ -417,7 +348,8 @@ impl ScriptFile {
if let Ok(file) = File::open(script) {
for mut line in io::BufReader::new(file).lines().skip(1).flatten() {
if line.starts_with('#') {
line = line[1..].trim().to_string();
line.retain(|c| c != '#');
line = line.trim().to_string();
line.push('\n');
lines_buf.push_str(&line);
} else {
@@ -471,21 +403,6 @@ impl ScriptConfig {
mod tests {
use super::*;
#[test]
fn script_headers_preserve_literal_hash_characters() {
let fixture =
std::env::temp_dir().join(format!("rustscan-literal-hash-{}.txt", std::process::id()));
fs::write(&fixture, "#!/usr/bin/env python3\n#tags = [\"C#\"]\n#call_format = \"fixture {{ip}} {{port}} https://example.invalid/#section\"\n\n").unwrap();
let parsed = ScriptFile::new(fixture.clone());
fs::remove_file(fixture).unwrap();
let parsed = parsed.expect("valid TOML metadata");
assert_eq!(parsed.tags, Some(vec!["C#".to_owned()]));
assert_eq!(
parsed.call_format.as_deref(),
Some("fixture {{ip}} {{port}} https://example.invalid/#section")
);
}
#[test]
fn find_and_parse_scripts() {
let scripts = find_scripts("fixtures/.rustscan_scripts".into()).unwrap();
@@ -578,40 +495,4 @@ mod tests {
assert_eq!(script_dir_base, dirs::home_dir().unwrap());
}
fn script_with_call_format(call_format: &str) -> ScriptFile {
ScriptFile {
path: Some(PathBuf::from("/tmp/script.txt")),
tags: None,
developer: None,
port: None,
ports_separator: None,
call_format: Some(call_format.to_string()),
}
}
#[test]
fn missing_script_program_is_reported() {
let scripts = vec![script_with_call_format("definitely-not-a-real-binary {{ip}}")];
let err = verify_script_programs(&scripts).unwrap_err();
assert!(err.to_string().contains("definitely-not-a-real-binary"));
}
#[test]
fn available_script_program_is_accepted() {
// `sh` is what the scripts are actually run through, so it is present
// on every platform that can execute them.
let scripts = vec![script_with_call_format("sh -c true {{ip}}")];
assert!(verify_script_programs(&scripts).is_ok());
}
#[test]
fn placeholder_and_assignment_prefixes_are_skipped() {
let scripts = vec![
script_with_call_format("{{script}} {{ip}} {{port}}"),
script_with_call_format("FOO=bar definitely-not-a-real-binary"),
script_with_call_format("/opt/custom/nmap {{ip}}"),
];
assert!(verify_script_programs(&scripts).is_ok());
}
}