mirror of
https://github.com/bee-san/RustScan.git
synced 2026-11-07 09:46:04 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46f982b9b | ||
|
|
521f8bf720 | ||
|
|
ab1e69f1af | ||
|
|
29d85a77df | ||
|
|
a79a471111 | ||
|
|
f4192fd367 | ||
|
|
e9a4972e1f | ||
|
|
2f81acf45b | ||
|
|
8f8a51adf2 | ||
|
|
1dda392d1c | ||
|
|
937903340a | ||
|
|
f33752812c | ||
|
|
9b48f03ecd | ||
|
|
32d7d2cb17 | ||
|
|
67c08a3310 | ||
|
|
b7119f7f3e |
No files matched your search
@@ -3,15 +3,15 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "monthly"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "monthly"
|
||||
- package-ecosystem: cargo
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: anyhow
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""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)))
|
||||
@@ -0,0 +1,984 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,256 @@
|
||||
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
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
components: clippy, rustfmt
|
||||
|
||||
Generated
+29
-189
@@ -103,24 +103,6 @@ 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"
|
||||
@@ -297,7 +279,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -306,15 +288,6 @@ 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"
|
||||
@@ -414,7 +387,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -482,7 +455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -508,7 +481,6 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
@@ -531,44 +503,12 @@ 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"
|
||||
@@ -590,7 +530,6 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
@@ -676,12 +615,6 @@ 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"
|
||||
@@ -1000,9 +933,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
@@ -1044,9 +977,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "lru-cache"
|
||||
@@ -1071,13 +1004,13 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1157,12 +1090,6 @@ 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"
|
||||
@@ -1232,20 +1159,6 @@ 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"
|
||||
@@ -1469,7 +1382,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1510,7 +1423,6 @@ dependencies = [
|
||||
"ansi_term",
|
||||
"anstream",
|
||||
"anyhow",
|
||||
"async-io",
|
||||
"cidr-utils",
|
||||
"clap",
|
||||
"colored",
|
||||
@@ -1522,6 +1434,7 @@ dependencies = [
|
||||
"gcd",
|
||||
"hickory-resolver",
|
||||
"itertools 0.15.0",
|
||||
"libc",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parameterized",
|
||||
@@ -1532,6 +1445,7 @@ dependencies = [
|
||||
"text_placeholder",
|
||||
"tokio",
|
||||
"toml",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1621,9 +1535,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -1666,7 +1580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1721,7 +1635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1812,9 +1726,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
version = "1.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -1836,9 +1750,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.6+spec-1.1.0"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
@@ -1851,27 +1765,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
@@ -2121,7 +2035,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2163,15 +2077,6 @@ 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"
|
||||
@@ -2205,30 +2110,13 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"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"
|
||||
@@ -2241,12 +2129,6 @@ 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"
|
||||
@@ -2259,12 +2141,6 @@ 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"
|
||||
@@ -2277,24 +2153,12 @@ 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"
|
||||
@@ -2307,12 +2171,6 @@ 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"
|
||||
@@ -2325,12 +2183,6 @@ 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"
|
||||
@@ -2343,12 +2195,6 @@ 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"
|
||||
@@ -2361,12 +2207,6 @@ 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"
|
||||
|
||||
+11
-4
@@ -19,9 +19,11 @@ exclude = [
|
||||
[dependencies]
|
||||
clap = { version = "4.6.1", features = ["derive", "wrap_help"] }
|
||||
colored = "3.1.1"
|
||||
tokio = { version = "1.53.1", features = ["rt", "net", "time"] }
|
||||
futures = "0.3"
|
||||
async-io = "2.4.0"
|
||||
# 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"] }
|
||||
rlimit = "0.11.0"
|
||||
log = "0.4.32"
|
||||
env_logger = "0.11.10"
|
||||
@@ -41,8 +43,13 @@ 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"] }
|
||||
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
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;
|
||||
use rustscan::scanner::{build_udp_payload_lookup, Scanner};
|
||||
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()];
|
||||
}
|
||||
@@ -53,52 +47,7 @@ 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));
|
||||
|
||||
@@ -111,6 +60,50 @@ 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();
|
||||
|
||||
@@ -72,3 +72,12 @@ 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.
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"metadata": {
|
||||
"base": "67c08a33100362c54b56fde784b0fd234f70fa11",
|
||||
"candidate": "a05592a8a88b77ad85d961137943dfadfe98b612",
|
||||
"environment": "Linux-7.2.6-1-cachyos-x86_64-with-glibc2.44",
|
||||
"rust": "rustc 1.98.1 (48a229cea 2026-09-01) (Arch Linux rust 1:1.98.1-1.1)",
|
||||
"hosts_bytes": 2760051,
|
||||
"profile": "release with LTO",
|
||||
"network_namespace": "unshare --user --map-root-user --net",
|
||||
"common_args": [
|
||||
"--no-config",
|
||||
"--config-path",
|
||||
"/dev/null",
|
||||
"--no-banner",
|
||||
"--scripts",
|
||||
"none",
|
||||
"--greppable",
|
||||
"--ports",
|
||||
"80",
|
||||
"--exclude-ports",
|
||||
"80"
|
||||
],
|
||||
"workloads": {
|
||||
"literal-ipv4": [
|
||||
"-a",
|
||||
"127.0.0.1"
|
||||
],
|
||||
"mixed-cidrs": [
|
||||
"-a",
|
||||
"127.0.0.1,192.0.2.0/30,2001:db8::/126",
|
||||
"--exclude-addresses",
|
||||
"192.0.2.1,2001:db8::1"
|
||||
]
|
||||
},
|
||||
"warmups_each": 2,
|
||||
"samples_each": 20,
|
||||
"binary_cpus": "0-3",
|
||||
"harness_cpus": "8-9",
|
||||
"binary_sha256": {
|
||||
"base": "4d6b090218ff36249ed95f14ec1a82626e8ea33854f3823f19a6de247b1524b8",
|
||||
"dns": "a9d6bf949f56c53d18cc85bc9b53b21bd85a63bcdd04ad78992d392c675c77ac"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"literal-ipv4": {
|
||||
"base_ms": 168.23068849999999,
|
||||
"dns_ms": 2.248762,
|
||||
"samples_each": 20
|
||||
},
|
||||
"mixed-cidrs": {
|
||||
"base_ms": 178.0855925,
|
||||
"dns_ms": 2.4539600000000004,
|
||||
"samples_each": 20
|
||||
}
|
||||
},
|
||||
"samples": {
|
||||
"literal-ipv4": {
|
||||
"base": [
|
||||
143.391226,
|
||||
146.562804,
|
||||
149.615525,
|
||||
151.188597,
|
||||
152.269193,
|
||||
159.499601,
|
||||
164.926958,
|
||||
164.347529,
|
||||
167.208931,
|
||||
165.433184,
|
||||
172.246939,
|
||||
169.252446,
|
||||
172.777899,
|
||||
170.729249,
|
||||
170.092681,
|
||||
171.863255,
|
||||
169.612842,
|
||||
170.857396,
|
||||
173.117403,
|
||||
175.206345
|
||||
],
|
||||
"dns": [
|
||||
2.241793,
|
||||
1.817341,
|
||||
2.39956,
|
||||
2.048986,
|
||||
3.123401,
|
||||
1.820017,
|
||||
3.615094,
|
||||
2.165312,
|
||||
2.675535,
|
||||
2.103261,
|
||||
2.531392,
|
||||
2.215141,
|
||||
2.572939,
|
||||
2.063803,
|
||||
2.671431,
|
||||
2.059846,
|
||||
2.598858,
|
||||
2.230916,
|
||||
2.678259,
|
||||
2.255731
|
||||
]
|
||||
},
|
||||
"mixed-cidrs": {
|
||||
"base": [
|
||||
175.504767,
|
||||
178.797842,
|
||||
186.183728,
|
||||
176.885958,
|
||||
177.989816,
|
||||
180.028568,
|
||||
177.66299,
|
||||
174.757221,
|
||||
175.468485,
|
||||
175.328023,
|
||||
177.639639,
|
||||
175.637593,
|
||||
177.922435,
|
||||
185.394396,
|
||||
182.587204,
|
||||
180.58311,
|
||||
178.961137,
|
||||
178.293186,
|
||||
180.482419,
|
||||
178.181369
|
||||
],
|
||||
"dns": [
|
||||
3.275193,
|
||||
2.114074,
|
||||
2.700771,
|
||||
2.183317,
|
||||
2.606538,
|
||||
2.368046,
|
||||
2.599702,
|
||||
2.238105,
|
||||
2.683892,
|
||||
2.292249,
|
||||
2.528366,
|
||||
2.114478,
|
||||
2.668921,
|
||||
2.103271,
|
||||
3.275514,
|
||||
2.27974,
|
||||
3.103311,
|
||||
2.265298,
|
||||
2.580743,
|
||||
2.379554
|
||||
]
|
||||
}
|
||||
},
|
||||
"invocations": 88
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Lazy DNS initialization benchmark
|
||||
|
||||
Compared current Tokio master `67c08a33100362c54b56fde784b0fd234f70fa11` with the lazy-resolver implementation
|
||||
at `a05592a8a88b77ad85d961137943dfadfe98b612` on 2026-10-02.
|
||||
|
||||
## Method
|
||||
|
||||
Both binaries are release builds with LTO, using Rust 1.98.1 on an Intel Core
|
||||
Ultra 7 165U running Linux 7.2.6-1-cachyos. Each workload used twenty measured
|
||||
runs and two warmups per binary, alternating A/B and B/A order. Binaries used
|
||||
CPUs 0-3; the harness used CPUs 8-9. No builds ran during measurement.
|
||||
|
||||
The harness ran in a private network namespace. Every invocation excluded
|
||||
its only requested port, so no scan traffic was sent. Configuration and
|
||||
scripts were disabled. Timing spans process launch through exit, including
|
||||
the same `taskset` affinity launcher for both builds.
|
||||
|
||||
Common arguments:
|
||||
|
||||
```sh
|
||||
--no-config --config-path /dev/null --no-banner --scripts none --greppable \
|
||||
--ports 80 --exclude-ports 80
|
||||
```
|
||||
|
||||
The literal case adds `-a 127.0.0.1`. The mixed case adds
|
||||
`-a 127.0.0.1,192.0.2.0/30,2001:db8::/126` and
|
||||
`--exclude-addresses 192.0.2.1,2001:db8::1`. Both use `RUST_LOG=rustscan=info`.
|
||||
|
||||
## Results
|
||||
|
||||
| Workload | Master median | Lazy DNS median | Time saved |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Literal IPv4 | 168.23 ms | 2.25 ms | 165.98 ms (98.66%) |
|
||||
| IPv4/IPv6 CIDRs and exclusions | 178.09 ms | 2.45 ms | 175.63 ms (98.62%) |
|
||||
|
||||
All 88 invocations exited successfully with the expected empty scan output.
|
||||
A separate syscall trace of the optimized literal-IP invocation recorded no
|
||||
reads of `/etc/hosts` or `/etc/resolv.conf` and no network connects or sends.
|
||||
|
||||
This host has a 2,760,051-byte hosts file. Eager Hickory initialization parses
|
||||
that file even for numeric targets. Systems with smaller hosts files will
|
||||
save less; this measures CLI startup, not socket throughput. Hostnames that
|
||||
need fallback DNS still pay resolver initialization once, and parsing order,
|
||||
file handling, deduplication and exclusions keep their existing behavior.
|
||||
|
||||
[Raw samples, setup and binary hashes](lazy-dns-benchmark-results.json) are
|
||||
included. Network-free regression tests verify that literal IPv4/IPv6 targets,
|
||||
CIDRs, exclusions, duplicates and empty inputs do not construct the resolver.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
@@ -1,49 +0,0 @@
|
||||
--- 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();
|
||||
+
|
||||
@@ -1,25 +0,0 @@
|
||||
{"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]}}}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,104 +0,0 @@
|
||||
# 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.
|
||||
+90
-11
@@ -1,4 +1,5 @@
|
||||
//! Provides functions to parse input IP addresses, CIDRs or files.
|
||||
use std::cell::LazyCell;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{prelude::*, BufReader};
|
||||
@@ -30,13 +31,32 @@ 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();
|
||||
let backup_resolver = get_resolver(&input.resolver);
|
||||
// Most scans use literal IPs or CIDRs. Defer the resolver's runtime and
|
||||
// hosts-file loading until a DNS fallback actually needs them, then reuse
|
||||
// that resolver for the rest of the targets and exclusions.
|
||||
let backup_resolver = LazyCell::new(create_resolver);
|
||||
let resolver = || &*backup_resolver;
|
||||
|
||||
for address in &input.addresses {
|
||||
let parsed_ips = parse_address(address, &backup_resolver);
|
||||
let parsed_ips = parse_address_with_resolver(address, &resolver);
|
||||
if !parsed_ips.is_empty() {
|
||||
ips.extend(parsed_ips);
|
||||
} else {
|
||||
@@ -58,7 +78,7 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(x) = read_ips_from_file(file_path, &backup_resolver) {
|
||||
if let Ok(x) = read_ips_from_file(file_path, &resolver) {
|
||||
ips.extend(x);
|
||||
} else {
|
||||
warning!(
|
||||
@@ -69,7 +89,7 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
|
||||
}
|
||||
}
|
||||
|
||||
let excluded_cidrs = parse_excluded_networks(&input.exclude_addresses, &backup_resolver);
|
||||
let excluded_cidrs = parse_excluded_networks_with_resolver(&input.exclude_addresses, &resolver);
|
||||
|
||||
// Remove duplicated/excluded IPs.
|
||||
let mut seen = BTreeSet::new();
|
||||
@@ -92,6 +112,13 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
|
||||
/// let ips = parse_address("127.0.0.1", &Resolver::default().unwrap());
|
||||
/// ```
|
||||
pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
|
||||
parse_address_with_resolver(address, &|| resolver)
|
||||
}
|
||||
|
||||
fn parse_address_with_resolver<'a>(
|
||||
address: &str,
|
||||
resolver: &impl Fn() -> &'a Resolver,
|
||||
) -> Vec<IpAddr> {
|
||||
if let Ok(addr) = IpAddr::from_str(address) {
|
||||
// `address` is an IP string
|
||||
vec![addr]
|
||||
@@ -110,14 +137,17 @@ pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
|
||||
}
|
||||
|
||||
/// Uses DNS to get the IPS associated with host
|
||||
fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr> {
|
||||
fn resolve_ips_from_host<'a>(
|
||||
source: &str,
|
||||
backup_resolver: &impl Fn() -> &'a Resolver,
|
||||
) -> Vec<IpAddr> {
|
||||
let mut ips: Vec<IpAddr> = Vec::new();
|
||||
|
||||
if let Ok(addrs) = source.to_socket_addrs() {
|
||||
for ip in addrs {
|
||||
ips.push(ip.ip());
|
||||
}
|
||||
} else if let Ok(addrs) = backup_resolver.lookup_ip(source) {
|
||||
} else if let Ok(addrs) = backup_resolver().lookup_ip(source) {
|
||||
ips.extend(addrs.iter());
|
||||
}
|
||||
|
||||
@@ -140,6 +170,13 @@ fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr
|
||||
pub fn parse_excluded_networks(
|
||||
exclude_addresses: &Option<Vec<String>>,
|
||||
resolver: &Resolver,
|
||||
) -> Vec<IpCidr> {
|
||||
parse_excluded_networks_with_resolver(exclude_addresses, &|| resolver)
|
||||
}
|
||||
|
||||
fn parse_excluded_networks_with_resolver<'a>(
|
||||
exclude_addresses: &Option<Vec<String>>,
|
||||
resolver: &impl Fn() -> &'a Resolver,
|
||||
) -> Vec<IpCidr> {
|
||||
exclude_addresses
|
||||
.iter()
|
||||
@@ -149,7 +186,10 @@ pub fn parse_excluded_networks(
|
||||
}
|
||||
|
||||
/// Parses a single address into an IpCidr, handling CIDR notation, IP addresses, and hostnames.
|
||||
fn parse_single_excluded_address(addr: &str, resolver: &Resolver) -> Vec<IpCidr> {
|
||||
fn parse_single_excluded_address<'a>(
|
||||
addr: &str,
|
||||
resolver: &impl Fn() -> &'a Resolver,
|
||||
) -> Vec<IpCidr> {
|
||||
if let Ok(cidr) = IpCidr::from_str(addr) {
|
||||
return vec![cidr];
|
||||
}
|
||||
@@ -253,9 +293,9 @@ fn read_resolver_from_file(path: &str) -> Result<Vec<IpAddr>, std::io::Error> {
|
||||
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
/// Parses an input file of IPs and uses those
|
||||
fn read_ips_from_file(
|
||||
fn read_ips_from_file<'a>(
|
||||
ips: &std::path::Path,
|
||||
backup_resolver: &Resolver,
|
||||
backup_resolver: &impl Fn() -> &'a Resolver,
|
||||
) -> Result<Vec<IpAddr>, std::io::Error> {
|
||||
let file = File::open(ips)?;
|
||||
let reader = BufReader::new(file);
|
||||
@@ -264,7 +304,7 @@ fn read_ips_from_file(
|
||||
|
||||
for address_line in reader.lines() {
|
||||
if let Ok(address) = address_line {
|
||||
ips.extend(parse_address(&address, backup_resolver));
|
||||
ips.extend(parse_address_with_resolver(&address, backup_resolver));
|
||||
} else {
|
||||
debug!("Line in file is not valid");
|
||||
}
|
||||
@@ -275,9 +315,48 @@ fn read_ips_from_file(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_addresses, Opts};
|
||||
use super::{parse_addresses, parse_addresses_with_resolver, Opts};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
#[test]
|
||||
fn literal_targets_and_exclusions_do_not_initialize_a_resolver() {
|
||||
let opts = Opts {
|
||||
addresses: vec![
|
||||
"192.0.2.0/30".to_owned(),
|
||||
"192.0.2.2".to_owned(),
|
||||
"2001:db8::/126".to_owned(),
|
||||
"2001:db8::3".to_owned(),
|
||||
],
|
||||
exclude_addresses: Some(vec![
|
||||
"192.0.2.0/31".to_owned(),
|
||||
"192.0.2.3".to_owned(),
|
||||
"2001:db8::/127".to_owned(),
|
||||
"2001:db8::3".to_owned(),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ips = parse_addresses_with_resolver(&opts, || {
|
||||
panic!("literal targets and exclusions must not initialize DNS")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ips,
|
||||
[
|
||||
"192.0.2.2".parse::<IpAddr>().unwrap(),
|
||||
"2001:db8::2".parse::<IpAddr>().unwrap(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_targets_do_not_initialize_a_resolver() {
|
||||
assert!(parse_addresses_with_resolver(&Opts::default(), || {
|
||||
panic!("empty targets must not initialize DNS")
|
||||
})
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_correct_addresses() {
|
||||
let opts = Opts {
|
||||
|
||||
+181
-6
@@ -1,6 +1,9 @@
|
||||
//! Provides a means to read, parse and hold configuration options for scans.
|
||||
use clap::{Parser, ValueEnum};
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum};
|
||||
use serde_derive::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -22,8 +25,11 @@ 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,
|
||||
}
|
||||
|
||||
@@ -231,18 +237,48 @@ 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 {
|
||||
let mut opts = Opts::parse();
|
||||
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;
|
||||
|
||||
if opts.ports.is_none() && opts.range.is_none() {
|
||||
opts.range = Some(PortRanges(vec![(LOWEST_PORT_NUMBER, TOP_PORT_NUMBER)]));
|
||||
}
|
||||
|
||||
opts
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
/// Validates options whose availability or semantics depend on the
|
||||
@@ -267,7 +303,8 @@ impl Opts {
|
||||
}
|
||||
|
||||
/// Reads the command line arguments into an Opts struct and merge
|
||||
/// values found within the user configuration file.
|
||||
/// values found within the user configuration file. Options given on the
|
||||
/// command line take precedence over the configuration file.
|
||||
pub fn merge(&mut self, config: &Config) {
|
||||
if !self.no_config {
|
||||
self.merge_required(config);
|
||||
@@ -280,7 +317,9 @@ impl Opts {
|
||||
($($field: ident),+) => {
|
||||
$(
|
||||
if let Some(e) = &config.$field {
|
||||
self.$field = e.clone();
|
||||
if !self.cli_args.contains(stringify!($field)) {
|
||||
self.$field = e.clone();
|
||||
}
|
||||
}
|
||||
)+
|
||||
}
|
||||
@@ -296,7 +335,7 @@ impl Opts {
|
||||
macro_rules! merge_optional {
|
||||
($($field: ident),+) => {
|
||||
$(
|
||||
if config.$field.is_some() {
|
||||
if config.$field.is_some() && !self.cli_args.contains(stringify!($field)) {
|
||||
self.$field = config.$field.clone();
|
||||
}
|
||||
)+
|
||||
@@ -337,6 +376,7 @@ impl Default for Opts {
|
||||
udp: false,
|
||||
closed: false,
|
||||
interval: 0,
|
||||
cli_args: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,6 +499,7 @@ 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};
|
||||
|
||||
@@ -488,6 +529,55 @@ 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();
|
||||
@@ -635,6 +725,91 @@ 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([
|
||||
|
||||
+8
-4
@@ -5,11 +5,13 @@
|
||||
//!
|
||||
//! 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).
|
||||
//!
|
||||
//! 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).
|
||||
//! 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).
|
||||
//!
|
||||
//! This example is compiled but never executed by the test suite.
|
||||
//!
|
||||
@@ -36,6 +38,8 @@
|
||||
//! 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()
|
||||
|
||||
+98
-23
@@ -107,14 +107,13 @@ fn main() {
|
||||
debug!("Scanner finished building: {scanner:?}");
|
||||
|
||||
let mut portscan_bench = NamedTimer::start("Portscan");
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
portscan_bench.end();
|
||||
benchmarks.push(portscan_bench);
|
||||
|
||||
@@ -129,20 +128,7 @@ fn main() {
|
||||
map.entry(socket.ip()).or_default().push(socket.port());
|
||||
}
|
||||
|
||||
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>'");
|
||||
if let Some(x) = no_open_ports_warning(&ips, &ports_per_ip, &closed_ports_per_ip, batch_size) {
|
||||
warning!(x, opts.greppable, opts.accessible);
|
||||
}
|
||||
|
||||
@@ -225,6 +211,35 @@ 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
|
||||
@@ -242,6 +257,25 @@ 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) {
|
||||
@@ -355,7 +389,48 @@ mod tests {
|
||||
use super::effective_batch_size;
|
||||
#[cfg(unix)]
|
||||
use super::{adjust_ulimit_size, infer_batch_size};
|
||||
use super::{print_opening, Opts};
|
||||
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."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::{collections::HashSet, fmt, io, net::IpAddr};
|
||||
|
||||
/// Keep the OS error and its target intact until diagnostics need formatting.
|
||||
#[derive(Debug)]
|
||||
struct ScanError {
|
||||
ip: IpAddr,
|
||||
error: io::Error,
|
||||
}
|
||||
|
||||
impl fmt::Display for ScanError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} {}", self.error, self.ip)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ScanError {}
|
||||
|
||||
/// Preserve the normal I/O result layout; only diagnostics need target context.
|
||||
pub(super) fn diagnostic_error(error: io::Error, ip: IpAddr, enabled: bool) -> io::Error {
|
||||
if enabled {
|
||||
io::Error::other(ScanError { ip, error })
|
||||
} else {
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ScanErrors {
|
||||
enabled: bool,
|
||||
limit: usize,
|
||||
messages: HashSet<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)));
|
||||
}
|
||||
}
|
||||
+328
-292
@@ -5,23 +5,58 @@ use crate::tui::println_safe;
|
||||
use log::debug;
|
||||
|
||||
mod socket_iterator;
|
||||
mod timeout;
|
||||
use socket_iterator::SocketIterator;
|
||||
use timeout::io_timeout;
|
||||
|
||||
mod errors;
|
||||
use errors::{diagnostic_error, is_descriptor_exhaustion, ScanErrors};
|
||||
|
||||
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, HashSet},
|
||||
future::Future,
|
||||
collections::HashMap,
|
||||
io,
|
||||
net::{IpAddr, Shutdown, SocketAddr},
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, 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
|
||||
///
|
||||
@@ -51,6 +86,19 @@ 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 {
|
||||
@@ -143,8 +191,6 @@ 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
|
||||
@@ -159,33 +205,21 @@ 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> {
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
/// 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();
|
||||
async fn scan(&self) -> Vec<PortStatus> {
|
||||
let ports = filter_excluded_ports(self.port_strategy.order(), &self.exclude_ports);
|
||||
let mut found_sockets: Vec<PortStatus> = Vec::new();
|
||||
let mut errors: HashSet<String> = HashSet::new();
|
||||
let mut errors =
|
||||
ScanErrors::new(log::log_enabled!(log::Level::Debug), self.ips.len() * 1000);
|
||||
|
||||
// Build UDP payload lookup once (only if we are scanning UDP).
|
||||
// This avoids cloning a big map into every spawned future and turns
|
||||
@@ -212,7 +246,7 @@ impl Scanner {
|
||||
// before moving on to the next port.
|
||||
for (i, port) in ports.iter().enumerate() {
|
||||
if i > 0 {
|
||||
delay(self.interval).await;
|
||||
sleep(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)
|
||||
@@ -220,52 +254,88 @@ impl Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Typical socket connection errors {errors:?}");
|
||||
debug!("Typical socket connection errors {:?}", errors.messages());
|
||||
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 HashSet<String>,
|
||||
errors: &mut ScanErrors,
|
||||
) {
|
||||
let mut ftrs = FuturesUnordered::new();
|
||||
|
||||
for _ in 0..self.batch_size {
|
||||
if let Some(socket) = sockets.next() {
|
||||
ftrs.push(self.scan_socket(socket, udp_payloads.clone()));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
} 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a socket, scan it self.tries times.
|
||||
/// Turns the address into a SocketAddr
|
||||
/// Deals with the `<result>` type
|
||||
/// If it experiences error ErrorKind::Other then too many files are open and it Panics!
|
||||
/// Else any other error, it returns the error in Result as a string
|
||||
/// Panics if the OS reports descriptor exhaustion.
|
||||
/// Other failures retain their I/O error, with target context for debug diagnostics.
|
||||
/// If no errors occur, it returns the port number in Result to signify the port is open.
|
||||
/// This function mainly deals with the logic of Results handling.
|
||||
/// # Example
|
||||
@@ -289,10 +359,7 @@ impl Scanner {
|
||||
match self.connect(socket).await {
|
||||
Ok(tcp_stream) => {
|
||||
debug!("Connection was successful, shutting down stream {}", socket);
|
||||
if let Err(e) = tcp_stream
|
||||
.into_std()
|
||||
.and_then(|stream| stream.shutdown(Shutdown::Both))
|
||||
{
|
||||
if let Err(e) = shutdown_both(tcp_stream) {
|
||||
debug!("Shutdown stream error {}", e);
|
||||
}
|
||||
self.fmt_ports(socket);
|
||||
@@ -308,14 +375,14 @@ impl Scanner {
|
||||
return Ok(PortStatus::Closed(socket));
|
||||
}
|
||||
|
||||
let mut error_string = e.to_string();
|
||||
|
||||
assert!(!error_string.to_lowercase().contains("too many open files"), "Too many open files. Please reduce batch size. The default is 5000. Try -b 2500.");
|
||||
assert!(!is_descriptor_exhaustion(&e), "Too many open files. Please reduce batch size. The default is 5000. Try -b 2500.");
|
||||
|
||||
if nr_try == tries {
|
||||
error_string.push(' ');
|
||||
error_string.push_str(&socket.ip().to_string());
|
||||
return Err(io::Error::other(error_string));
|
||||
return Err(diagnostic_error(
|
||||
e,
|
||||
socket.ip(),
|
||||
log::log_enabled!(log::Level::Debug),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -362,30 +429,22 @@ impl Scanner {
|
||||
/// ```
|
||||
///
|
||||
async fn connect(&self, socket: SocketAddr) -> io::Result<TcpStream> {
|
||||
io_timeout(self.timeout, TcpStream::connect(socket)).await
|
||||
timeout(self.timeout, TcpStream::connect(socket))
|
||||
.await
|
||||
.unwrap_or_else(|_elapsed| Err(timed_out()))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
let local_addr = match socket {
|
||||
SocketAddr::V4(_) => "0.0.0.0:0".parse::<SocketAddr>().unwrap(),
|
||||
SocketAddr::V6(_) => "[::]:0".parse::<SocketAddr>().unwrap(),
|
||||
SocketAddr::V4(_) => SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)),
|
||||
SocketAddr::V6(_) => SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0)),
|
||||
};
|
||||
|
||||
UdpSocket::bind(local_addr).await
|
||||
let udp_socket = std::net::UdpSocket::bind(local_addr)?;
|
||||
udp_socket.set_nonblocking(true)?;
|
||||
Ok(udp_socket)
|
||||
}
|
||||
|
||||
/// Performs a UDP scan on the specified socket with a payload and wait duration
|
||||
@@ -409,32 +468,59 @@ impl Scanner {
|
||||
payload: &[u8],
|
||||
wait: Duration,
|
||||
) -> io::Result<bool> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let udp_socket = match Self::udp_bind(socket) {
|
||||
Ok(udp_socket) => udp_socket,
|
||||
Err(e) => {
|
||||
debug!("Error binding UDP socket: {e:?}");
|
||||
Err(e)
|
||||
return 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,197 +547,69 @@ 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: 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);
|
||||
});
|
||||
}
|
||||
// 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.
|
||||
|
||||
fn test_scanner() -> Scanner {
|
||||
let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
|
||||
@@ -669,6 +627,35 @@ 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();
|
||||
@@ -705,6 +692,55 @@ 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
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
}
|
||||
+121
-2
@@ -143,9 +143,78 @@ 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 {
|
||||
@@ -348,8 +417,7 @@ 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.retain(|c| c != '#');
|
||||
line = line.trim().to_string();
|
||||
line = line[1..].trim().to_string();
|
||||
line.push('\n');
|
||||
lines_buf.push_str(&line);
|
||||
} else {
|
||||
@@ -403,6 +471,21 @@ 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();
|
||||
@@ -495,4 +578,40 @@ 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user