Compare commits

..
Author SHA1 Message Date
bee c6f994361f Fix CD release process
It appears our CD did not run in our pre-release, this tells it to run for tag releases
2024-11-25 09:30:56 +00:00
47 changed files with 4896 additions and 10360 deletions

No files matched your search

+3 -3
View File
@@ -3,15 +3,15 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
interval: "daily"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "monthly"
interval: "daily"
- package-ecosystem: cargo
directory: "/"
schedule:
interval: monthly
interval: daily
open-pull-requests-limit: 10
ignore:
- dependency-name: anyhow
@@ -1,57 +0,0 @@
"""Provision TCP memory for repeated scans on a disposable macOS CI runner."""
import ctypes
import json
import os
import subprocess
class Statistics(ctypes.Structure):
# XNU's bsd/sys/mem_acct_private.h, struct memacct_statistics.
_fields_ = [("peak", ctypes.c_uint64), ("allocated", ctypes.c_int64),
("softlimit", ctypes.c_uint64), ("hardlimit", ctypes.c_uint64),
("name", ctypes.c_char * 16)]
def tcp_memory(new_limit: int | None = None) -> dict:
"""Read the TCP accounting object, optionally raising its hard limit."""
libc = ctypes.CDLL(None, use_errno=True)
libc.sysctlnametomib.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_size_t)]
libc.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint,
ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t),
ctypes.c_void_p, ctypes.c_size_t]
mib = (ctypes.c_int * 24)()
count = ctypes.c_size_t(22)
if libc.sysctlnametomib(b"kern.memacct", mib, ctypes.byref(count)):
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
def query(operation: int, index: int, output, replacement=None):
mib[count.value], mib[count.value + 1] = operation, index
length = ctypes.c_size_t(ctypes.sizeof(output))
if libc.sysctl(mib, count.value + 2, ctypes.byref(output), ctypes.byref(length),
ctypes.byref(replacement) if replacement is not None else None,
ctypes.sizeof(replacement) if replacement is not None else 0):
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
return length.value
names = ctypes.create_string_buffer(8 * 16)
length = query(5, 0, names) # MEM_ACCT_SUBSYSTEMS
index = next(i for i in range(length // 16)
if names.raw[i * 16:(i + 1) * 16].rstrip(b"\0") == b"TCP")
stats = Statistics()
query(6, index, stats) # MEM_ACCT_ALL_SUBSYSTEM_STATISTICS
previous_limit = stats.hardlimit
if new_limit is not None and 0 < previous_limit < new_limit:
query(3, index, ctypes.c_uint64(), ctypes.c_uint64(new_limit))
query(6, index, stats)
return {"previous_hardlimit": previous_limit,
**{name: getattr(stats, name) for name in ("peak", "allocated", "softlimit", "hardlimit")}}
if __name__ == "__main__":
# The default TCP budget is 1/32 of physical memory. Repeated 10,000-socket
# batches hit ENOBUFS on Darwin 25.6 even on master. Give both builds 1/8
# before timing; retain every listener check and performance threshold.
memory = int(subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True))
print(json.dumps(tcp_memory(memory // 8)))
-984
View File
@@ -1,984 +0,0 @@
#!/usr/bin/env python3
"""Compare RustScan builds by scanning listeners on the loopback interface.
CI-only tooling for ``.github/workflows/scan-benchmark.yml``; it is not part
of the test suite. It port-scans 127.0.0.1, so only run it on a disposable CI
runner, never on a developer machine or a shared host.
``run`` starts ``serve`` (TCP listeners and UDP responders on 127.0.0.1) in a
separate process, runs every scenario with every build (interleaving the
builds so that drift on the runner affects them all alike), checks that all
builds report the same open ports, and writes the raw measurements as JSON
plus a Markdown summary of the medians. On Linux the workflow also sets up a
host behind an artificial delay (a network namespace on the runner, with its
own ``serve``), so that answers arrive after a round trip, as they do from a
remote host, instead of while the probe is being sent.
Measured per run:
* scan time: RustScan's own "Portscan" timer (``RUST_LOG=rustscan=info``),
reported as throughput (sockets per second);
* wall time of the whole process, and the time until the first ``Open`` line
appears on stdout (latency to the first result);
* peak RSS and CPU time (user + system) of the process;
* peak number of open file descriptors (Windows: handles) and of threads,
sampled every few milliseconds while the scan runs.
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import re
import selectors
import signal
import socket
import statistics
import subprocess
import sys
import threading
import time
from dataclasses import asdict, dataclass, field
LOOPBACK = "127.0.0.1"
# Ports `serve` listens on. They stay below 32768, i.e. below the ephemeral
# port ranges of Linux (32768-60999), macOS and Windows (49152-65535): a scan
# of a port inside the ephemeral range can occasionally connect a socket to
# itself and report a bogus open port.
TCP_PORTS = tuple(range(1024, 32768, 101)) # 315 open TCP ports
UDP_PORTS = tuple(range(1031, 32768, 997)) # 32 open UDP ports
SWEEP = (1, 32767)
SAMPLE_INTERVAL = 0.005 # seconds between fd/thread samples
RUN_TIMEOUT = 300 # seconds before a run is killed and counted as failed
# --------------------------------------------------------------------------
# Listener process
# --------------------------------------------------------------------------
def serve(addresses: list[str]) -> int:
"""Accepts (and closes) TCP connections and answers every UDP datagram."""
selector = selectors.DefaultSelector()
bound: dict[str, dict[str, list[int]]] = {"tcp": {}, "udp": {}, "busy": {}}
for address in addresses:
for kind, ports in (("tcp", TCP_PORTS), ("udp", UDP_PORTS)):
sock_type = socket.SOCK_STREAM if kind == "tcp" else socket.SOCK_DGRAM
for port in ports:
sock = socket.socket(socket.AF_INET, sock_type)
if os.name != "nt":
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((address, port))
except OSError:
sock.close()
bound["busy"].setdefault(f"{kind} {address}", []).append(port)
continue
if kind == "tcp":
sock.listen(1024)
sock.setblocking(False)
selector.register(sock, selectors.EVENT_READ, kind)
bound[kind].setdefault(address, []).append(port)
print(json.dumps(bound), flush=True)
while True:
for key, _ in selector.select(timeout=1.0):
sock = key.fileobj
if key.data == "tcp":
while True:
try:
conn, _ = sock.accept()
except OSError: # BlockingIOError once the queue is empty
break
conn.close()
else:
while True:
try:
_, peer = sock.recvfrom(4096)
except ConnectionResetError:
# Windows reports an earlier ICMP error here; skip it.
continue
except OSError: # BlockingIOError once the queue is empty
break
try:
sock.sendto(b"rustscan-benchmark\n", peer)
except OSError:
pass
def start_server(addresses: list[str]) -> tuple[subprocess.Popen, dict]:
args = [arg for address in addresses for arg in ("--address", address)]
proc = subprocess.Popen(
[sys.executable, os.path.abspath(__file__), "serve", *args],
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
text=True,
)
line = proc.stdout.readline()
if not line:
raise SystemExit("the listener process exited before it was ready")
return proc, json.loads(line)
# --------------------------------------------------------------------------
# Scenarios
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Scenario:
name: str
title: str
addresses: tuple[str, ...]
ranges: tuple[tuple[int, int], ...] = ()
ports: tuple[int, ...] = ()
batch: int = 4500
timeout_ms: int = 1500
udp: bool = False
repeat: int = 1 # extra samples for very short scenarios
excluded_ports: tuple[int, ...] = ()
@property
def sockets(self) -> int:
span = sum(end - start + 1 for start, end in self.ranges)
excluded = sum(self.covers(port) for port in set(self.excluded_ports))
return len(self.addresses) * (len(self.ports) + span - excluded)
def covers(self, port: int) -> bool:
return port in self.ports or any(s <= port <= e for s, e in self.ranges)
def args(self) -> list[str]:
args = ["-a", ",".join(self.addresses)]
if self.ports:
args += ["-p", ",".join(map(str, self.ports))]
else:
args += ["-r", ",".join(f"{s}-{e}" for s, e in self.ranges)]
args += ["-b", str(self.batch), "-t", str(self.timeout_ms)]
if self.udp:
args.append("--udp")
if self.excluded_ports:
args += ["--exclude-ports", ",".join(map(str, self.excluded_ports))]
return args
def expected(self, listeners: dict) -> set[str]:
by_address = listeners["udp" if self.udp else "tcp"]
return {
f"{address}:{port}"
for address in self.addresses
for port in by_address.get(address, [])
if self.covers(port) and port not in self.excluded_ports
}
def describe(self) -> str:
hosts = f"{len(self.addresses)} addresses" if len(self.addresses) > 1 else self.addresses[0]
return f"{self.title}: `{' '.join(self.args()[2:])}` on {hosts}"
def build_scenarios(
system: str, filtered: tuple[int, int] | None, delayed: str | None, delay_ms: int
) -> list[Scenario]:
lo = (LOOPBACK,)
if system == "Windows":
# Windows retries a refused connection for about a second, so every
# closed port there takes as long as a filtered one: sweep fewer.
sweep, small_sweep = ((1, 8191),), ((1, 2047),)
else:
sweep = small_sweep = (SWEEP,)
def ports(ranges: tuple[tuple[int, int], ...]) -> str:
return f"{sum(e - s + 1 for s, e in ranges):,} ports"
scenarios = [
Scenario("tcp-1-port", "TCP, 1 open port", lo, ports=(TCP_PORTS[0],), repeat=3),
Scenario("tcp-open", "TCP, open listeners only", lo, ports=TCP_PORTS, repeat=3),
Scenario("tcp-sweep", f"TCP, {ports(sweep)}, default batch", lo, sweep),
Scenario(
"tcp-sweep-excluded",
f"TCP, {ports(sweep)}, 1,024 excluded ports",
lo,
sweep,
excluded_ports=tuple(range(1024, 2048)),
),
Scenario(
"tcp-sweep-b500", f"TCP, {ports(small_sweep)}, small batch", lo, small_sweep, batch=500
),
Scenario("tcp-sweep-b10000", f"TCP, {ports(sweep)}, large batch", lo, sweep, batch=10000),
]
if system == "Linux":
# Linux answers on all of 127.0.0.0/8; other systems only on 127.0.0.1.
hosts = tuple(f"127.0.0.{i}" for i in range(1, 9))
scenarios.append(
Scenario("tcp-8-hosts", "TCP, 8 hosts x 4,096 ports", hosts, ((1, 4096),))
)
if delayed:
scenarios.append(
Scenario("tcp-delayed", f"TCP, {ports(sweep)}, {delay_ms} ms away", (delayed,), sweep)
)
if filtered:
dropped = (filtered,)
scenarios += [
Scenario(
"tcp-filtered", "TCP, 2,000 filtered ports", lo, dropped, batch=500, timeout_ms=250
),
Scenario(
"tcp-mixed",
f"TCP, {ports(sweep)} + 2,000 filtered ports",
lo,
sweep + dropped,
timeout_ms=500,
),
]
scenarios.append(
Scenario("udp-sweep", f"UDP, {ports(sweep)}", lo, sweep, timeout_ms=500, udp=True)
)
scenarios.append(
Scenario(
"udp-open", "UDP, echo responders only", lo, ports=UDP_PORTS,
timeout_ms=500, udp=True, repeat=9,
)
)
if delayed:
scenarios.append(
Scenario(
"udp-delayed",
f"UDP, {ports(sweep)}, {delay_ms} ms away",
(delayed,),
sweep,
timeout_ms=500,
udp=True,
)
)
if filtered:
scenarios.append(
Scenario(
"udp-filtered",
"UDP, 2,000 filtered ports",
lo,
(filtered,),
batch=500,
timeout_ms=250,
udp=True,
)
)
return scenarios
# --------------------------------------------------------------------------
# Running one scan
# --------------------------------------------------------------------------
@dataclass
class RunResult:
build: str
scenario: str
sockets: int = 0
ok: bool = True
error: str = ""
returncode: int | None = None
wall_s: float | None = None
scan_s: float | None = None
first_open_s: float | None = None
rss_kb: int | None = None
cpu_s: float | None = None
peak_fds: int | None = None
peak_threads: int | None = None
open: list[str] = field(default_factory=list)
@property
def rate(self) -> float | None:
return self.sockets / self.scan_s if self.scan_s else None
SCAN_TIME = re.compile(r"Portscan\s*\|\s*([0-9.]+)\s*s")
OPEN_LINE = re.compile(r"^Open (\S+)$")
SUMMARY_LINE = re.compile(r"^(\S+) -> \[([0-9,]*)\]$")
class Sampler(threading.Thread):
"""Samples the open descriptors and the threads of a running process."""
def __init__(self, proc: subprocess.Popen):
super().__init__(daemon=True)
self.proc = proc
self.done = threading.Event()
self.peak_fds: int | None = None
self.peak_threads: int | None = None
self.system = platform.system()
self.libproc = darwin_libproc() if self.system == "Darwin" else None
def sample(self) -> tuple[int | None, int | None]:
pid = self.proc.pid
if self.system == "Linux":
fds = len(os.listdir(f"/proc/{pid}/fd"))
threads = None
with open(f"/proc/{pid}/status", encoding="ascii", errors="replace") as status:
for line in status:
if line.startswith("Threads:"):
threads = int(line.split()[1])
break
return fds, threads
if self.libproc is not None:
return darwin_fds_and_threads(self.libproc, pid)
if self.system == "Windows":
return windows_handle_count(self.proc), None
return None, None
def run(self) -> None:
while not self.done.is_set():
try:
fds, threads = self.sample()
except (OSError, ValueError):
return # the process is gone
if fds is not None:
self.peak_fds = max(self.peak_fds or 0, fds)
if threads is not None:
self.peak_threads = max(self.peak_threads or 0, threads)
self.done.wait(SAMPLE_INTERVAL)
def darwin_libproc():
import ctypes
try:
libproc = ctypes.CDLL("/usr/lib/libproc.dylib")
except OSError:
return None
libproc.proc_pidinfo.argtypes = [
ctypes.c_int,
ctypes.c_int,
ctypes.c_uint64,
ctypes.c_void_p,
ctypes.c_int,
]
libproc.proc_pidinfo.restype = ctypes.c_int
return libproc
def darwin_fds_and_threads(libproc, pid: int) -> tuple[int | None, int | None]:
import ctypes
proc_pidlistfds, proc_pidtaskinfo = 1, 4
size = libproc.proc_pidinfo(pid, proc_pidlistfds, 0, None, 0)
if size <= 0:
raise OSError("process is gone")
buf = ctypes.create_string_buffer(size)
used = libproc.proc_pidinfo(pid, proc_pidlistfds, 0, ctypes.addressof(buf), size)
if used <= 0:
raise OSError("process is gone")
fds = used // 8 # sizeof(struct proc_fdinfo)
class TaskInfo(ctypes.Structure): # struct proc_taskinfo
_fields_ = [
*((name, ctypes.c_uint64) for name in ("vsize", "rsize", "user", "system", "tu", "ts")),
*(
(name, ctypes.c_int32)
for name in (
"policy",
"faults",
"pageins",
"cow_faults",
"messages_sent",
"messages_received",
"syscalls_mach",
"syscalls_unix",
"csw",
"threadnum",
"numrunning",
"priority",
)
),
]
info = TaskInfo()
got = libproc.proc_pidinfo(
pid, proc_pidtaskinfo, 0, ctypes.addressof(info), ctypes.sizeof(info)
)
return fds, (info.threadnum if got == ctypes.sizeof(info) else None)
def windows_handle_count(proc: subprocess.Popen) -> int | None:
import ctypes
from ctypes import wintypes
count = wintypes.DWORD()
handle = wintypes.HANDLE(int(proc._handle)) # Popen keeps the handle open
if not ctypes.windll.kernel32.GetProcessHandleCount(handle, ctypes.byref(count)):
raise OSError("process is gone")
return count.value
def windows_memory_and_cpu(proc: subprocess.Popen) -> tuple[int | None, float | None]:
import ctypes
from ctypes import wintypes
class Counters(ctypes.Structure): # PROCESS_MEMORY_COUNTERS
_fields_ = [
("cb", wintypes.DWORD),
("PageFaultCount", wintypes.DWORD),
("PeakWorkingSetSize", ctypes.c_size_t),
("WorkingSetSize", ctypes.c_size_t),
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
("QuotaPagedPoolUsage", ctypes.c_size_t),
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
("PagefileUsage", ctypes.c_size_t),
("PeakPagefileUsage", ctypes.c_size_t),
]
handle = wintypes.HANDLE(int(proc._handle))
counters = Counters()
counters.cb = ctypes.sizeof(counters)
rss_kb = None
if ctypes.windll.psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb):
rss_kb = counters.PeakWorkingSetSize // 1024
times = [wintypes.FILETIME() for _ in range(4)] # creation, exit, kernel, user
cpu_s = None
if ctypes.windll.kernel32.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)):
ticks = sum((t.dwHighDateTime << 32) | t.dwLowDateTime for t in times[2:])
cpu_s = ticks / 1e7 # 100 ns units
return rss_kb, cpu_s
def run_once(name: str, binary: str, scenario: Scenario) -> RunResult:
result = RunResult(build=name, scenario=scenario.name, sockets=scenario.sockets)
cmd = [
binary,
*scenario.args(),
"--scripts",
"none",
"--accessible",
"--no-banner",
"--no-config",
]
env = dict(os.environ, RUST_LOG="rustscan=info")
stdout_lines: list[str] = []
stderr_chunks: list[bytes] = []
start = time.perf_counter()
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, env=env
)
def read_stdout() -> None:
for raw in proc.stdout:
line = raw.decode("utf-8", "replace").rstrip("\r\n")
if result.first_open_s is None and line.startswith("Open "):
result.first_open_s = time.perf_counter() - start
stdout_lines.append(line)
readers = [
threading.Thread(target=read_stdout, daemon=True),
threading.Thread(target=lambda: stderr_chunks.append(proc.stderr.read()), daemon=True),
]
for reader in readers:
reader.start()
sampler = Sampler(proc)
sampler.start()
# On Unix the child is reaped with os.wait4 below, so the watchdog must
# not go through Popen (which would try to reap it as well).
kill = proc.kill if os.name == "nt" else lambda: os.kill(proc.pid, signal.SIGKILL)
watchdog = threading.Timer(RUN_TIMEOUT, kill)
watchdog.start()
try:
if hasattr(os, "wait4"):
_, status, usage = os.wait4(proc.pid, 0)
result.wall_s = time.perf_counter() - start
proc.returncode = os.waitstatus_to_exitcode(status)
maxrss = usage.ru_maxrss # KiB on Linux, bytes on macOS
result.rss_kb = maxrss // 1024 if platform.system() == "Darwin" else maxrss
result.cpu_s = usage.ru_utime + usage.ru_stime
else:
proc.wait()
result.wall_s = time.perf_counter() - start
result.rss_kb, result.cpu_s = windows_memory_and_cpu(proc)
finally:
watchdog.cancel()
sampler.done.set()
sampler.join()
for reader in readers:
reader.join()
result.returncode = proc.returncode
result.peak_fds = sampler.peak_fds
result.peak_threads = sampler.peak_threads
stderr = b"".join(stderr_chunks).decode("utf-8", "replace")
opened: set[str] = set()
summarised: set[str] = set()
for line in stdout_lines:
if match := OPEN_LINE.match(line):
opened.add(match.group(1))
elif match := SUMMARY_LINE.match(line):
summarised.update(f"{match.group(1)}:{p}" for p in match.group(2).split(",") if p)
result.open = sorted(opened)
if scan_time := SCAN_TIME.search(stderr):
result.scan_s = float(scan_time.group(1))
problems = []
if proc.returncode != 0:
problems.append(f"exit code {proc.returncode}")
if result.scan_s is None:
problems.append("no 'Portscan' timer in the log")
if summarised != opened:
problems.append(f"'Open' lines and the summary disagree: {sorted(summarised ^ opened)[:5]}")
if problems:
result.ok = False
tail = "\n".join(stderr.strip().splitlines()[-15:])
result.error = "; ".join(problems) + (f"\nstderr tail:\n{tail}" if tail else "")
return result
# --------------------------------------------------------------------------
# Environment
# --------------------------------------------------------------------------
def sysctl(*names: str) -> list[str]:
return subprocess.run(
["sysctl", "-n", *names], capture_output=True, text=True, check=True
).stdout.split("\n")
def ephemeral_range() -> tuple[int, int] | None:
system = platform.system()
try:
if system == "Linux":
with open("/proc/sys/net/ipv4/ip_local_port_range", encoding="ascii") as f:
low, high = map(int, f.read().split())
return low, high
if system == "Darwin":
first, last = sysctl("net.inet.ip.portrange.first", "net.inet.ip.portrange.last")[:2]
return int(first), int(last)
except (OSError, ValueError, subprocess.CalledProcessError):
return None
return (49152, 65535) if system == "Windows" else None
def diagnose_tcp(binary: str, scenario: Scenario, listeners: dict) -> dict:
"""Untimed failure capture on a disposable macOS runner, using debug logs."""
from macos_tcp_memory import tcp_memory
def states() -> dict[str, int]:
snapshot = subprocess.run(
["netstat", "-an", "-p", "tcp"], capture_output=True, text=True, check=True
)
counts: dict[str, int] = {}
for line in snapshot.stdout.splitlines():
if line.startswith("tcp"):
state = line.split()[-1]
counts[state] = counts.get(state, 0) + 1
return counts
before = states()
memory_before = tcp_memory()
probe = subprocess.run(
[binary, *scenario.args(), "--scripts", "none", "--accessible", "--no-banner", "--no-config"],
env=dict(os.environ, RUST_LOG="rustscan=info,rustscan::scanner=debug"),
capture_output=True, text=True, check=True,
)
opened = {
match.group(1) for line in probe.stdout.splitlines()
if (match := OPEN_LINE.match(line))
}
return {
"before": before, "after": states(),
"memory_before": memory_before, "memory_after": tcp_memory(),
"missing": sorted(scenario.expected(listeners) - opened),
"errors": [line for line in probe.stderr.splitlines()
if "Typical socket connection errors" in line],
}
def environment() -> dict[str, object]:
system = platform.system()
info: dict[str, object] = {
"system": system,
"release": platform.release(),
"machine": platform.machine(),
"cpus": os.cpu_count(),
"cpu": platform.processor(),
"python": platform.python_version(),
"ephemeral_ports": ephemeral_range(),
}
try:
if system == "Linux":
with open("/proc/cpuinfo", encoding="ascii", errors="replace") as f:
for line in f:
if line.lower().startswith(("model name", "cpu model")):
info["cpu"] = line.split(":", 1)[1].strip()
break
elif system == "Darwin":
info["cpu"] = sysctl("machdep.cpu.brand_string")[0].strip()
except (OSError, subprocess.CalledProcessError):
pass
try:
import resource
info["open_file_limit"] = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
except (ImportError, OSError, ValueError):
pass
return info
# --------------------------------------------------------------------------
# Analysis and reporting
# --------------------------------------------------------------------------
Results = dict[str, dict[str, list[RunResult]]]
# key, title, formatter, higher is better
METRICS = (
("rate", "Throughput (sockets/s, from RustScan's scan timer)", lambda v: f"{v:,.0f}", True),
("wall_s", "Wall time of the whole process", lambda v: f"{v * 1000:,.0f} ms", False),
("first_open_s", "Time to the first open port", lambda v: f"{v * 1000:,.1f} ms", False),
("rss_kb", "Peak RSS", lambda v: f"{v / 1024:,.1f} MiB", False),
("cpu_s", "CPU time (user + system)", lambda v: f"{v * 1000:,.0f} ms", False),
("peak_fds", "Peak open descriptors (sampled; Windows: handles)", lambda v: f"{v:,.0f}", False),
("peak_threads", "Peak threads (sampled)", lambda v: f"{v:,.0f}", False),
)
FLAG_WORSE = 10.0 # percent
def metric_values(runs: list[RunResult], key: str) -> list[float]:
return [float(getattr(r, key)) for r in runs if r.ok and getattr(r, key) is not None]
def median(values: list[float]) -> float | None:
return statistics.median(values) if values else None
def worse_by(value: float, base: float, higher_is_better: bool) -> float:
"""How much worse `value` is than `base`, in percent (negative: better)."""
pct = (value / base - 1.0) * 100.0
return -pct if higher_is_better else pct
def check_open_ports(
results: Results, scenarios: list[Scenario], listeners: dict, baseline: str
) -> tuple[list[str], list[str], list[str]]:
"""Returns (failed runs, open-port mismatches, notes).
No build may miss more listeners than the baseline: none at all if the
baseline found every listener in every run, or barely more if it did not
either (a runner too slow for the scenario's timeout). Ports that are
open beyond the listeners (services already running on the runner) must
match what the baseline found: a build may not miss a port the baseline
found in every run, nor report one the baseline never found.
"""
failures, mismatches, notes = [], [], []
for sc in scenarios:
expected = sc.expected(listeners)
per_build = results[sc.name]
def missed(runs: list[RunResult]) -> int:
return sum(len(expected - set(r.open)) for r in runs if r.ok)
base_missed = missed(per_build[baseline])
allowed = base_missed + max(3, base_missed // 4) if base_missed else 0
if base_missed:
notes.append(
f"`{sc.name}`: `{baseline}` itself missed {base_missed} of "
f"{len(expected) * len(per_build[baseline])} listener answers"
)
base_extra = [set(r.open) - expected for r in per_build[baseline] if r.ok]
always = set.intersection(*base_extra) if base_extra else set()
ever = set.union(*base_extra) if base_extra else set()
for name, runs in per_build.items():
for index, res in enumerate(runs, start=1):
if not res.ok:
failures.append(f"`{sc.name}` `{name}` run {index}: {res.error.splitlines()[0]}")
if name == baseline:
continue
if (count := missed(runs)) > allowed:
mismatches.append(
f"`{sc.name}` `{name}`: missed {count} listener answers "
f"(`{baseline}`: {base_missed})"
)
for index, res in enumerate(runs, start=1):
if not res.ok:
continue
found = set(res.open)
where = f"`{sc.name}` `{name}` run {index}"
if lost := always - found:
mismatches.append(f"{where}: missed ports `{baseline}` always found {sorted(lost)[:5]}")
if bogus := (found - expected) - ever:
mismatches.append(f"{where}: reported ports `{baseline}` never found {sorted(bogus)[:5]}")
return failures, mismatches, notes
def gate(
results: Results,
scenarios: list[Scenario],
baseline: str,
candidate: str,
max_slowdown: float,
max_memory: float,
) -> list[str]:
"""Scenarios where `candidate` is clearly worse than `baseline`."""
problems = []
for sc in scenarios:
checks = [("wall_s", False, max_slowdown, 0.002), ("rss_kb", False, max_memory, 1024)]
if sc.sockets >= 1000: # throughput is meaningless for a handful of sockets
checks.append(("rate", True, max_slowdown, 0))
for key, higher, limit, floor in checks:
base = median(metric_values(results[sc.name][baseline], key))
value = median(metric_values(results[sc.name][candidate], key))
if not base or value is None or abs(value - base) <= floor:
continue
if (worse := worse_by(value, base, higher)) > limit:
problems.append(f"{sc.name}: {key} {worse:+.1f}% worse than {baseline} (limit {limit}%)")
return problems
def render_markdown(report: dict, results: Results, scenarios: list[Scenario]) -> str:
builds = report["builds"]
baseline = report["baseline"]
env = report["environment"]
out = [f"### Scan benchmark: {env['system']} {env['machine']}", ""]
out.append(
"; ".join(f"`{b['name']}`: {b['label']}" for b in builds)
+ f". {report['runs']} interleaved runs per build and scenario (`tcp-1-port`: x3), medians."
f" Runner: {env.get('cpu') or '?'}, {env['cpus']} CPUs, {env['system']} {env['release']},"
f" open-file limit {env.get('open_file_limit', 'n/a')}."
)
out.append("")
problems = report["failures"] + report["mismatches"]
tcp = sum(len(ports) for ports in report["listeners"]["tcp"].values())
udp = sum(len(ports) for ports in report["listeners"]["udp"].values())
if not problems:
out.append(
f"✅ Every run of every build exited cleanly; no build missed more of the {tcp} TCP "
f"and {udp} UDP listeners than `{baseline}`, or disagreed with it about other ports."
)
else:
out.append(f"❌ {len(report['failures'])} failed runs, {len(report['mismatches'])} open-port mismatches:")
out += [f"- {p}" for p in problems[:25]]
if report["notes"]:
out.append("")
out += [f"- ⚠️ {note}" for note in report["notes"]]
out.append("")
out.append("| Scenario | What is scanned |")
out.append("|---|---|")
out += [f"| `{sc.name}` | {sc.describe()} ({sc.sockets:,} sockets) |" for sc in scenarios]
out.append("")
out.append(f"Changes are relative to `{baseline}`; ❗ marks a median more than {FLAG_WORSE:.0f}% worse.")
out.append("")
for key, title, fmt, higher in METRICS:
rows = []
for sc in scenarios:
base_value = median(metric_values(results[sc.name][baseline], key))
cells = []
for build in builds:
value = median(metric_values(results[sc.name][build["name"]], key))
if value is None:
cells.append("–")
continue
cell = fmt(value)
if build["name"] != baseline and base_value:
pct = (value / base_value - 1.0) * 100.0
flag = " ❗" if worse_by(value, base_value, higher) > FLAG_WORSE else ""
cell += f" ({pct:+.1f}%{flag})"
cells.append(cell)
if any(c != "–" for c in cells):
rows.append(f"| `{sc.name}` | " + " | ".join(cells) + " |")
out.append(f"**{title}**")
out.append("")
if rows:
out.append("| Scenario | " + " | ".join(f"`{b['name']}`" for b in builds) + " |")
out.append("|---|" + "---:|" * len(builds))
out += rows
else:
out.append("_Not measured on this platform._")
out.append("")
return "\n".join(out)
# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def parse_pairs(values: list[str], option: str) -> dict[str, str]:
pairs = {}
for value in values:
name, sep, rest = value.partition("=")
if not sep or not name or not rest:
raise SystemExit(f"--{option} expects NAME=VALUE, got {value!r}")
pairs[name] = rest
return pairs
def run(args: argparse.Namespace) -> int:
builds = parse_pairs(args.build, "build")
labels = parse_pairs(args.label, "label")
baseline = args.baseline or next(iter(builds))
if baseline not in builds:
raise SystemExit(f"unknown baseline build {baseline!r}")
for name, path in builds.items():
if not os.path.isfile(path):
raise SystemExit(f"build {name!r}: {path} does not exist")
if args.gate and args.gate not in builds:
raise SystemExit(f"unknown --gate build {args.gate!r}")
if args.delayed_host and not args.delayed_listeners:
raise SystemExit("--delayed-host needs --delayed-listeners")
filtered = None
if args.filtered:
low, _, high = args.filtered.partition("-")
filtered = (int(low), int(high))
scenarios = build_scenarios(platform.system(), filtered, args.delayed_host, args.delay_ms)
if args.only:
wanted = set(args.only.split(","))
scenarios = [sc for sc in scenarios if sc.name in wanted]
if ephemeral := ephemeral_range():
low, high = ephemeral
for sc in scenarios:
for start, end in sc.ranges:
if start <= high and end >= low:
raise SystemExit(
f"scenario {sc.name} scans {start}-{end}, which overlaps the ephemeral "
f"port range {low}-{high}: a scan could connect a socket to itself"
)
server, listeners = start_server([LOOPBACK])
if args.delayed_host:
# The workflow runs a second `serve` on the delayed host (inside a
# network namespace) and stores its first line in this file.
with open(args.delayed_listeners, encoding="utf-8") as f:
far = json.loads(f.readline())
for kind in ("tcp", "udp"):
listeners[kind][args.delayed_host] = far[kind].get(args.delayed_host, [])
for kind in ("tcp", "udp"):
counts = {address: len(ports) for address, ports in listeners[kind].items()}
print(f"{kind} listeners: {counts}", flush=True)
if listeners["busy"]:
print(f"already in use: {listeners['busy']}", flush=True)
names = list(builds)
results: Results = {sc.name: {name: [] for name in names} for sc in scenarios}
diagnostics = []
try:
warmup = next((sc for sc in scenarios if sc.name == "tcp-sweep"), scenarios[0])
for name in names:
run_once(name, builds[name], warmup)
rounds = args.runs * max(sc.repeat for sc in scenarios)
for index in range(rounds):
shift = index % len(names)
order = names[shift:] + names[:shift]
for sc in scenarios:
if index >= args.runs * sc.repeat:
continue
for name in order:
res = run_once(name, builds[name], sc)
results[sc.name][name].append(res)
print(
f"[{index + 1}/{rounds}] {sc.name:<17} {name:<14} ok={res.ok} "
f"scan={res.scan_s} wall={res.wall_s} first={res.first_open_s} "
f"rss={res.rss_kb} cpu={res.cpu_s} fds={res.peak_fds} "
f"threads={res.peak_threads} open={len(res.open)}",
flush=True,
)
if not res.ok:
print(f" {res.error}", flush=True)
if platform.system() == "Darwin" and not diagnostics and any(
sc.expected(listeners) - set(results[sc.name][name][-1].open) for name in names
):
# A failed pair cannot support a performance claim. Capture
# its OS errors immediately, outside both measured scans.
for name in names:
record = {"build": name, "scenario": sc.name,
**diagnose_tcp(builds[name], sc, listeners)}
diagnostics.append(record)
print("TCP diagnosis: " + json.dumps(record), flush=True)
finally:
server.kill()
server.wait()
failures, mismatches, notes = check_open_ports(results, scenarios, listeners, baseline)
report = {
"environment": environment(),
"baseline": baseline,
"builds": [{"name": n, "label": labels.get(n, n), "path": builds[n]} for n in names],
"runs": args.runs,
"listeners": listeners,
"scenarios": [asdict(sc) | {"sockets": sc.sockets} for sc in scenarios],
"results": {
sc: {n: [asdict(r) | {"rate": r.rate} for r in runs] for n, runs in per.items()}
for sc, per in results.items()
},
"failures": failures,
"mismatches": mismatches,
"notes": notes,
"diagnostics": diagnostics,
}
markdown = render_markdown(report, results, scenarios)
if args.json:
with open(args.json, "w", encoding="utf-8") as f:
json.dump(report, f, indent=1)
if args.markdown:
with open(args.markdown, "w", encoding="utf-8") as f:
f.write(markdown + "\n")
print(markdown)
status = 1 if failures or mismatches else 0
if args.gate:
for problem in gate(results, scenarios, baseline, args.gate, args.max_slowdown, args.max_memory):
print(f"::error::performance regression: {problem}", flush=True)
status = 1
return status
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
sub = parser.add_subparsers(dest="command", required=True)
listen = sub.add_parser("serve", help="run the loopback listeners (started by `run`)")
listen.add_argument("--address", action="append", default=[], help="address to listen on")
bench = sub.add_parser("run", help="benchmark the given builds")
bench.add_argument("--build", action="append", required=True, metavar="NAME=PATH")
bench.add_argument("--label", action="append", default=[], metavar="NAME=TEXT")
bench.add_argument("--baseline", help="build the others are compared with (default: the first)")
bench.add_argument("--runs", type=int, default=9, help="interleaved runs per build and scenario")
bench.add_argument(
"--filtered", metavar="START-END", help="ports the runner drops; enables filtered scenarios"
)
bench.add_argument(
"--delayed-host",
metavar="ADDRESS",
help="address behind an artificial delay, with its own `serve`; enables delayed scenarios",
)
bench.add_argument(
"--delayed-listeners", metavar="FILE", help="first output line of that host's `serve`"
)
bench.add_argument("--delay-ms", type=int, default=5, help="that delay, for the summary")
bench.add_argument("--only", metavar="NAMES", help="comma-separated scenarios to run")
bench.add_argument("--json", help="write the raw results to this file")
bench.add_argument("--markdown", help="write the Markdown summary to this file")
bench.add_argument("--gate", metavar="NAME", help="fail if this build regresses against the baseline")
bench.add_argument("--max-slowdown", type=float, default=15.0, help="percent, throughput and wall time")
bench.add_argument("--max-memory", type=float, default=25.0, help="percent, peak RSS")
args = parser.parse_args()
# The summary contains emoji; Windows runners default to a legacy code page.
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
return serve(args.address or [LOOPBACK]) if args.command == "serve" else run(args)
if __name__ == "__main__":
sys.exit(main())
+61 -81
View File
@@ -1,22 +1,15 @@
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
- '*'
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]
@@ -46,35 +39,40 @@ jobs:
path: target/aarch64-unknown-linux-gnu/release/rustscan
pkg_config_path: /usr/lib/x86_64-linux-gnu/pkgconfig
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- 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@v0
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@v4
with:
executable-name: rustscan
target: ${{ matrix.target }}
archive-name: ${{ matrix.name }}
changes-file: ""
name: ${{ matrix.name }}
path: ${{ matrix.path }}
- uses: actions/upload-artifact@v4
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
env:
IN_PIPELINE: true
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- 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
@@ -83,7 +81,7 @@ jobs:
- name: Deb Build
run: cargo deb --target=x86_64-unknown-linux-musl
- name: Upload Deb Artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: rustscan.deb
path: ./target/x86_64-unknown-linux-musl/debian/*
@@ -92,54 +90,65 @@ jobs:
env:
IN_PIPELINE: true
runs-on: macos-latest
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- 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@v0
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@v4
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@v4
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
- uses: actions/checkout@v4
- 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@v0
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@v4
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@v4
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 +156,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
- uses: actions/checkout@v4
- 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@v0
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@v4
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 }}
+21
View File
@@ -0,0 +1,21 @@
name: Check Markdown links
on:
push:
branches:
- master
paths-ignore:
- '.github/**'
schedule:
# Run everyday at 9:00 AM (See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html#tag_20_25_07)
- cron: "0 9 * * *"
jobs:
markdown-link-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
use-quiet-mode: 'yes'
config-file: './.github/workflows/mlc_config.json'
-256
View File
@@ -1,256 +0,0 @@
name: Scan benchmark
# Compares the scanning speed and resource use of a change with its base.
#
# Each run builds both versions in release mode and runs
# .github/scan-benchmark/scan_bench.py, which scans listeners it opens on
# 127.0.0.1 inside the runner (plus closed and, on Linux, filtered ports) and
# reports medians over interleaved runs. Nothing outside the runner is
# scanned. The unit tests deliberately never open sockets; this workflow is
# where scanning behaviour and performance are measured.
#
# It only runs for pull requests that touch the scanner, its runtime or its
# dependencies, or when started by hand, so it does not slow down normal CI.
on:
pull_request:
branches: [master]
paths:
- "src/scanner/**"
- "src/main.rs"
- "src/lib.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/scan-benchmark/**"
- ".github/workflows/scan-benchmark.yml"
workflow_dispatch:
inputs:
base:
description: "Branch, tag or commit to compare against"
default: "master"
runs:
description: "Interleaved runs per build and scenario"
default: "9"
extra:
description: "More builds to compare, as space-separated NAME=REF pairs"
default: ""
permissions:
contents: read
concurrency:
group: scan-benchmark-${{ github.ref }}
cancel-in-progress: true
jobs:
benchmark:
name: Benchmark (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
defaults:
run:
shell: bash
env:
# Ports the Linux runners drop, to measure filtered ports (timeouts).
# They lie above Linux's ephemeral port range (32768-60999).
FILTERED_PORTS: "61000-62999"
# The Linux runners' host behind a 5 ms delay (see "Set up a host 5 ms away").
DELAYED_HOST: "10.200.0.2"
RUNS: ${{ inputs.runs || '9' }}
EXTRA: ${{ inputs.extra }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Check the benchmark script
run: python -m py_compile .github/scan-benchmark/scan_bench.py
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0
with:
toolchain: stable
# build.rs formats the generated payload table with rustfmt.
components: rustfmt
# Caching is handled by Swatinem/rust-cache below.
cache: false
rustflags: ""
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Build the change
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
exe=""
if [ "$RUNNER_OS" = Windows ]; then exe=".exe"; fi
echo "EXE=$exe" >> "$GITHUB_ENV"
mkdir -p "$RUNNER_TEMP/bin"
cargo build --release --locked
cp "target/release/rustscan$exe" "$RUNNER_TEMP/bin/head$exe"
if [ -n "$PR_NUMBER" ]; then
label="#$PR_NUMBER at ${PR_HEAD::7}, merged into its base"
else
label="$(git log -1 --format='%h %s' HEAD)"
fi
echo "HEAD_LABEL=$label" >> "$GITHUB_ENV"
- name: Build the base and any extra builds
env:
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
INPUT_BASE: ${{ inputs.base }}
run: |
build_ref() {
local name="$1" ref="$2"
if [[ ! "$name" =~ ^[A-Za-z0-9._+-]+$ ]]; then
echo "::error::invalid build name '$name'"
exit 1
fi
git fetch --no-tags --depth=1 origin "$ref"
git worktree add --detach "$RUNNER_TEMP/src-$name" FETCH_HEAD
cargo build --release --locked \
--manifest-path "$RUNNER_TEMP/src-$name/Cargo.toml" --target-dir target
cp "target/release/rustscan$EXE" "$RUNNER_TEMP/bin/$name$EXE"
echo "$name=$(git log -1 --format='%h %s' FETCH_HEAD)" >> "$RUNNER_TEMP/labels"
}
if [ "$EVENT" = pull_request ]; then base="$PR_BASE"; else base="${INPUT_BASE:-master}"; fi
build_ref base "$base"
for spec in $EXTRA; do
build_ref "${spec%%=*}" "${spec#*=}"
done
cat "$RUNNER_TEMP/labels"
- name: Drop traffic to the filtered ports
if: runner.os == 'Linux'
run: |
if ! command -v iptables >/dev/null; then
echo "::warning::iptables is not available; skipping the filtered-port scenarios"
exit 0
fi
for proto in tcp udp; do
sudo iptables -I INPUT -i lo -p "$proto" --dport "${FILTERED_PORTS/-/:}" -j DROP
done
sudo iptables -S INPUT
echo "FILTERED_OK=1" >> "$GITHUB_ENV"
- name: Set up a host 5 ms away
if: runner.os == 'Linux'
run: |
# A network namespace on the runner, reached over a veth pair whose
# outbound side delays every packet. Its answers (SYN-ACKs, resets,
# ICMP errors, UDP replies) arrive a round trip after the probe, as
# from a remote host, instead of while the probe is being sent.
skip() {
echo "::warning::$1; skipping the delayed-host scenarios"
exit 0
}
sudo ip netns add far || skip "cannot create a network namespace"
sudo ip link add rs-near type veth peer name rs-far || skip "cannot create a veth pair"
sudo ip link set rs-far netns far || skip "cannot move the veth end"
sudo ip addr add 10.200.0.1/30 dev rs-near || skip "cannot address the veth pair"
sudo ip link set rs-near up || skip "cannot bring up the veth pair"
sudo ip netns exec far ip addr add "$DELAYED_HOST/30" dev rs-far || skip "cannot address the namespace"
sudo ip netns exec far ip link set rs-far up || skip "cannot bring up the namespace"
sudo ip netns exec far ip link set lo up || true
# Answer every probe to a closed UDP port, like the loopback
# interface does (ICMP errors are rate limited everywhere else).
for setting in icmp_ratelimit=0 icmp_msgs_per_sec=100000000 icmp_msgs_burst=100000000; do
sudo ip netns exec far sysctl -qw "net.ipv4.$setting" || true
done
sudo tc qdisc add dev rs-near root netem delay 5ms limit 1000000 || skip "netem is not available"
tc qdisc show dev rs-near
sudo ip netns exec far "$(command -v python)" .github/scan-benchmark/scan_bench.py \
serve --address "$DELAYED_HOST" > "$RUNNER_TEMP/far.json" 2> "$RUNNER_TEMP/far.log" &
for _ in $(seq 100); do
if [ -s "$RUNNER_TEMP/far.json" ]; then break; fi
sleep 0.1
done
if [ ! -s "$RUNNER_TEMP/far.json" ]; then
cat "$RUNNER_TEMP/far.log" || true
skip "the delayed host's listeners did not start"
fi
head -c 200 "$RUNNER_TEMP/far.json"; echo
echo "DELAYED_OK=1" >> "$GITHUB_ENV"
- name: Provision macOS TCP memory for repeated socket batches
if: runner.os == 'macOS'
run: |
# XNU's TCP accounting hard limit otherwise makes both builds miss
# listeners with ENOBUFS during repeated sweeps. Apply one budget to
# both builds before timing; keep all input sizes and gates intact.
sudo "$(command -v python)" .github/scan-benchmark/macos_tcp_memory.py
- name: Describe the runner
continue-on-error: true
run: |
uname -a
if [ "$RUNNER_OS" = Linux ]; then
nproc
grep -m1 'model name' /proc/cpuinfo || true
sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse net.core.somaxconn || true
ss -Hltnu | sort -k5 | head -n 50 || true
elif [ "$RUNNER_OS" = macOS ]; then
for oid in hw.ncpu machdep.cpu.brand_string kern.maxfilesperproc \
net.inet.ip.portrange.first net.inet.ip.portrange.last \
kern.ipc.maxsockbuf kern.ipc.nmbclusters; do
sysctl "$oid" || true
done
fi
- name: Run the benchmark
run: |
# Allow the largest batch size without RustScan shrinking it to fit
# the descriptor limit (macOS defaults to 256, and caps processes at
# kern.maxfilesperproc, 10240 on its runners).
if [ "$RUNNER_OS" != Windows ]; then
for limit in 65536 24576 10240; do
ulimit -n "$limit" 2>/dev/null && break
done
echo "open-file limit: $(ulimit -n)"
fi
# Fail on wrong results, or if the change is clearly slower or
# bigger than its base (see --max-slowdown/--max-memory).
args=(--runs "$RUNS" --gate head --json bench-results.json --markdown bench-results.md)
if [ -n "${FILTERED_OK:-}" ]; then args+=(--filtered "$FILTERED_PORTS"); fi
if [ -n "${DELAYED_OK:-}" ]; then
args+=(--delayed-host "$DELAYED_HOST" --delayed-listeners "$RUNNER_TEMP/far.json")
fi
args+=(--build "base=$RUNNER_TEMP/bin/base$EXE")
args+=(--build "head=$RUNNER_TEMP/bin/head$EXE" --label "head=$HEAD_LABEL")
while IFS= read -r line; do
name="${line%%=*}"
args+=(--label "$line")
if [ "$name" != base ]; then
args+=(--build "$name=$RUNNER_TEMP/bin/$name$EXE")
fi
done < "$RUNNER_TEMP/labels"
python .github/scan-benchmark/scan_bench.py run "${args[@]}"
- name: Publish the summary
if: always()
run: |
if [ -f bench-results.md ]; then cat bench-results.md >> "$GITHUB_STEP_SUMMARY"; fi
- name: Upload the results
if: always()
uses: actions/upload-artifact@v7
with:
name: scan-benchmark-${{ matrix.os }}
path: |
bench-results.json
bench-results.md
if-no-files-found: ignore
+8 -19
View File
@@ -1,45 +1,34 @@
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
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.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
- name: Install Just
uses: extractions/setup-just@v4
uses: extractions/setup-just@v2
- name: Run tests
- name: Run just
run: just test
-2
View File
@@ -1,8 +1,6 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# generated file by build.rs for mapping nmap-payloads to rust
src/generated.rs
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
+5
View File
@@ -17,7 +17,12 @@ jobs:
script:
- cargo build
- cargo test
- "ulimit -n 5000; cargo test timelimits:: -- --ignored --test-threads=1 --show-output"
- 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
+675 -905
View File
File diff suppressed because it is too large. Load diff
+19 -27
View File
@@ -1,6 +1,6 @@
[package]
name = "rustscan"
version = "2.4.1"
version = "2.3.0"
authors = ["Autumn <[email protected]>"]
edition = "2018"
description = "Faster Nmap Scanning with Rust"
@@ -17,41 +17,33 @@ 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"] }
rlimit = "0.11.0"
log = "0.4.32"
env_logger = "0.11.10"
anstream = "=1.0.0"
dirs = "7.0.0"
clap = { version = "4.5.13", features = ["derive", "wrap_help"] }
colored = "2.1.0"
async-std = "1.13.0"
futures = "0.3"
rlimit = "0.10.2"
log = "0.4.22"
env_logger = "0.11.5"
anstream = "=0.6.18"
dirs = "5.0.1"
gcd = "2.0.1"
rand = "0.10.1"
rand = "0.8.5"
colorful = "0.3.2"
ansi_term = "0.12.1"
toml = "1.1.2"
toml = "0.8.19"
serde = "1.0.124"
serde_derive = "1.0.116"
cidr-utils = "0.7.1"
itertools = "0.15.0"
hickory-resolver = { version = "0.24.3", features = ["dns-over-rustls"] }
cidr-utils = "0.6.1"
itertools = "0.13.0"
hickory-resolver = { version = "0.24.0", features = ["dns-over-rustls"] }
anyhow = "1.0.40"
text_placeholder = { version = "0.5", features = ["struct_context"] }
once_cell = "1.21.4"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock"] }
once_cell = "1.20.2"
[dev-dependencies]
parameterized = "2.0.0"
criterion = { version = "0.8", features = ["html_reports"] }
wait-timeout = "0.2"
criterion = { version = "0.5", features = ["html_reports"] }
[package.metadata.deb]
depends = "$auto, nmap"
@@ -71,5 +63,5 @@ path = "src/main.rs"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tarpaulin_include)"] }
[[bench]]
name = "benchmark_helpers"
name = "benchmark_portscan"
harness = false
+25
View File
@@ -0,0 +1,25 @@
# Build Stage
FROM rust:alpine as builder
LABEL maintainer="RustScan <https://github.com/RustScan>"
RUN apk add --no-cache build-base
# Encourage some layer caching here rather then copying entire directory that includes docs to builder container ~CMN
WORKDIR /app/rustscan
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
RUN cargo build --release
# Release Stage
FROM alpine:3.20.3 as release
LABEL author="Hydragyrum <https://github.com/Hydragyrum>"
LABEL author="LeoFVO <https://github.com/LeoFVO>"
RUN addgroup -S rustscan && \
adduser -S -G rustscan rustscan && \
ulimit -n 100000 && \
apk add --no-cache nmap nmap-scripts wget ca-certificates bind-tools
USER rustscan
COPY --from=builder /app/rustscan/target/release/rustscan /usr/local/bin/rustscan
ENTRYPOINT [ "/usr/local/bin/rustscan" ]
+4 -10
View File
@@ -19,10 +19,6 @@ The Modern Port Scanner. **Find ports quickly (3 seconds at its fastest)**. Run
# 🛠️ Installation
You can install RustScan's binary from our [releases page](https://github.com/RustScan/RustScan/releases).
We would prefer you to install with a package manager so it is tested and works for your system.
RustScan is in many repositories already. Install it with whatever tools you wish:
[![Packaging status](https://repology.org/badge/vertical-allrepos/rustscan.svg)](https://repology.org/project/rustscan/versions)
@@ -40,7 +36,7 @@ MacOS:
Arch:
```
pacman -S rustscan
yay rustscan
```
# ✨ Features
@@ -55,14 +51,12 @@ 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) | |
| :book: [Installation Guide][links-table-1] | :books: [Documentation][links-table-2] | :parrot: [Discord][discord] |
## 🙋 Table of Contents
- 📖 [Installation Guide][toc-install]
- 🐋 [Docker Usage][toc-docker-usage]
- 🐧 [ARM Support](docs/arm-support.md)
- 🦜 [Discord][discord]
- 🤸 [Usage][usage-1]
@@ -211,7 +205,7 @@ This project follows the [all-contributors](https://github.com/all-contributors/
[speed-3]: https://github.com/RustScan/RustScan/wiki/Increasing-Speed-&-Accuracy "Increasing Speed & Accuracy"
[toc-community]: https://github.com/RustScan/RustScan#-community "Community"
[links-table-1]: https://github.com/RustScan/RustScan#-full-installation-guide "Full installation guide"
[links-table-2]: https://github.com/bee-san/RustScan/wiki "Rustscan"
[links-table-2]: https://rustscan.github.io/RustScan/ "Rustscan"
[discord]: http://discord.skerritt.blog "Discord blog"
[toc-install]: https://github.com/RustScan/RustScan/wiki/Installation-Guide "Installation Guide Wiki"
[toc-docker-usage]: https://github.com/RustScan/RustScan/wiki/Installation-Guide#docker- "Docker Installation Guide Wiki"
@@ -234,4 +228,4 @@ This project follows the [all-contributors](https://github.com/all-contributors/
[badge-3]: https://img.shields.io/github/downloads/rustscan/rustscan/total?label=GitHub%20Downloads
[badge-4]: https://img.shields.io/crates/d/rustscan?label=Cargo%20Downloads
[badge-5]: https://img.shields.io/discord/754001738184392704
[badge-6]: https://github.com/RustScan/RustScan/actions/workflows/build.yml/badge.svg?branch=master
[badge-6]: https://github.com/RustScan/RustScan/workflows/Build/badge.svg?branch=master
-133
View File
@@ -1,133 +0,0 @@
use criterion::{criterion_group, criterion_main, Criterion};
use rustscan::generated::get_parsed_data;
use rustscan::input::{Opts, PortRanges, ScanOrder};
use rustscan::port_strategy::PortStrategy;
use rustscan::scanner::{build_udp_payload_lookup, Scanner};
use std::collections::BTreeMap;
use std::hint::black_box;
use std::net::IpAddr;
use std::time::Duration;
fn bench_address() {
let _addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
}
fn bench_port_strategy() {
let range = PortRanges(vec![(1, 1_000)]);
let _strategy = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
}
fn bench_address_parsing() {
let opts = Opts {
addresses: vec![
"127.0.0.1".to_owned(),
"10.2.0.1".to_owned(),
"192.168.0.0/24".to_owned(),
],
exclude_addresses: Some(vec![
"10.0.0.0/8".to_owned(),
"172.16.0.0/12".to_owned(),
"192.168.0.0/16".to_owned(),
"172.16.0.1".to_owned(),
]),
..Default::default()
};
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));
c.bench_function("port strategy", |b| b.iter(bench_port_strategy));
let mut address_group = c.benchmark_group("address parsing");
address_group.measurement_time(Duration::from_secs(10));
address_group.bench_function("parse addresses with exclusions", |b| {
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);
criterion_main!(benches);
+17
View File
@@ -0,0 +1,17 @@
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;
fn fibonacci(n: u64) -> u64 {
match n {
0 => 1,
1 => 1,
n => fibonacci(n - 1) + fibonacci(n - 2),
}
}
fn criterion_benchmark(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
+19 -91
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;
}
@@ -145,7 +141,7 @@ fn ports_v(fp_map: &BTreeMap<i32, String>) -> BTreeMap<i32, Vec<u16>> {
} else if !segment.is_empty() {
match segment.parse::<u16>() {
Ok(port) => port_list.push(port),
Err(_) => println!("Error parsing port: {segment}"),
Err(_) => println!("Error parsing port: {}", segment),
}
}
}
@@ -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_digit() {
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
+4 -4
View File
@@ -1,5 +1,5 @@
# Dockerfile for RustScan development environment
# Provides a containerized setup with Rust 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 and clippy for cargo.
RUN rustup component add rustfmt clippy
+2 -16
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:
@@ -67,17 +67,3 @@ root@container:/rustscan# cargo fmt
root@container:/rustscan# cargo clippy
root@container:/rustscan# cargo test
```
Automated tests and benchmarks must run without network traffic or external script
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.
-4
View File
@@ -1,4 +0,0 @@
tags = ["core_approved", "example"]
ports = ["80", "443", "8080"]
developer = ["example", "https://example.org"]
directory = "fixtures/.rustscan_scripts"
+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}}"
# Sriptfile parser stops at the first blank line with parsing.
# 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}}"
# Sriptfile parser stops at the first blank line with parsing.
# 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}}"
# Sriptfile parser stops at the first blank line with parsing.
# 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.
# Scriptfile parser stops at the first blank line with parsing.
# 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
+1 -2
View File
@@ -1,7 +1,6 @@
test:
cargo test
cargo nextest run
cargo clippy -- --deny warnings
cargo clippy --tests -- --deny warnings
cargo fmt --check
cargo doc --workspace --all-features --no-deps --document-private-items
+85 -338
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};
@@ -7,7 +6,7 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::path::Path;
use std::str::FromStr;
use cidr_utils::cidr::{IpCidr, IpInet};
use cidr_utils::cidr::IpCidr;
use hickory_resolver::{
config::{NameServerConfig, Protocol, ResolverConfig, ResolverOpts},
Resolver,
@@ -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,13 +69,10 @@ fn parse_addresses_with_resolver(
}
}
let excluded_cidrs = parse_excluded_networks_with_resolver(&input.exclude_addresses, &resolver);
// Remove duplicated/excluded IPs.
let mut seen = BTreeSet::new();
ips.retain(|ip| seen.insert(*ip) && !excluded_cidrs.iter().any(|cidr| cidr.contains(ip)));
ips
ips.into_iter()
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
/// Given a string, parse it as a host, IP address, or CIDR.
@@ -112,98 +89,33 @@ 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]
} else if let Ok(net_addr) = IpInet::from_str(address) {
// `address` is a CIDR string
net_addr.network().into_iter().addresses().collect()
} else {
// `address` is a hostname or DNS name
// attempt default DNS lookup
match format!("{address}:80").to_socket_addrs() {
Ok(mut iter) => vec![iter.next().unwrap().ip()],
// default lookup didn't work, so try again with the dedicated resolver
Err(_) => resolve_ips_from_host(address, resolver),
}
}
IpCidr::from_str(address)
.map(|cidr| cidr.iter().map(|c| c.address()).collect())
.ok()
.or_else(|| {
format!("{}:{}", &address, 80)
.to_socket_addrs()
.ok()
.map(|mut iter| vec![iter.next().unwrap().ip()])
})
.unwrap_or_else(|| resolve_ips_from_host(address, resolver))
}
/// 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());
}
ips
}
/// Parses excluded networks from a list of addresses.
///
/// This function handles three types of inputs:
/// 1. CIDR notation (e.g. "192.168.0.0/24")
/// 2. Single IP addresses (e.g. "192.168.0.1")
/// 3. Hostnames that need to be resolved (e.g. "example.com")
///
/// ```rust
/// # use rustscan::address::parse_excluded_networks;
/// # use hickory_resolver::Resolver;
/// let resolver = Resolver::default().unwrap();
/// let excluded = parse_excluded_networks(&Some(vec!["192.168.0.0/24".to_owned()]), &resolver);
/// ```
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()
.flatten()
.flat_map(|addr| parse_single_excluded_address(addr, resolver))
.collect()
}
/// 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> {
if let Ok(cidr) = IpCidr::from_str(addr) {
return vec![cidr];
}
if let Ok(ip) = IpAddr::from_str(addr) {
return vec![IpCidr::new_host(ip)];
}
resolve_ips_from_host(addr, resolver)
.into_iter()
.map(IpCidr::new_host)
.collect()
}
/// Derive a DNS resolver.
///
/// 1. if the `resolver` parameter has been set:
@@ -231,56 +143,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 +166,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 +177,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,55 +188,13 @@ 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::{get_resolver, parse_addresses, Opts};
use std::net::Ipv4Addr;
#[test]
fn parse_correct_addresses() {
let opts = Opts {
addresses: vec!["127.0.0.1".to_owned(), "192.168.0.0/30".to_owned()],
..Default::default()
};
let mut opts = Opts::default();
opts.addresses = vec!["127.0.0.1".to_owned(), "192.168.0.0/30".to_owned()];
let ips = parse_addresses(&opts);
assert_eq!(
@@ -379,69 +210,62 @@ mod tests {
}
#[test]
fn parse_addresses_with_address_exclusions() {
let opts = Opts {
addresses: vec!["192.168.0.0/30".to_owned()],
exclude_addresses: Some(vec!["192.168.0.1".to_owned()]),
..Default::default()
};
fn parse_correct_host_addresses() {
let mut opts = Opts::default();
opts.addresses = vec!["google.com".to_owned()];
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(192, 168, 0, 0),
Ipv4Addr::new(192, 168, 0, 2),
Ipv4Addr::new(192, 168, 0, 3)
]
);
assert_eq!(ips.len(), 1);
}
#[test]
fn parse_addresses_with_cidr_exclusions() {
let opts = Opts {
addresses: vec!["192.168.0.0/29".to_owned()],
exclude_addresses: Some(vec!["192.168.0.0/30".to_owned()]),
..Default::default()
};
fn parse_correct_and_incorrect_addresses() {
let mut opts = Opts::default();
opts.addresses = vec!["127.0.0.1".to_owned(), "im_wrong".to_owned()];
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(192, 168, 0, 4),
Ipv4Addr::new(192, 168, 0, 5),
Ipv4Addr::new(192, 168, 0, 6),
Ipv4Addr::new(192, 168, 0, 7),
]
);
assert_eq!(ips, [Ipv4Addr::new(127, 0, 0, 1),]);
}
#[test]
fn parse_addresses_with_incorrect_address_exclusions() {
let opts = Opts {
addresses: vec!["192.168.0.0/30".to_owned()],
exclude_addresses: Some(vec!["192.168.0.1".to_owned()]),
..Default::default()
};
fn parse_incorrect_addresses() {
let mut opts = Opts::default();
opts.addresses = vec!["im_wrong".to_owned(), "300.10.1.1".to_owned()];
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(192, 168, 0, 0),
Ipv4Addr::new(192, 168, 0, 2),
Ipv4Addr::new(192, 168, 0, 3)
]
);
assert!(ips.is_empty());
}
#[test]
fn parse_hosts_file_and_incorrect_hosts() {
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
let mut opts = Opts::default();
opts.addresses = vec!["fixtures/hosts.txt".to_owned()];
let ips = parse_addresses(&opts);
assert_eq!(ips.len(), 3);
}
#[test]
fn parse_empty_hosts_file() {
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
let mut opts = Opts::default();
opts.addresses = vec!["fixtures/empty_hosts.txt".to_owned()];
let ips = parse_addresses(&opts);
assert_eq!(ips.len(), 0);
}
#[test]
fn parse_naughty_host_file() {
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
let mut opts = Opts::default();
opts.addresses = vec!["fixtures/naughty_string.txt".to_owned()];
let ips = parse_addresses(&opts);
assert_eq!(ips.len(), 0);
}
#[test]
fn parse_duplicate_cidrs() {
let opts = Opts {
addresses: vec!["79.98.104.0/21".to_owned(), "79.98.104.0/24".to_owned()],
..Default::default()
};
let mut opts = Opts::default();
opts.addresses = vec!["79.98.104.0/21".to_owned(), "79.98.104.0/24".to_owned()];
let ips = parse_addresses(&opts);
@@ -449,102 +273,25 @@ mod tests {
}
#[test]
fn parse_overspecific_cidr() {
// a canonical CIDR string has 0 in all host bits, but we want to treat any CIDR-like string as CIDR
let opts = Opts {
addresses: vec!["192.128.1.1/24".to_owned()],
..Default::default()
};
fn resolver_default_cloudflare() {
let opts = Opts::default();
let ips = parse_addresses(&opts);
let resolver = get_resolver(&opts.resolver);
let lookup = resolver.lookup_ip("www.example.com.").unwrap();
assert_eq!(ips.len(), 256);
assert!(opts.resolver.is_none());
assert!(lookup.iter().next().is_some());
}
#[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),
]
);
}
fn resolver_args_google_dns() {
let mut opts = Opts::default();
// https://developers.google.com/speed/public-dns
opts.resolver = Some("8.8.8.8,8.8.4.4".to_owned());
#[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),
]
);
}
let resolver = get_resolver(&opts.resolver);
let lookup = resolver.lookup_ip("www.example.com.").unwrap();
#[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),
]
);
assert!(lookup.iter().next().is_some());
}
}
+7 -5
View File
@@ -42,11 +42,13 @@ impl Benchmark {
let mut summary = String::from("\nRustScan Benchmark Summary");
for timer in &self.named_timers {
if let Some(start) = timer.start {
if let Some(end) = timer.end {
let runtime_secs = end.saturating_duration_since(start).as_secs_f32();
summary.push_str(&format!("\n{0: <10} | {1: <10}s", timer.name, runtime_secs));
}
if timer.start.is_some() && timer.end.is_some() {
let runtime_secs = timer
.end
.unwrap()
.saturating_duration_since(timer.start.unwrap())
.as_secs_f32();
summary.push_str(&format!("\n{0: <10} | {1: <10}s", timer.name, runtime_secs));
}
}
summary
+2996
View File
File diff suppressed because it is too large. Load diff
+60 -521
View File
@@ -1,9 +1,7 @@
//! Provides a means to read, parse and hold configuration options for scans.
use clap::parser::ValueSource;
use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum};
use clap::{Parser, ValueEnum};
use serde_derive::Deserialize;
use std::collections::HashSet;
use std::ffi::OsString;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
@@ -25,102 +23,40 @@ pub enum ScanOrder {
/// - custom will read the ScriptConfig file and the available scripts in the predefined folders
#[derive(Deserialize, Debug, ValueEnum, Clone, PartialEq, Eq, Copy)]
pub enum ScriptsRequired {
#[serde(alias = "none")]
None,
#[serde(alias = "default")]
Default,
#[serde(alias = "custom")]
Custom,
}
/// 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)]
@@ -145,18 +81,14 @@ 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)]
pub no_config: bool,
/// Hide the banner
#[arg(long)]
pub no_banner: bool,
/// Custom path to config file
#[arg(short, long, value_parser)]
pub config_path: Option<PathBuf>,
@@ -178,7 +110,7 @@ pub struct Opts {
/// it will do every port at the same time. Although, your OS may not
/// support this.
#[arg(short, long, default_value = "4500")]
pub batch_size: usize,
pub batch_size: u16,
/// The timeout in milliseconds before a port is assumed to be closed.
#[arg(short, long, default_value = "1500")]
@@ -189,10 +121,9 @@ 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>,
pub ulimit: Option<u64>,
/// The order of scanning to be performed. The "serial" option will
/// scan ports in ascending order while the "random" option will scan
@@ -220,91 +151,28 @@ pub struct Opts {
#[arg(short, long, value_delimiter = ',')]
pub exclude_ports: Option<Vec<u16>>,
/// A list of comma separated CIDRs, IPs, or hosts to be excluded from scanning.
#[arg(short = 'x', long = "exclude-addresses", value_delimiter = ',')]
pub exclude_addresses: Option<Vec<String>>,
/// 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,
/// Ids of the options given explicitly on the command line. The
/// configuration file never overrides these (see [`Opts::merge`]).
#[arg(skip)]
pub cli_args: HashSet<String>,
}
#[cfg(not(tarpaulin_include))]
impl Opts {
pub fn read() -> Self {
Self::try_read_from(std::env::args_os()).unwrap_or_else(|e| e.exit())
}
/// Parses `args` like [`Opts::read`] does, returning an error instead of
/// exiting.
fn try_read_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let mut command = Self::command();
let mut matches = command.try_get_matches_from_mut(args)?;
// clap fills in default values, so the parsed struct alone cannot
// tell `-b 4500` apart from no `-b` at all. Record what the user
// actually typed so that `merge` lets it win over the config file.
let cli_args = command
.get_arguments()
.map(|arg| arg.get_id().as_str())
.filter(|id| matches.value_source(id) == Some(ValueSource::CommandLine))
.map(str::to_owned)
.collect();
let mut opts =
Self::from_arg_matches_mut(&mut matches).map_err(|e| e.format(&mut command))?;
opts.cli_args = cli_args;
let mut opts = Opts::parse();
if opts.ports.is_none() && opts.range.is_none() {
opts.range = Some(PortRanges(vec![(LOWEST_PORT_NUMBER, TOP_PORT_NUMBER)]));
opts.range = Some(PortRange {
start: LOWEST_PORT_NUMBER,
end: TOP_PORT_NUMBER,
});
}
Ok(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(())
opts
}
/// Reads the command line arguments into an Opts struct and merge
/// values found within the user configuration file. Options given on the
/// command line take precedence over the configuration file.
/// values found within the user configuration file.
pub fn merge(&mut self, config: &Config) {
if !self.no_config {
self.merge_required(config);
@@ -317,9 +185,7 @@ impl Opts {
($($field: ident),+) => {
$(
if let Some(e) = &config.$field {
if !self.cli_args.contains(stringify!($field)) {
self.$field = e.clone();
}
self.$field = e.clone();
}
)+
}
@@ -327,7 +193,7 @@ impl Opts {
merge_required!(
addresses, greppable, accessible, batch_size, timeout, tries, scan_order, scripts,
command, udp, no_banner, closed, interval
command, udp
);
}
@@ -335,7 +201,7 @@ impl Opts {
macro_rules! merge_optional {
($($field: ident),+) => {
$(
if config.$field.is_some() && !self.cli_args.contains(stringify!($field)) {
if config.$field.is_some() {
self.$field = config.$field.clone();
}
)+
@@ -344,10 +210,14 @@ impl Opts {
// Only use top ports when the user asks for them
if self.top && config.ports.is_some() {
self.ports = config.ports.clone();
let mut ports: Vec<u16> = Vec::with_capacity(config.ports.as_ref().unwrap().len());
for entry in config.ports.as_ref().unwrap().keys() {
ports.push(entry.parse().unwrap());
}
self.ports = Some(ports);
}
merge_optional!(range, resolver, ulimit, exclude_ports, exclude_addresses);
merge_optional!(range, resolver, ulimit, exclude_ports);
}
}
@@ -367,16 +237,11 @@ impl Default for Opts {
resolver: None,
scan_order: ScanOrder::Serial,
no_config: true,
no_banner: false,
top: false,
scripts: ScriptsRequired::Default,
config_path: None,
exclude_ports: None,
exclude_addresses: None,
udp: false,
closed: false,
interval: 0,
cli_args: HashSet::new(),
}
}
}
@@ -388,24 +253,20 @@ impl Default for Opts {
#[derive(Debug, Deserialize)]
pub struct Config {
addresses: Option<Vec<String>>,
ports: Option<Vec<u16>>,
range: Option<PortRanges>,
ports: Option<HashMap<String, u16>>,
range: Option<PortRange>,
greppable: Option<bool>,
accessible: Option<bool>,
batch_size: Option<usize>,
batch_size: Option<u16>,
timeout: Option<u32>,
tries: Option<u8>,
ulimit: Option<usize>,
ulimit: Option<u64>,
resolver: Option<String>,
scan_order: Option<ScanOrder>,
command: Option<Vec<String>>,
scripts: Option<ScriptsRequired>,
exclude_ports: Option<Vec<u16>>,
exclude_addresses: Option<Vec<String>>,
udp: Option<bool>,
no_banner: Option<bool>,
closed: Option<bool>,
interval: Option<u64>,
}
#[cfg(not(tarpaulin_include))]
@@ -420,29 +281,13 @@ impl Config {
/// addresses = ["127.0.0.1", "127.0.0.1"]
/// ports = [80, 443, 8080]
/// greppable = true
/// scan_order = "Serial"
/// 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;
}
}
default_config_path()
});
let config_path = custom_config_path.unwrap_or_else(default_config_path);
if config_path.exists() {
content = match fs::read_to_string(config_path) {
Ok(content) => content,
@@ -453,9 +298,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);
}
};
@@ -464,30 +307,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.");
};
config_path.push(".rustscan.toml");
config_path
}
/// Returns the deprecated home directory config path used for backwards compatibility.
pub fn old_default_config_path() -> PathBuf {
let Some(mut config_path) = dirs::home_dir() else {
panic!("Could not infer config file path.");
};
@@ -499,9 +320,8 @@ pub fn old_default_config_path() -> PathBuf {
mod tests {
use clap::{CommandFactory, Parser};
use parameterized::parameterized;
use std::collections::HashSet;
use super::{Config, Opts, PortRanges, ScanOrder, ScriptsRequired};
use super::{Config, Opts, PortRange, ScanOrder, ScriptsRequired};
impl Config {
fn default() -> Self {
@@ -520,64 +340,11 @@ mod tests {
scan_order: Some(ScanOrder::Random),
scripts: None,
exclude_ports: None,
exclude_addresses: None,
udp: Some(false),
no_banner: None,
closed: Some(false),
interval: None,
}
}
}
fn assert_config_script_mode(value: &str, expected: ScriptsRequired) {
let config: Config = toml::from_str(&format!("scripts = \"{value}\""))
.expect("documented script mode must deserialize");
assert_eq!(config.scripts, Some(expected));
}
#[test]
fn config_scripts_lowercase_none() {
assert_config_script_mode("none", ScriptsRequired::None);
}
#[test]
fn config_scripts_lowercase_default() {
assert_config_script_mode("default", ScriptsRequired::Default);
}
#[test]
fn config_scripts_lowercase_custom() {
assert_config_script_mode("custom", ScriptsRequired::Custom);
}
#[test]
fn config_scripts_legacy_and_unknown_controls() {
assert_config_script_mode("None", ScriptsRequired::None);
assert_config_script_mode("Default", ScriptsRequired::Default);
assert_config_script_mode("Custom", ScriptsRequired::Custom);
for invalid in ["disabled", "unknown", ""] {
assert!(toml::from_str::<Config>(&format!("scripts = \"{invalid}\"")).is_err());
}
}
#[test]
fn config_read_accepts_documented_lowercase_script_modes() {
for (value, expected) in [
("none", ScriptsRequired::None),
("default", ScriptsRequired::Default),
("custom", ScriptsRequired::Custom),
] {
let fixture = std::env::temp_dir().join(format!(
"rustscan-config-scripts-{}-{value}.toml",
std::process::id()
));
std::fs::write(&fixture, format!("scripts = \"{value}\"\n")).unwrap();
let config = Config::read(Some(fixture.clone()));
std::fs::remove_file(fixture).unwrap();
assert_eq!(config.scripts, Some(expected));
}
}
#[test]
fn verify_cli() {
Opts::command().debug_assert();
@@ -603,82 +370,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();
@@ -714,7 +405,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());
@@ -724,159 +418,4 @@ mod tests {
assert_eq!(opts.ulimit, config.ulimit);
assert_eq!(opts.resolver, config.resolver);
}
#[test]
fn cli_arguments_override_config() {
// Regression test for #722: the config file used to silently replace
// options given on the command line, e.g. `-r` with its `range`.
let mut opts = Opts::try_read_from([
"rustscan",
"-a",
"192.168.0.1",
"-r",
"8600-8650",
"-b",
"100",
// Passing clap's default value explicitly must still win.
"-t",
"1500",
"--tries",
"3",
"--scan-order",
"serial",
"--scripts",
"none",
"-g",
"--",
"-sV",
])
.unwrap();
let config: Config = toml::from_str(
r#"
addresses = ["127.0.0.1"]
range = { start = 1, end = 100 }
batch_size = 25000
timeout = 1000
tries = 1
scan_order = "Random"
scripts = "Custom"
greppable = false
command = ["-A"]
"#,
)
.unwrap();
opts.merge(&config);
assert_eq!(opts.addresses, vec!["192.168.0.1".to_owned()]);
assert_eq!(opts.range, Some(PortRanges(vec![(8_600, 8_650)])));
assert_eq!(opts.batch_size, 100);
assert_eq!(opts.timeout, 1_500);
assert_eq!(opts.tries, 3);
assert_eq!(opts.scan_order, ScanOrder::Serial);
assert_eq!(opts.scripts, ScriptsRequired::None);
assert!(opts.greppable);
assert_eq!(opts.command, vec!["-sV".to_owned()]);
}
#[test]
fn config_fills_arguments_not_given_on_cli() {
let mut opts = Opts::try_read_from(["rustscan", "-a", "192.168.0.1"]).unwrap();
let config: Config = toml::from_str(
r#"
addresses = ["127.0.0.1"]
range = { start = 1, end = 100 }
batch_size = 25000
timeout = 1000
tries = 3
scan_order = "Random"
greppable = true
command = ["-A"]
"#,
)
.unwrap();
opts.merge(&config);
// Only `-a` was typed; clap defaults such as `-b 4500` must not count.
assert_eq!(opts.cli_args, HashSet::from(["addresses".to_owned()]));
assert_eq!(opts.addresses, vec!["192.168.0.1".to_owned()]);
assert_eq!(opts.range, Some(PortRanges(vec![(1, 100)])));
assert_eq!(opts.batch_size, 25_000);
assert_eq!(opts.timeout, 1_000);
assert_eq!(opts.tries, 3);
assert_eq!(opts.scan_order, ScanOrder::Random);
assert!(opts.greppable);
assert_eq!(opts.command, vec!["-A".to_owned()]);
}
#[test]
fn parses_comma_separated_ranges() {
let opts = Opts::parse_from([
"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);
}
}
+19 -29
View File
@@ -5,46 +5,36 @@
//!
//! The core scanning behaviour is managed by
//! [`Scanner`](crate::scanner::Scanner) which in turn requires a
//! [`PortStrategy`](crate::port_strategy::PortStrategy).
//! [`PortStrategy`](crate::port_strategy::PortStrategy):
//!
//! The scanner is asynchronous and runs on [Tokio](https://docs.rs/tokio):
//! await [`Scanner::run`](crate::scanner::Scanner::run) from a Tokio runtime
//! with the I/O and time drivers enabled. It never spawns tasks, so a
//! current-thread runtime is enough (see [`Scanner`](crate::scanner::Scanner)
//! for details).
//!
//! This example is compiled but never executed by the test suite.
//!
//! ```no_run
//! ```rust
//! 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 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 range = PortRange {
//! start: 1,
//! end: 1_000,
//! };
//! let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
//! let scanner = Scanner::new(
//! &addrs, // the addresses to scan
//! 10, // batch_size is how many ports at a time should be scanned
//! Duration::from_millis(100), //T imeout is the time RustScan should wait before declaring a port closed. As datatype Duration.
//! 1, // Tries, how many retries should RustScan do?
//! true, // greppable is whether or not RustScan should print things, or wait until the end to print only the ip
//! strategy, // the port strategy used
//! true, // accessible, should the output be A11Y compliant?
//! vec![9000], // What ports should RustScan exclude?
//! false, // is this a UDP scan?
//! &addrs,
//! 10,
//! Duration::from_millis(100),
//! 1,
//! true,
//! strategy,
//! true,
//! vec![9000],
//! false,
//! );
//!
//! // 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);
//! }
+94 -266
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;
@@ -23,10 +23,9 @@ extern crate dirs;
// Average value for Ubuntu
#[cfg(unix)]
const DEFAULT_FILE_DESCRIPTORS_LIMIT: usize = 8000;
const DEFAULT_FILE_DESCRIPTORS_LIMIT: u64 = 8000;
// Safest batch size based on experimentation
#[cfg(unix)]
const AVERAGE_BATCH_SIZE: usize = 3000;
const AVERAGE_BATCH_SIZE: u16 = 3000;
#[macro_use]
extern crate log;
@@ -47,12 +46,7 @@ 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:?}");
debug!("Main() `opts` arguments are {:?}", opts);
let scripts_to_run: Vec<ScriptFile> = match init_scripts(&opts.scripts) {
Ok(scripts_to_run) => scripts_to_run,
@@ -66,9 +60,9 @@ fn main() {
}
};
debug!("Scripts initialized {scripts_to_run:?}");
debug!("Scripts initialized {:?}", &scripts_to_run);
if !opts.greppable && !opts.accessible && !opts.no_banner {
if !opts.greppable && !opts.accessible {
print_opening(&opts);
}
@@ -83,10 +77,18 @@ fn main() {
std::process::exit(1);
}
let batch_size = effective_batch_size(&opts);
debug!("Effective batch size: {batch_size}");
#[cfg(unix)]
let batch_size: u16 = infer_batch_size(&opts, adjust_ulimit_size(&opts));
let mut scanner = Scanner::new(
#[cfg(not(unix))]
let batch_size: u16 = AVERAGE_BATCH_SIZE;
// Added by wasuaje - 01/26/2024:
// exclude_ports is an exclusion port list
//
// Added by brendanglancy - 5/19/2024:
// udp is an option to do a udp scan
let scanner = Scanner::new(
&ips,
batch_size,
Duration::from_millis(opts.timeout.into()),
@@ -96,39 +98,37 @@ 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:?}");
);
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());
}
if let Some(x) = no_open_ports_warning(&ips, &ports_per_ip, &closed_ports_per_ip, batch_size) {
for ip in ips {
if ports_per_ip.contains_key(&ip) {
continue;
}
// If we got here it means the IP was not found within the HashMap, this
// means the scan couldn't find any open ports for it.
let x = format!("Looks like I didn't find any open ports for {:?}. This is usually caused by a high batch size.
\n*I used {} batch size, consider lowering it with {} or a comfortable number for your system.
\n Alternatively, increase the timeout if your ping is high. Rustscan -t 2000 for 2000 milliseconds (2s) timeout.\n",
ip,
opts.batch_size,
"'rustscan -b <batch_size> -a <ip address>'");
warning!(x, opts.greppable, opts.accessible);
}
@@ -141,19 +141,17 @@ 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(" ");
debug!("Extra args vec {user_extra_args:?}");
debug!("Extra args vec {:?}", user_extra_args);
if script_f.call_format.is_some() {
let mut call_f = script_f.call_format.unwrap();
call_f.push(' ');
@@ -163,7 +161,7 @@ fn main() {
opts.greppable,
opts.accessible
);
debug!("Call format {call_f}");
debug!("Call format {}", call_f);
script_f.call_format = Some(call_f);
}
}
@@ -180,7 +178,7 @@ fn main() {
);
match script.run() {
Ok(script_result) => {
detail!(script_result.clone(), opts.greppable, opts.accessible);
detail!(script_result.to_string(), opts.greppable, opts.accessible);
}
Err(e) => {
warning!(&format!("Error {e}"), opts.greppable, opts.accessible);
@@ -189,109 +187,36 @@ 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);
rustscan_bench.end();
benchmarks.push(rustscan_bench);
debug!("Benchmarks raw {benchmarks:?}");
debug!("Benchmarks raw {:?}", benchmarks);
info!("{}", benchmarks.summary());
}
/// Builds the hint printed when the scan found no open ports.
///
/// A host without open ports is normal when another host answered (a subnet
/// sweep for one port): replies got through at this batch size and timeout,
/// so batch size and timeout are unlikely to be the cause. The hint is printed
/// once, and only when no host answered with an open or, under `--closed`, a
/// closed port.
fn no_open_ports_warning(
ips: &[IpAddr],
ports_per_ip: &HashMap<IpAddr, Vec<u16>>,
closed_ports_per_ip: &HashMap<IpAddr, Vec<u16>>,
batch_size: usize,
) -> Option<String> {
if !ports_per_ip.is_empty() || !closed_ports_per_ip.is_empty() {
return None;
}
let target = match ips {
[ip] => format!("for {ip:?}"),
_ => format!("on any of the {} addresses", ips.len()),
};
Some(format!("Looks like I didn't find any open ports {}. This is usually caused by a high batch size.
\n*I used {} batch size, consider lowering it with {} or a comfortable number for your system.
\n Alternatively, increase the timeout if your ping is high. Rustscan -t 2000 for 2000 milliseconds (2s) timeout.\n",
target,
batch_size,
"'rustscan -b <batch_size> -a <ip address>'"))
}
/// Determines the actual batch size used by the scanner.
///
/// Unix systems may reduce the requested batch size according to the process
/// 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) {
debug!("Printing opening");
let s = r#".----. .-. .-. .----..---. .----. .---. .--. .-. .-.
| {} }| { } |{ {__ {_ _}{ {__ / ___} / {} \ | `| |
| .-. \| {_} |.-._} } | | .-._} }\ }/ /\ \| |\ |
`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'
The Modern Day Port Scanner."#;
println_safe(format_args!("{}", 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()));
let s = format!(
"{}\n{}\n{}\n{}\n{}",
r#".----. .-. .-. .----..---. .----. .---. .--. .-. .-."#,
r#"| {} }| { } |{ {__ {_ _}{ {__ / ___} / {} \ | `| |"#,
r#"| .-. \| {_} |.-._} } | | .-._} }\ }/ /\ \| |\ |"#,
r#"`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'"#,
r#"The Modern Day Port Scanner."#
);
println!("{}", s.gradient(Color::Green).bold());
let info = format!(
"{}\n{}\n{}\n{}",
r#"________________________________________"#,
r#": http://discord.skerritt.blog :"#,
r#": https://github.com/RustScan/RustScan :"#,
r#" --------------------------------------"#
);
println!("{}", info.gradient(Color::Yellow).bold());
funny_opening!();
let config_path = opts
@@ -304,26 +229,13 @@ The Modern Day Port Scanner."#;
opts.greppable,
opts.accessible
);
if opts.config_path.is_none() {
let old_config_path = input::old_default_config_path();
detail!(
format!(
"For backwards compatibility, the config file may also be at {old_config_path:?}"
),
opts.greppable,
opts.accessible
);
}
}
#[cfg(unix)]
fn adjust_ulimit_size(opts: &Opts) -> usize {
fn adjust_ulimit_size(opts: &Opts) -> u64 {
use rlimit::Resource;
use std::convert::TryInto;
if let Some(limit) = opts.ulimit {
let limit = limit as u64;
if Resource::NOFILE.set(limit, limit).is_ok() {
detail!(
format!("Automatically increasing ulimit value to {limit}."),
@@ -340,15 +252,14 @@ 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
}
#[cfg(unix)]
fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
let mut batch_size = opts.batch_size;
fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
use std::convert::TryInto;
let mut batch_size: u64 = opts.batch_size.into();
// Adjust the batch size when the ulimit value is lower than the desired batch size
if ulimit < batch_size {
@@ -359,8 +270,8 @@ fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
// When the OS supports high file limits like 8000, but the user
// 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
if ulimit < AVERAGE_BATCH_SIZE.into() {
// 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);
@@ -368,7 +279,7 @@ fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
batch_size = ulimit / 2;
} else if ulimit > DEFAULT_FILE_DESCRIPTORS_LIMIT {
info!("Batch size is now average batch size");
batch_size = AVERAGE_BATCH_SIZE;
batch_size = AVERAGE_BATCH_SIZE.into();
} else {
batch_size = ulimit - 100;
}
@@ -381,64 +292,21 @@ fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
}
batch_size
.try_into()
.expect("Couldn't fit the batch size into a u16.")
}
#[cfg(test)]
mod tests {
#[cfg(windows)]
use super::effective_batch_size;
#[cfg(unix)]
use super::{adjust_ulimit_size, infer_batch_size};
use super::{no_open_ports_warning, print_opening, Opts};
use std::collections::HashMap;
use std::net::IpAddr;
fn hosts(n: u8) -> Vec<IpAddr> {
(1..=n).map(|i| IpAddr::from([192, 0, 2, i])).collect()
}
#[test]
fn no_open_ports_silent_when_another_host_answered() {
// Sweeping a subnet for one port: one host has it open, the rest do not.
let ips = hosts(254);
let ports_per_ip = HashMap::from([(ips[0], vec![22])]);
assert!(no_open_ports_warning(&ips, &ports_per_ip, &HashMap::new(), 4500).is_none());
}
#[test]
fn no_open_ports_silent_when_a_host_answered_closed() {
// With --closed, a refused connection also proves replies get through.
let ips = hosts(254);
let closed_ports_per_ip = HashMap::from([(ips[0], vec![443])]);
assert!(no_open_ports_warning(&ips, &HashMap::new(), &closed_ports_per_ip, 4500).is_none());
}
#[test]
fn no_open_ports_warns_once_when_nothing_found() {
let warning = no_open_ports_warning(&hosts(254), &HashMap::new(), &HashMap::new(), 4500);
let warning = warning.expect("one hint when nothing was found");
assert!(warning.contains("on any of the 254 addresses."));
assert!(warning.contains("4500 batch size"));
}
#[test]
fn no_open_ports_names_the_single_host() {
let warning = no_open_ports_warning(&hosts(1), &HashMap::new(), &HashMap::new(), 4500);
let warning = warning.expect("one hint for the single host");
assert!(warning.contains("for 192.0.2.1."));
}
use super::{print_opening, Opts};
#[test]
#[cfg(unix)]
fn batch_size_lowered() {
let opts = Opts {
batch_size: 50_000,
..Default::default()
};
let mut opts = Opts::default();
opts.batch_size = 50_000;
let batch_size = infer_batch_size(&opts, 120);
assert!(batch_size < opts.batch_size);
@@ -447,92 +315,52 @@ mod tests {
#[test]
#[cfg(unix)]
fn batch_size_lowered_average_size() {
let opts = Opts {
batch_size: 50_000,
..Default::default()
};
let mut opts = Opts::default();
opts.batch_size = 50_000;
let batch_size = infer_batch_size(&opts, 9_000);
assert_eq!(batch_size, 3_000);
assert!(batch_size == 3_000);
}
#[test]
#[cfg(unix)]
fn batch_size_equals_ulimit_lowered() {
// because ulimit and batch size are same size, batch size is lowered
// to ULIMIT - 100
let opts = Opts {
batch_size: 50_000,
..Default::default()
};
let mut opts = Opts::default();
opts.batch_size = 50_000;
let batch_size = infer_batch_size(&opts, 5_000);
assert_eq!(batch_size, 4_900);
assert!(batch_size == 4_900);
}
#[test]
#[cfg(unix)]
fn batch_size_adjusted_2000() {
// ulimit == batch_size
let opts = Opts {
batch_size: 50_000,
ulimit: Some(2_000),
..Default::default()
};
let mut opts = Opts::default();
opts.batch_size = 50_000;
opts.ulimit = Some(2_000);
let batch_size = adjust_ulimit_size(&opts);
assert_eq!(batch_size, 2_000);
assert!(batch_size == 2_000);
}
#[test]
#[cfg(unix)]
fn test_high_ulimit_no_greppable_mode() {
let opts = Opts {
batch_size: 10,
greppable: false,
..Default::default()
};
let mut opts = Opts::default();
opts.batch_size = 10;
opts.greppable = false;
let batch_size = infer_batch_size(&opts, 1_000_000);
assert_eq!(batch_size, opts.batch_size);
assert!(batch_size == opts.batch_size);
}
#[test]
fn test_print_opening_no_panic() {
let opts = Opts::default();
let mut opts = Opts::default();
opts.ulimit = Some(2_000);
// 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);
}
}
+29 -39
View File
@@ -1,8 +1,8 @@
//! Provides a means to hold configuration options specifically for port scanning.
mod range_iterator;
use crate::input::{PortRanges, ScanOrder};
use rand::rng;
use crate::input::{PortRange, ScanOrder};
use rand::seq::SliceRandom;
use rand::thread_rng;
use range_iterator::RangeIterator;
/// Represents options of port scanning.
@@ -17,23 +17,25 @@ 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()),
ScanOrder::Random => {
let mut rng = rng();
let mut rng = thread_rng();
let mut ports = ports.unwrap();
ports.shuffle(&mut rng);
PortStrategy::Manual(ports)
@@ -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);
}
}
+61 -255
View File
@@ -1,196 +1,71 @@
use gcd::Gcd;
use rand::RngExt;
use rand::Rng;
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);
let mut rng = rand::rng();
let first = rng.random_range(0..total);
// Randomly choose a number within the range to be the first
// and assign it as a pick.
let mut rng = rand::thread_rng();
let normalized_first_pick = rng.gen_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"),
)
}
}
@@ -208,14 +83,14 @@ fn pick_random_coprime(end: u32) -> u32 {
let range_boundary = end / 4;
let lower_range = range_boundary;
let upper_range = end - range_boundary;
let mut rng = rand::rng();
let mut candidate = rng.random_range(lower_range..upper_range);
let mut rng = rand::thread_rng();
let mut candidate = rng.gen_range(lower_range..upper_range);
for _ in 0..10 {
if end.gcd(candidate) == 1 {
return candidate;
}
candidate = rng.random_range(lower_range..upper_range);
candidate = rng.gen_range(lower_range..upper_range);
}
end - 1
@@ -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
}
}
-163
View File
@@ -1,163 +0,0 @@
use std::{collections::HashSet, fmt, io, net::IpAddr};
/// Keep the OS error and its target intact until diagnostics need formatting.
#[derive(Debug)]
struct ScanError {
ip: IpAddr,
error: io::Error,
}
impl fmt::Display for ScanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.error, self.ip)
}
}
impl std::error::Error for ScanError {}
/// Preserve the normal I/O result layout; only diagnostics need target context.
pub(super) fn diagnostic_error(error: io::Error, ip: IpAddr, enabled: bool) -> io::Error {
if enabled {
io::Error::other(ScanError { ip, error })
} else {
error
}
}
pub(super) struct ScanErrors {
enabled: bool,
limit: usize,
messages: HashSet<String>,
}
impl ScanErrors {
pub(super) fn new(enabled: bool, limit: usize) -> Self {
Self {
enabled,
limit,
messages: HashSet::new(),
}
}
pub(super) fn record(&mut self, failure: io::Error) {
if self.enabled && self.messages.len() < self.limit {
self.messages.insert(failure.to_string());
}
}
pub(super) fn messages(&self) -> &HashSet<String> {
&self.messages
}
}
/// Detect descriptor exhaustion without allocating or interpreting OS messages.
#[cfg(unix)]
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
matches!(error.raw_os_error(), Some(libc::EMFILE | libc::ENFILE))
}
#[cfg(windows)]
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
error.raw_os_error() == Some(windows_sys::Win32::Networking::WinSock::WSAEMFILE)
}
// Retain the previous fallback for targets without Unix or Winsock error codes.
#[cfg(not(any(unix, windows)))]
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
error
.to_string()
.to_lowercase()
.contains("too many open files")
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fmt, net::SocketAddr};
#[derive(Debug)]
struct MustNotFormat;
impl fmt::Display for MustNotFormat {
fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
panic!("discarded diagnostics must not format errors")
}
}
impl std::error::Error for MustNotFormat {}
fn failure(error: io::Error) -> io::Error {
diagnostic_error(error, "127.0.0.1".parse().unwrap(), true)
}
#[test]
fn disabled_diagnostics_do_not_format_errors() {
let mut errors = ScanErrors::new(false, 1000);
errors.record(diagnostic_error(
io::Error::other(MustNotFormat),
"127.0.0.1".parse().unwrap(),
false,
));
assert!(errors.messages().is_empty());
}
#[test]
fn disabled_diagnostics_preserve_the_original_os_error() {
let original = io::Error::from_raw_os_error(123);
let error = diagnostic_error(original, "127.0.0.1".parse().unwrap(), false);
assert_eq!(error.raw_os_error(), Some(123));
}
#[test]
fn full_diagnostics_do_not_format_more_errors() {
let mut errors = ScanErrors::new(true, 1);
errors.record(failure(io::ErrorKind::ConnectionRefused.into()));
errors.record(failure(io::Error::other(MustNotFormat)));
assert_eq!(errors.messages().len(), 1);
}
#[test]
fn enabled_diagnostics_keep_error_details_and_deduplicate_per_ip() {
let mut errors = ScanErrors::new(true, 1000);
for port in [80, 443] {
errors.record(diagnostic_error(
io::Error::other("test socket error"),
SocketAddr::from(([127, 0, 0, 1], port)).ip(),
true,
));
}
assert_eq!(errors.messages().len(), 1);
assert!(errors.messages().contains("test socket error 127.0.0.1"));
}
#[cfg(unix)]
#[test]
fn detects_unix_descriptor_limits_by_code() {
for code in [libc::EMFILE, libc::ENFILE] {
assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error(
code
)));
}
assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error(
libc::ECONNREFUSED
)));
}
#[cfg(windows)]
#[test]
fn detects_winsock_descriptor_limits_by_code() {
use windows_sys::Win32::Networking::WinSock::{WSAECONNREFUSED, WSAEMFILE};
assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error(
WSAEMFILE
)));
assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error(
WSAECONNREFUSED
)));
}
#[cfg(any(unix, windows))]
#[test]
fn descriptor_check_never_formats_an_error() {
assert!(!is_descriptor_exhaustion(&io::Error::other(MustNotFormat)));
}
}
+322 -572
View File
File diff suppressed because it is too large. Load diff
+30 -102
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),
}
}
}
impl Iterator for SocketIterator<'_> {
#[allow(clippy::doc_link_with_quotes)]
impl<'s> Iterator for SocketIterator<'s> {
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);
}
}
}
+89 -188
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:
//!
@@ -105,20 +108,16 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
scripts_to_run.push(default_script);
}
ScriptsRequired::Custom => {
let script_config = ScriptConfig::read_config()?;
debug!("Script config \n{script_config:?}");
let script_dir_base = if let Some(config_directory) = &script_config.directory {
PathBuf::from(config_directory)
} else {
dirs::home_dir().ok_or_else(|| anyhow!("Could not infer scripts path."))?
};
let script_paths = find_scripts(script_dir_base)?;
debug!("Scripts paths \n{script_paths:?}");
let scripts_dir_base =
dirs::home_dir().ok_or_else(|| anyhow!("Could not infer scripts path."))?;
let script_paths = find_scripts(scripts_dir_base)?;
debug!("Scripts paths \n{:?}", script_paths);
let parsed_scripts = parse_scripts(script_paths);
debug!("Scripts parsed \n{parsed_scripts:?}");
debug!("Scripts parsed \n{:?}", parsed_scripts);
let script_config = ScriptConfig::read_config()?;
debug!("Script config \n{:?}", script_config);
// Only Scripts that contain all the tags found in ScriptConfig will be selected.
if let Some(config_hashset) = script_config.tags {
@@ -132,93 +131,24 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
} else {
debug!(
"\nScript tags does not match config tags {:?} {}",
script_hashset,
&script_hashset,
script.path.unwrap().display()
);
}
}
}
}
debug!("\nScript(s) to run {scripts_to_run:?}");
debug!("\nScript(s) to run {:?}", scripts_to_run);
}
}
verify_script_programs(&scripts_to_run)?;
Ok(scripts_to_run)
}
/// Whether `path/program` is something the shell could run.
#[cfg(not(windows))]
fn program_exists_in(dir: &std::path::Path, program: &str) -> bool {
dir.join(program).is_file()
}
/// Whether `path/program` is something `cmd.exe` could run, which appends
/// entries from `PATHEXT` to a bare name.
#[cfg(windows)]
fn program_exists_in(dir: &std::path::Path, program: &str) -> bool {
if dir.join(program).is_file() {
return true;
}
let extensions = std::env::var("PATHEXT").unwrap_or_default();
extensions
.split(';')
.filter(|extension| !extension.is_empty())
.any(|extension| dir.join(format!("{program}{extension}")).is_file())
}
fn program_is_available(program: &str) -> bool {
match std::env::var_os("PATH") {
Some(path) => std::env::split_paths(&path).any(|dir| program_exists_in(&dir, program)),
None => false,
}
}
/// Check that the program each selected script starts with is available.
///
/// Scripts run through `sh -c`, so a missing binary surfaces only as a bare
/// "Exit code = 127" once the scan has already finished. Report it up front
/// instead, before a long scan has been wasted.
fn verify_script_programs(scripts_to_run: &[ScriptFile]) -> Result<()> {
for script in scripts_to_run {
let call_format = match &script.call_format {
Some(call_format) => call_format,
None => continue,
};
let program = match call_format.split_whitespace().next() {
Some(program) => program,
None => continue,
};
// A leading assignment (`FOO=bar nmap ...`) or an absolute path is not
// a bare program name, and `{{...}}` placeholders are only filled in
// when the script actually runs, so leave those formats alone.
if program.contains('=')
|| program.contains('/')
|| program.contains('$')
|| program.contains("{{")
{
continue;
}
if !program_is_available(program) {
return Err(anyhow!(
"The script `{}` runs `{}`, which is not installed or not in PATH. \
Install it, or skip scripts with `--scripts none`.",
script
.path
.as_ref()
.map_or_else(|| "default".to_string(), |path| path.display().to_string()),
program
));
}
}
Ok(())
}
pub fn parse_scripts(scripts: Vec<PathBuf>) -> Vec<ScriptFile> {
let mut parsed_scripts: Vec<ScriptFile> = Vec::with_capacity(scripts.len());
for script in scripts {
debug!("Parsing script {}", script.display());
debug!("Parsing script {}", &script.display());
if let Some(script_file) = ScriptFile::new(script) {
parsed_scripts.push(script_file);
}
@@ -290,7 +220,7 @@ impl Script {
// Some variables get changed before read, and compiler throws warning on warn(unused_assignments)
#[allow(unused_assignments)]
pub fn run(self) -> Result<String> {
debug!("run self {:?}", self);
debug!("run self {:?}", &self);
let separator = self.ports_separator.unwrap_or_else(|| ",".into());
@@ -335,14 +265,14 @@ impl Script {
};
to_run = default_template.fill_with_struct(&exec_parts)?;
}
debug!("\nScript format to run {to_run}");
debug!("\nScript format to run {}", to_run);
execute_script(&to_run)
}
}
#[cfg(not(tarpaulin_include))]
fn execute_script(script: &str) -> Result<String> {
debug!("\nScript arguments {script}");
debug!("\nScript arguments {}", script);
let (cmd, arg) = if cfg!(unix) {
("sh", "-c")
@@ -351,7 +281,7 @@ fn execute_script(script: &str) -> Result<String> {
};
match Command::new(cmd)
.args([arg, script])
.args(&[arg, script])
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.output()
@@ -380,15 +310,16 @@ fn execute_script(script: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
Err(error) => {
debug!("Command error {error}",);
debug!("Command error {}", error.to_string());
Err(anyhow!(error.to_string()))
}
}
}
pub fn find_scripts(path: PathBuf) -> Result<Vec<PathBuf>> {
pub fn find_scripts(mut path: PathBuf) -> Result<Vec<PathBuf>> {
path.push(".rustscan_scripts");
if path.is_dir() {
debug!("Scripts folder found {}", path.display());
debug!("Scripts folder found {}", &path.display());
let mut files_vec: Vec<PathBuf> = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
@@ -417,7 +348,8 @@ impl ScriptFile {
if let Ok(file) = File::open(script) {
for mut line in io::BufReader::new(file).lines().skip(1).flatten() {
if line.starts_with('#') {
line = line[1..].trim().to_string();
line.retain(|c| c != '#');
line = line.trim().to_string();
line.push('\n');
lines_buf.push_str(&line);
} else {
@@ -425,20 +357,20 @@ impl ScriptFile {
}
}
} else {
debug!("Failed to read file: {}", real_path.display());
debug!("Failed to read file: {}", &real_path.display());
return None;
}
debug!("ScriptFile {} lines\n{}", real_path.display(), lines_buf);
debug!("ScriptFile {} lines\n{}", &real_path.display(), &lines_buf);
match toml::from_str::<ScriptFile>(&lines_buf) {
Ok(mut parsed) => {
debug!("Parsed ScriptFile{} \n{:?}", real_path.display(), parsed);
debug!("Parsed ScriptFile{} \n{:?}", &real_path.display(), &parsed);
parsed.path = Some(real_path);
// parsed_scripts.push(parsed);
Some(parsed)
}
Err(e) => {
debug!("Failed to parse ScriptFile headers {e}");
debug!("Failed to parse ScriptFile headers {}", e.to_string());
None
}
}
@@ -450,7 +382,6 @@ pub struct ScriptConfig {
pub tags: Option<Vec<String>>,
pub ports: Option<Vec<String>>,
pub developer: Option<Vec<String>>,
pub directory: Option<String>,
}
#[cfg(not(tarpaulin_include))]
@@ -469,28 +400,27 @@ impl ScriptConfig {
#[cfg(test)]
mod tests {
use super::*;
use super::{find_scripts, parse_scripts, Script, ScriptFile};
#[test]
fn script_headers_preserve_literal_hash_characters() {
let fixture =
std::env::temp_dir().join(format!("rustscan-literal-hash-{}.txt", std::process::id()));
fs::write(&fixture, "#!/usr/bin/env python3\n#tags = [\"C#\"]\n#call_format = \"fixture {{ip}} {{port}} https://example.invalid/#section\"\n\n").unwrap();
let parsed = ScriptFile::new(fixture.clone());
fs::remove_file(fixture).unwrap();
let parsed = parsed.expect("valid TOML metadata");
assert_eq!(parsed.tags, Some(vec!["C#".to_owned()]));
assert_eq!(
parsed.call_format.as_deref(),
Some("fixture {{ip}} {{port}} https://example.invalid/#section")
);
// Function for testing only, it inserts static values into ip and open_ports
// Doesn't use impl in case it's implemented in the super module at some point
fn into_script(script_f: ScriptFile) -> Script {
Script::build(
script_f.path,
"127.0.0.1".parse().unwrap(),
vec![80, 8080],
script_f.port,
script_f.ports_separator,
script_f.tags,
script_f.call_format,
)
}
#[test]
fn find_and_parse_scripts() {
let scripts = find_scripts("fixtures/.rustscan_scripts".into()).unwrap();
let scripts = find_scripts("fixtures/".into()).unwrap();
let scripts = parse_scripts(scripts);
assert_eq!(scripts.len(), 1);
assert_eq!(scripts.len(), 4);
}
#[test]
@@ -506,6 +436,26 @@ mod tests {
.unwrap();
}
#[test]
#[should_panic]
fn open_script_file_invalid_call_format() {
let mut script_f =
ScriptFile::new("fixtures/.rustscan_scripts/test_script.txt".into()).unwrap();
script_f.call_format = Some("qwertyuiop".to_string());
let script: Script = into_script(script_f);
let _output = script.run().unwrap();
}
#[test]
#[should_panic]
fn open_script_file_missing_call_format() {
let mut script_f =
ScriptFile::new("fixtures/.rustscan_scripts/test_script.txt".into()).unwrap();
script_f.call_format = None;
let script: Script = into_script(script_f);
let _output = script.run().unwrap();
}
#[test]
#[should_panic]
fn open_nonexisting_script_file() {
@@ -530,88 +480,39 @@ 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())
);
}
#[test]
fn test_custom_directory_config() {
// Create test config
let config_str = r#"
tags = ["core_approved", "example"]
directory = "fixtures/.rustscan_scripts"
"#;
#[cfg(unix)]
fn run_bash_script() {
let script_f = ScriptFile::new("fixtures/.rustscan_scripts/test_script.sh".into()).unwrap();
let script: Script = into_script(script_f);
let output = script.run().unwrap();
// output has a newline at the end by default, .trim() trims it
assert_eq!(output.trim(), "127.0.0.1 80,8080");
}
let config: ScriptConfig = toml::from_str(config_str).unwrap();
#[test]
fn run_python_script() {
let script_f = ScriptFile::new("fixtures/.rustscan_scripts/test_script.py".into()).unwrap();
let script: Script = into_script(script_f);
let output = script.run().unwrap();
// output has a newline at the end by default, .trim() trims it
assert_eq!(
config.directory,
Some("fixtures/.rustscan_scripts".to_string())
output.trim(),
"Python script ran with arguments ['fixtures/.rustscan_scripts/test_script.py', '127.0.0.1', '80,8080']"
);
// Test that the directory is actually used
let script_dir_base = PathBuf::from(config.directory.unwrap());
let scripts = find_scripts(script_dir_base).unwrap();
// Verify we found the test script
assert!(scripts.iter().any(|p| p
.file_name()
.and_then(|f| f.to_str())
.map(|s| s == "test_script.txt")
.unwrap_or(false)));
}
#[test]
fn test_default_directory_fallback() {
let config_str = r#"
tags = ["core_approved", "example"]
"#;
let config: ScriptConfig = toml::from_str(config_str).unwrap();
assert_eq!(config.directory, None);
// Test fallback to home directory
let script_dir_base = if let Some(config_directory) = &config.directory {
PathBuf::from(config_directory)
} else {
dirs::home_dir().unwrap()
};
assert_eq!(script_dir_base, dirs::home_dir().unwrap());
}
fn script_with_call_format(call_format: &str) -> ScriptFile {
ScriptFile {
path: Some(PathBuf::from("/tmp/script.txt")),
tags: None,
developer: None,
port: None,
ports_separator: None,
call_format: Some(call_format.to_string()),
}
}
#[test]
fn missing_script_program_is_reported() {
let scripts = vec![script_with_call_format("definitely-not-a-real-binary {{ip}}")];
let err = verify_script_programs(&scripts).unwrap_err();
assert!(err.to_string().contains("definitely-not-a-real-binary"));
}
#[test]
fn available_script_program_is_accepted() {
// `sh` is what the scripts are actually run through, so it is present
// on every platform that can execute them.
let scripts = vec![script_with_call_format("sh -c true {{ip}}")];
assert!(verify_script_programs(&scripts).is_ok());
}
#[test]
fn placeholder_and_assignment_prefixes_are_skipped() {
let scripts = vec![
script_with_call_format("{{script}} {{ip}} {{port}}"),
script_with_call_format("FOO=bar definitely-not-a-real-binary"),
script_with_call_format("/opt/custom/nmap {{ip}}"),
];
assert!(verify_script_programs(&scripts).is_ok());
#[cfg(unix)]
fn run_perl_script() {
let script_f = ScriptFile::new("fixtures/.rustscan_scripts/test_script.pl".into()).unwrap();
let script: Script = into_script(script_f);
let output = script.run().unwrap();
// output has a newline at the end by default, .trim() trims it
assert_eq!(output.trim(), "Total args passed to fixtures/.rustscan_scripts/test_script.pl : 2\nArg # 1 : 127.0.0.1\nArg # 2 : 80,8080");
}
}
+15 -59
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("[>]"),
RGansi_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
));
);
}
}
};
@@ -111,7 +68,7 @@ macro_rules! output {
macro_rules! funny_opening {
// prints a funny quote / opening
() => {
use rand::seq::IndexedRandom;
use rand::seq::SliceRandom;
let quotes = vec![
"Nmap? More like slowmap.🐢",
"🌍HACK THE PLANET🌍",
@@ -140,10 +97,9 @@ macro_rules! funny_opening {
"RustScan: Exploring the digital landscape, one IP at a time.",
"TreadStone was here 🚀",
"With RustScan, I scan ports so fast, even my firewall gets whiplash 💨",
"Scanning ports so fast, even the internet got a speeding ticket!",
];
let random_quote = quotes.choose(&mut rand::rng()).unwrap();
let random_quote = quotes.choose(&mut rand::thread_rng()).unwrap();
$crate::tui::println_safe(format_args!("{}\n", random_quote));
println!("{}\n", random_quote);
};
}
+163
View File
@@ -0,0 +1,163 @@
/*
* Test rustscan against different targets with a time limit.
* The tests assumes target/debug/rustscan has already been built.
*
* The tests are #[ignore] to avoid running them during normal development.
*
* Their tests in the timelimits module are run by travis during CI.
*/
use std::process::Command;
use std::time::Duration;
use wait_timeout::ChildExt;
const TIMEOUT_MARGIN: u32 = 3;
#[cfg(not(tarpaulin_include))]
fn run_rustscan_with_timeout(args: &[&str], timeout: Duration) {
println!("Running: target/debug/rustscan: {}", args.join(" "));
use std::time::Instant;
let start = Instant::now();
let mut child = Command::new("target/debug/rustscan")
.args(args)
.spawn()
.unwrap();
let mut tries = TIMEOUT_MARGIN;
loop {
match child.wait_timeout(timeout).unwrap() {
Some(_status) => break,
None => {
tries -= 1;
if tries == 0 {
// child hasn't exited yet
child.kill().unwrap();
child.wait().unwrap().code().unwrap();
panic!("Timeout while running command");
}
}
}
}
let end = Instant::now();
let duration = end.saturating_duration_since(start).as_secs_f32();
println!("time: {:1.1}s", duration);
}
mod timelimits {
#[test]
#[ignore]
fn scan_localhost() {
let timeout = super::Duration::from_secs(25);
super::run_rustscan_with_timeout(&["--greppable", "--no-nmap", "127.0.0.1"], timeout);
}
#[test]
#[ignore]
fn scan_google_com() {
super::run_rustscan_with_timeout(
&[
"--greppable",
"--no-nmap",
"-u",
"5000",
"-b",
"2500",
"google.com",
],
super::Duration::from_secs(28),
);
}
#[test]
#[ignore]
fn scan_example_com() {
super::run_rustscan_with_timeout(
&[
"--greppable",
"--no-nmap",
"-u",
"5000",
"-b",
"2500",
"example.com",
],
super::Duration::from_secs(28),
);
}
#[test]
#[ignore]
fn scan_rustscan_cmnatic_co_uk() {
super::run_rustscan_with_timeout(
&[
"--greppable",
"--no-nmap",
"-u",
"5000",
"-b",
"2500",
"rustscan.cmnatic.co.uk",
],
super::Duration::from_secs(26),
);
}
#[test]
#[ignore]
fn udp_scan_localhost() {
let timeout = super::Duration::from_secs(25);
super::run_rustscan_with_timeout(&["--greppable", "127.0.0.1", "--udp"], timeout);
}
#[test]
#[ignore]
fn udp_scan_google_com() {
super::run_rustscan_with_timeout(
&[
"--udp",
"--greppable",
"-u",
"5000",
"-b",
"2500",
"google.com",
],
super::Duration::from_secs(28),
);
}
#[test]
#[ignore]
fn udp_scan_example_com() {
super::run_rustscan_with_timeout(
&[
"--udp",
"--greppable",
"-u",
"5000",
"-b",
"2500",
"example.com",
],
super::Duration::from_secs(28),
);
}
#[test]
#[ignore]
fn udp_scan_rustscan_cmnatic_co_uk() {
super::run_rustscan_with_timeout(
&[
"--udp",
"--greppable",
"-u",
"5000",
"-b",
"2500",
"rustscan.cmnatic.co.uk",
],
super::Duration::from_secs(26),
);
}
}
-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");
}