Compare commits

..
Author SHA1 Message Date
Autumn (Bee) dc1116d9cc test: remove network scans and external script execution 2026-10-01 17:39:16 +09:00
37 changed files with 853 additions and 8558 deletions

No files matched your search

@@ -1,57 +0,0 @@
"""Provision TCP memory for repeated scans on a disposable macOS CI runner."""
import ctypes
import json
import os
import subprocess
class Statistics(ctypes.Structure):
# XNU's bsd/sys/mem_acct_private.h, struct memacct_statistics.
_fields_ = [("peak", ctypes.c_uint64), ("allocated", ctypes.c_int64),
("softlimit", ctypes.c_uint64), ("hardlimit", ctypes.c_uint64),
("name", ctypes.c_char * 16)]
def tcp_memory(new_limit: int | None = None) -> dict:
"""Read the TCP accounting object, optionally raising its hard limit."""
libc = ctypes.CDLL(None, use_errno=True)
libc.sysctlnametomib.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_size_t)]
libc.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint,
ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t),
ctypes.c_void_p, ctypes.c_size_t]
mib = (ctypes.c_int * 24)()
count = ctypes.c_size_t(22)
if libc.sysctlnametomib(b"kern.memacct", mib, ctypes.byref(count)):
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
def query(operation: int, index: int, output, replacement=None):
mib[count.value], mib[count.value + 1] = operation, index
length = ctypes.c_size_t(ctypes.sizeof(output))
if libc.sysctl(mib, count.value + 2, ctypes.byref(output), ctypes.byref(length),
ctypes.byref(replacement) if replacement is not None else None,
ctypes.sizeof(replacement) if replacement is not None else 0):
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
return length.value
names = ctypes.create_string_buffer(8 * 16)
length = query(5, 0, names) # MEM_ACCT_SUBSYSTEMS
index = next(i for i in range(length // 16)
if names.raw[i * 16:(i + 1) * 16].rstrip(b"\0") == b"TCP")
stats = Statistics()
query(6, index, stats) # MEM_ACCT_ALL_SUBSYSTEM_STATISTICS
previous_limit = stats.hardlimit
if new_limit is not None and 0 < previous_limit < new_limit:
query(3, index, ctypes.c_uint64(), ctypes.c_uint64(new_limit))
query(6, index, stats)
return {"previous_hardlimit": previous_limit,
**{name: getattr(stats, name) for name in ("peak", "allocated", "softlimit", "hardlimit")}}
if __name__ == "__main__":
# The default TCP budget is 1/32 of physical memory. Repeated 10,000-socket
# batches hit ENOBUFS on Darwin 25.6 even on master. Give both builds 1/8
# before timing; retain every listener check and performance threshold.
memory = int(subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True))
print(json.dumps(tcp_memory(memory // 8)))
-977
View File
@@ -1,977 +0,0 @@
#!/usr/bin/env python3
"""Compare RustScan builds by scanning listeners on the loopback interface.
CI-only tooling for ``.github/workflows/scan-benchmark.yml``; it is not part
of the test suite. It port-scans 127.0.0.1, so only run it on a disposable CI
runner, never on a developer machine or a shared host.
``run`` starts ``serve`` (TCP listeners and UDP responders on 127.0.0.1) in a
separate process, runs every scenario with every build (interleaving the
builds so that drift on the runner affects them all alike), checks that all
builds report the same open ports, and writes the raw measurements as JSON
plus a Markdown summary of the medians. On Linux the workflow also sets up a
host behind an artificial delay (a network namespace on the runner, with its
own ``serve``), so that answers arrive after a round trip, as they do from a
remote host, instead of while the probe is being sent.
Measured per run:
* scan time: RustScan's own "Portscan" timer (``RUST_LOG=rustscan=info``),
reported as throughput (sockets per second);
* wall time of the whole process, and the time until the first ``Open`` line
appears on stdout (latency to the first result);
* peak RSS and CPU time (user + system) of the process;
* peak number of open file descriptors (Windows: handles) and of threads,
sampled every few milliseconds while the scan runs.
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import re
import selectors
import signal
import socket
import statistics
import subprocess
import sys
import threading
import time
from dataclasses import asdict, dataclass, field
LOOPBACK = "127.0.0.1"
# Ports `serve` listens on. They stay below 32768, i.e. below the ephemeral
# port ranges of Linux (32768-60999), macOS and Windows (49152-65535): a scan
# of a port inside the ephemeral range can occasionally connect a socket to
# itself and report a bogus open port.
TCP_PORTS = tuple(range(1024, 32768, 101)) # 315 open TCP ports
UDP_PORTS = tuple(range(1031, 32768, 997)) # 32 open UDP ports
SWEEP = (1, 32767)
SAMPLE_INTERVAL = 0.005 # seconds between fd/thread samples
RUN_TIMEOUT = 300 # seconds before a run is killed and counted as failed
# --------------------------------------------------------------------------
# Listener process
# --------------------------------------------------------------------------
def serve(addresses: list[str]) -> int:
"""Accepts (and closes) TCP connections and answers every UDP datagram."""
selector = selectors.DefaultSelector()
bound: dict[str, dict[str, list[int]]] = {"tcp": {}, "udp": {}, "busy": {}}
for address in addresses:
for kind, ports in (("tcp", TCP_PORTS), ("udp", UDP_PORTS)):
sock_type = socket.SOCK_STREAM if kind == "tcp" else socket.SOCK_DGRAM
for port in ports:
sock = socket.socket(socket.AF_INET, sock_type)
if os.name != "nt":
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((address, port))
except OSError:
sock.close()
bound["busy"].setdefault(f"{kind} {address}", []).append(port)
continue
if kind == "tcp":
sock.listen(1024)
sock.setblocking(False)
selector.register(sock, selectors.EVENT_READ, kind)
bound[kind].setdefault(address, []).append(port)
print(json.dumps(bound), flush=True)
while True:
for key, _ in selector.select(timeout=1.0):
sock = key.fileobj
if key.data == "tcp":
while True:
try:
conn, _ = sock.accept()
except OSError: # BlockingIOError once the queue is empty
break
conn.close()
else:
while True:
try:
_, peer = sock.recvfrom(4096)
except ConnectionResetError:
# Windows reports an earlier ICMP error here; skip it.
continue
except OSError: # BlockingIOError once the queue is empty
break
try:
sock.sendto(b"rustscan-benchmark\n", peer)
except OSError:
pass
def start_server(addresses: list[str]) -> tuple[subprocess.Popen, dict]:
args = [arg for address in addresses for arg in ("--address", address)]
proc = subprocess.Popen(
[sys.executable, os.path.abspath(__file__), "serve", *args],
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
text=True,
)
line = proc.stdout.readline()
if not line:
raise SystemExit("the listener process exited before it was ready")
return proc, json.loads(line)
# --------------------------------------------------------------------------
# Scenarios
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Scenario:
name: str
title: str
addresses: tuple[str, ...]
ranges: tuple[tuple[int, int], ...] = ()
ports: tuple[int, ...] = ()
batch: int = 4500
timeout_ms: int = 1500
udp: bool = False
repeat: int = 1 # extra samples for very short scenarios
excluded_ports: tuple[int, ...] = ()
@property
def sockets(self) -> int:
span = sum(end - start + 1 for start, end in self.ranges)
excluded = sum(self.covers(port) for port in set(self.excluded_ports))
return len(self.addresses) * (len(self.ports) + span - excluded)
def covers(self, port: int) -> bool:
return port in self.ports or any(s <= port <= e for s, e in self.ranges)
def args(self) -> list[str]:
args = ["-a", ",".join(self.addresses)]
if self.ports:
args += ["-p", ",".join(map(str, self.ports))]
else:
args += ["-r", ",".join(f"{s}-{e}" for s, e in self.ranges)]
args += ["-b", str(self.batch), "-t", str(self.timeout_ms)]
if self.udp:
args.append("--udp")
if self.excluded_ports:
args += ["--exclude-ports", ",".join(map(str, self.excluded_ports))]
return args
def expected(self, listeners: dict) -> set[str]:
by_address = listeners["udp" if self.udp else "tcp"]
return {
f"{address}:{port}"
for address in self.addresses
for port in by_address.get(address, [])
if self.covers(port) and port not in self.excluded_ports
}
def describe(self) -> str:
hosts = f"{len(self.addresses)} addresses" if len(self.addresses) > 1 else self.addresses[0]
return f"{self.title}: `{' '.join(self.args()[2:])}` on {hosts}"
def build_scenarios(
system: str, filtered: tuple[int, int] | None, delayed: str | None, delay_ms: int
) -> list[Scenario]:
lo = (LOOPBACK,)
if system == "Windows":
# Windows retries a refused connection for about a second, so every
# closed port there takes as long as a filtered one: sweep fewer.
sweep, small_sweep = ((1, 8191),), ((1, 2047),)
else:
sweep = small_sweep = (SWEEP,)
def ports(ranges: tuple[tuple[int, int], ...]) -> str:
return f"{sum(e - s + 1 for s, e in ranges):,} ports"
scenarios = [
Scenario("tcp-1-port", "TCP, 1 open port", lo, ports=(TCP_PORTS[0],), repeat=3),
Scenario("tcp-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)
)
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())
+55 -77
View File
@@ -1,22 +1,13 @@
name: CD Pipeline
on:
push:
branches:
- "master"
tags:
- "*"
# Build jobs only need to read the repository. The publish job below is the
# only job that gets write access, and it only runs for tag pushes.
permissions:
contents: read
on: [push]
jobs:
build-nix:
env:
IN_PIPELINE: true
runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/master'
strategy:
matrix:
type: [ubuntu-x64, ubuntu-x86, armv7, aarch64]
@@ -48,23 +39,28 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@v2
- name: Build binary
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: ${{ matrix.target }}
args: "--locked --release"
strip: true
toolchain: stable
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
- name: Build tar.gz for homebrew installs
if: matrix.type == 'ubuntu-x64'
run: |
tar czf ${{ matrix.name }}.tar.gz -C target/x86_64-unknown-linux-musl/release rustscan
- uses: actions/upload-artifact@v7
with:
executable-name: rustscan
target: ${{ matrix.target }}
archive-name: ${{ matrix.name }}
changes-file: ""
name: ${{ matrix.name }}
path: ${{ matrix.path }}
- uses: actions/upload-artifact@v7
if: matrix.type == 'ubuntu-x64'
with:
name: ${{ matrix.name }}.tar.gz
path: ${{ matrix.name }}.tar.gz
build-deb:
needs: [build-nix]
runs-on: ubuntu-latest
@@ -74,7 +70,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install cargo-deb
run: cargo install -f cargo-deb
- uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
- uses: awalsh128/cache-apt-pkgs-action@v1
with:
packages: musl-tools # provides musl-gcc
version: 1.0
@@ -92,54 +88,65 @@ jobs:
env:
IN_PIPELINE: true
runs-on: macos-latest
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v7
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@v2
- name: Build binary
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: x86_64-apple-darwin
args: "--locked --release"
strip: true
toolchain: stable
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
- name: Build tar.gz for homebrew installs
run: |
tar czf x86_64-macos-rustscan.tar.gz -C target/x86_64-apple-darwin/release rustscan
- uses: actions/upload-artifact@v7
with:
executable-name: rustscan
target: x86_64-apple-darwin
archive-name: x86_64-macos-rustscan
changes-file: ""
name: x86_64-macos-rustscan
path: target/x86_64-apple-darwin/release/rustscan
- uses: actions/upload-artifact@v7
with:
name: x86_64-macos-rustscan.tar.gz
path: x86_64-macos-rustscan.tar.gz
build-macos-aarch64:
env:
IN_PIPELINE: true
runs-on: macos-latest
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v7
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@v2
- name: Build binary
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: aarch64-apple-darwin
args: "--locked --release"
strip: true
toolchain: stable
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
- name: Build tar.gz for homebrew installs
run: |
tar czf aarch64-macos-rustscan.tar.gz -C target/aarch64-apple-darwin/release rustscan
- uses: actions/upload-artifact@v7
with:
executable-name: rustscan
target: aarch64-apple-darwin
archive-name: aarch64-macos-rustscan
changes-file: ""
name: aarch64-macos-rustscan
path: target/aarch64-apple-darwin/release/rustscan
- uses: actions/upload-artifact@v7
with:
name: aarch64-macos-rustscan.tar.gz
path: aarch64-macos-rustscan.tar.gz
build-windows:
env:
IN_PIPELINE: true
runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/master'
strategy:
matrix:
type: [windows-x64, windows-x86]
@@ -147,55 +154,26 @@ jobs:
- type: windows-x64
os: windows-latest
target: x86_64-pc-windows-msvc
name: x86_64-windows-rustscan
name: x86_64-windows-rustscan.exe
path: target\x86_64-pc-windows-msvc\release\rustscan.exe
- type: windows-x86
os: windows-latest
target: i686-pc-windows-msvc
name: x86-windows-rustscan
name: x86-windows-rustscan.exe
path: target\i686-pc-windows-msvc\release\rustscan.exe
steps:
- uses: actions/checkout@v7
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
uses: Swatinem/rust-cache@v2
- name: Build binary
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: ${{ matrix.target }}
target: ${{ matrix.target }}
args: "--locked --release"
strip: true
toolchain: stable
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
- uses: actions/upload-artifact@v7
with:
executable-name: rustscan
target: ${{ matrix.target }}
archive-name: ${{ matrix.name }}
changes-file: ""
# Runs only for tag pushes (tags can only be created by maintainers), and
# only after every build job succeeded, so a release never ships partially.
publish:
if: github.ref_type == 'tag'
needs:
- build-nix
- build-deb
- build-macos
- build-macos-aarch64
- build-windows
runs-on: ubuntu-latest
permissions:
contents: write # create the release / upload release assets
steps:
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
merge-multiple: true
- name: Publish artifacts to GitHub release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
fail_on_unmatched_files: true
files: |
*.deb
*.sha256
*.tar.gz
*.zip
name: ${{ matrix.name }}
path: ${{ matrix.path }}
+14
View File
@@ -0,0 +1,14 @@
name: Dependabot Auto-Merge
jobs:
dependabot:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- name: Enable auto-merge for Dependabot PRs
run: |
gh pr review --approve "$PR_URL"
gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-256
View File
@@ -1,256 +0,0 @@
name: Scan benchmark
# Compares the scanning speed and resource use of a change with its base.
#
# Each run builds both versions in release mode and runs
# .github/scan-benchmark/scan_bench.py, which scans listeners it opens on
# 127.0.0.1 inside the runner (plus closed and, on Linux, filtered ports) and
# reports medians over interleaved runs. Nothing outside the runner is
# scanned. The unit tests deliberately never open sockets; this workflow is
# where scanning behaviour and performance are measured.
#
# It only runs for pull requests that touch the scanner, its runtime or its
# dependencies, or when started by hand, so it does not slow down normal CI.
on:
pull_request:
branches: [master]
paths:
- "src/scanner/**"
- "src/main.rs"
- "src/lib.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/scan-benchmark/**"
- ".github/workflows/scan-benchmark.yml"
workflow_dispatch:
inputs:
base:
description: "Branch, tag or commit to compare against"
default: "master"
runs:
description: "Interleaved runs per build and scenario"
default: "9"
extra:
description: "More builds to compare, as space-separated NAME=REF pairs"
default: ""
permissions:
contents: read
concurrency:
group: scan-benchmark-${{ github.ref }}
cancel-in-progress: true
jobs:
benchmark:
name: Benchmark (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
defaults:
run:
shell: bash
env:
# Ports the Linux runners drop, to measure filtered ports (timeouts).
# They lie above Linux's ephemeral port range (32768-60999).
FILTERED_PORTS: "61000-62999"
# The Linux runners' host behind a 5 ms delay (see "Set up a host 5 ms away").
DELAYED_HOST: "10.200.0.2"
RUNS: ${{ inputs.runs || '9' }}
EXTRA: ${{ inputs.extra }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Check the benchmark script
run: python -m py_compile .github/scan-benchmark/scan_bench.py
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.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
+6 -17
View File
@@ -1,39 +1,28 @@
name: Test
on:
pull_request:
branches: [master]
push:
branches:
- master
permissions:
contents: read
jobs:
test:
name: Test Suite
strategy:
matrix:
# ubuntu-24.04-arm is the GitHub-hosted arm64 Linux runner;
# macos-latest already runs on Apple Silicon (arm64).
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
rust: [stable]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: ${{ matrix.rust }}
components: clippy, rustfmt
# Caching is handled by Swatinem/rust-cache below.
cache: false
# Keep RUSTFLAGS unset; `just test` already denies Clippy warnings.
rustflags: ""
override: true
- uses: taiki-e/install-action@nextest
- uses: Swatinem/rust-cache@v2
@@ -41,5 +30,5 @@ jobs:
- name: Install Just
uses: extractions/setup-just@v4
- name: Run tests
- name: Run just
run: just test
+4
View File
@@ -18,6 +18,10 @@ script:
- cargo build
- cargo test
- cargo fmt -- --check
after_failure:
- wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
- chmod +x send.sh
- "./send.sh failure $WEBHOOK_URL"
after_success: |
if [ -n "${GITHUB_TOKEN}" ]; then
[ "${TRAVIS_BRANCH} " = master ] &&
Generated
+346 -46
View File
@@ -118,6 +118,119 @@ version = "1.0.96"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4"
[[package]]
name = "async-channel"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
dependencies = [
"concurrent-queue",
"event-listener 2.5.3",
"futures-core",
]
[[package]]
name = "async-channel"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"slab",
]
[[package]]
name = "async-global-executor"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c"
dependencies = [
"async-channel 2.3.1",
"async-executor",
"async-io",
"async-lock",
"blocking",
"futures-lite",
"once_cell",
]
[[package]]
name = "async-io"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059"
dependencies = [
"async-lock",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix 0.38.44",
"slab",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "async-lock"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
dependencies = [
"event-listener 5.4.0",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-std"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b"
dependencies = [
"async-channel 1.9.0",
"async-global-executor",
"async-io",
"async-lock",
"crossbeam-utils",
"futures-channel",
"futures-core",
"futures-io",
"futures-lite",
"gloo-timers",
"kv-log-macro",
"log",
"memchr",
"once_cell",
"pin-project-lite",
"pin-utils",
"slab",
"wasm-bindgen-futures",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.86"
@@ -126,9 +239,15 @@ checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.4.0"
@@ -162,6 +281,19 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
[[package]]
name = "blocking"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea"
dependencies = [
"async-channel 2.3.1",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "bumpalo"
version = "3.17.0"
@@ -258,9 +390,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.7"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
dependencies = [
"clap_builder",
"clap_derive",
@@ -268,9 +400,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.6.7"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
dependencies = [
"anstream",
"anstyle",
@@ -281,14 +413,14 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.7"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn",
]
[[package]]
@@ -318,6 +450,15 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb474a9c3219a8254ead020421ecf1b90427f29b55f6aae9a2471fa62c126ef"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -428,7 +569,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -446,7 +587,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -488,6 +629,39 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "event-listener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "event-listener"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2"
dependencies = [
"event-listener 5.4.0",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -511,6 +685,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
@@ -533,12 +708,47 @@ 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.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"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",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -560,6 +770,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
@@ -605,6 +816,18 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "half"
version = "2.4.1"
@@ -651,6 +874,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
[[package]]
name = "hickory-proto"
version = "0.24.4"
@@ -827,7 +1056,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -948,7 +1177,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -961,6 +1190,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kv-log-macro"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
dependencies = [
"log",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -989,6 +1227,12 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -1016,6 +1260,9 @@ name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
dependencies = [
"value-bag",
]
[[package]]
name = "lru-cache"
@@ -1141,9 +1388,15 @@ dependencies = [
"indexmap 1.9.3",
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[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"
@@ -1185,6 +1438,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "plotters"
version = "0.3.7"
@@ -1213,6 +1477,21 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "polling"
version = "3.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix 0.38.44",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "portable-atomic"
version = "1.11.0"
@@ -1244,7 +1523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55"
dependencies = [
"proc-macro2",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -1290,9 +1569,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.3"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.1",
@@ -1432,6 +1711,19 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.1.3"
@@ -1441,7 +1733,7 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"linux-raw-sys 0.11.0",
"windows-sys 0.60.2",
]
@@ -1483,6 +1775,7 @@ dependencies = [
"ansi_term",
"anstream",
"anyhow",
"async-std",
"cidr-utils",
"clap",
"colored",
@@ -1497,12 +1790,11 @@ dependencies = [
"log",
"once_cell",
"parameterized",
"rand 0.10.3",
"rand 0.10.2",
"rlimit",
"serde",
"serde_derive",
"text_placeholder",
"tokio",
"toml",
]
@@ -1576,7 +1868,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -1654,17 +1946,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.1"
@@ -1673,7 +1954,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -1682,7 +1963,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"rustix 1.1.3",
"windows-sys 0.60.2",
]
@@ -1723,7 +2004,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -1734,7 +2015,7 @@ checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -1799,9 +2080,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.1.6+spec-1.1.0"
version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
dependencies = [
"indexmap 2.13.0",
"serde_core",
@@ -1855,7 +2136,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -1914,6 +2195,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -1976,10 +2263,23 @@ dependencies = [
"log",
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
@@ -1998,7 +2298,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -2367,7 +2667,7 @@ dependencies = [
"heck",
"indexmap 2.13.0",
"prettyplease",
"syn 2.0.117",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
@@ -2383,7 +2683,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
@@ -2457,7 +2757,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
"synstructure",
]
@@ -2479,7 +2779,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
[[package]]
@@ -2499,7 +2799,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
"synstructure",
]
@@ -2522,5 +2822,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn",
]
+2 -5
View File
@@ -19,11 +19,8 @@ exclude = [
[dependencies]
clap = { version = "4.6.1", features = ["derive", "wrap_help"] }
colored = "3.1.1"
# Async runtime for the scanner. hickory-resolver already depends on tokio, so
# this adds no crates; only the features the scanner itself uses are listed.
tokio = { version = "1.44.2", features = ["rt", "net", "time"] }
# Only `FuturesUnordered`/`StreamExt` are used; the executor is not needed.
futures = { version = "0.3", default-features = false, features = ["std"] }
async-std = "1.13.2"
futures = "0.3"
rlimit = "0.11.0"
log = "0.4.32"
env_logger = "0.11.10"
-2
View File
@@ -56,13 +56,11 @@ Arch:
| <!--Installation Guide--> | <!--Documentation--> | <!--Discord--> |
| :----------------------------------------: | :------------------------------------: | :-------------------------: |
| :book: [Installation Guide][toc-install] | :books: [Documentation][links-table-2] | :parrot: [Discord][discord] |
| | :penguin: [ARM Support](docs/arm-support.md) | |
## 🙋 Table of Contents
- 📖 [Installation Guide][toc-install]
- 🐋 [Docker Usage][toc-docker-usage]
- 🐧 [ARM Support](docs/arm-support.md)
- 🦜 [Discord][discord]
- 🤸 [Usage][usage-1]
+5 -86
View File
@@ -1,10 +1,6 @@
use criterion::{criterion_group, criterion_main, Criterion};
use rustscan::generated::get_parsed_data;
use rustscan::input::{Opts, PortRanges, ScanOrder};
use rustscan::input::{Opts, PortRange, ScanOrder};
use rustscan::port_strategy::PortStrategy;
use rustscan::scanner::{build_udp_payload_lookup, Scanner};
use std::collections::BTreeMap;
use std::hint::black_box;
use std::net::IpAddr;
use std::time::Duration;
@@ -13,7 +9,10 @@ fn bench_address() {
}
fn bench_port_strategy() {
let range = PortRanges(vec![(1, 1_000)]);
let range = PortRange {
start: 1,
end: 1_000,
};
let _strategy = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
}
@@ -35,18 +34,6 @@ fn bench_address_parsing() {
let _ips = rustscan::address::parse_addresses(&opts);
}
// Replicates the old UDP payload selection behavior:
// scan the whole UDP payload map and find the last payload whose port list contains `port`.
fn old_payload_for_port(udp_map: &'static BTreeMap<Vec<u16>, Vec<u8>>, port: u16) -> &'static [u8] {
let mut payload: &'static [u8] = b"";
for (ports, value) in udp_map.iter() {
if ports.contains(&port) {
payload = value.as_slice();
}
}
payload
}
fn criterion_benchmark(c: &mut Criterion) {
// Benching helper functions
c.bench_function("parse address", |b| b.iter(bench_address));
@@ -59,74 +46,6 @@ fn criterion_benchmark(c: &mut Criterion) {
b.iter(bench_address_parsing)
});
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();
let lookup = build_udp_payload_lookup(udp_map);
let ports: Vec<u16> = (1..=4096).collect();
c.bench_function("udp payload lookup/old scan map 1..4096", |b| {
b.iter(|| {
for &p in ports.iter() {
let payload = old_payload_for_port(black_box(udp_map), black_box(p));
black_box(payload);
}
})
});
c.bench_function("udp payload lookup/new hashmap 1..4096", |b| {
b.iter(|| {
for &p in ports.iter() {
let payload = lookup.get(&p).copied().unwrap_or(b"");
black_box(payload);
}
})
});
}
criterion_group!(benches, criterion_benchmark);
+18 -90
View File
@@ -25,10 +25,6 @@ pub fn main() {
let mut curr = String::new();
for line in data.trim().split('\n') {
// Strip a trailing carriage return so CRLF checkouts parse
// identically to LF ones (otherwise `\r` pollutes port tokens and
// blank lines stop looking blank).
let line = line.strip_suffix('\r').unwrap_or(line);
if line.contains('#') || line.is_empty() {
continue;
}
@@ -171,43 +167,38 @@ fn payloads_v(fp_map: &BTreeMap<i32, String>) -> BTreeMap<i32, Vec<u8>> {
let mut payb_linenr: BTreeMap<i32, Vec<u8>> = BTreeMap::new();
for (&line_nr, data) in fp_map {
if data.contains('"') {
let start = data.find('"').expect("payload opening \" not found");
// Pass from the opening quote: the decoder pairs quotes itself
// to split multi-line segments.
payb_linenr.insert(line_nr, parser(data[start..].trim()));
if data.contains('\"') {
let start = data.find('\"').expect("payload opening \" not found");
let payloads = &data[start + 1..];
payb_linenr.insert(line_nr, parser(payloads.trim()));
}
}
payb_linenr
}
/// Converts a quoted Nmap payload block to bytes.
/// Converts a hexadecimal string to a Vec<u8>
///
/// # Arguments
///
/// * `payload` - The raw block text starting at its first opening quote:
/// one or more `"..."` segments separated by whitespace
/// * `payload` - A string slice containing the hexadecimal payload
///
/// # Returns
///
/// The decoded probe bytes: segments are decoded with [`decode_segment`]
/// and concatenated with no separators, so multi-line probes reassemble
/// exactly.
/// A vector of bytes representing the decoded payload
fn parser(payload: &str) -> Vec<u8> {
let payload = payload.trim_matches('"');
let mut tmp_str = String::new();
let mut bytes: Vec<u8> = Vec::new();
let mut rest = payload.trim();
while let Some(open) = rest.find('"') {
let after_open = &rest[open + 1..];
match split_segment(after_open) {
Some((segment, remainder)) => {
decode_segment(segment, &mut bytes);
rest = remainder;
}
None => {
// Unterminated trailing quote: decode what's left literally.
decode_segment(after_open, &mut bytes);
break;
for (idx, char) in payload.chars().enumerate() {
if char == '\\' && payload.chars().nth(idx + 1) == Some('x') {
continue;
} else if char.is_ascii_hexdigit() {
tmp_str.push(char);
if tmp_str.len() == 2 {
bytes.push(u8::from_str_radix(&tmp_str, 16).unwrap());
tmp_str.clear();
}
}
}
@@ -215,69 +206,6 @@ fn parser(payload: &str) -> Vec<u8> {
bytes
}
/// Splits off the first `"..."` segment: `text` must start just after an
/// opening quote. Escaped quotes (`\"`) do not terminate the segment.
///
/// Returns the segment body and the remainder after the closing quote.
fn split_segment(text: &str) -> Option<(&str, &str)> {
let mut escaped = false;
for (i, c) in text.char_indices() {
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if c == '"' {
return Some((&text[..i], &text[i + 1..]));
}
}
None
}
/// Decodes one quoted Nmap payload segment in C-style escape format:
/// `\xNN` becomes a byte, common single-char escapes (`\\`, `\"`,
/// `\0`, `\n`, `\r`, `\t`) decode to their value, and every other
/// character contributes its literal bytes.
///
/// Preserving literal text matters: several probes embed plain-text
/// protocol words (the SNMP `public` community string, NetBIOS names,
/// LDAP `objectClass`, SSDP headers). The previous hexdigits-only
/// decoding mangled them into wrong bytes, so agents never answered.
fn decode_segment(segment: &str, bytes: &mut Vec<u8>) {
let mut chars = segment.chars();
while let Some(c) = chars.next() {
if c != '\\' {
let mut buf = [0; 4];
bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
continue;
}
match chars.next() {
Some('x') => {
let hi = chars.next().unwrap_or('0');
let lo = chars.next().unwrap_or('0');
let hex: String = [hi, lo].iter().collect();
bytes.push(u8::from_str_radix(&hex, 16).unwrap_or(0));
}
Some('0') => bytes.push(0),
Some('n') => bytes.push(b'\n'),
Some('r') => bytes.push(b'\r'),
Some('t') => bytes.push(b'\t'),
Some('\\') => bytes.push(b'\\'),
Some('"') => bytes.push(b'"'),
// Unknown escape: keep the character literally, matching Nmap,
// which passes unrecognized escapes through untouched.
Some(other) => {
let mut buf = [0; 4];
bytes.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());
}
None => bytes.push(b'\\'),
}
}
}
/// Combines the ports BTreeMap and the Payloads BTreeMap
///
/// # Arguments
+5 -3
View File
@@ -1,5 +1,7 @@
# Dockerfile for RustScan development environment
# Provides a containerized setup with Rust and development tools
# Provides a containerized setup with Rust, nmap, and development tools
FROM rust
# Install rustfmt for code formatting and clippy for linting.
RUN rustup component add rustfmt clippy
# Install nmap first.
RUN apt-get update -qy && apt-get install -qy nmap
# Then install rustfmt for code formatting and clippy for linting.
RUN rustup component add rustfmt clippy
+2 -11
View File
@@ -54,10 +54,10 @@ You can now modify RustScan files with your favorite editor, once you want to co
root@container:/rustscan# cargo build
```
You can check the command-line interface without starting a scan:
You are now ready to use RustScan:
```bash
root@container:/rustscan# cargo run -- --help
root@container:/rustscan# cargo run -- -b 2000 -t 5000 -a 127.0.0.1
```
You can also format, lint with `clippy` and test the code with the following commands:
@@ -72,12 +72,3 @@ Automated tests and benchmarks must run without network traffic or external scri
execution. Keep coverage focused on parsing, configuration, port ordering, socket
address iteration, and timing helpers. Do not add tests that run TCP/UDP scans
(including localhost), resolve hostnames, or launch Nmap or other scripts.
Scanning behaviour and speed are measured separately, by the opt-in "Scan benchmark"
GitHub Actions workflow (`.github/workflows/scan-benchmark.yml`). It runs for pull
requests that touch the scanner, its runtime or its dependencies, and can be started
by hand from the Actions tab. It builds the change and its base in release mode,
scans listeners that it opens on 127.0.0.1 inside the runner, checks that both
builds find the same open ports, and adds a comparison table to the job summary. Its
script (`.github/scan-benchmark/scan_bench.py`) port-scans the loopback interface,
so it is meant for disposable CI runners only.
-38
View File
@@ -1,38 +0,0 @@
# ARM support
This page covers which ARM targets RustScan's CI builds and tests, and how to build ARM binaries yourself.
## What CI covers
| Target | Built by | Tested by |
| --- | --- | --- |
| `aarch64-unknown-linux-gnu` (64-bit ARM Linux) | `build-nix` job in [build.yml](../.github/workflows/build.yml) | `Test Suite (ubuntu-24.04-arm)` in [test.yml](../.github/workflows/test.yml) |
| `armv7-unknown-linux-gnueabihf` (32-bit ARM Linux) | `build-nix` job in [build.yml](../.github/workflows/build.yml) | not tested in CI |
| `aarch64-apple-darwin` (Apple Silicon macOS) | `build-macos-aarch64` job in [build.yml](../.github/workflows/build.yml) | `Test Suite (macos-latest)` in [test.yml](../.github/workflows/test.yml) |
- **Builds** use [`houseabsolute/actions-rust-cross`](https://github.com/houseabsolute/actions-rust-cross). It cross-compiles the Linux ARM targets with [`cross`](https://github.com/cross-rs/cross) on x86_64 runners.
- **Tests** run natively on GitHub-hosted arm64 runners: `ubuntu-24.04-arm` for Linux, and `macos-latest`, which runs on Apple Silicon. They run the same `just test` recipe as every other platform.
## Building ARM binaries locally
The simplest option is `cross`, which runs the build in a container that already has the right toolchain and linker. It needs Docker or Podman.
```sh
cargo install cross
cross build --locked --release --target aarch64-unknown-linux-gnu
# or, for 32-bit ARM:
cross build --locked --release --target armv7-unknown-linux-gnueabihf
```
The binary ends up at `target/<target>/release/rustscan`.
On an ARM machine (for example a Raspberry Pi running a 64-bit OS, or an Apple Silicon Mac) you don't need `cross`; a normal native build works:
```sh
cargo build --locked --release
```
## Troubleshooting
- **`error[E0463]: can't find crate for std`, or linker errors:** you're cross-compiling with plain `cargo`. Either use `cross`, or install the target (`rustup target add <target>`) along with a matching cross linker.
- **`cross` can't pull its image:** check that Docker or Podman is running and that your user can reach it.
-151
View File
@@ -1,151 +0,0 @@
{
"metadata": {
"base": "67c08a33100362c54b56fde784b0fd234f70fa11",
"candidate": "a05592a8a88b77ad85d961137943dfadfe98b612",
"environment": "Linux-7.2.6-1-cachyos-x86_64-with-glibc2.44",
"rust": "rustc 1.98.1 (48a229cea 2026-09-01) (Arch Linux rust 1:1.98.1-1.1)",
"hosts_bytes": 2760051,
"profile": "release with LTO",
"network_namespace": "unshare --user --map-root-user --net",
"common_args": [
"--no-config",
"--config-path",
"/dev/null",
"--no-banner",
"--scripts",
"none",
"--greppable",
"--ports",
"80",
"--exclude-ports",
"80"
],
"workloads": {
"literal-ipv4": [
"-a",
"127.0.0.1"
],
"mixed-cidrs": [
"-a",
"127.0.0.1,192.0.2.0/30,2001:db8::/126",
"--exclude-addresses",
"192.0.2.1,2001:db8::1"
]
},
"warmups_each": 2,
"samples_each": 20,
"binary_cpus": "0-3",
"harness_cpus": "8-9",
"binary_sha256": {
"base": "4d6b090218ff36249ed95f14ec1a82626e8ea33854f3823f19a6de247b1524b8",
"dns": "a9d6bf949f56c53d18cc85bc9b53b21bd85a63bcdd04ad78992d392c675c77ac"
}
},
"summary": {
"literal-ipv4": {
"base_ms": 168.23068849999999,
"dns_ms": 2.248762,
"samples_each": 20
},
"mixed-cidrs": {
"base_ms": 178.0855925,
"dns_ms": 2.4539600000000004,
"samples_each": 20
}
},
"samples": {
"literal-ipv4": {
"base": [
143.391226,
146.562804,
149.615525,
151.188597,
152.269193,
159.499601,
164.926958,
164.347529,
167.208931,
165.433184,
172.246939,
169.252446,
172.777899,
170.729249,
170.092681,
171.863255,
169.612842,
170.857396,
173.117403,
175.206345
],
"dns": [
2.241793,
1.817341,
2.39956,
2.048986,
3.123401,
1.820017,
3.615094,
2.165312,
2.675535,
2.103261,
2.531392,
2.215141,
2.572939,
2.063803,
2.671431,
2.059846,
2.598858,
2.230916,
2.678259,
2.255731
]
},
"mixed-cidrs": {
"base": [
175.504767,
178.797842,
186.183728,
176.885958,
177.989816,
180.028568,
177.66299,
174.757221,
175.468485,
175.328023,
177.639639,
175.637593,
177.922435,
185.394396,
182.587204,
180.58311,
178.961137,
178.293186,
180.482419,
178.181369
],
"dns": [
3.275193,
2.114074,
2.700771,
2.183317,
2.606538,
2.368046,
2.599702,
2.238105,
2.683892,
2.292249,
2.528366,
2.114478,
2.668921,
2.103271,
3.275514,
2.27974,
3.103311,
2.265298,
2.580743,
2.379554
]
}
},
"invocations": 88
}
-48
View File
@@ -1,48 +0,0 @@
# Lazy DNS initialization benchmark
Compared current Tokio master `67c08a33100362c54b56fde784b0fd234f70fa11` with the lazy-resolver implementation
at `a05592a8a88b77ad85d961137943dfadfe98b612` on 2026-10-02.
## Method
Both binaries are release builds with LTO, using Rust 1.98.1 on an Intel Core
Ultra 7 165U running Linux 7.2.6-1-cachyos. Each workload used twenty measured
runs and two warmups per binary, alternating A/B and B/A order. Binaries used
CPUs 0-3; the harness used CPUs 8-9. No builds ran during measurement.
The harness ran in a private network namespace. Every invocation excluded
its only requested port, so no scan traffic was sent. Configuration and
scripts were disabled. Timing spans process launch through exit, including
the same `taskset` affinity launcher for both builds.
Common arguments:
```sh
--no-config --config-path /dev/null --no-banner --scripts none --greppable \
--ports 80 --exclude-ports 80
```
The literal case adds `-a 127.0.0.1`. The mixed case adds
`-a 127.0.0.1,192.0.2.0/30,2001:db8::/126` and
`--exclude-addresses 192.0.2.1,2001:db8::1`. Both use `RUST_LOG=rustscan=info`.
## Results
| Workload | Master median | Lazy DNS median | Time saved |
| --- | ---: | ---: | ---: |
| Literal IPv4 | 168.23 ms | 2.25 ms | 165.98 ms (98.66%) |
| IPv4/IPv6 CIDRs and exclusions | 178.09 ms | 2.45 ms | 175.63 ms (98.62%) |
All 88 invocations exited successfully with the expected empty scan output.
A separate syscall trace of the optimized literal-IP invocation recorded no
reads of `/etc/hosts` or `/etc/resolv.conf` and no network connects or sends.
This host has a 2,760,051-byte hosts file. Eager Hickory initialization parses
that file even for numeric targets. Systems with smaller hosts files will
save less; this measures CLI startup, not socket throughput. Hostnames that
need fallback DNS still pay resolver initialization once, and parsing order,
file handling, deduplication and exclusions keep their existing behavior.
[Raw samples, setup and binary hashes](lazy-dns-benchmark-results.json) are
included. Network-free regression tests verify that literal IPv4/IPv6 targets,
CIDRs, exclusions, duplicates and empty inputs do not construct the resolver.
File diff suppressed because it is too large. Load diff
-114
View File
@@ -1,114 +0,0 @@
# Port exclusion preparation benchmark
Compared Tokio master `b7119f7f3eb1601e81126ef3572699ed7b3b7c0b` with
implementation `270561d553688acbd99f61c460cdfa7c76c1423e` on 2026-10-02.
## Method
Release builds with LTO, Rust 1.98.1, Intel Core Ultra 7 165U, Linux
7.2.6-1-cachyos. Both versions use the same lockfile and identical
`benches/benchmark_helpers.rs`; copy the final benchmark harness into the
baseline worktree before building it. The benchmark runs the production
`Scanner::run_with_status` with **zero target addresses**, so it opens no
sockets and does no DNS or scripting. It includes generating/cloning the
port list, filtering and empty scan setup. Scanner construction and the
current-thread Tokio runtime construction are outside the timer.
Manual ports are `1..=port_count`; exclusions are
`(i * 13 % 65536) as u16` for `i` in `0..excluded_count`. Each case has two
independent twenty-sample Criterion runs per build, a 500 ms warmup and a
2 s target measurement. Cases run in A/B then B/A order on CPU 0. No builds
or other local checks ran during timing. CPU frequency was not fixed on
this shared laptop, so the table retains both repetitions rather than
selecting one result. Values are Criterion's mean point estimates.
Build command for each worktree:
```sh
cargo bench --locked --bench benchmark_helpers --no-run
```
The saved benchmark executables are then run serially, for each case:
```sh
CARGO_TARGET_DIR=/tmp/preparation-results taskset -c 0 /path/to/benchmark_helpers \
--bench 'port preparation/65535 ports, 4096 exclusions' --save-baseline base-a
```
Repeat with the candidate binary as `head-a`, then the candidate as
`head-b` and the baseline as `base-b`. To run the group normally without
comparing builds:
```sh
cargo bench --locked --bench benchmark_helpers -- 'port preparation'
```
## Results
Times are microseconds; A and B are separate matched repetitions.
| Ports | Exclusions | Master µs A / B | Optimized µs A / B | Time saved A / B |
| ---: | ---: | ---: | ---: | ---: |
| 1 | 1 | 0.289 / 0.286 | 0.261 / 0.271 | 9.7% / 5.2% |
| 16 | 4 | 0.260 / 0.268 | 0.232 / 0.237 | 10.6% / 11.6% |
| 64 | 1 | 0.433 / 0.437 | 0.237 / 0.239 | 45.2% / 45.4% |
| 64 | 4,096 | 6.231 / 10.093 | 2.484 / 3.038 | 60.1% / 69.9% |
| 4,096 | 0 | 10.889 / 10.037 | 0.335 / 0.397 | 96.9% / 96.0% |
| 4,096 | 64 | 20.680 / 20.363 | 2.930 / 3.064 | 85.8% / 85.0% |
| 4,096 | 4,096 | 524.223 / 519.670 | 6.805 / 6.710 | 98.7% / 98.7% |
| 65,535 | 0 | 166.113 / 115.686 | 3.655 / 2.333 | 97.8% / 98.0% |
| 65,535 | 4 | 175.717 / 181.343 | 29.769 / 30.230 | 83.1% / 83.3% |
| 65,535 | 64 | 207.617 / 206.910 | 31.595 / 31.462 | 84.8% / 84.8% |
| 65,535 | 1,024 | 2018.463 / 3271.564 | 33.403 / 33.520 | 98.3% / 99.0% |
| 65,535 | 4,096 | 8811.234 / 8469.706 | 59.853 / 63.211 | 99.3% / 99.3% |
All twelve cases improved in both repetitions. This measures **preparation,
not end-to-end network scan speed**: many scans are dominated by socket I/O,
timeouts and reporting. In particular, the default no-exclusion saving is
only a fraction of a millisecond.
## Implementation and correctness
With no exclusions, keep the already generated vector. For fewer than 64
ports, filter in place with linear membership checks; this avoids bitmap
setup and a second allocation. Larger lists use an 8 KiB bitmap covering
all 65,536 values representable by `u16`, then retain ports in place. This
changes the large-list filtering cost from ports × exclusions to ports +
exclusions, while preserving order, duplicate ports and boundary values.
The helper is kept out of line: release assembly showed an inlined bitmap
reserved 8,424 bytes in every async poll, whereas the final poll frame uses
344 bytes and the helper reserves its bitmap only during preparation.
Exploration found that in-place linear membership slows large cases. A
first production candidate also slowed 64 ports with 4,096 exclusions;
using the bitmap for that case corrected the regression before the final
matched runs. The cutoff is a performance choice, not a limit on input.
One focused network-free regression test compares the implementation with
an independent membership oracle for empty inputs, endpoints 0/65535,
duplicate ports/exclusions, all ports, and a frozen random order.
`cargo test --locked` passes 74 tests with one pre-existing ignored doctest.
All-target Clippy, formatting, documentation and diff checks pass. The
CI-only scan harness adds an excluded-port sweep using the existing
listeners, checking expected results and using the actual attempted socket
count for throughput.
[Raw Criterion samples, confidence intervals, revisions and binary hashes](port-exclusion-benchmark-results.json)
are included. Hosted correctness/performance results are linked in the PR.
## macOS runner setup
Repeated hosted sweeps on Darwin 25.6 returned `ENOBUFS` (error 55) on
both master and the candidate, missing fixture listeners. Such runs are
invalid for performance claims. The workflow provisions the TCP memory
budget once before either build is measured, raising it from the default
1/32 to 1/8 of physical memory when necessary, and prints the actual limit
and allocation counters. It retains every scenario, repeat, listener
comparison and performance threshold. An immediate untimed diagnostic
scan records errors and kernel counters if a measured pair misses a
listener; that failed dataset is not used to claim a speedup.
This uses XNU's [TCP budget initialization](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/netinet/tcp_subr.c)
and [memory accounting interface](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/mem_acct_private.h).
It changes the disposable benchmark environment, not RustScan's behavior
or the operating system configuration on users' machines.
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/perl
#tags = ["core_approved", "example",]
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "perl {{script}} {{ip}} {{port}}"
# The Scriptfile parser stops parsing at the first blank line.
# This script will run itself as an argument with the system installed perl interpreter, ports will be concatenated with "," .
# Unused field: trigger_port = "80"
# get total arg passed to this script
my $total = $#ARGV + 1;
my $counter = 1;
# get script name
my $scriptname = $0;
print "Total args passed to $scriptname : $total\n";
# Use loop to print all args stored in an array called @ARGV
foreach my $a(@ARGV) {
print "Arg # $counter : $a\n";
$counter++;
}
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/python3
#tags = ["core_approved", "example",]
#developer = [ "example", "https://example.org" ]
#trigger_port = "80"
#call_format = "python3 {{script}} {{ip}} {{port}}"
# The Scriptfile parser stops parsing at the first blank line.
# This script will run itself as an argument with the system installed python interpreter, only scanning port 80.
# Unused filed: ports_separator = ","
import sys
print('Python script ran with arguments', str(sys.argv))
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
#tags = ["core_approved", "example",]
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "bash {{script}} {{ip}} {{port}}"
# The Scriptfile parser stops parsing at the first blank line.
# This script will run itself as an argument with the system installed bash interpreter, scanning all ports concatenated with "," .
# Unused filed: trigger_port = "80"
# print all arguments passed to the script
echo $@
+5 -3
View File
@@ -1,7 +1,9 @@
Metadata-only test fixture; not executable.
#!intentional_blank_line
#tags = ["core_approved", "example"]
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "fixture {{ip}} {{port}}"
#call_format = "nmap -vvv -p {{port}} {{ip}}"
The parser reads only the metadata above. This file contains no script body.
# The Scriptfile parser stops parsing at the first blank line.
# This script will run the system installed nmap, ports will be concatenated with "," .
# Unused field: trigger_port = "80"
@@ -1,7 +1,7 @@
Metadata-only test fixture; not executable.
#!intentional_blank_line
#tags = "core_approved"
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "fixture {{ip}} {{port}}"
#call_format = "nmap -vvv -p {{port}} {{ip}}"
# tags has to be an array, thus this file won't be parsed
# tags has to be an array, thus this file won't be parsed
View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
127.0.0.1
google.com
example.com
66666666666666.666666666666666666.66666666666666666.6666666666
....
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.radiatorFixtures
+17 -222
View File
@@ -1,5 +1,4 @@
//! Provides functions to parse input IP addresses, CIDRs or files.
use std::cell::LazyCell;
use std::collections::BTreeSet;
use std::fs::{self, File};
use std::io::{prelude::*, BufReader};
@@ -31,32 +30,13 @@ use crate::warning;
/// ```
///
/// Finally, any duplicates are removed to avoid excessive scans.
///
/// # Blocking
///
/// This function blocks: it reads files and resolves host names, and
/// hickory's synchronous [`Resolver`] runs a Tokio runtime of its own, which
/// Tokio refuses to drop inside an asynchronous context. Call it before you
/// start your runtime (as the `rustscan` binary does) or from
/// `tokio::task::spawn_blocking`, not directly from async code.
pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
parse_addresses_with_resolver(input, || get_resolver(&input.resolver))
}
fn parse_addresses_with_resolver(
input: &Opts,
create_resolver: impl FnOnce() -> Resolver,
) -> Vec<IpAddr> {
let mut ips: Vec<IpAddr> = Vec::new();
let mut unresolved_addresses: Vec<&str> = Vec::new();
// Most scans use literal IPs or CIDRs. Defer the resolver's runtime and
// hosts-file loading until a DNS fallback actually needs them, then reuse
// that resolver for the rest of the targets and exclusions.
let backup_resolver = LazyCell::new(create_resolver);
let resolver = || &*backup_resolver;
let backup_resolver = get_resolver(&input.resolver);
for address in &input.addresses {
let parsed_ips = parse_address_with_resolver(address, &resolver);
let parsed_ips = parse_address(address, &backup_resolver);
if !parsed_ips.is_empty() {
ips.extend(parsed_ips);
} else {
@@ -78,7 +58,7 @@ fn parse_addresses_with_resolver(
continue;
}
if let Ok(x) = read_ips_from_file(file_path, &resolver) {
if let Ok(x) = read_ips_from_file(file_path, &backup_resolver) {
ips.extend(x);
} else {
warning!(
@@ -89,7 +69,7 @@ fn parse_addresses_with_resolver(
}
}
let excluded_cidrs = parse_excluded_networks_with_resolver(&input.exclude_addresses, &resolver);
let excluded_cidrs = parse_excluded_networks(&input.exclude_addresses, &backup_resolver);
// Remove duplicated/excluded IPs.
let mut seen = BTreeSet::new();
@@ -112,13 +92,6 @@ fn parse_addresses_with_resolver(
/// let ips = parse_address("127.0.0.1", &Resolver::default().unwrap());
/// ```
pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
parse_address_with_resolver(address, &|| resolver)
}
fn parse_address_with_resolver<'a>(
address: &str,
resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpAddr> {
if let Ok(addr) = IpAddr::from_str(address) {
// `address` is an IP string
vec![addr]
@@ -137,17 +110,14 @@ fn parse_address_with_resolver<'a>(
}
/// Uses DNS to get the IPS associated with host
fn resolve_ips_from_host<'a>(
source: &str,
backup_resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpAddr> {
fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr> {
let mut ips: Vec<IpAddr> = Vec::new();
if let Ok(addrs) = source.to_socket_addrs() {
for ip in addrs {
ips.push(ip.ip());
}
} else if let Ok(addrs) = backup_resolver().lookup_ip(source) {
} else if let Ok(addrs) = backup_resolver.lookup_ip(source) {
ips.extend(addrs.iter());
}
@@ -170,13 +140,6 @@ fn resolve_ips_from_host<'a>(
pub fn parse_excluded_networks(
exclude_addresses: &Option<Vec<String>>,
resolver: &Resolver,
) -> Vec<IpCidr> {
parse_excluded_networks_with_resolver(exclude_addresses, &|| resolver)
}
fn parse_excluded_networks_with_resolver<'a>(
exclude_addresses: &Option<Vec<String>>,
resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpCidr> {
exclude_addresses
.iter()
@@ -186,10 +149,7 @@ fn parse_excluded_networks_with_resolver<'a>(
}
/// Parses a single address into an IpCidr, handling CIDR notation, IP addresses, and hostnames.
fn parse_single_excluded_address<'a>(
addr: &str,
resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpCidr> {
fn parse_single_excluded_address(addr: &str, resolver: &Resolver) -> Vec<IpCidr> {
if let Ok(cidr) = IpCidr::from_str(addr) {
return vec![cidr];
}
@@ -231,56 +191,17 @@ fn get_resolver(resolver: &Option<String>) -> Resolver {
Protocol::Udp,
));
}
Resolver::new(config, resolver_opts()).unwrap()
Resolver::new(config, ResolverOpts::default()).unwrap()
}
None => match system_resolver() {
None => match Resolver::from_system_conf() {
Ok(resolver) => resolver,
Err(_) => Resolver::new(ResolverConfig::cloudflare_tls(), resolver_opts()).unwrap(),
Err(_) => {
Resolver::new(ResolverConfig::cloudflare_tls(), ResolverOpts::default()).unwrap()
}
},
}
}
/// `true` on Windows when the `SystemRoot` environment variable is unset.
///
/// hickory-resolver locates the hosts file via
/// `std::env::var_os("SystemRoot").expect(...)`, which panics when the
/// variable is missing. Processes spawned with a minimal environment
/// (services, scheduled tasks, WMI) can lack `SystemRoot`, and the panic is
/// fatal under this crate's `panic = "abort"` release profile.
fn windows_system_root_missing() -> bool {
cfg!(windows) && std::env::var_os("SystemRoot").is_none()
}
/// Derives a resolver from the system configuration, e.g. `/etc/resolv.conf`
/// on *nix or the registry on Windows.
///
/// Returns an error without touching the system configuration when doing so
/// would panic inside hickory-resolver (see [`windows_system_root_missing`]).
fn system_resolver() -> std::io::Result<Resolver> {
if windows_system_root_missing() {
debug!("SystemRoot is not set; skipping system resolver configuration");
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"SystemRoot environment variable is not set",
));
}
Resolver::from_system_conf()
}
/// Resolver options that are safe to use in the current environment.
///
/// hickory-resolver eagerly loads the hosts file when `use_hosts_file` is set
/// (the default), which hits the same missing-`SystemRoot` panic described in
/// [`windows_system_root_missing`]; disable it in that case.
fn resolver_opts() -> ResolverOpts {
let mut opts = ResolverOpts::default();
if windows_system_root_missing() {
opts.use_hosts_file = false;
}
opts
}
/// Parses and input file of IPs for use in DNS resolution.
fn read_resolver_from_file(path: &str) -> Result<Vec<IpAddr>, std::io::Error> {
let ips = fs::read_to_string(path)?
@@ -293,9 +214,9 @@ fn read_resolver_from_file(path: &str) -> Result<Vec<IpAddr>, std::io::Error> {
#[cfg(not(tarpaulin_include))]
/// Parses an input file of IPs and uses those
fn read_ips_from_file<'a>(
fn read_ips_from_file(
ips: &std::path::Path,
backup_resolver: &impl Fn() -> &'a Resolver,
backup_resolver: &Resolver,
) -> Result<Vec<IpAddr>, std::io::Error> {
let file = File::open(ips)?;
let reader = BufReader::new(file);
@@ -304,7 +225,7 @@ fn read_ips_from_file<'a>(
for address_line in reader.lines() {
if let Ok(address) = address_line {
ips.extend(parse_address_with_resolver(&address, backup_resolver));
ips.extend(parse_address(&address, backup_resolver));
} else {
debug!("Line in file is not valid");
}
@@ -315,47 +236,8 @@ fn read_ips_from_file<'a>(
#[cfg(test)]
mod tests {
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());
}
use super::{parse_addresses, Opts};
use std::net::Ipv4Addr;
#[test]
fn parse_correct_addresses() {
@@ -460,91 +342,4 @@ mod tests {
assert_eq!(ips.len(), 256);
}
#[test]
fn parse_non_canonical_cidr_mid_block() {
// 192.168.1.13/29: .13 = 0000 1101, mask clears last 3 bits → .8 = 0000 1000
// network is 192.168.1.8/29, spanning .8 through .15
let opts = Opts {
addresses: vec!["192.168.1.13/29".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(192, 168, 1, 8),
Ipv4Addr::new(192, 168, 1, 9),
Ipv4Addr::new(192, 168, 1, 10),
Ipv4Addr::new(192, 168, 1, 11),
Ipv4Addr::new(192, 168, 1, 12),
Ipv4Addr::new(192, 168, 1, 13),
Ipv4Addr::new(192, 168, 1, 14),
Ipv4Addr::new(192, 168, 1, 15),
]
);
}
#[test]
fn parse_non_canonical_cidr_last_in_block() {
// 192.168.1.15/29: last address in the block, should still resolve to same .8–.15 network
let opts = Opts {
addresses: vec!["192.168.1.15/29".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(192, 168, 1, 8),
Ipv4Addr::new(192, 168, 1, 9),
Ipv4Addr::new(192, 168, 1, 10),
Ipv4Addr::new(192, 168, 1, 11),
Ipv4Addr::new(192, 168, 1, 12),
Ipv4Addr::new(192, 168, 1, 13),
Ipv4Addr::new(192, 168, 1, 14),
Ipv4Addr::new(192, 168, 1, 15),
]
);
}
#[test]
fn parse_non_canonical_cidr_crosses_third_octet() {
// 192.168.1.5/23: host bits span into the third octet
// .1.5 in 23-bit context → network is 192.168.0.0/23, spanning .0.0 through .1.255 (512 addresses)
let opts = Opts {
addresses: vec!["192.168.1.5/23".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips.first(),
Some(&IpAddr::V4(Ipv4Addr::new(192, 168, 0, 0)))
);
assert_eq!(
ips.last(),
Some(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 255)))
);
assert_eq!(ips.len(), 512);
}
#[test]
fn parse_non_canonical_cidr_slash30() {
// 10.0.0.7/30: .7 = 0000 0111, mask clears last 2 bits → .4 = 0000 0100
// network is 10.0.0.4/30, spanning .4 through .7
let opts = Opts {
addresses: vec!["10.0.0.7/30".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(10, 0, 0, 4),
Ipv4Addr::new(10, 0, 0, 5),
Ipv4Addr::new(10, 0, 0, 6),
Ipv4Addr::new(10, 0, 0, 7),
]
);
}
}
+44 -305
View File
@@ -27,94 +27,35 @@ pub enum ScriptsRequired {
Custom,
}
/// The port ranges to scan, as inclusive `(start, end)` pairs.
///
/// Parsed from `-r/--range` (e.g. `1-500,1000-2500`) and from the `range`
/// key of the configuration file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortRanges(pub Vec<(u16, u16)>);
/// Accepted spellings of `range` in the configuration file.
#[derive(Deserialize)]
#[serde(untagged)]
enum PortRangesConfig {
/// `range = { start = 1, end = 1000 }`: the format used before multiple
/// ranges were supported, kept so existing configuration files still work.
Single { start: u16, end: u16 },
/// `range = "1-500,1000-2500"`: the same syntax as `--range`.
Text(String),
/// `range = [[1, 500], [1000, 2500]]`.
Pairs(Vec<(u16, u16)>),
}
impl<'de> serde::Deserialize<'de> for PortRanges {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let pairs = match PortRangesConfig::deserialize(deserializer)? {
PortRangesConfig::Single { start, end } => vec![(start, end)],
PortRangesConfig::Text(text) => {
return parse_ranges(&text).map_err(serde::de::Error::custom)
}
PortRangesConfig::Pairs(pairs) => pairs,
};
if pairs.is_empty() {
return Err(serde::de::Error::custom("expected at least one port range"));
}
if let Some(&(start, end)) = pairs.iter().find(|&&(start, end)| start > end) {
return Err(serde::de::Error::custom(format!(
"invalid port range {start}-{end}: start must not be greater than end"
)));
}
Ok(PortRanges(pairs))
}
/// Represents the range of ports to be scanned.
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct PortRange {
pub start: u16,
pub end: u16,
}
#[cfg(not(tarpaulin_include))]
/// Parse a single `start-end` token (e.g. "100-200") into `(start, end)`.
/// Returns `None` when the token is malformed, cannot be parsed as `u16`,
/// or `start > end`.
fn parse_range(input: &str) -> Option<(u16, u16)> {
let mut parts = input.trim().splitn(2, '-').map(str::trim);
let a = parts.next()?;
let b = parts.next()?;
fn parse_range(input: &str) -> Result<PortRange, String> {
let range = input
.split('-')
.map(str::parse)
.collect::<Result<Vec<u16>, std::num::ParseIntError>>();
let start = a.parse::<u16>().ok()?;
let end = b.parse::<u16>().ok()?;
if start <= end {
Some((start, end))
} else {
None
}
}
#[cfg(not(tarpaulin_include))]
/// Parse a comma-separated list of `start-end` ranges into `PortRanges`.
///
/// Errors with a helpful message identifying the bad token.
fn parse_ranges(input: &str) -> Result<PortRanges, String> {
let s = input.trim();
if s.is_empty() {
return Err("empty input: expected one or more comma-separated 'start-end' pairs".into());
if range.is_err() {
return Err(String::from(
"the range format must be 'start-end'. Example: 1-1000.",
));
}
let ranges_res: Result<Vec<(u16, u16)>, String> = s
.split(',')
.map(|token| {
let t = token.trim();
parse_range(t).ok_or_else(|| {
format!(
"invalid range token `{}` — expected `start-end` with 0 <= start <= end <= 65535",
t
)
})
})
.collect();
ranges_res.map(PortRanges)
match range.unwrap().as_slice() {
[start, end] => Ok(PortRange {
start: *start,
end: *end,
}),
_ => Err(String::from(
"the range format must be 'start-end'. Example: 1-1000.",
)),
}
}
#[derive(Parser, Debug, Clone)]
@@ -139,9 +80,9 @@ pub struct Opts {
#[arg(short, long, value_delimiter = ',')]
pub ports: Option<Vec<u16>>,
/// Ranges of ports as comma-separated start-end pairs. Example: 1-500,1000-2500,4000-7000
#[arg(short, long, conflicts_with = "ports", value_parser = parse_ranges)]
pub range: Option<PortRanges>,
/// A range of ports with format start-end. Example: 1-1000.
#[arg(short, long, conflicts_with = "ports", value_parser = parse_range)]
pub range: Option<PortRange>,
/// Whether to ignore the configuration file or not.
#[arg(short, long)]
@@ -183,8 +124,7 @@ pub struct Opts {
#[arg(long, default_value = "1")]
pub tries: u8,
/// Automatically increases the Unix file-descriptor limit.
#[cfg_attr(not(unix), arg(hide = true))]
/// Automatically ups the ULIMIT with the value you provided.
#[arg(short, long)]
pub ulimit: Option<usize>,
@@ -221,16 +161,6 @@ pub struct Opts {
/// UDP scanning mode, finds UDP ports that send back responses
#[arg(long)]
pub udp: bool,
/// Also list TCP ports that actively refused the connection (closed).
/// Closed ports are only printed; scripts are never run against them.
#[arg(long)]
pub closed: bool,
/// Milliseconds to wait after scanning a port (on every address) before
/// scanning the next one, for slow, low-noise scans. 0 disables the delay.
#[arg(long, default_value = "0", value_name = "MS")]
pub interval: u64,
}
#[cfg(not(tarpaulin_include))]
@@ -239,33 +169,15 @@ impl Opts {
let mut opts = Opts::parse();
if opts.ports.is_none() && opts.range.is_none() {
opts.range = Some(PortRanges(vec![(LOWEST_PORT_NUMBER, TOP_PORT_NUMBER)]));
opts.range = Some(PortRange {
start: LOWEST_PORT_NUMBER,
end: TOP_PORT_NUMBER,
});
}
opts
}
/// Validates options whose availability or semantics depend on the
/// operating system.
///
/// # Errors
///
/// Returns an error when an option is unsupported on the current platform.
pub fn validate_platform(&self) -> Result<(), String> {
#[cfg(not(unix))]
{
if self.ulimit.is_some() {
return Err(
"--ulimit is only supported on Unix-like operating systems. \
On Windows, use --batch-size (-b) to control scan concurrency."
.to_owned(),
);
}
}
Ok(())
}
/// Reads the command line arguments into an Opts struct and merge
/// values found within the user configuration file.
pub fn merge(&mut self, config: &Config) {
@@ -288,7 +200,7 @@ impl Opts {
merge_required!(
addresses, greppable, accessible, batch_size, timeout, tries, scan_order, scripts,
command, udp, no_banner, closed, interval
command, udp, no_banner
);
}
@@ -335,8 +247,6 @@ impl Default for Opts {
exclude_ports: None,
exclude_addresses: None,
udp: false,
closed: false,
interval: 0,
}
}
}
@@ -349,7 +259,7 @@ impl Default for Opts {
pub struct Config {
addresses: Option<Vec<String>>,
ports: Option<Vec<u16>>,
range: Option<PortRanges>,
range: Option<PortRange>,
greppable: Option<bool>,
accessible: Option<bool>,
batch_size: Option<usize>,
@@ -364,8 +274,6 @@ pub struct Config {
exclude_addresses: Option<Vec<String>>,
udp: Option<bool>,
no_banner: Option<bool>,
closed: Option<bool>,
interval: Option<u64>,
}
#[cfg(not(tarpaulin_include))]
@@ -383,24 +291,15 @@ impl Config {
/// scan_order = "Serial"
/// exclude_ports = [8080, 9090, 80]
/// udp = false
/// closed = false
/// interval = 0
///
pub fn read(custom_config_path: Option<PathBuf>) -> Self {
let mut content = String::new();
let config_path = custom_config_path.unwrap_or_else(|| {
// Try the XDG-idiomatic location first, then fall back to legacy
// paths so existing users keep working unchanged.
for path in [
default_config_path(),
legacy_dot_config_path(),
old_default_config_path(),
] {
if path.exists() {
return path;
}
let path = default_config_path();
match path.exists() {
true => path,
false => old_default_config_path(),
}
default_config_path()
});
if config_path.exists() {
@@ -413,9 +312,7 @@ impl Config {
let config: Config = match toml::from_str(&content) {
Ok(config) => config,
Err(e) => {
crate::tui::println_safe(format_args!(
"Found {e} in configuration file.\nAborting scan.\n"
));
println!("Found {e} in configuration file.\nAborting scan.\n");
std::process::exit(1);
}
};
@@ -424,21 +321,8 @@ impl Config {
}
}
/// Returns the preferred config file path: `$XDG_CONFIG_HOME/rustscan/config.toml`
/// on Linux (with the usual `~/.config` fallback when the variable is unset),
/// and the platform-equivalent `dirs::config_dir()` location on macOS / Windows.
/// Constructs default path to config toml
pub fn default_config_path() -> PathBuf {
let Some(mut config_path) = dirs::config_dir() else {
panic!("Could not infer config file path.");
};
config_path.push("rustscan");
config_path.push("config.toml");
config_path
}
/// Returns the transitional `$XDG_CONFIG_HOME/.rustscan.toml` path that older
/// builds wrote to. Kept readable for backwards compatibility.
pub fn legacy_dot_config_path() -> PathBuf {
let Some(mut config_path) = dirs::config_dir() else {
panic!("Could not infer config file path.");
};
@@ -460,7 +344,7 @@ mod tests {
use clap::{CommandFactory, Parser};
use parameterized::parameterized;
use super::{Config, Opts, PortRanges, ScanOrder, ScriptsRequired};
use super::{Config, Opts, PortRange, ScanOrder, ScriptsRequired};
impl Config {
fn default() -> Self {
@@ -482,8 +366,6 @@ mod tests {
exclude_addresses: None,
udp: Some(false),
no_banner: None,
closed: Some(false),
interval: None,
}
}
}
@@ -513,82 +395,6 @@ mod tests {
assert_eq!(command, opts.command);
}
#[test]
fn parses_explicit_batch_size() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "-b", "1234"]);
assert_eq!(opts.batch_size, 1234);
}
#[test]
fn parses_explicit_long_batch_size() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--batch-size", "4321"]);
assert_eq!(opts.batch_size, 4321);
}
#[test]
#[cfg(windows)]
fn windows_platform_validation_accepts_batch_size() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--batch-size", "500"]);
assert!(opts.validate_platform().is_ok());
assert_eq!(opts.batch_size, 500);
}
#[test]
#[cfg(windows)]
fn windows_rejects_ulimit() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--ulimit", "5000"]);
let error = opts
.validate_platform()
.expect_err("Windows must reject --ulimit");
assert!(error.contains("--ulimit"));
assert!(error.contains("Unix"));
assert!(error.contains("--batch-size"));
}
#[test]
#[cfg(windows)]
fn windows_hides_ulimit_from_help() {
let help = Opts::command().render_long_help().to_string();
assert!(
!help.contains("--ulimit"),
"--ulimit should not be advertised on Windows"
);
}
#[test]
#[cfg(windows)]
fn windows_rejects_ulimit_from_config_merge() {
let mut opts = Opts {
no_config: false,
..Default::default()
};
let mut config = Config::default();
config.ulimit = Some(5_000);
opts.merge(&config);
let error = opts
.validate_platform()
.expect_err("Windows must reject --ulimit supplied by configuration");
assert!(error.contains("--ulimit"));
}
#[test]
#[cfg(unix)]
fn unix_platform_validation_accepts_ulimit() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--ulimit", "5000"]);
assert!(opts.validate_platform().is_ok());
assert_eq!(opts.ulimit, Some(5_000));
}
#[test]
fn opts_no_merge_when_config_is_ignored() {
let mut opts = Opts::default();
@@ -624,7 +430,10 @@ mod tests {
fn opts_merge_optional_arguments() {
let mut opts = Opts::default();
let mut config = Config::default();
config.range = Some(PortRanges(vec![(1, 1_000)]));
config.range = Some(PortRange {
start: 1,
end: 1_000,
});
config.ulimit = Some(1_000);
config.resolver = Some("1.1.1.1".to_owned());
@@ -634,74 +443,4 @@ mod tests {
assert_eq!(opts.ulimit, config.ulimit);
assert_eq!(opts.resolver, config.resolver);
}
#[test]
fn parses_comma_separated_ranges() {
let opts = Opts::parse_from([
"rustscan",
"-a",
"127.0.0.1",
"-r",
"1-100, 200-300,5000-5100",
]);
assert_eq!(
opts.range,
Some(PortRanges(vec![(1, 100), (200, 300), (5_000, 5_100)]))
);
}
#[test]
fn rejects_malformed_ranges() {
for range in ["", "300-200", "1-100,", "1-2-3", "a-b", "1-70000"] {
assert!(
Opts::try_parse_from(["rustscan", "-a", "127.0.0.1", "-r", range]).is_err(),
"{:?} should be rejected",
range
);
}
}
#[test]
fn config_range_accepts_legacy_table() {
let config: Config = toml::from_str("range = { start = 1, end = 1000 }").unwrap();
assert_eq!(config.range, Some(PortRanges(vec![(1, 1_000)])));
}
#[test]
fn config_range_accepts_string_and_pairs() {
let expected = Some(PortRanges(vec![(1, 100), (200, 300)]));
let config: Config = toml::from_str("range = \"1-100,200-300\"").unwrap();
assert_eq!(config.range, expected);
let config: Config = toml::from_str("range = [[1, 100], [200, 300]]").unwrap();
assert_eq!(config.range, expected);
}
#[test]
fn config_range_rejects_invalid_ranges() {
for range in [
"range = { start = 10, end = 1 }",
"range = [[10, 1]]",
"range = []",
"range = \"10-1\"",
] {
assert!(
toml::from_str::<Config>(range).is_err(),
"{:?} should be rejected",
range
);
}
}
#[test]
fn parses_interval_in_milliseconds() {
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--interval", "250"]);
assert_eq!(opts.interval, 250);
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1"]);
assert_eq!(opts.interval, 0);
}
}
+8 -16
View File
@@ -5,26 +5,24 @@
//!
//! The core scanning behaviour is managed by
//! [`Scanner`](crate::scanner::Scanner) which in turn requires a
//! [`PortStrategy`](crate::port_strategy::PortStrategy).
//!
//! The scanner is asynchronous and runs on [Tokio](https://docs.rs/tokio):
//! await [`Scanner::run`](crate::scanner::Scanner::run) from a Tokio runtime
//! with the I/O and time drivers enabled. It never spawns tasks, so a
//! current-thread runtime is enough (see [`Scanner`](crate::scanner::Scanner)
//! for details).
//! [`PortStrategy`](crate::port_strategy::PortStrategy):
//!
//! This example is compiled but never executed by the test suite.
//!
//! ```no_run
//! use async_std::task::block_on;
//! use std::{net::IpAddr, time::Duration};
//!
//! use rustscan::input::{PortRanges, ScanOrder};
//! use rustscan::input::{PortRange, ScanOrder};
//! use rustscan::port_strategy::PortStrategy;
//! use rustscan::scanner::Scanner;
//!
//! fn main() {
//! let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
//! let range = PortRanges(vec![(1, 1_000)]);
//! let range = PortRange {
//! start: 1,
//! end: 1_000,
//! };
//! let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random); // can be serial, random or manual https://github.com/RustScan/RustScan/blob/master/src/port_strategy/mod.rs
//! let scanner = Scanner::new(
//! &addrs, // the addresses to scan
@@ -38,13 +36,7 @@
//! 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()
//! .unwrap();
//! let scan_result = runtime.block_on(scanner.run());
//! let scan_result = block_on(scanner.run());
//!
//! println!("{:?}", scan_result);
//! }
+27 -131
View File
@@ -5,12 +5,12 @@
use rustscan::benchmark::{Benchmark, NamedTimer};
use rustscan::input::{self, Config, Opts, ScriptsRequired};
use rustscan::port_strategy::PortStrategy;
use rustscan::scanner::{PortStatus, Scanner};
use rustscan::scanner::Scanner;
use rustscan::scripts::{init_scripts, Script, ScriptFile};
use rustscan::tui::println_safe;
use rustscan::{detail, funny_opening, output, warning};
use colorful::{Color, Colorful};
use futures::executor::block_on;
use std::collections::HashMap;
use std::net::IpAddr;
use std::string::ToString;
@@ -25,7 +25,6 @@ extern crate dirs;
#[cfg(unix)]
const DEFAULT_FILE_DESCRIPTORS_LIMIT: usize = 8000;
// Safest batch size based on experimentation
#[cfg(unix)]
const AVERAGE_BATCH_SIZE: usize = 3000;
#[macro_use]
@@ -47,11 +46,6 @@ fn main() {
let config = Config::read(opts.config_path.clone());
opts.merge(&config);
if let Err(message) = opts.validate_platform() {
eprintln!("error: {message}");
std::process::exit(2);
}
debug!("Main() `opts` arguments are {opts:?}");
let scripts_to_run: Vec<ScriptFile> = match init_scripts(&opts.scripts) {
@@ -83,10 +77,13 @@ fn main() {
std::process::exit(1);
}
let batch_size = effective_batch_size(&opts);
debug!("Effective batch size: {batch_size}");
#[cfg(unix)]
let batch_size: usize = infer_batch_size(&opts, adjust_ulimit_size(&opts));
let mut scanner = Scanner::new(
#[cfg(not(unix))]
let batch_size: usize = AVERAGE_BATCH_SIZE;
let scanner = Scanner::new(
&ips,
batch_size,
Duration::from_millis(opts.timeout.into()),
@@ -96,36 +93,21 @@ fn main() {
opts.accessible,
opts.exclude_ports.unwrap_or_default(),
opts.udp,
)
.with_open_port_output();
if opts.closed {
scanner = scanner.with_closed_ports();
}
if opts.interval > 0 {
scanner = scanner.with_interval(Duration::from_millis(opts.interval));
}
);
debug!("Scanner finished building: {scanner:?}");
let mut portscan_bench = NamedTimer::start("Portscan");
let scan_result = match run_scan(&scanner) {
Ok(scan_result) => scan_result,
Err(e) => {
eprintln!("error: could not start the scan: {e}");
std::process::exit(1);
}
};
let scan_result = block_on(scanner.run());
portscan_bench.end();
benchmarks.push(portscan_bench);
let mut ports_per_ip: HashMap<IpAddr, Vec<u16>> = HashMap::new();
let mut closed_ports_per_ip: HashMap<IpAddr, Vec<u16>> = HashMap::new();
let mut ports_per_ip = HashMap::new();
for status in scan_result {
let (map, socket) = match status {
PortStatus::Open(socket) => (&mut ports_per_ip, socket),
PortStatus::Closed(socket) => (&mut closed_ports_per_ip, socket),
};
map.entry(socket.ip()).or_default().push(socket.port());
for socket in scan_result {
ports_per_ip
.entry(socket.ip())
.or_insert_with(Vec::new)
.push(socket.port());
}
for ip in ips {
@@ -140,7 +122,7 @@ fn main() {
\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,
opts.batch_size,
"'rustscan -b <batch_size> -a <ip address>'");
warning!(x, opts.greppable, opts.accessible);
}
@@ -154,15 +136,13 @@ fn main() {
// if option scripts is none, no script will be spawned
if opts.greppable || opts.scripts == ScriptsRequired::None {
println_safe(format_args!("{ip} -> [{ports_str}]"));
println!("{ip} -> [{ports_str}]");
continue;
}
detail!("Starting Script(s)", opts.greppable, opts.accessible);
// Run all the scripts we found and parsed based on the script config file tags field.
for script_f in &scripts_to_run {
// Clone only the script being run instead of the whole list per IP.
let mut script_f = script_f.clone();
for mut script_f in scripts_to_run.clone() {
// This part allows us to add commandline arguments to the Script call_format, appending them to the end of the command.
if !opts.command.is_empty() {
let user_extra_args = &opts.command.join(" ");
@@ -202,19 +182,6 @@ fn main() {
}
}
// Closed ports are only listed; scripts are never run against them.
if opts.closed {
println_safe(format_args!("closed ports:"));
for (ip, ports) in &closed_ports_per_ip {
let ports_str = ports
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
println_safe(format_args!("{ip} -> [{ports_str}]"));
}
}
// To use the runtime benchmark, run the process as: RUST_LOG=info ./rustscan
script_bench.end();
benchmarks.push(script_bench);
@@ -224,42 +191,6 @@ fn main() {
info!("{}", benchmarks.summary());
}
/// Determines the actual batch size used by the scanner.
///
/// Unix systems may reduce the requested batch size according to the process
/// file-descriptor limit. Other platforms, including Windows, use the batch
/// size explicitly requested by the user.
fn effective_batch_size(opts: &Opts) -> usize {
#[cfg(unix)]
{
infer_batch_size(opts, adjust_ulimit_size(opts))
}
#[cfg(not(unix))]
{
opts.batch_size
}
}
/// 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) {
@@ -270,12 +201,12 @@ fn print_opening(opts: &Opts) {
`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'
The Modern Day Port Scanner."#;
println_safe(format_args!("{}", s.gradient(Color::Green).bold()));
println!("{}", s.gradient(Color::Green).bold());
let info = r#"________________________________________
: http://discord.skerritt.blog :
: https://github.com/RustScan/RustScan :
--------------------------------------"#;
println_safe(format_args!("{}", info.gradient(Color::Yellow).bold()));
println!("{}", info.gradient(Color::Yellow).bold());
funny_opening!();
let config_path = opts
@@ -324,10 +255,7 @@ fn adjust_ulimit_size(opts: &Opts) -> usize {
}
let (soft, _) = Resource::NOFILE.get().unwrap();
// On 32-bit targets the soft limit (e.g. RLIM_INFINITY) may not fit in a
// usize. Fall back to the conservative default rather than usize::MAX,
// which would skip every batch-size adjustment in infer_batch_size.
soft.try_into().unwrap_or(DEFAULT_FILE_DESCRIPTORS_LIMIT)
soft.try_into().unwrap_or(usize::MAX)
}
#[cfg(unix)]
@@ -344,7 +272,7 @@ fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
// selected a batch size higher than this we should reduce it to
// a lower number.
if ulimit < AVERAGE_BATCH_SIZE {
// ulimit is smaller than average batch size
// ulimit is smaller than aveage batch size
// user must have very small ulimit
// decrease batch size to half of ulimit
warning!("Your file limit is very small, which negatively impacts RustScan's speed. Use the Docker image, or up the Ulimit with '--ulimit 5000'. ", opts.greppable, opts.accessible);
@@ -369,8 +297,6 @@ fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
#[cfg(test)]
mod tests {
#[cfg(windows)]
use super::effective_batch_size;
#[cfg(unix)]
use super::{adjust_ulimit_size, infer_batch_size};
use super::{print_opening, Opts};
@@ -441,41 +367,11 @@ mod tests {
#[test]
fn test_print_opening_no_panic() {
let opts = Opts::default();
let opts = Opts {
ulimit: Some(2_000),
..Default::default()
};
// print opening should not panic
print_opening(&opts);
}
#[test]
#[cfg(windows)]
fn windows_batch_size_uses_requested_value() {
let opts = Opts {
batch_size: 50,
..Default::default()
};
assert_eq!(effective_batch_size(&opts), 50);
}
#[test]
#[cfg(windows)]
fn windows_batch_size_preserves_large_requested_value() {
let opts = Opts {
batch_size: 12_345,
..Default::default()
};
assert_eq!(effective_batch_size(&opts), 12_345);
}
#[test]
#[cfg(windows)]
fn windows_batch_size_preserves_single_connection() {
let opts = Opts {
batch_size: 1,
..Default::default()
};
assert_eq!(effective_batch_size(&opts), 1);
}
}
+27 -37
View File
@@ -1,6 +1,6 @@
//! Provides a means to hold configuration options specifically for port scanning.
mod range_iterator;
use crate::input::{PortRanges, ScanOrder};
use crate::input::{PortRange, ScanOrder};
use rand::rng;
use rand::seq::SliceRandom;
use range_iterator::RangeIterator;
@@ -17,18 +17,20 @@ pub enum PortStrategy {
}
impl PortStrategy {
pub fn pick(range: &Option<PortRanges>, ports: Option<Vec<u16>>, order: ScanOrder) -> Self {
pub fn pick(range: &Option<PortRange>, ports: Option<Vec<u16>>, order: ScanOrder) -> Self {
match order {
ScanOrder::Serial if ports.is_none() => {
let port_ranges = range.as_ref().unwrap();
let range = range.as_ref().unwrap();
PortStrategy::Serial(SerialRange {
range: port_ranges.0.clone(),
start: range.start,
end: range.end,
})
}
ScanOrder::Random if ports.is_none() => {
let port_ranges = range.as_ref().unwrap();
let range = range.as_ref().unwrap();
PortStrategy::Random(RandomRange {
range: port_ranges.0.clone(),
start: range.start,
end: range.end,
})
}
ScanOrder::Serial => PortStrategy::Manual(ports.unwrap()),
@@ -60,12 +62,13 @@ trait RangeOrder {
/// ascending order.
#[derive(Debug)]
pub struct SerialRange {
range: Vec<(u16, u16)>,
start: u16,
end: u16,
}
impl RangeOrder for SerialRange {
fn generate(&self) -> Vec<u16> {
RangeIterator::new_serial(&self.range).collect()
(self.start..=self.end).collect()
}
}
@@ -73,7 +76,8 @@ impl RangeOrder for SerialRange {
/// a random order. This vector is built following the LCG algorithm.
#[derive(Debug)]
pub struct RandomRange {
range: Vec<(u16, u16)>,
start: u16,
end: u16,
}
impl RangeOrder for RandomRange {
@@ -87,47 +91,33 @@ impl RangeOrder for RandomRange {
// port numbers close to each other are pretty slim due to the way the
// algorithm works.
fn generate(&self) -> Vec<u16> {
RangeIterator::new_random(&self.range).collect()
RangeIterator::new(self.start.into(), self.end.into()).collect()
}
}
#[cfg(test)]
mod tests {
use super::PortStrategy;
use crate::input::{PortRanges, ScanOrder};
use std::collections::HashSet;
use crate::input::{PortRange, ScanOrder};
fn expected_ports_from_ranges(input: &[(u16, u16)]) -> Vec<u16> {
let mut s = HashSet::new();
for &(start, end) in input {
for p in start..=end {
s.insert(p);
}
}
let mut v: Vec<u16> = s.into_iter().collect();
v.sort_unstable();
v
}
#[test]
fn serial_strategy_with_range() {
let ranges = PortRanges(vec![(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)]);
let strategy = PortStrategy::pick(&Some(ranges.clone()), None, ScanOrder::Serial);
let range = PortRange { start: 1, end: 100 };
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Serial);
let result = strategy.order();
let expected = expected_ports_from_ranges(&ranges.0);
assert_eq!(expected, result);
let expected_range = (1..=100).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
}
#[test]
fn random_strategy_with_range() {
let ranges = PortRanges(vec![(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)]);
let strategy = PortStrategy::pick(&Some(ranges.clone()), None, ScanOrder::Random);
let range = PortRange { start: 1, end: 100 };
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let mut result = strategy.order();
let expected = expected_ports_from_ranges(&ranges.0);
let expected_range = (1..=100).collect::<Vec<u16>>();
assert_ne!(expected_range, result);
assert_ne!(expected, result);
result.sort_unstable();
assert_eq!(expected, result);
assert_eq!(expected_range, result);
}
#[test]
@@ -141,10 +131,10 @@ mod tests {
fn random_strategy_with_ports() {
let strategy = PortStrategy::pick(&None, Some((1..10).collect()), ScanOrder::Random);
let mut result = strategy.order();
let expected = (1..10).collect::<Vec<u16>>();
assert_ne!(expected, result);
let expected_range = (1..10).collect::<Vec<u16>>();
assert_ne!(expected_range, result);
result.sort_unstable();
assert_eq!(expected, result);
assert_eq!(expected_range, result);
}
}
+56 -250
View File
@@ -2,195 +2,70 @@ use gcd::Gcd;
use rand::RngExt;
use std::convert::TryInto;
/// Yields ports produced from a collection of (possibly overlapping)
/// inclusive `u16` ranges in two modes:
///
/// **Randomized** — created by `RangeIterator::new_random`:
/// - The algorithm generates a permutation of indices `0..N-1` using the
/// additive congruential step `x_{i+1} = (x_i + step) % N`.
/// - `step` is chosen so `gcd(step, N) == 1` to ensure the sequence is a
/// full-length cycle (visits each index exactly once).
/// - `x_0` (the seed stored as `normalized_first_pick`) is chosen uniformly
/// in `0..N`.
///
/// For more information: <https://en.wikipedia.org/wiki/Linear_congruential_generator>
///
/// **Serial** — `RangeIterator::new_serial`:
/// Iterates the input ranges in the **original input order** and yields
/// each port the first time it is encountered. Duplicate ports (from
/// overlapping ranges) are skipped using a 65_536-entry "seen" table.
pub struct RangeIterator {
active: bool,
total: u32,
normalized_end: u32,
normalized_first_pick: u32,
normalized_pick: u32,
actual_start: u32,
step: u32,
ranges: Vec<(u32, u32)>,
prefix: Vec<u32>,
serial_itr: Option<Box<dyn Iterator<Item = u16>>>,
/// `seen[port]` is true once `port` has been yielded (serial mode only).
serial_seen: Option<Vec<bool>>,
}
/// An iterator that follows the `Linear Congruential Generator` algorithm.
///
/// For more information: <https://en.wikipedia.org/wiki/Linear_congruential_generator>
impl RangeIterator {
/// Construct a randomized iterator (LCG permutation).
/// Receives the start and end of a range and normalize
/// these values before selecting a coprime for the end of the range
/// which will server as the step for the algorithm.
///
/// Preconditions:
/// - `input` must contain at least one `(u16,u16)`
/// and each pair must satisfy `start <= end`.
pub fn new_random(input: &[(u16, u16)]) -> Self {
// normalize & merge into (start, len) u32 pairs
// Example: [(10,12),(11,15)] -> merged [(10,6)]
let mut ranges: Vec<(u32, u32)> = input
.iter()
.map(|(s, e)| {
let start = *s as u32;
let end_excl = (*e as u32) + 1; // convert inclusive -> exclusive
(start, end_excl)
})
.collect();
/// For example, the range `1000-2500` will be normalized to `0-1500`
/// before going through the algorithm.
pub fn new(start: u32, end: u32) -> Self {
let normalized_end = end - start + 1;
let step = pick_random_coprime(normalized_end);
ranges.sort_unstable_by_key(|&(s, _)| s);
let mut merged: Vec<(u32, u32)> = Vec::with_capacity(ranges.len());
if !ranges.is_empty() {
let mut iter = ranges.into_iter();
let (mut cur_s, mut cur_end) = iter.next().unwrap(); // cur_end exclusive
for (s, end_excl) in iter {
if s <= cur_end {
// overlap/adjacent -> extend
if end_excl > cur_end {
cur_end = end_excl;
}
} else {
// push disjoint segment as (start, len)
merged.push((cur_s, cur_end - cur_s)); // len = excl - start
cur_s = s;
cur_end = end_excl;
}
}
merged.push((cur_s, cur_end - cur_s));
}
// build prefix sums (prefix[0] = 0; prefix.len() == merged.len() + 1)
let prefix = merged.iter().fold(vec![0u32], |mut acc, (_, len)| {
let last = acc.last().unwrap();
acc.push(last.saturating_add(*len));
acc
});
// total is guaranteed > 0 by precondition (input.len() >= 1)
let total = *prefix.last().unwrap();
// pick step and seed
let step = pick_random_coprime(total);
// Randomly choose a number within the range to be the first
// and assign it as a pick.
let mut rng = rand::rng();
let first = rng.random_range(0..total);
let normalized_first_pick = rng.random_range(0..normalized_end);
Self {
active: true,
total,
normalized_first_pick: first,
normalized_pick: first,
normalized_end,
step,
ranges: merged,
prefix,
serial_itr: None,
serial_seen: None,
}
}
/// Construct a serial iterator that yields ports in original input order,
/// skipping duplicates. The deduplication is done on the fly with a seen-table.
///
/// Preconditions:
/// - `input` must contain at least one `(u16,u16)` and each pair must satisfy `start <= end`.
pub fn new_serial(input: &[(u16, u16)]) -> Self {
// Build a serial iterator that yields ports in *input order* (start..=end).
// We keep the merged ranges/prefix empty here (they are not needed for serial mode).
let input = input.to_vec();
let serial_itr = input.into_iter().flat_map(|(start, end)| start..=end);
let serial_itr_boxed: Box<dyn Iterator<Item = u16>> = Box::new(serial_itr);
// One entry per possible port (0..=65535).
let seen = vec![false; usize::from(u16::MAX) + 1];
Self {
active: true,
total: 0,
normalized_first_pick: 0,
normalized_pick: 0,
step: 0,
ranges: Vec::new(),
prefix: Vec::new(),
serial_itr: Some(serial_itr_boxed),
serial_seen: Some(seen),
normalized_first_pick,
normalized_pick: normalized_first_pick,
actual_start: start,
}
}
}
impl Iterator for RangeIterator {
type Item = u16;
/// Advance the iterator by one port.
///
/// 1. Read the current normalized index `cur`.
/// 2. Compute `next = (cur + step) % total` and update `normalized_pick`.
/// 3. If `next == normalized_first_pick` mark `active = false` (we completed the cycle).
/// 4. Map the returned index `cur` into the merged ranges via the prefix array:
/// - find the range index `idx` where `prefix[idx] <= cur < prefix[idx+1]`,
/// - offset = `cur - prefix[idx]`,
/// - port = `ranges[idx].0 + offset`.
/// 5. Return `port as u16`.
///
// The next step is always bound by the formula: N+1 = (N + STEP) % TOP_OF_THE_RANGE
// It will only stop once we generate a number equal to the first generated number.
fn next(&mut self) -> Option<Self::Item> {
if !self.active {
return None;
}
// SERIAL iterator fast-path: preserve original input order but skip duplicates.
if let (Some(it), Some(seen)) = (self.serial_itr.as_mut(), self.serial_seen.as_mut()) {
for p in it.by_ref() {
// Yield each port only the first time it is seen.
if !std::mem::replace(&mut seen[usize::from(p)], true) {
return Some(p);
}
// otherwise skip duplicate and continue
}
// serial iterator exhausted: drop it and mark inactive
self.serial_itr = None;
self.serial_seen = None;
self.active = false;
return None;
}
let current_pick = self.normalized_pick;
let next_pick = (current_pick + self.step) % self.normalized_end;
// RANDOMIZED (LCG) path
let cur = self.normalized_pick;
let next = (cur + self.step) % self.total;
// if next equals the original seed we finished the cycle after returning cur
if next == self.normalized_first_pick {
// If the next pick is equal to the first pick this means that
// we have iterated through the entire range.
if next_pick == self.normalized_first_pick {
self.active = false;
}
self.normalized_pick = next;
// Map cur -> port using prefix + ranges (binary search)
let mut lo: usize = 0;
let mut hi: usize = self.ranges.len();
while lo < hi {
let mid = (lo + hi) / 2;
if self.prefix[mid + 1] > cur {
hi = mid;
} else {
lo = mid + 1;
}
}
let idx = lo;
let offset = cur - self.prefix[idx];
let (start, _len) = self.ranges[idx];
let port = (start + offset)
.try_into()
.expect("Could not convert u32 to u16");
Some(port)
self.normalized_pick = next_pick;
Some(
(self.actual_start + current_pick)
.try_into()
.expect("Could not convert u32 to u16"),
)
}
}
@@ -223,105 +98,36 @@ fn pick_random_coprime(end: u32) -> u32 {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
// Helper: collect, sort and return ports produced by randomized RangeIterator
fn generate_sorted_from_ranges_random(input: &[(u16, u16)]) -> Vec<u16> {
let mut it = RangeIterator::new_random(input);
let mut v: Vec<u16> = it.by_ref().collect();
v.sort_unstable();
v
}
// Helper: collect, sort and return ports produced by serial RangeIterator
fn generate_sorted_from_ranges_serial(input: &[(u16, u16)]) -> Vec<u16> {
let mut it = RangeIterator::new_serial(input);
let mut v: Vec<u16> = it.by_ref().collect();
v.sort_unstable();
v
}
// Build expected sorted unique ports from input ranges (inclusive)
fn expected_ports_from_ranges(input: &[(u16, u16)]) -> Vec<u16> {
let mut s = HashSet::new();
for &(start, end) in input {
for p in start..=end {
s.insert(p);
}
}
let mut v: Vec<u16> = s.into_iter().collect();
v.sort_unstable();
v
}
use super::RangeIterator;
#[test]
fn random_range_iterator_test() {
// small disjoint ranges
let input = &[(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)];
let result = generate_sorted_from_ranges_random(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
fn range_iterator_iterates_through_the_entire_range() {
let result = generate_sorted_range(1, 10);
let expected_range = (1..=10).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
// larger disjoint ranges
let input = &[(1u16, 100u16), (200u16, 500u16)];
let result = generate_sorted_from_ranges_random(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
let result = generate_sorted_range(1, 100);
let expected_range = (1..=100).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
// overlapping and adjacent
let input = &[(10u16, 20u16), (15u16, 25u16), (26u16, 30u16)];
let result = generate_sorted_from_ranges_random(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
let result = generate_sorted_range(1, 1000);
let expected_range = (1..=1000).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
// near-full domain (heavy): we only assert lengths & equality
let input = &[(1u16, 65_535u16)];
let result = generate_sorted_from_ranges_random(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected.len(), result.len());
assert_eq!(expected, result);
let result = generate_sorted_range(1, 65_535);
let expected_range = (1..=65_535).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
// multiple disjoint ranges - check dedupe & coverage
let input = &[(50u16, 100u16), (1000u16, 2000u16), (30000u16, 30010u16)];
let result = generate_sorted_from_ranges_random(input);
let set_len = result.iter().copied().collect::<HashSet<u16>>().len();
assert_eq!(set_len, result.len());
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
let result = generate_sorted_range(1000, 2000);
let expected_range = (1000..=2000).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
}
#[test]
fn serial_range_iterator_test() {
// serial should preserve input-order semantics but here we only assert
// coverage (no duplicates) by sorting results and comparing expected set.
fn generate_sorted_range(start: u32, end: u32) -> Vec<u16> {
let range = RangeIterator::new(start, end);
let mut result = range.into_iter().collect::<Vec<u16>>();
result.sort_unstable();
// small disjoint ranges
let input = &[(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)];
let result = generate_sorted_from_ranges_serial(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
// overlapping and adjacent
let input = &[(10u16, 20u16), (15u16, 25u16), (26u16, 30u16)];
let result = generate_sorted_from_ranges_serial(input);
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
// multiple disjoint ranges
let input = &[(50u16, 100u16), (1000u16, 2000u16), (30000u16, 30010u16)];
let result = generate_sorted_from_ranges_serial(input);
let set_len = result.iter().copied().collect::<HashSet<u16>>().len();
assert_eq!(set_len, result.len());
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
// all possible inputs
let input = &[(u16::MIN, u16::MAX)];
let result = generate_sorted_from_ranges_serial(input);
let set_len = result.iter().copied().collect::<HashSet<u16>>().len();
assert_eq!(set_len, result.len());
let expected = expected_ports_from_ranges(input);
assert_eq!(expected, result);
result
}
}
+107 -581
View File
@@ -1,81 +1,23 @@
//! Core functionality for actual scanning behaviour.
use crate::generated::get_parsed_data;
use crate::port_strategy::PortStrategy;
use crate::tui::println_safe;
use log::debug;
mod socket_iterator;
use socket_iterator::SocketIterator;
use async_std::net::TcpStream;
use async_std::prelude::*;
use async_std::{io, net::UdpSocket};
use colored::Colorize;
use futures::stream::{FuturesUnordered, StreamExt};
use futures::stream::FuturesUnordered;
use std::collections::BTreeMap;
use std::future::poll_fn;
use std::task::Poll;
use std::{
collections::{HashMap, HashSet},
io,
net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr},
collections::HashSet,
net::{IpAddr, Shutdown, SocketAddr},
num::NonZeroU8,
sync::Arc,
time::Duration,
};
use tokio::io::Interest;
use tokio::net::{TcpStream, UdpSocket};
use tokio::time::{sleep, timeout};
/// How many sockets the scan starts or finishes (counted together) between
/// two polls of the runtime's I/O driver; see `Scanner::scan_sockets`.
///
/// Every round ends with a non-blocking poll of the OS selector, so this
/// keeps the overhead far below 1% while bounding the time between two
/// polls to about a millisecond on Linux.
const WORK_PER_TURN: usize = 128;
/// Keeps the generated order, including duplicate ports, while removing exclusions.
// Keep the bitmap out of the async polling frame, which runs on every wake.
#[inline(never)]
fn filter_excluded_ports(mut ports: Vec<u16>, excluded: &[u16]) -> Vec<u16> {
if excluded.is_empty() {
return ports;
}
// Bitmap setup costs more than membership checks on short port lists.
if ports.len() < 64 {
ports.retain(|port| !excluded.contains(port));
return ports;
}
// The complete u16 port space fits in an 8 KiB bitmap.
let mut excluded_bits = [0_u64; 1024];
for &port in excluded {
excluded_bits[usize::from(port) / 64] |= 1 << (port % 64);
}
ports.retain(|&port| excluded_bits[usize::from(port) / 64] & (1 << (port % 64)) == 0);
ports
}
/// UDP payload lookup: port -> payload bytes
///
/// `get_parsed_data()` returns a `&'static BTreeMap<...>`, so we can store
/// references to the payload bytes without cloning them.
#[doc(hidden)]
pub type UdpPayloadLookup = HashMap<u16, &'static [u8]>;
#[doc(hidden)]
pub fn build_udp_payload_lookup(udp_map: &'static BTreeMap<Vec<u16>, Vec<u8>>) -> UdpPayloadLookup {
let mut lookup: UdpPayloadLookup = HashMap::new();
for (ports, payload_vec) in udp_map.iter() {
let payload: &'static [u8] = payload_vec.as_slice();
for &port in ports.iter() {
// Preserve existing behavior: if duplicates exist, last insert wins.
lookup.insert(port, payload);
}
}
lookup
}
/// The class for the scanner
/// IP is data type IpAddr and is the IP address
@@ -83,19 +25,6 @@ pub fn build_udp_payload_lookup(udp_map: &'static BTreeMap<Vec<u16>, Vec<u8>>) -
/// batch_size is how many ports at a time should be scanned
/// Timeout is the time RustScan should wait before declaring a port closed. As datatype Duration.
/// greppable is whether or not RustScan should print things, or wait until the end to print only the ip and open ports.
///
/// # Runtime
///
/// The futures returned by [`Scanner::run`] and [`Scanner::run_with_status`]
/// use Tokio sockets and timers, so they must be polled from within a
/// [Tokio](https://docs.rs/tokio) runtime that has both the I/O and the time
/// drivers enabled (for example `#[tokio::main]`, or a runtime built with
/// `enable_all()`). Polling them from another executor panics.
///
/// The scan never spawns tasks: every socket is driven from the one future
/// you await, so a current-thread runtime is enough (it is what the
/// `rustscan` binary uses), and the future is `Send` if you prefer to spawn
/// it on a multi-threaded runtime.
#[cfg(not(tarpaulin_include))]
#[derive(Debug)]
pub struct Scanner {
@@ -108,19 +37,6 @@ pub struct Scanner {
accessible: bool,
exclude_ports: Vec<u16>,
udp: bool,
print_open_ports: bool,
report_closed: bool,
interval: Duration,
}
/// The outcome for a single socket, as returned by [`Scanner::run_with_status`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortStatus {
/// The TCP connection succeeded, or the UDP target answered.
Open(SocketAddr),
/// The target actively refused the TCP connection (for example with a
/// RST). Only reported when [`Scanner::with_closed_ports`] is enabled.
Closed(SocketAddr),
}
// Allowing too many arguments for clippy.
@@ -147,189 +63,58 @@ impl Scanner {
accessible,
exclude_ports,
udp,
print_open_ports: false,
report_closed: false,
interval: Duration::ZERO,
}
}
/// Enables the CLI's incremental open-port output.
///
/// Library callers are quiet by default and can inspect the sockets returned by [`Self::run`].
#[must_use]
pub fn with_open_port_output(mut self) -> Self {
self.print_open_ports = true;
self
}
/// Also reports TCP ports that actively refuse the connection, as
/// [`PortStatus::Closed`] from [`Self::run_with_status`].
///
/// Ports that time out are still treated as filtered and are not reported.
/// UDP scans never report closed ports.
#[must_use]
pub fn with_closed_ports(mut self) -> Self {
self.report_closed = true;
self
}
/// Waits `interval` after scanning one port (on every address) before
/// scanning the next port, for slow, low-noise scans.
///
/// Within a port, up to `batch_size` addresses are still scanned
/// concurrently. A zero interval (the default) scans all sockets in
/// batches without any delay.
#[must_use]
pub fn with_interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
/// Runs scan_range with chunk sizes
/// If you want to run RustScan normally, this is the entry point used
/// Returns all open sockets.
/// Returns all open ports as `Vec<u16>`
pub async fn run(&self) -> Vec<SocketAddr> {
self.run_with_status()
.await
.into_iter()
.filter_map(|status| match status {
PortStatus::Open(socket) => Some(socket),
PortStatus::Closed(_) => None,
})
.collect()
}
/// 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.
pub async fn run_with_status(&self) -> Vec<PortStatus> {
// Every in-flight socket is polled from this single future through a
// `FuturesUnordered`. Under Tokio's cooperative budget the future
// would be forced to yield after ~128 sockets made progress, and the
// `FuturesUnordered` would then re-poll every other ready socket just
// to have it return `Pending` again. Opt out; `scan_sockets` yields to
// the runtime by itself, in rounds of `WORK_PER_TURN` sockets.
tokio::task::unconstrained(self.scan()).await
}
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 ports: Vec<u16> = self
.port_strategy
.order()
.iter()
.filter(|&port| !self.exclude_ports.contains(port))
.copied()
.collect();
let mut socket_iterator: SocketIterator = SocketIterator::new(&self.ips, &ports);
let mut open_sockets: Vec<SocketAddr> = Vec::new();
let mut ftrs = FuturesUnordered::new();
let mut errors: HashSet<String> = HashSet::new();
let udp_map = get_parsed_data();
// Build UDP payload lookup once (only if we are scanning UDP).
// This avoids cloning a big map into every spawned future and turns
// payload selection from O(n) to O(1).
let udp_payloads: Option<Arc<UdpPayloadLookup>> = if self.udp {
Some(Arc::new(build_udp_payload_lookup(get_parsed_data())))
} else {
None
};
for _ in 0..self.batch_size {
if let Some(socket) = socket_iterator.next() {
ftrs.push(self.scan_socket(socket, udp_map.clone()));
} else {
break;
}
}
debug!("Start scanning sockets. \nBatch size {}\nNumber of ip-s {}\nNumber of ports {}\nTargets all together {}\nInterval between ports {:?}",
debug!("Start scanning sockets. \nBatch size {}\nNumber of ip-s {}\nNumber of ports {}\nTargets all together {} ",
self.batch_size,
self.ips.len(),
ports.len(),
(self.ips.len() * ports.len()),
self.interval);
(self.ips.len() * ports.len()));
if self.interval.is_zero() {
let sockets = SocketIterator::new(&self.ips, &ports);
self.scan_sockets(sockets, &udp_payloads, &mut found_sockets, &mut errors)
.await;
} else {
// Scan one port (on every address) at a time and wait `interval`
// before moving on to the next port.
for (i, port) in ports.iter().enumerate() {
if i > 0 {
sleep(self.interval).await;
while let Some(result) = ftrs.next().await {
if let Some(socket) = socket_iterator.next() {
ftrs.push(self.scan_socket(socket, udp_map.clone()));
}
match result {
Ok(socket) => open_sockets.push(socket),
Err(e) => {
let error_string = e.to_string();
if errors.len() < self.ips.len() * 1000 {
errors.insert(error_string);
}
}
let sockets = SocketIterator::new(&self.ips, std::slice::from_ref(port));
self.scan_sockets(sockets, &udp_payloads, &mut found_sockets, &mut errors)
.await;
}
}
debug!("Typical socket connection errors {errors:?}");
debug!("Sockets found: {:?}", found_sockets);
found_sockets
}
/// Scans every socket yielded by `sockets`, keeping at most `batch_size`
/// connection attempts in flight.
///
/// Starts sockets and handles finished ones in rounds of at most
/// [`WORK_PER_TURN`] of either, and lets the runtime poll for I/O and fire
/// timers between rounds. One poll of the scan future otherwise lasts
/// until no socket is ready, which can take long: starting a whole large
/// batch at once, or a run of UDP probes that all finish straight away.
/// Sockets that finish in the meantime are only noticed once the poll
/// ends, so their results come late, and if the poll outlasts the timeout
/// their timers fire before their answers are seen.
async fn scan_sockets(
&self,
mut sockets: SocketIterator<'_>,
udp_payloads: &Option<Arc<UdpPayloadLookup>>,
found_sockets: &mut Vec<PortStatus>,
errors: &mut HashSet<String>,
) {
let mut ftrs = FuturesUnordered::new();
loop {
let mut work = 0;
while work < WORK_PER_TURN {
let mut started = false;
if ftrs.len() < self.batch_size {
if let Some(socket) = sockets.next() {
ftrs.push(self.scan_socket(socket, udp_payloads.clone()));
started = true;
work += 1;
}
}
// Poll once without waiting; this starts the new socket.
match poll_fn(|cx| Poll::Ready(ftrs.poll_next_unpin(cx))).await {
Poll::Ready(Some(result)) => {
self.record(result, found_sockets, errors);
work += 1;
}
// Nothing in flight and nothing left to start.
Poll::Ready(None) => return,
// Nothing has finished; keep starting sockets while there is room.
Poll::Pending if started => {}
Poll::Pending => break,
}
}
if work >= WORK_PER_TURN {
tokio::task::yield_now().await;
} 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 HashSet<String>,
) {
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);
}
}
}
debug!("Open Sockets found: {:?}", open_sockets);
open_sockets
}
/// Given a socket, scan it self.tries times.
@@ -349,10 +134,10 @@ impl Scanner {
async fn scan_socket(
&self,
socket: SocketAddr,
udp_payloads: Option<Arc<UdpPayloadLookup>>,
) -> io::Result<PortStatus> {
udp_map: BTreeMap<Vec<u16>, Vec<u8>>,
) -> io::Result<SocketAddr> {
if self.udp {
return self.scan_udp_socket(socket, udp_payloads).await;
return self.scan_udp_socket(socket, udp_map).await;
}
let tries = self.tries.get();
@@ -360,22 +145,15 @@ impl Scanner {
match self.connect(socket).await {
Ok(tcp_stream) => {
debug!("Connection was successful, shutting down stream {}", socket);
if let Err(e) = shutdown_both(tcp_stream) {
if let Err(e) = tcp_stream.shutdown(Shutdown::Both) {
debug!("Shutdown stream error {}", e);
}
self.fmt_ports(socket);
debug!("Return Ok after {nr_try} tries");
return Ok(PortStatus::Open(socket));
return Ok(socket);
}
Err(e) => {
// A refused connection is a definitive answer, so there is
// no point in retrying it.
if self.report_closed && e.kind() == io::ErrorKind::ConnectionRefused {
self.fmt_closed_port(socket);
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.");
@@ -394,17 +172,19 @@ impl Scanner {
async fn scan_udp_socket(
&self,
socket: SocketAddr,
udp_payloads: Option<Arc<UdpPayloadLookup>>,
) -> io::Result<PortStatus> {
let payload: &[u8] = udp_payloads
.as_ref()
.and_then(|m| m.get(&socket.port()).copied())
.unwrap_or(b"");
udp_map: BTreeMap<Vec<u16>, Vec<u8>>,
) -> io::Result<SocketAddr> {
let mut payload: Vec<u8> = Vec::new();
for (key, value) in udp_map {
if key.contains(&socket.port()) {
payload = value;
}
}
let tries = self.tries.get();
for _ in 1..=tries {
match self.udp_scan(socket, payload, self.timeout).await {
Ok(true) => return Ok(PortStatus::Open(socket)),
match self.udp_scan(socket, &payload, self.timeout).await {
Ok(true) => return Ok(socket),
Ok(false) => continue,
Err(e) => return Err(e),
}
@@ -430,22 +210,35 @@ impl Scanner {
/// ```
///
async fn connect(&self, socket: SocketAddr) -> io::Result<TcpStream> {
timeout(self.timeout, TcpStream::connect(socket))
.await
.unwrap_or_else(|_elapsed| Err(timed_out()))
let stream = io::timeout(
self.timeout,
async move { TcpStream::connect(socket).await },
)
.await?;
Ok(stream)
}
/// Binds a non-blocking UDP socket on the unspecified address of the
/// target's address family, so we can send and receive packets.
fn udp_bind(socket: SocketAddr) -> io::Result<std::net::UdpSocket> {
/// Binds to a UDP socket so we can send and receive packets
/// # Example
///
/// ```compile_fail
/// # use std::net::{IpAddr, Ipv6Addr, SocketAddr};
/// let port: u16 = 80;
/// // ip is an IpAddr type
/// let ip = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1));
/// let socket = SocketAddr::new(ip, port);
/// scanner.udp_bind(socket);
/// // returns Result which is either Ok(stream) for port is open, or Err for port is closed.
/// // Timeout occurs after self.timeout seconds
/// ```
///
async fn udp_bind(&self, socket: SocketAddr) -> io::Result<UdpSocket> {
let local_addr = match socket {
SocketAddr::V4(_) => SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)),
SocketAddr::V6(_) => SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0)),
SocketAddr::V4(_) => "0.0.0.0:0".parse::<SocketAddr>().unwrap(),
SocketAddr::V6(_) => "[::]:0".parse::<SocketAddr>().unwrap(),
};
let udp_socket = std::net::UdpSocket::bind(local_addr)?;
udp_socket.set_nonblocking(true)?;
Ok(udp_socket)
UdpSocket::bind(local_addr).await
}
/// Performs a UDP scan on the specified socket with a payload and wait duration
@@ -469,310 +262,43 @@ impl Scanner {
payload: &[u8],
wait: Duration,
) -> io::Result<bool> {
let udp_socket = match Self::udp_bind(socket) {
Ok(udp_socket) => udp_socket,
match self.udp_bind(socket).await {
Ok(udp_socket) => {
let mut buf = [0u8; 1024];
udp_socket.connect(socket).await?;
udp_socket.send(payload).await?;
match io::timeout(wait, udp_socket.recv(&mut buf)).await {
Ok(size) => {
debug!("Received {size} bytes");
self.fmt_ports(socket);
Ok(true)
}
Err(e) => {
if e.kind() == io::ErrorKind::TimedOut {
Ok(false)
} else {
Err(e)
}
}
}
}
Err(e) => {
debug!("Error binding UDP socket: {e:?}");
return Err(e);
println!("Err E binding sock {e:?}");
Err(e)
}
};
let mut buf = [0u8; 1024];
udp_socket.connect(socket)?;
// Send the probe and try the first receive straight away, as
// async-std did. Tokio's readiness-based I/O would first wait for the
// reactor to report the socket ready, costing every probe extra trips
// through the event loop, while the probe can almost always be sent
// immediately and, on the local host, the answer (often an ICMP "port
// unreachable", seen as a refused connection) is usually already
// there when the send returns. The socket is only registered with
// Tokio when we really have to wait.
let sent = match udp_socket.send(payload) {
Ok(_) => true,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => false,
Err(e) => return Err(e),
};
let early = if sent {
udp_socket.recv(&mut buf)
} else {
Err(io::ErrorKind::WouldBlock.into())
};
let received = match early {
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
let udp_socket = UdpSocket::from_std(udp_socket)?;
if !sent {
udp_socket.send(payload).await?;
}
match timeout(wait, recv_or_error(&udp_socket, &mut buf)).await {
Ok(received) => received,
// Nothing came back in time.
Err(_elapsed) => return Ok(false),
}
}
early => early,
};
match received {
Ok(size) => {
debug!("Received {size} bytes");
self.fmt_ports(socket);
Ok(true)
}
Err(e) if e.kind() == io::ErrorKind::TimedOut => Ok(false),
Err(e) => Err(e),
}
}
/// Formats and prints the port status
fn fmt_ports(&self, socket: SocketAddr) {
if self.print_open_ports && !self.greppable {
if !self.greppable {
if self.accessible {
println_safe(format_args!("Open {socket}"));
println!("Open {socket}");
} else {
println_safe(format_args!("Open {}", socket.to_string().purple()));
}
}
}
/// Prints a closed port (CLI output only, never in greppable mode).
fn fmt_closed_port(&self, socket: SocketAddr) {
if self.print_open_ports && !self.greppable {
if self.accessible {
println_safe(format_args!("Closed {socket}"));
} else {
println_safe(format_args!("Closed {}", socket.to_string().red()));
println!("Open {}", socket.to_string().purple());
}
}
}
}
/// 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};
// 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()];
let strategy = PortStrategy::pick(&Some(PortRanges(vec![(1, 1)])), None, ScanOrder::Serial);
Scanner::new(
&addrs,
1,
Duration::from_millis(100),
1,
false,
strategy,
false,
Vec::new(),
false,
)
}
#[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();
assert!(!scanner.print_open_ports);
}
#[test]
fn cli_can_enable_open_port_output() {
let scanner = test_scanner().with_open_port_output();
assert!(scanner.print_open_ports);
}
#[test]
fn closed_ports_are_not_reported_by_default() {
assert!(!test_scanner().report_closed);
}
#[test]
fn closed_port_reporting_is_opt_in() {
assert!(test_scanner().with_closed_ports().report_closed);
}
#[test]
fn no_interval_by_default() {
assert!(test_scanner().interval.is_zero());
}
#[test]
fn with_interval_sets_the_delay_between_ports() {
let scanner = test_scanner().with_interval(Duration::from_millis(250));
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
/// decoding mangled it into a 28-byte probe that agents never answered.
#[test]
fn udp_snmp_probe_bytes_match_nmap() {
let payload = get_parsed_data()
.iter()
.find(|(ports, _)| ports.contains(&161))
.map(|(_, payload)| payload)
.expect("no UDP payload registered for port 161");
let expected: Vec<u8> = vec![
0x30, 0x1f, 0x02, 0x01, 0x00, 0x04, 0x06, b'p', b'u', b'b', b'l', b'i', b'c', 0xa1,
0x12, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x02, 0x01, 0x00, 0x30, 0x07, 0x30, 0x05,
0x06, 0x01, 0x00, 0x05, 0x00,
];
assert_eq!(*payload, expected);
}
/// The SSDP probe mixes `\xNN` escapes, `\"` escapes and literal text
/// across two quoted segments: segments must decode and concatenate
/// with no separators.
#[test]
fn udp_ssdp_probe_decodes_escapes_and_literal_text() {
let payload = get_parsed_data()
.iter()
.find(|(ports, _)| ports.contains(&1900))
.map(|(_, payload)| payload)
.expect("no UDP payload registered for port 1900");
let expected =
b"M-SEARCH * HTTP/1.1\r\nHost: 239.255.255.250:1900\r\nMan: \"ssdp:discover\"\r\nMX: 5\r\nST: ssdp:all\r\n\r\n"
.to_vec();
assert_eq!(*payload, expected);
}
}
+29 -101
View File
@@ -1,78 +1,55 @@
use itertools::{iproduct, Product};
use std::iter::FusedIterator;
use std::net::{IpAddr, SocketAddr};
use std::slice;
/// An iterator that receives a slice of IPs and ports and returns a
/// [`SocketAddr`] for each IP/port pair until all combinations are exhausted.
///
/// The goal of this iterator is to walk every IP and port combination
/// *without* allocating a big intermediate buffer — the alternative would be
/// materialising a `Vec<SocketAddr>` of every pair up front.
///
/// # Ordering
///
/// The IP/port order is intentionally reversed inside `product_it`: we want
/// `iproduct!` to iterate *all IPs for one port* before advancing to the next
/// port ("hold the port, go through all the IPs, then advance the port").
///
/// # Example
///
/// `SocketIterator` lives in a private module, so this example is not run as
/// a doctest; `goes_through_every_ip_port_combination` checks the same order.
///
/// ```ignore
/// # use std::net::IpAddr;
/// let ips = [
/// "127.0.0.1".parse::<IpAddr>().unwrap(),
/// "192.168.0.1".parse::<IpAddr>().unwrap(),
/// ];
/// let ports = [80u16, 443];
///
/// let mut it = SocketIterator::new(&ips, &ports);
/// assert_eq!(it.next(), Some("127.0.0.1:80".parse().unwrap()));
/// assert_eq!(it.next(), Some("192.168.0.1:80".parse().unwrap()));
/// assert_eq!(it.next(), Some("127.0.0.1:443".parse().unwrap()));
/// assert_eq!(it.next(), Some("192.168.0.1:443".parse().unwrap()));
/// assert_eq!(it.next(), None);
/// ```
#[derive(Clone)]
pub struct SocketIterator<'s> {
// No boxing: the iterator owns the concrete slice iterators directly.
// This keeps `SocketIterator` allocation-free and makes `Clone` free too.
product_it: Product<slice::Iter<'s, u16>, slice::Iter<'s, IpAddr>>,
// product_it is a cartesian product iterator over
// the slices of ports and IP addresses.
//
// The IP/port order is intentionally reversed here since we want
// the itertools::iproduct! macro below to generate the pairs with
// all the IPs for one port before moving on to the next one
// ("hold the port, go through all the IPs, then advance the port...").
// See also the comments in the iterator implementation for an example.
product_it:
Product<Box<std::slice::Iter<'s, u16>>, Box<std::slice::Iter<'s, std::net::IpAddr>>>,
}
/// An iterator that receives a slice of IPs and ports and returns a Socket
/// for each IP and port pair until all of these combinations are exhausted.
/// The goal of this iterator is to go over every IP and port combination
/// without generating a big memory footprint. The alternative would be
/// generating a vector containing all these combinations.
impl<'s> SocketIterator<'s> {
pub fn new(ips: &'s [IpAddr], ports: &'s [u16]) -> Self {
let ports_it = Box::new(ports.iter());
let ips_it = Box::new(ips.iter());
Self {
// `iproduct!` calls `.into_iter()` on each argument; `slice::Iter`
// is already an iterator, so this is a no-op (no allocation).
product_it: iproduct!(ports.iter(), ips.iter()),
product_it: iproduct!(ports_it, ips_it),
}
}
}
#[allow(clippy::doc_link_with_quotes)]
impl Iterator for SocketIterator<'_> {
type Item = SocketAddr;
/// Returns the next socket, or `None` when all combinations are exhausted.
/// Every IP is paired with the same port until the port advances.
/// Returns a socket based on the combination of one of the provided
/// IPs and ports or None when these combinations are exhausted. Every
/// IP will have the same port until a port is incremented.
///
/// let it = SocketIterator::new(&["127.0.0.1", "192.168.0.1"], &[80, 443]);
/// it.next(); // 127.0.0.1:80
/// it.next(); // 192.168.0.1:80
/// it.next(); // 127.0.0.1:443
/// it.next(); // 192.168.0.1:443
/// it.next(); // None
fn next(&mut self) -> Option<Self::Item> {
self.product_it
.next()
.map(|(port, ip)| SocketAddr::new(*ip, *port))
}
// Delegate the size hint so callers like `collect()` can pre-size buffers.
fn size_hint(&self) -> (usize, Option<usize>) {
self.product_it.size_hint()
}
}
// `Product` and `slice::Iter` are both fused, so we are too.
impl FusedIterator for SocketIterator<'_> {}
#[cfg(test)]
mod tests {
use super::SocketIterator;
@@ -95,53 +72,4 @@ mod tests {
assert_eq!(Some(SocketAddr::new(addrs[1], ports[2])), it.next());
assert_eq!(None, it.next());
}
#[test]
fn size_hint_is_exact() {
let addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
let ports: Vec<u16> = vec![22, 80, 443];
let mut it = SocketIterator::new(&addrs, &ports);
assert_eq!(it.size_hint(), (3, Some(3)));
it.next();
assert_eq!(it.size_hint(), (2, Some(2)));
}
#[test]
fn clone_resumes_independently() {
let addrs = [
"127.0.0.1".parse::<IpAddr>().unwrap(),
"192.168.0.1".parse::<IpAddr>().unwrap(),
];
let ports: Vec<u16> = vec![22, 80];
let mut a = SocketIterator::new(&addrs, &ports);
assert_eq!(a.next(), Some(SocketAddr::new(addrs[0], ports[0])));
let b = a.clone(); // b starts where a is now
// 2 IPs x 2 ports = 4 sockets, one of which was consumed before cloning.
let rest = [
SocketAddr::new(addrs[1], ports[0]),
SocketAddr::new(addrs[0], ports[1]),
SocketAddr::new(addrs[1], ports[1]),
];
assert_eq!(a.collect::<Vec<_>>(), rest);
assert_eq!(b.collect::<Vec<_>>(), rest);
}
#[test]
fn empty_inputs_yield_nothing() {
let addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
let ports = [22u16];
for mut it in [
SocketIterator::new(&[], &ports),
SocketIterator::new(&addrs, &[]),
] {
assert_eq!(it.size_hint(), (0, Some(0)));
assert_eq!(it.next(), None);
// Stays exhausted, as promised by the `FusedIterator` impl.
assert_eq!(it.next(), None);
}
}
}
+7 -4
View File
@@ -46,9 +46,12 @@
//!
//! - `fixtures/test_rustscan_scripts.toml`
//!
//! Metadata-only test fixture:
//! Script file examples:
//!
//! - `fixtures/.rustscan_scripts/test_script.txt`
//! - `fixtures/test_script.py`
//! - `fixtures/test_script.pl`
//! - `fixtures/test_script.sh`
//! - `fixtures/test_script.txt`
//!
//! `call_format` in script files can be of 2 variants:
//!
@@ -407,7 +410,7 @@ mod tests {
fn find_and_parse_scripts() {
let scripts = find_scripts("fixtures/.rustscan_scripts".into()).unwrap();
let scripts = parse_scripts(scripts);
assert_eq!(scripts.len(), 1);
assert_eq!(scripts.len(), 4);
}
#[test]
@@ -447,7 +450,7 @@ mod tests {
assert_eq!(script_f.ports_separator, Some(",".to_string()));
assert_eq!(
script_f.call_format,
Some("fixture {{ip}} {{port}}".to_string())
Some("nmap -vvv -p {{port}} {{ip}}".to_string())
);
}
+12 -55
View File
@@ -1,55 +1,20 @@
//! Utilities for terminal output during scanning.
use std::io::Write;
/// Prints a single line to stdout without ever panicking.
///
/// `println!` panics when writing to stdout fails, which is fatal under the
/// `panic = "abort"` release profile. That includes the `BrokenPipe` error
/// produced when a downstream consumer (e.g. `rustscan -g ... | head`)
/// closes the pipe, so instead the process exits quietly when that happens.
/// Other output errors are reported on stderr before exiting.
///
/// All user-facing output should go through this function (the `warning!`,
/// `detail!`, `output!` and `funny_opening!` macros already do) instead of
/// `println!`.
pub fn println_safe(args: std::fmt::Arguments<'_>) {
match writeln!(std::io::stdout().lock(), "{args}") {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
// the reader end of the pipe is gone; nothing left to print to
std::process::exit(0);
}
Err(e) => {
let _ = writeln!(std::io::stderr(), "rustscan: failed writing to stdout: {e}");
std::process::exit(1);
}
}
}
/// Terminal User Interface Module for RustScan
/// Defines macros to use
#[macro_export]
macro_rules! warning {
($name:expr) => {
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Red.bold().paint("[!]"),
$name
));
println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name);
};
($name:expr, $greppable:expr, $accessible:expr) => {
// if not greppable then print, otherwise no else statement so do not print.
if !$greppable {
if $accessible {
// Don't print the ascii art
$crate::tui::println_safe(format_args!("{}", $name));
println!("{}", $name);
} else {
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Red.bold().paint("[!]"),
$name
));
println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name);
}
}
};
@@ -58,24 +23,16 @@ macro_rules! warning {
#[macro_export]
macro_rules! detail {
($name:expr) => {
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Blue.bold().paint("[~]"),
$name
));
println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name);
};
($name:expr, $greppable:expr, $accessible:expr) => {
// if not greppable then print, otherwise no else statement so do not print.
if !$greppable {
if $accessible {
// Don't print the ascii art
$crate::tui::println_safe(format_args!("{}", $name));
println!("{}", $name);
} else {
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Blue.bold().paint("[~]"),
$name
));
println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name);
}
}
};
@@ -84,24 +41,24 @@ macro_rules! detail {
#[macro_export]
macro_rules! output {
($name:expr) => {
$crate::tui::println_safe(format_args!(
println!(
"{} {}",
ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
$name
));
);
};
($name:expr, $greppable:expr, $accessible:expr) => {
// if not greppable then print, otherwise no else statement so do not print.
if !$greppable {
if $accessible {
// Don't print the ascii art
$crate::tui::println_safe(format_args!("{}", $name));
println!("{}", $name);
} else {
$crate::tui::println_safe(format_args!(
println!(
"{} {}",
ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
$name
));
);
}
}
};
@@ -144,6 +101,6 @@ macro_rules! funny_opening {
];
let random_quote = quotes.choose(&mut rand::rng()).unwrap();
$crate::tui::println_safe(format_args!("{}\n", random_quote));
println!("{}\n", random_quote);
};
}
-32
View File
@@ -1,32 +0,0 @@
use rustscan::generated::get_parsed_data;
use rustscan::scanner::build_udp_payload_lookup;
#[test]
fn udp_payload_lookup_contains_common_udp_ports() {
let udp_map = get_parsed_data();
let lookup = build_udp_payload_lookup(udp_map);
// These are common UDP services; the payload database should include them.
assert!(
lookup.contains_key(&53),
"expected UDP payload for DNS (53)"
);
assert!(
lookup.contains_key(&123),
"expected UDP payload for NTP (123)"
);
}
#[test]
fn udp_payload_lookup_payloads_are_non_empty_for_known_ports() {
let udp_map = get_parsed_data();
let lookup = build_udp_payload_lookup(udp_map);
// Don't assert exact bytes (the generated payload set may evolve),
// but it should not be empty for these well-known protocols.
let dns = lookup.get(&53).expect("missing DNS payload");
assert!(!dns.is_empty(), "DNS payload should not be empty");
let ntp = lookup.get(&123).expect("missing NTP payload");
assert!(!ntp.is_empty(), "NTP payload should not be empty");
}