Compare commits

...
Author SHA1 Message Date
dependabot[bot] 3d568d3d04 build(deps): bump windows-sys from 0.59.0 to 0.61.2
Bumps [windows-sys](https://github.com/microsoft/windows-rs) from 0.59.0 to 0.61.2.
- [Release notes](https://github.com/microsoft/windows-rs/releases)
- [Commits](https://github.com/microsoft/windows-rs/commits)

---
updated-dependencies:
- dependency-name: windows-sys
  dependency-version: 0.61.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-06 09:03:12 +00:00
Autumn (Bee)andAutumn Skerritt 937903340a Avoid formatting discarded scan errors (#950)
* perf(scanner): defer TCP error diagnostics

* bench(scanner): check open-only TCP and UDP paths

* fix(scanner): Import HashSet in tests after main merge

---------

Co-authored-by: Autumn Skerritt <[email protected]>
2026-10-05 19:17:26 +09:00
dependabot[bot] f33752812c build(deps): bump tokio from 1.44.2 to 1.50.0 (#952)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.44.2 to 1.50.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.44.2...tokio-1.50.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-05 18:39:29 +09:00
Autumn (Bee) 9b48f03ecd fix(config): let command-line options override the config file (#947)
`Opts::merge` copied every value present in the config file over the
parsed command line, so e.g. `-r 8600-8650` was silently replaced by the
config's `range` and the requested ports were never scanned (`-p` only
survived because `PortStrategy::pick` prefers `ports` over `range`).
The same happened to `-a`, `-b`, `-t`, `--tries`, `-g`, `--scripts`,
`--scan-order`, the trailing nmap command and every other merged option.
This contradicts the documented behaviour: "Any arguments passed
directly to the binary will override the default provided in this
config file".

`Opts::parse()` cannot tell a value typed by the user from a clap
default, so `Opts::read()` now parses through `ArgMatches` and records
the ids of the arguments whose `value_source` is `CommandLine` in a new
skipped `cli_args` field. `merge_required`/`merge_optional` leave those
fields alone; options not given on the command line are still filled in
from the config file. `--top` and `--no-config` behave as before.

Add pure parsing tests (no sockets) covering both directions.

Fixes #722
2026-10-03 06:41:48 +01:00
Autumn (Bee) 32d7d2cb17 Defer fallback DNS initialization for literal targets (#949)
* Initialize the fallback DNS resolver only when needed

* Record lazy DNS startup benchmark results

* docs(perf): remeasure lazy DNS against merged Tokio master

* docs(perf): verify lazy DNS on current master
2026-10-02 20:45:12 +09:00
Autumn (Bee) 67c08a3310 Speed up port exclusions and avoid redundant port copies (#954)
* test(scanner): preserve port filtering order and boundaries

* perf(scanner): reuse port lists and index large exclusions

* perf(scanner): use bitmap for dense exclusions

* bench(scanner): document production port preparation speedups

* ci(bench): diagnose repeated macOS listener misses

* ci(bench): capture socket errors immediately after listener misses

* perf(scanner): keep bitmap storage out of repeated async polls

* ci(bench): provision macOS TCP memory for repeated scans
2026-10-02 20:14:56 +09:00
Autumn (Bee)andVrtgs b7119f7f3e Port to tokio (supersedes #856, closes #944) (#951)
* refactor(scanner): run the scanner on tokio instead of async-std

async-std is discontinued upstream (RUSTSEC-2025-0052). tokio is already in
the dependency tree through hickory-resolver, so switching adds no crate and
removes 28 from Cargo.lock (async-std, async-io, polling,
async-global-executor, blocking, futures-executor, ...).

This is a like-for-like port. The scanner keeps its architecture and the
library API keeps its signatures:

- a single `FuturesUnordered` keeps at most `batch_size` sockets in flight
  from one future; nothing is spawned per socket;
- connect and UDP receive timeouts use `tokio::time::timeout` and report the
  same `TimedOut` error that async-std's `io::timeout` did;
- open TCP sockets are still shut down in both directions before they are
  closed (through `into_std()`, because tokio only has a write-side shutdown);
- `--interval` sleeps with `tokio::time::sleep`.

The binary runs the scan on a current-thread runtime that exists only for
the scan. Address resolution (blocking DNS lookups and file reads) runs
before the runtime is created and scripts run after it is dropped, so neither
can stall the scan, and hickory's internal runtime is never nested in ours.

Library users now have to poll the scan futures from a tokio runtime with the
I/O and time drivers enabled. The crate and `Scanner` docs say so, and the
crate example shows how.

Builds on the earlier port in #856 by @vrtgs.

Co-authored-by: Vrtgs <[email protected]>

* perf(scanner): opt the scan out of tokio's cooperative budget

The scan polls every in-flight socket from one future through a
`FuturesUnordered`. tokio's cooperative scheduling gives that future a budget
of 128 units per poll. Once it is spent, every tokio resource returns
`Pending` and defers its wake-up, but `FuturesUnordered` still polls all the
other ready sockets in that same poll. When thousands of connections finish
together (a burst of refusals, or a batch of timeouts firing at once), the
scan keeps re-polling sockets only to park them again, roughly 128 at a time.

async-std had no budget. `tokio::task::unconstrained` restores that
behaviour: one poll handles every ready socket. The future still returns
`Pending`, and so lets the runtime poll I/O and timers, as soon as no socket
is ready.

* perf(scanner): send UDP probes before registering the socket with tokio

tokio's socket I/O is readiness based: `UdpSocket::send` and `recv` first
wait until the reactor has reported the socket ready and only then make the
system call. async-io, which async-std used, made the call first and only
waited after `WouldBlock`.

With a straight port, every UDP probe therefore made an extra trip through
the event loop before its send. Probes to closed ports on the local host
also no longer finished in their first poll: there the ICMP "port
unreachable" is usually queued before `send` returns, so async-std's first
`recv` already saw the refusal.

Bind a non-blocking std socket instead, send the probe and try the first
receive straight away. The socket is only handed to tokio, and registered
with its reactor, when there is really something to wait for. Errors and
timeouts are reported exactly as before.

* ci: add an opt-in scan benchmark comparing a change with its base

The test suite deliberately never opens sockets (see #942), so nothing in CI
showed whether a change to the scanner makes RustScan slower, hungrier or
wrong. The tokio port (#944) needs exactly that evidence.

The "Scan benchmark" workflow runs for pull requests that touch the scanner,
its runtime or its dependencies, and on demand. It builds the change and its
base in release mode on Linux x64/arm64, macOS and Windows runners. It then
runs .github/scan-benchmark/scan_bench.py, which:

- opens 315 TCP listeners and 32 UDP responders on 127.0.0.1 inside the
  runner. Nothing outside the runner is scanned. Ports stay out of the
  ephemeral range, so a scan can never connect a socket to itself;
- scans them with both builds, alternating the build order between runs:
  full TCP sweeps with batch sizes 500/4500/10000, 8 loopback hosts, one
  port, a UDP sweep, and on Linux ports that iptables drops (filtered TCP and
  UDP, and a mixed sweep), so timeouts are measured too;
- fails unless every run finds every listener and no build reports ports the
  base never found, or misses ports the base always found;
- reports medians of throughput (from RustScan's own scan timer), wall time,
  time to the first open port, peak RSS, CPU time, and sampled peak
  descriptors and threads. The table goes to the job summary, the raw data
  to an artifact;
- fails if the change's median throughput or wall time is more than 15%
  worse, or its peak RSS more than 25% worse, in any scenario.

contributing.md documents the workflow as the place where scanning is
measured, next to the rule that tests must not scan.

* ci(scan-benchmark): temporarily compare the port's intermediate builds (to be reverted)

* Revert "ci(scan-benchmark): temporarily compare the port's intermediate builds (to be reverted)"

This reverts commit 30ac107c55.

* fix(scanner): let the runtime poll I/O while a batch is started

The scan drives every socket from one future on a current-thread runtime,
so the runtime only polls for I/O and fires timers between two polls of
that future. A poll only ended once no socket was ready, which could take
a long time:

- starting a whole batch at once: 8,191 sockets took longer than a 500 ms
  timeout on the Windows runner, so connections that had succeeded during
  the fill were only seen after their timers fired. The benchmark found no
  open port at all with `-b 10000 -t 500` there;
- UDP probes to closed ports on the local host finish straight away, so a
  whole sweep ran in one poll, and the replies from open ports waited until
  the end: the first open UDP port was reported 2.6x later than with
  async-std.

`scan_sockets` now starts and finishes sockets in rounds of at most 128
(`WORK_PER_TURN`, both counted together) and calls `yield_now()` between
rounds, which defers its wake-up until the runtime has polled the OS
selector without blocking. Each round still keeps up to `batch_size`
sockets in flight; the batch now fills over several rounds instead of in
one go. The cost is one extra non-blocking `epoll_wait` (or equivalent)
per 128 sockets.

* fix(scanner): see ICMP errors on UDP sockets that wait in tokio

A UDP probe that gets no answer straight away is handed to tokio to wait for
the reply. On Linux, an ICMP "port unreachable" that arrives later only
raises EPOLLERR on the socket. tokio does not count that as readable, so
`UdpSocket::recv` slept until the timeout instead of returning the refusal.
async-io treated EPOLLERR as readable.

The straight port (before the eager probe) showed the effect: the UDP sweep
of the local host took 4.1 s instead of 1.0 s, because every closed port
waited for the 500 ms timeout. With the eager probe it only hit closed ports
whose ICMP arrived after the first receive. That is rare on the local host
but the common case for any remote host, where it turned every closed port
into a timeout.

`recv_or_error` waits for `READABLE | ERROR` readiness and hands out the
queued error (`take_error`), so a refused probe ends as soon as the ICMP
arrives, as before.

* ci(scan-benchmark): add a delayed host, fit Windows, compare misses

What the first runs showed:

- Only answers that arrive while the probe is still being sent were
  measured: on the loopback interface, refusals and ICMP errors are queued
  before connect()/send() returns. A remote host answers a round trip
  later, which is the path where tokio's UDP receive missed ICMP errors.
  On Linux the workflow now also sets up a host 5 ms away (a network
  namespace behind a netem-delayed veth pair, with ICMP rate limiting off,
  running its own listeners), and the benchmark sweeps it over TCP and UDP.
- Windows retries a refused connection for about a second. With
  `-t 500`, 8,191 concurrent attempts made even the base miss listeners.
  Windows now uses the default timeout and sweeps 8,191 ports (2,047 for
  the 500-socket batch).
- A build now fails the check only if it misses more listeners than the
  base does (any miss at all if the base misses none). Misses by the base
  are reported as a warning in the summary.
- The macOS runner has no `net.inet.icmp.icmplim`, which failed the
  "describe" step, and caps descriptors at 10240, so the 65536 limit
  could not be set. The describe step can no longer fail the job, and
  10240 is tried as well.

* docs(address): say that parse_addresses must not run inside async code

hickory's synchronous resolver runs its own tokio runtime, and tokio panics
when a runtime is dropped inside an asynchronous context. That was already
true before, but library users now have to run the scanner on tokio, so
they are more likely to call `parse_addresses` from async code. Document
the two safe ways: call it before starting the runtime (as the binary does)
or through `spawn_blocking`.

---------

Co-authored-by: Vrtgs <[email protected]>
2026-10-02 17:45:31 +09:00
Syed Ghufran HassanandAutumn Skerritt cfb8645901 Refactor SocketIterator for allocation-free performance (#945)
* Refactor SocketIterator for allocation-free performance

Refactor SocketIterator to eliminate boxing and improve memory efficiency. Update documentation and tests to reflect changes.

* fix(socket_iterator): fix clone test off-by-one and skip private doc example

- clone_resumes_independently expected None after 3 of 4 sockets; assert
  the exact remaining sequence for both the original and the clone.
- The struct doc example referenced a placeholder crate and a private
  module, so it could never compile as a doctest; mark it ignore.
- Add a test for empty IP/port inputs covering size_hint and the
  FusedIterator promise.

---------

Co-authored-by: Autumn Skerritt <[email protected]>
2026-10-01 22:03:40 +09:00
ReacherandAutumn Skerritt d7cdad29c6 refactor(main): avoid per-IP script list clones and safer ulimit fallback (#905)
Small cleanups in main.rs:

- Iterate over the parsed scripts by reference and clone only the script being run, instead of cloning the whole list for every IP.
- Use `or_default()` for the per-IP port map.
- If the soft file-descriptor limit does not fit in a `usize` (32-bit targets), fall back to `DEFAULT_FILE_DESCRIPTORS_LIMIT` instead of `usize::MAX`, which bypassed all batch-size adjustments.
- Fix a typo.

The effective-batch-size warning fix originally proposed here landed via #921.

Co-authored-by: Autumn Skerritt <[email protected]>
2026-10-01 20:24:16 +09:00
dependabot[bot] ea9e6f0736 build(deps): bump toml from 1.1.5+spec-1.1.0 to 1.1.6+spec-1.1.0 (#931)
Bumps [toml](https://github.com/toml-rs/toml) from 1.1.5+spec-1.1.0 to 1.1.6+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v1.1.5...toml-v1.1.6)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.1.6+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 20:23:25 +09:00
dependabot[bot] 2c854eb551 build(deps): bump clap from 4.6.2 to 4.6.7 (#934)
Bumps [clap](https://github.com/clap-rs/clap) from 4.6.2 to 4.6.7.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.2...clap_complete-v4.6.7)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 20:23:10 +09:00
dependabot[bot] 1c3fd8c378 build(deps): bump rand from 0.10.2 to 0.10.3 (#936)
Bumps [rand](https://github.com/rust-random/rand) from 0.10.2 to 0.10.3.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.10.2...0.10.3)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 20:22:54 +09:00
onsaliandAutumn Skerritt 4e64b5f00a feat: optional interval between scanned ports (#713)
New opt-in `--interval <MS>` option (also `interval` in the config file): after a port has been scanned on every address, wait that many milliseconds before scanning the next port, for slow, low-noise scans. Within a port `--batch-size` still limits concurrency; with the default of 0 scanning behaviour is unchanged.

Library: `Scanner::with_interval(Duration)` is added; `Scanner::new()` is unchanged.

Co-authored-by: Autumn Skerritt <[email protected]>
2026-10-01 20:19:49 +09:00
fabianbeesandAutumn Skerritt faa21b7d14 feat: option to also list closed ports (#736)
New `--closed` option (also `closed = true` in the config file) that additionally lists TCP ports that actively refused the connection: printed live as `Closed ip:port` and summarised under `closed ports:` at the end (also in greppable mode). Scripts are never run against closed ports, and without `--closed` scanning behaviour is unchanged.

Library: `Scanner::with_closed_ports()` and `Scanner::run_with_status() -> Vec<PortStatus>` are added; `Scanner::new()` and `run()` are unchanged.

Co-authored-by: Autumn Skerritt <[email protected]>
2026-10-01 20:14:48 +09:00
satyanand choudharyandAutumn Skerritt bf74950995 feat: support comma-separated multi-range port input (#852)
`-r/--range` now accepts several comma-separated ranges, e.g. `-r 1-100,200-300,5000-5100`. Overlapping/adjacent ranges are merged; random order uses the existing LCG walk extended over the merged ranges (every port visited exactly once), serial order follows the input order and skips duplicates. Reversed ranges (`300-200`) are now rejected instead of underflowing.

`range` in the config file keeps accepting the old `{ start = 1, end = 1000 }` table and additionally accepts `"1-500,1000-2500"` or `[[1, 500], [1000, 2500]]`.

Library API: `input::PortRange { start, end }` is replaced by `input::PortRanges(Vec<(u16, u16)>)`; `PortStrategy::pick` keeps its signature otherwise.

Fixes #835.

Co-authored-by: Autumn Skerritt <[email protected]>
2026-10-01 20:09:16 +09:00
Niklas Saari c027ebc7da test(address): cover CIDR notation with host bits set (#899)
Add regression tests for CIDR inputs whose host bits are set (e.g. `192.168.1.13/29` -> 192.168.1.8–15, `192.168.1.5/23`, `10.0.0.7/30`). The parsing itself was already fixed on master by 3bdac9c (IpInet accepts host bits), so the proposed normalize_cidr helper was dropped as unreachable.
2026-10-01 19:59:17 +09:00
DanDrewManand0xxreacher 183eafd22e fix: honor configured batch size on Windows and reject unsupported --ulimit (#921)
On native Windows, use the configured `-b/--batch-size` instead of a hard-coded 3000, and report the effective batch size (not the raw CLI value) in the "no open ports" hint on all platforms. `--ulimit` is hidden from Windows help and rejected on Windows (exit code 2), including when it comes from the config file. Unix batch-size/ulimit inference is unchanged.

Effective-batch-size reporting fix originally identified by @0xxreacher in #905.

Co-authored-by: 0xxreacher <[email protected]>
2026-10-01 19:57:28 +09:00
ChrisJr404 e497adf530 fix(config): use canonical XDG path for default config (#904)
Look for the config file at the XDG-style `$XDG_CONFIG_HOME/rustscan/config.toml` first (via `dirs::config_dir()`, so the platform equivalent on macOS/Windows), then fall back to the previous `$XDG_CONFIG_HOME/.rustscan.toml` and `~/.rustscan.toml` locations so existing configs keep working.

Fixes #741.
2026-10-01 19:55:08 +09:00
Chase Dustin 9ef667caed perf(scanner): precompute UDP payload lookup (#886)
Build a port -> payload lookup once per UDP scan instead of cloning the whole nmap payload BTreeMap into every scanned socket and scanning it linearly. Payload selection keeps the previous "last match wins" semantics.

Adds a socket-free micro-benchmark (benches/benchmark_helpers.rs) and lookup tests (tests/udp_payload_lookup.rs).
2026-10-01 19:54:38 +09:00
Andrew Barnes 002f736073 fix: keep library scans quiet by default (#916)
`Scanner` no longer prints each open socket to stdout by default, so library users can embed it and use the returned sockets without unsolicited output. The CLI opts in via the new `Scanner::with_open_port_output()` builder. The UDP socket bind error is now logged at debug level instead of printed.

Fixes #706.
2026-10-01 19:52:04 +09:00
kyd 1c2a982320 fix(build): decode literal text in UDP payload probes (#938)
Rewrite the nmap-payloads decoder in build.rs to follow Nmap's C-style escape format: `\xNN` and single-character escapes are decoded, all other characters are kept literally, and multi-line quoted segments are concatenated. Literal protocol text (e.g. the SNMP `public` community string, NetBIOS names, SSDP headers) was previously dropped, producing malformed probes. Also strip CR line endings when reading nmap-payloads.

Adds regression tests pinning the SNMP (161) and SSDP (1900) probe bytes.

Fixes #933.
2026-10-01 19:48:04 +09:00
jk 9644b3a2cd fix: exit quietly on closed stdout and survive missing SystemRoot (#941)
Route all user-facing stdout writes through tui::println_safe, which exits 0 on BrokenPipe (e.g. `rustscan -g ... | head`) instead of panicking (fatal under panic = "abort"). On Windows, skip hickory-resolver's system configuration and hosts file when SystemRoot is unset, falling back to the existing cloudflare_tls resolver.

Fixes #939, fixes #940.
2026-10-01 19:43:53 +09:00
herbenderblerandAutumn Skerritt a21bbb31bb ci: run the test suite on arm64 Linux runners (#868)
Add the GitHub-hosted ubuntu-24.04-arm runner to the test matrix so the suite runs natively on arm64 Linux (macos-latest already covers arm64 macOS; build.yml already cross-builds armv7/aarch64 Linux binaries), and document ARM support in docs/arm-support.md.

Fixes #683.

Co-authored-by: Autumn Skerritt <[email protected]>
2026-10-01 19:42:49 +09:00
Brad CowieandAutumn Skerritt 8b143f8ad7 ci: upload build artifacts to GitHub releases (#858)
Build release archives with houseabsolute/actions-rust-release and publish them (plus the .deb and checksums) to the GitHub release when a tag is pushed. CD now runs on pushes to master and tags only.

Also: read-only default workflow token (publish job gets contents: write), third-party actions pinned to release SHAs, fail on unmatched release assets, and the disabled trigger-less dependabot auto-merge workflow is removed.

Fixes #700, fixes #801.

Co-authored-by: Autumn Skerritt <[email protected]>
2026-10-01 19:41:56 +09:00
Sujyot KambleandAutumn Skerritt 111316f446 ci: replace deprecated actions-rs toolchain in test workflow (#853)
Replace the deprecated actions-rs/toolchain action with actions-rust-lang/setup-rust-toolchain (pinned to v1.17.0, clippy + rustfmt components, built-in cache and default RUSTFLAGS disabled) and restrict the workflow token to contents: read.

Co-authored-by: Autumn Skerritt <[email protected]>
2026-10-01 19:32:38 +09:00
Autumn (Bee) 8be97b841d test: clean obsolete executable fixtures and CI hooks (#943) 2026-10-01 18:05:37 +09:00
Autumn (Bee) 7446a42e56 test: remove network scans and external script execution (#942) 2026-10-01 17:44:10 +09:00
Autumn (Bee) e9ba3fa547 Merge pull request #935 from rezamqds/master
Fixes a typo in the Scriptfile parser comment (Sriptfile → Scriptfile) and improves the wording for clarity.
2026-09-21 19:26:33 +09:00
rezamqds 9a45d26e99 Fix typo in Scriptfile comment
Fix typo in comment about Scriptfile parser
2026-09-20 17:28:35 +03:30
rezamqds 6cb99747e1 Fix typo in Scriptfile comment
Clarify comment on scriptfile parser behavior
2026-09-20 17:28:31 +03:30
rezamqds bc80fbbad4 Fix typo in Scriptfile comment
Fix comment for clarity in test_script.py
2026-09-20 17:27:10 +03:30
rezamqds 3168580b86 Fix typo in Scriptfile comment
Fix comment for clarity in test_script.pl
2026-09-20 17:24:58 +03:30
Autumn (Bee) 2e78c22644 Merge pull request #930 from bee-san/fix/dependabot-clippy
chore: merge Dependabot updates and restore stable Clippy checks
2026-09-16 15:31:58 +09:00
Autumn Skerritt 996f818b5e Merge remote-tracking branch 'origin/dependabot/cargo/log-0.4.34' into fix/dependabot-clippy 2026-09-16 06:13:58 +00:00
Autumn Skerritt 90c5c1766b Merge commit '55c9f2a1c87b63f23363f6626954b871bbf4ab8e' into fix/dependabot-clippy 2026-09-16 06:13:37 +00:00
Autumn Skerritt f570d9ff8e Merge commit 'ce880ebe8c83be476d217f96ca26c2f90e6261c3' into fix/dependabot-clippy 2026-09-16 06:13:36 +00:00
Autumn Skerritt 42a3c87c83 Merge commit '64f60a97eddd7a0445be732175f272960ae1bd08' into fix/dependabot-clippy
# Conflicts:
#	Cargo.toml
2026-09-16 06:13:36 +00:00
Autumn Skerritt 1e93f44d8b Merge commit 'a084c1b3b9d6831074ff1929316a14a510e32430' into fix/dependabot-clippy 2026-09-16 06:12:26 +00:00
Autumn Skerritt bf290d7c7f Merge commit 'c668636a619ca96906be7d0b2aaf4ed0356560e3' into fix/dependabot-clippy 2026-09-16 06:12:26 +00:00
Autumn Skerritt 160dc7a3a2 Merge commit '730f709fd631955582993bc03d2aab5f580aa63c' into fix/dependabot-clippy 2026-09-16 06:12:26 +00:00
Autumn Skerritt 2d52f239ca Merge commit '827acd9cb00255662954cc36b5a90f6dbe5ba54f' into fix/dependabot-clippy 2026-09-16 06:12:26 +00:00
Autumn Skerritt 5900974fda Merge commit 'f9bc5abf212d85154d91b973f4761ac62950db11' into fix/dependabot-clippy 2026-09-16 06:12:26 +00:00
Autumn Skerritt d0d388c813 Merge commit '82c1a9373adfce5345c1745f90c9984656fbadc3' into fix/dependabot-clippy 2026-09-16 06:12:26 +00:00
Autumn Skerritt 5492dd729c fix: satisfy current stable Clippy without suppressing checks 2026-09-16 06:12:25 +00:00
Autumn Skerritt b84f1fa046 Merge remote-tracking branch 'origin/master' into fix/dependabot-clippy 2026-09-16 06:05:38 +00:00
dependabot[bot] 3d4db2b6a1 build(deps): bump log from 0.4.32 to 0.4.34
Bumps [log](https://github.com/rust-lang/log) from 0.4.32 to 0.4.34.
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.32...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-16 06:05:03 +00:00
dependabot[bot] 1967598d96 build(deps): bump dirs from 6.0.0 to 7.0.0 (#924)
Bumps dirs from 6.0.0 to 7.0.0.

---
updated-dependencies:
- dependency-name: dirs
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 15:02:40 +09:00
dependabot[bot] 94be5711d9 build(deps): bump toml from 1.1.2+spec-1.1.0 to 1.1.5+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 1.1.2+spec-1.1.0 to 1.1.5+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v1.1.2...toml-v1.1.5)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.1.5+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-07 09:03:00 +00:00
dependabot[bot] 55c9f2a1c8 build(deps): bump clap from 4.6.1 to 4.6.2
Bumps [clap](https://github.com/clap-rs/clap) from 4.6.1 to 4.6.2.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.1...clap_complete-v4.6.2)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-16 09:03:00 +00:00
dependabot[bot] ce880ebe8c build(deps): bump rand from 0.10.1 to 0.10.2
Bumps [rand](https://github.com/rust-random/rand) from 0.10.1 to 0.10.2.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.10.1...0.10.2)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-02 09:02:49 +00:00
dependabot[bot] 64f60a97ed build(deps): bump cidr-utils from 0.6.2 to 0.7.1
Bumps [cidr-utils](https://github.com/magiclen/cidr-utils) from 0.6.2 to 0.7.1.
- [Commits](https://github.com/magiclen/cidr-utils/compare/v0.6.2...v0.7.1)

---
updated-dependencies:
- dependency-name: cidr-utils
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-29 09:03:10 +00:00
dependabot[bot] a084c1b3b9 build(deps): bump env_logger from 0.11.10 to 0.11.11
Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.10 to 0.11.11.
- [Release notes](https://github.com/rust-cli/env_logger/releases)
- [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/env_logger/compare/v0.11.10...v0.11.11)

---
updated-dependencies:
- dependency-name: env_logger
  dependency-version: 0.11.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-26 09:02:48 +00:00
dependabot[bot] c668636a61 build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-19 09:03:11 +00:00
dependabot[bot] 730f709fd6 build(deps): bump itertools from 0.14.0 to 0.15.0
Bumps [itertools](https://github.com/rust-itertools/itertools) from 0.14.0 to 0.15.0.
- [Changelog](https://github.com/rust-itertools/itertools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-itertools/itertools/compare/v0.14.0...v0.15.0)

---
updated-dependencies:
- dependency-name: itertools
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-17 09:03:14 +00:00
Roger G. Coram e9dadb4a30 Merge pull request #902 from bee-san/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 4 to 6
2026-06-10 11:33:31 +01:00
Roger G. Coram 036297fbb9 Merge pull request #898 from bee-san/dependabot/cargo/clap-4.6.1
build(deps): bump clap from 4.6.0 to 4.6.1
2026-06-10 11:32:43 +01:00
Roger G. Coram e7db81a478 Merge pull request #908 from bee-san/dependabot/cargo/log-0.4.32
build(deps): bump log from 0.4.29 to 0.4.32
2026-06-10 11:32:09 +01:00
Roger G. Coram 7c7018530b Merge pull request #896 from bee-san/dependabot/cargo/toml-1.1.2spec-1.1.0
build(deps): bump toml from 1.1.0+spec-1.1.0 to 1.1.2+spec-1.1.0
2026-06-10 11:31:47 +01:00
Roger G. Coram 3abd1abe68 Merge pull request #897 from bee-san/dependabot/cargo/rand-0.10.1
build(deps): bump rand from 0.10.0 to 0.10.1
2026-06-10 11:31:13 +01:00
dependabot[bot] a4099a4ec2 build(deps): bump log from 0.4.29 to 0.4.32
Bumps [log](https://github.com/rust-lang/log) from 0.4.29 to 0.4.32.
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.29...0.4.32)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-04 09:02:56 +00:00
dependabot[bot] 827acd9cb0 build(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-30 09:06:04 +00:00
dependabot[bot] e724d1953c build(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-30 09:06:00 +00:00
dependabot[bot] f9bc5abf21 build(deps): bump houseabsolute/actions-rust-cross from 0 to 1
Bumps [houseabsolute/actions-rust-cross](https://github.com/houseabsolute/actions-rust-cross) from 0 to 1.
- [Release notes](https://github.com/houseabsolute/actions-rust-cross/releases)
- [Changelog](https://github.com/houseabsolute/actions-rust-cross/blob/v1/Changes.md)
- [Commits](https://github.com/houseabsolute/actions-rust-cross/compare/v0...v1)

---
updated-dependencies:
- dependency-name: houseabsolute/actions-rust-cross
  dependency-version: '1'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-30 09:05:56 +00:00
dependabot[bot] 82c1a9373a build(deps): bump extractions/setup-just from 2 to 4
Bumps [extractions/setup-just](https://github.com/extractions/setup-just) from 2 to 4.
- [Release notes](https://github.com/extractions/setup-just/releases)
- [Commits](https://github.com/extractions/setup-just/compare/v2...v4)

---
updated-dependencies:
- dependency-name: extractions/setup-just
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-30 09:05:52 +00:00
dependabot[bot] 45ece51c63 build(deps): bump clap from 4.6.0 to 4.6.1
Bumps [clap](https://github.com/clap-rs/clap) from 4.6.0 to 4.6.1.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.0...clap_complete-v4.6.1)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-21 09:04:00 +00:00
dependabot[bot] e234127874 build(deps): bump rand from 0.10.0 to 0.10.1
Bumps [rand](https://github.com/rust-random/rand) from 0.10.0 to 0.10.1.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.10.0...0.10.1)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-13 09:13:48 +00:00
dependabot[bot] 053a3eb0eb build(deps): bump toml from 1.1.0+spec-1.1.0 to 1.1.2+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 1.1.0+spec-1.1.0 to 1.1.2+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v1.1.0...toml-v1.1.2)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.1.2+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-02 09:03:34 +00:00
Roger G. Coram b03379890e Merge pull request #893 from bee-san/dependabot/cargo/env_logger-0.11.10
build(deps): bump env_logger from 0.11.9 to 0.11.10
2026-03-26 07:47:59 +00:00
Roger G. Coram 52ba449b70 Merge pull request #894 from bee-san/dependabot/cargo/toml-1.1.0spec-1.1.0
build(deps): bump toml from 1.0.6+spec-1.1.0 to 1.1.0+spec-1.1.0
2026-03-26 07:47:33 +00:00
dependabot[bot] 79768931e3 build(deps): bump toml from 1.0.6+spec-1.1.0 to 1.1.0+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 1.0.6+spec-1.1.0 to 1.1.0+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v1.0.6...toml-v1.1.0)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.1.0+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-24 09:03:49 +00:00
dependabot[bot] 3c3a69b06b build(deps): bump env_logger from 0.11.9 to 0.11.10
Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.9 to 0.11.10.
- [Release notes](https://github.com/rust-cli/env_logger/releases)
- [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/env_logger/compare/v0.11.9...v0.11.10)

---
updated-dependencies:
- dependency-name: env_logger
  dependency-version: 0.11.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-24 09:03:39 +00:00
Roger G. Coram 487acbe382 Merge pull request #888 from bee-san/dependabot/cargo/toml-1.0.6spec-1.1.0
build(deps): bump toml from 1.0.4+spec-1.1.0 to 1.0.6+spec-1.1.0
2026-03-14 10:17:00 +00:00
Roger G. Coram 8115b2d62e Merge pull request #889 from bee-san/dependabot/cargo/clap-4.6.0
build(deps): bump clap from 4.5.60 to 4.6.0
2026-03-14 10:16:24 +00:00
Roger G. Coram 99c3c1a650 Merge pull request #890 from bee-san/dependabot/cargo/once_cell-1.21.4
build(deps): bump once_cell from 1.21.3 to 1.21.4
2026-03-14 10:15:47 +00:00
dependabot[bot] f366ce741d build(deps): bump once_cell from 1.21.3 to 1.21.4
Bumps [once_cell](https://github.com/matklad/once_cell) from 1.21.3 to 1.21.4.
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.21.3...v1.21.4)

---
updated-dependencies:
- dependency-name: once_cell
  dependency-version: 1.21.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-13 09:03:56 +00:00
dependabot[bot] 6f388ae152 build(deps): bump clap from 4.5.60 to 4.6.0
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.60 to 4.6.0.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.60...clap_complete-v4.6.0)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-13 09:03:46 +00:00
dependabot[bot] a11b624644 build(deps): bump toml from 1.0.4+spec-1.1.0 to 1.0.6+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 1.0.4+spec-1.1.0 to 1.0.6+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v1.0.4...toml-v1.0.6)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.0.6+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-09 09:05:12 +00:00
Roger G. Coram 85c080ee56 Merge pull request #887 from bee-san/dependabot/cargo/toml-1.0.4spec-1.1.0
build(deps): bump toml from 1.0.0+spec-1.1.0 to 1.0.4+spec-1.1.0
2026-03-07 11:00:36 +00:00
Roger G. Coram b367fbe48e Merge pull request #885 from bee-san/dependabot/cargo/clap-4.5.60
build(deps): bump clap from 4.5.58 to 4.5.60
2026-03-07 10:59:44 +00:00
dependabot[bot] 50aed26d29 build(deps): bump toml from 1.0.0+spec-1.1.0 to 1.0.4+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 1.0.0+spec-1.1.0 to 1.0.4+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v1.0.0...toml-v1.0.4)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.0.4+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-05 09:03:36 +00:00
dependabot[bot] 1ceb758acf build(deps): bump clap from 4.5.58 to 4.5.60
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.58 to 4.5.60.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.58...clap_complete-v4.5.60)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-20 09:03:53 +00:00
Roger G. Coram 07aa04a94d Merge pull request #880 from bee-san/dependabot/cargo/clap-4.5.58
build(deps): bump clap from 4.5.57 to 4.5.58
2026-02-13 08:34:08 +00:00
dependabot[bot] 42d2f6128e build(deps): bump clap from 4.5.57 to 4.5.58
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.57 to 4.5.58.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.57...clap_complete-v4.5.58)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.58
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-12 16:40:40 +00:00
Roger G. Coram f71ed28ff7 Merge pull request #879 from bee-san/dependabot/cargo/anstream-1.0.0
build(deps): bump anstream from 0.6.20 to 1.0.0
2026-02-12 16:39:41 +00:00
dependabot[bot] bb8aa7fa80 build(deps): bump anstream from 0.6.20 to 1.0.0
Bumps [anstream](https://github.com/rust-cli/anstyle) from 0.6.20 to 1.0.0.
- [Commits](https://github.com/rust-cli/anstyle/compare/anstream-v0.6.20...anstream-v1.0.0)

---
updated-dependencies:
- dependency-name: anstream
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-12 16:30:24 +00:00
Roger G. Coram cd9852ae9d Merge pull request #878 from bee-san/dependabot/cargo/toml-1.0.0spec-1.1.0
build(deps): bump toml from 0.9.12+spec-1.1.0 to 1.0.0+spec-1.1.0
2026-02-12 16:28:44 +00:00
Roger G. Coram 6cd10727ee Merge pull request #877 from bee-san/dependabot/cargo/env_logger-0.11.9
build(deps): bump env_logger from 0.11.8 to 0.11.9
2026-02-12 16:27:39 +00:00
dependabot[bot] 034cbccf7e build(deps): bump toml from 0.9.12+spec-1.1.0 to 1.0.0+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.12+spec-1.1.0 to 1.0.0+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.12...toml-v1.0.0)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.0.0+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-12 09:03:59 +00:00
dependabot[bot] 6bae2a1aa2 build(deps): bump env_logger from 0.11.8 to 0.11.9
Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.8 to 0.11.9.
- [Release notes](https://github.com/rust-cli/env_logger/releases)
- [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/env_logger/compare/v0.11.8...v0.11.9)

---
updated-dependencies:
- dependency-name: env_logger
  dependency-version: 0.11.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-12 09:03:47 +00:00
Roger G. Coram 36e303e6ad Merge pull request #876 from bee-san/dependabot/cargo/toml-0.9.12spec-1.1.0
build(deps): bump toml from 0.9.7 to 0.9.12+spec-1.1.0
2026-02-12 08:22:13 +00:00
dependabot[bot] 3c2c869ba9 build(deps): bump toml from 0.9.7 to 0.9.12+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.7 to 0.9.12+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.7...toml-v0.9.12)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.12+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-11 09:03:49 +00:00
Roger G. Coramanddependabot[bot] 7f2fc87aa3 build(deps): bump rand from 0.9.2 to 0.10.0 (#875)
* build(deps): bump rand from 0.9.2 to 0.10.0

Bumps [rand](https://github.com/rust-random/rand) from 0.9.2 to 0.10.0.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/rand_core-0.9.2...0.10.0)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

* fix: change rand import

as per recent changelog.

---------

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 10:58:40 +00:00
Roger G. Coram 6440aed8a7 Merge pull request #872 from bee-san/dependabot/cargo/criterion-0.8.2
build(deps): bump criterion from 0.7.0 to 0.8.2
2026-02-10 10:16:05 +00:00
dependabot[bot] 51efbca7aa build(deps): bump criterion from 0.7.0 to 0.8.2
Bumps [criterion](https://github.com/criterion-rs/criterion.rs) from 0.7.0 to 0.8.2.
- [Release notes](https://github.com/criterion-rs/criterion.rs/releases)
- [Changelog](https://github.com/criterion-rs/criterion.rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/criterion-rs/criterion.rs/compare/criterion-plot-v0.7.0...criterion-v0.8.2)

---
updated-dependencies:
- dependency-name: criterion
  dependency-version: 0.8.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-10 10:01:16 +00:00
Roger G. Coram c78975fa87 Merge pull request #871 from bee-san/dependabot/cargo/clap-4.5.57
build(deps): bump clap from 4.5.48 to 4.5.57
2026-02-10 09:59:42 +00:00
dependabot[bot] 3a7230127f build(deps): bump clap from 4.5.48 to 4.5.57
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.48 to 4.5.57.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.48...clap_complete-v4.5.57)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.57
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-10 09:53:36 +00:00
Roger G. Coram 6dbf9f51cb Merge pull request #870 from bee-san/dependabot/cargo/bytes-1.11.1
build(deps): bump bytes from 1.10.0 to 1.11.1
2026-02-10 09:46:24 +00:00
dependabot[bot] c7a265e270 build(deps): bump bytes from 1.10.0 to 1.11.1
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.10.0 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.10.0...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-10 09:40:51 +00:00
Roger G. Coram e3d31b3957 Merge pull request #869 from bee-san/dependabot/cargo/rlimit-0.11.0
build(deps): bump rlimit from 0.10.2 to 0.11.0
2026-02-10 09:39:22 +00:00
dependabot[bot] b0965dc946 build(deps): bump rlimit from 0.10.2 to 0.11.0
Bumps [rlimit](https://github.com/Nugine/rlimit) from 0.10.2 to 0.11.0.
- [Changelog](https://github.com/Nugine/rlimit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Nugine/rlimit/compare/v0.10.2...v0.11.0)

---
updated-dependencies:
- dependency-name: rlimit
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-10 09:22:27 +00:00
Roger G. Coram 2c66b4203a Merge pull request #865 from bee-san/dependabot/cargo/colored-3.1.1
build(deps): bump colored from 3.0.0 to 3.1.1
2026-02-10 09:21:26 +00:00
dependabot[bot] c8596c7679 build(deps): bump colored from 3.0.0 to 3.1.1
Bumps [colored](https://github.com/mackwic/colored) from 3.0.0 to 3.1.1.
- [Release notes](https://github.com/mackwic/colored/releases)
- [Changelog](https://github.com/colored-rs/colored/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mackwic/colored/compare/v3.0.0...v3.1.1)

---
updated-dependencies:
- dependency-name: colored
  dependency-version: 3.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-10 09:16:32 +00:00
Roger G. Coram aeef44c6e0 test: correct Clippy warnings (#874)
[`unnecessary_unwrap`](https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#unnecessary_unwrap)
2026-02-10 08:37:35 +00:00
Roger G. Coram b228abfaa9 Merge pull request #857 from bee-san/dependabot/cargo/cidr-utils-0.6.2
build(deps): bump cidr-utils from 0.6.1 to 0.6.2
2026-02-10 08:18:19 +00:00
Roger G. Coram 7fbd96be7c Merge pull request #850 from bee-san/dependabot/cargo/log-0.4.29
build(deps): bump log from 0.4.28 to 0.4.29
2026-02-10 08:14:51 +00:00
dependabot[bot] 4a7ca7c00b build(deps): bump cidr-utils from 0.6.1 to 0.6.2
Bumps [cidr-utils](https://github.com/magiclen/cidr-utils) from 0.6.1 to 0.6.2.
- [Commits](https://github.com/magiclen/cidr-utils/compare/v0.6.1...v0.6.2)

---
updated-dependencies:
- dependency-name: cidr-utils
  dependency-version: 0.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-12-26 09:03:16 +00:00
dependabot[bot] 60a5ae0102 build(deps): bump log from 0.4.28 to 0.4.29
Bumps [log](https://github.com/rust-lang/log) from 0.4.28 to 0.4.29.
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.28...0.4.29)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-12-03 09:05:12 +00:00
bee 1b74455a35 fix(ci) 2025-11-27 11:11:39 +09:00
Jakub Kołodziej 00d1003afa Change batch_size and ulimit type (#811)
* Change batch_size and ulimit type from u16 and u64 to usize

* add error handling for overflow

* remove unnecessary conversions `clippy`

* fix missed

* chore: fix CI (unrelated)
2025-11-27 11:04:53 +09:00
4c056a9039 [Enhancement] Read config file from platform-specific config folder (#839)
* read config file from platform-specific config folder

* Update src/input.rs

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Autumn (Bee) <[email protected]>
Co-authored-by: Copilot <[email protected]>
2025-11-27 10:50:14 +09:00
defigli a40c4ed17f feat: implement config file support for no_banner (fixes #845) (#847) 2025-11-27 10:49:23 +09:00
bee 59c6f10fad fix ci 2025-11-27 10:48:45 +09:00
bee ec2d714e39 fix tests 2025-11-27 10:46:39 +09:00
Ben Woods c19726c74f README.md: fix arch install, install guide link (#816)
The example installation for Arch Linux previously showed installation via yay, which is an unofficial package manager only required if installing from the AUR. Since rustscan is in the official extra package repository, change the instructions to use pacman.

While here, also fix link to full install guide.
2025-11-24 22:46:01 +09:00
Autumn (Bee) 2600036621 break dependabot 2025-09-24 20:02:10 +09:00
github-actions[bot] 9596712a19 Merge pull request #833 from bee-san/dependabot/cargo/clap-4.5.48
build(deps): bump clap from 4.5.47 to 4.5.48
2025-09-22 09:16:08 +00:00
dependabot[bot] fe98a3b6ef build(deps): bump clap from 4.5.47 to 4.5.48
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.47 to 4.5.48.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.47...clap_complete-v4.5.48)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.48
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-09-22 09:15:55 +00:00
github-actions[bot] f4411e551a Merge pull request #832 from bee-san/dependabot/cargo/toml-0.9.7
build(deps): bump toml from 0.9.6 to 0.9.7
2025-09-19 09:04:20 +00:00
dependabot[bot] 0640d94f53 build(deps): bump toml from 0.9.6 to 0.9.7
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.6 to 0.9.7.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.6...toml-v0.9.7)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-09-19 09:04:09 +00:00
github-actions[bot] d3508f8192 Merge pull request #830 from bee-san/dependabot/cargo/toml-0.9.6
build(deps): bump toml from 0.9.5 to 0.9.6
2025-09-16 09:05:32 +00:00
dependabot[bot] aacde90cf1 build(deps): bump toml from 0.9.5 to 0.9.6
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.5 to 0.9.6.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.5...toml-v0.9.6)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-09-16 09:05:17 +00:00
github-actions[bot] 81a367e40b Merge pull request #828 from bee-san/dependabot/cargo/log-0.4.28
build(deps): bump log from 0.4.27 to 0.4.28
2025-09-04 12:59:57 +00:00
dependabot[bot] cc418f08b4 build(deps): bump log from 0.4.27 to 0.4.28
Bumps [log](https://github.com/rust-lang/log) from 0.4.27 to 0.4.28.
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.27...0.4.28)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-09-04 12:59:46 +00:00
github-actions[bot] 6781108db1 Merge pull request #827 from bee-san/dependabot/cargo/clap-4.5.47
build(deps): bump clap from 4.5.46 to 4.5.47
2025-09-03 23:58:30 +00:00
dependabot[bot] ea8015747f build(deps): bump clap from 4.5.46 to 4.5.47
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.46 to 4.5.47.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.46...clap_complete-v4.5.47)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.47
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-09-03 23:58:18 +00:00
github-actions[bot] c09fb7bcfd Merge pull request #826 from bee-san/dependabot/cargo/clap-4.5.46
build(deps): bump clap from 4.5.45 to 4.5.46
2025-08-27 18:22:09 +00:00
dependabot[bot] 6b96e84878 build(deps): bump clap from 4.5.45 to 4.5.46
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.45 to 4.5.46.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.45...clap_complete-v4.5.46)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-08-27 18:21:55 +00:00
github-actions[bot] 6f8b7f42f4 Merge pull request #823 from bee-san/dependabot/cargo/async-std-1.13.2
build(deps): bump async-std from 1.13.1 to 1.13.2
2025-08-15 09:19:32 +00:00
dependabot[bot] 24ff49622c build(deps): bump async-std from 1.13.1 to 1.13.2
Bumps [async-std](https://github.com/async-rs/async-std) from 1.13.1 to 1.13.2.
- [Release notes](https://github.com/async-rs/async-std/releases)
- [Changelog](https://github.com/async-rs/async-std/blob/main/CHANGELOG.md)
- [Commits](https://github.com/async-rs/async-std/compare/v1.13.1...v1.13.2)

---
updated-dependencies:
- dependency-name: async-std
  dependency-version: 1.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-08-15 09:19:22 +00:00
github-actions[bot] 87399a1583 Merge pull request #821 from bee-san/dependabot/cargo/clap-4.5.45
build(deps): bump clap from 4.5.44 to 4.5.45
2025-08-13 09:50:37 +00:00
dependabot[bot] 578457f6bd build(deps): bump clap from 4.5.44 to 4.5.45
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.44 to 4.5.45.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.44...clap_complete-v4.5.45)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-08-13 09:50:26 +00:00
github-actions[bot] e315f82d03 Merge pull request #820 from bee-san/dependabot/cargo/clap-4.5.44
build(deps): bump clap from 4.5.43 to 4.5.44
2025-08-12 15:16:50 +00:00
dependabot[bot] 63c9fdc05a build(deps): bump clap from 4.5.43 to 4.5.44
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.43 to 4.5.44.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.43...clap_complete-v4.5.44)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-08-12 15:16:40 +00:00
github-actions[bot] 6a1dd069b1 Merge pull request #819 from bee-san/dependabot/cargo/clap-4.5.43
build(deps): bump clap from 4.5.41 to 4.5.43
2025-08-07 09:07:30 +00:00
dependabot[bot] c89fad6c58 build(deps): bump clap from 4.5.41 to 4.5.43
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.41 to 4.5.43.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.41...clap_complete-v4.5.43)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-08-07 09:07:21 +00:00
github-actions[bot] ac83fe7da0 Merge pull request #818 from bee-san/dependabot/cargo/anstream-0.6.20
build(deps): bump anstream from 0.6.19 to 0.6.20
2025-08-06 09:28:03 +00:00
dependabot[bot] 3a06c22b43 build(deps): bump anstream from 0.6.19 to 0.6.20
Bumps [anstream](https://github.com/rust-cli/anstyle) from 0.6.19 to 0.6.20.
- [Commits](https://github.com/rust-cli/anstyle/compare/anstream-v0.6.19...anstream-v0.6.20)

---
updated-dependencies:
- dependency-name: anstream
  dependency-version: 0.6.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-08-06 09:27:54 +00:00
github-actions[bot] 9524b3503d Merge pull request #817 from bee-san/dependabot/cargo/toml-0.9.5
build(deps): bump toml from 0.9.4 to 0.9.5
2025-08-05 16:19:40 +00:00
dependabot[bot] eb11342a66 build(deps): bump toml from 0.9.4 to 0.9.5
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.4 to 0.9.5.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.4...toml-v0.9.5)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-08-05 16:19:26 +00:00
github-actions[bot] 7e8230f762 Merge pull request #814 from bee-san/dependabot/cargo/toml-0.9.4
build(deps): bump toml from 0.9.3 to 0.9.4
2025-07-30 09:37:43 +00:00
dependabot[bot] d51fbb7411 build(deps): bump toml from 0.9.3 to 0.9.4
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.3 to 0.9.4.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.3...toml-v0.9.4)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-30 09:37:25 +00:00
github-actions[bot] 56a234b41f Merge pull request #813 from bee-san/dependabot/cargo/criterion-0.7.0
build(deps): bump criterion from 0.6.0 to 0.7.0
2025-07-29 09:09:25 +00:00
dependabot[bot] 59b645aa0e build(deps): bump criterion from 0.6.0 to 0.7.0
Bumps [criterion](https://github.com/bheisler/criterion.rs) from 0.6.0 to 0.7.0.
- [Changelog](https://github.com/bheisler/criterion.rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bheisler/criterion.rs/compare/0.6.0...0.7.0)

---
updated-dependencies:
- dependency-name: criterion
  dependency-version: 0.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-29 09:09:12 +00:00
github-actions[bot] f5cbfef10f Merge pull request #812 from bee-san/dependabot/cargo/toml-0.9.3
build(deps): bump toml from 0.9.2 to 0.9.3
2025-07-29 09:09:02 +00:00
dependabot[bot] 0772c4948d build(deps): bump toml from 0.9.2 to 0.9.3
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.2 to 0.9.3.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.2...toml-v0.9.3)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-29 09:08:52 +00:00
github-actions[bot] 06e37f114e Merge pull request #808 from bee-san/dependabot/cargo/rand-0.9.2
build(deps): bump rand from 0.9.1 to 0.9.2
2025-07-21 10:22:19 +00:00
dependabot[bot] 18a71f2625 build(deps): bump rand from 0.9.1 to 0.9.2
Bumps [rand](https://github.com/rust-random/rand) from 0.9.1 to 0.9.2.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/rand_core-0.9.1...rand_core-0.9.2)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-21 10:22:10 +00:00
github-actions[bot] 48f826e545 Merge pull request #806 from bee-san/dependabot/cargo/toml-0.9.2
build(deps): bump toml from 0.9.1 to 0.9.2
2025-07-14 11:40:37 +00:00
dependabot[bot] f1006e4b3c build(deps): bump toml from 0.9.1 to 0.9.2
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.1 to 0.9.2.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.1...toml-v0.9.2)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-14 11:40:26 +00:00
github-actions[bot] a7257acbbb Merge pull request #804 from bee-san/dependabot/cargo/toml-0.9.1
build(deps): bump toml from 0.9.0 to 0.9.1
2025-07-11 09:41:19 +00:00
dependabot[bot] 23724292e4 build(deps): bump toml from 0.9.0 to 0.9.1
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.0 to 0.9.1.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.0...toml-v0.9.1)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-11 09:41:08 +00:00
Melroy dsilva e1e5e7a020 (BUG)Typo in the tui.rs file and change in the contributing.Dockerfile (#797)
* change in the contributing.Docker File

* typo in the tui.rs file !
2025-07-11 15:55:57 +09:00
ggFROOK 2e85a6e31d change from HashMap to Vec (#792) 2025-07-11 15:54:25 +09:00
github-actions[bot] 9b43d1c6ce Merge pull request #803 from bee-san/dependabot/cargo/clap-4.5.41
build(deps): bump clap from 4.5.40 to 4.5.41
2025-07-10 09:30:17 +00:00
dependabot[bot] 7f7de101dc build(deps): bump clap from 4.5.40 to 4.5.41
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.40 to 4.5.41.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.40...clap_complete-v4.5.41)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.41
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-10 09:30:06 +00:00
github-actions[bot] c353cf5fd8 Merge pull request #802 from bee-san/dependabot/cargo/toml-0.9.0
build(deps): bump toml from 0.8.23 to 0.9.0
2025-07-09 09:50:32 +00:00
dependabot[bot] 94b9c0e30a build(deps): bump toml from 0.8.23 to 0.9.0
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.23 to 0.9.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.23...toml-v0.9.0)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-07-09 09:50:20 +00:00
github-actions[bot] 042875afec Merge pull request #794 from bee-san/dependabot/cargo/clap-4.5.40
build(deps): bump clap from 4.5.39 to 4.5.40
2025-06-10 09:31:15 +00:00
dependabot[bot] edc90a4db3 build(deps): bump clap from 4.5.39 to 4.5.40
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.39 to 4.5.40.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.39...clap_complete-v4.5.40)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.40
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-06-10 09:31:05 +00:00
github-actions[bot] 028554c18f Merge pull request #793 from bee-san/dependabot/cargo/toml-0.8.23
build(deps): bump toml from 0.8.22 to 0.8.23
2025-06-09 09:44:13 +00:00
dependabot[bot] f979926275 build(deps): bump toml from 0.8.22 to 0.8.23
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.22 to 0.8.23.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.22...toml-v0.8.23)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.8.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-06-09 09:44:03 +00:00
github-actions[bot] cf1eaf8e5c Merge pull request #790 from bee-san/dependabot/cargo/anstream-0.6.19
build(deps): bump anstream from 0.6.18 to 0.6.19
2025-06-05 09:33:59 +00:00
dependabot[bot] 618b8aa9a9 build(deps): bump anstream from 0.6.18 to 0.6.19
Bumps [anstream](https://github.com/rust-cli/anstyle) from 0.6.18 to 0.6.19.
- [Commits](https://github.com/rust-cli/anstyle/compare/anstream-v0.6.18...anstream-v0.6.19)

---
updated-dependencies:
- dependency-name: anstream
  dependency-version: 0.6.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-06-05 09:33:46 +00:00
Roger G. Coram 0f23a671a5 Merge pull request #789 from zombiepigdragon/permissive-cidr
Allow CIDR addresses with non-zero host bits
2025-06-02 06:34:28 -07:00
Alex Rawson 3bdac9c9c7 allow CIDR addresses with non-zero host bits
fixes #777
2025-05-31 13:44:45 -05:00
Roger G. Coram 1085a723c4 Merge pull request #787 from mikhail5555/improve-excluded_nets-filter-performance
Improve excluded nets filter performance dractically
2025-05-31 09:37:48 -07:00
Mikhail Epifanov e1c8c254a1 fix all clippy errors 2025-05-31 15:07:13 +02:00
github-actions[bot] 05e5c50f49 Merge pull request #788 from bee-san/dependabot/cargo/clap-4.5.39
build(deps): bump clap from 4.5.38 to 4.5.39
2025-05-28 09:50:31 +00:00
dependabot[bot] f42f887da3 build(deps): bump clap from 4.5.38 to 4.5.39
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.38 to 4.5.39.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.38...clap_complete-v4.5.39)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-05-28 09:50:20 +00:00
Mikhail Epifanov d5b7f4a024 use and a flat_map for the addresses 2025-05-23 14:04:53 +02:00
Mikhail Epifanov c5d54880fc switch to IpCidr library 2025-05-22 15:30:13 +02:00
Mikhail Epifanov 179d7fdfb8 add more realistic exclude_addresses 2025-05-22 15:18:59 +02:00
Mikhail Epifanov 2176262f22 use IpNets for filtering instead of individual IPs 2025-05-22 15:03:31 +02:00
github-actions[bot] 447283cf23 Merge pull request #786 from bee-san/dependabot/cargo/criterion-0.6.0
build(deps): bump criterion from 0.5.1 to 0.6.0
2025-05-19 09:15:54 +00:00
dependabot[bot] 8b317cec1f build(deps): bump criterion from 0.5.1 to 0.6.0
Bumps [criterion](https://github.com/bheisler/criterion.rs) from 0.5.1 to 0.6.0.
- [Changelog](https://github.com/bheisler/criterion.rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bheisler/criterion.rs/compare/0.5.1...0.6.0)

---
updated-dependencies:
- dependency-name: criterion
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-05-19 09:15:41 +00:00
github-actions[bot] 08ccf624c1 Merge pull request #785 from bee-san/dependabot/cargo/clap-4.5.38
build(deps): bump clap from 4.5.37 to 4.5.38
2025-05-12 09:41:37 +00:00
dependabot[bot] 16e45cea2b build(deps): bump clap from 4.5.37 to 4.5.38
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.37 to 4.5.38.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.37...clap_complete-v4.5.38)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.38
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-05-12 09:41:26 +00:00
github-actions[bot] a5c36c3f52 Merge pull request #784 from bee-san/dependabot/cargo/toml-0.8.22
build(deps): bump toml from 0.8.20 to 0.8.22
2025-04-29 09:06:33 +00:00
dependabot[bot] 1124c46f37 build(deps): bump toml from 0.8.20 to 0.8.22
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.20 to 0.8.22.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.20...toml-v0.8.22)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.8.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-04-29 09:06:23 +00:00
github-actions[bot] 8330e4363d Merge pull request #782 from bee-san/dependabot/cargo/clap-4.5.37
build(deps): bump clap from 4.5.36 to 4.5.37
2025-04-21 11:27:15 +00:00
dependabot[bot] 08ee05ff2d build(deps): bump clap from 4.5.36 to 4.5.37
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.36 to 4.5.37.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.36...clap_complete-v4.5.37)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.37
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-04-21 11:27:04 +00:00
github-actions[bot] 34c285af75 Merge pull request #781 from bee-san/dependabot/cargo/rand-0.9.1
build(deps): bump rand from 0.9.0 to 0.9.1
2025-04-18 10:00:21 +00:00
dependabot[bot] e4eeeac6ef build(deps): bump rand from 0.9.0 to 0.9.1
Bumps [rand](https://github.com/rust-random/rand) from 0.9.0 to 0.9.1.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.9.0...rand_core-0.9.1)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-04-18 10:00:10 +00:00
github-actions[bot] a70bd8b374 Merge pull request #779 from bee-san/dependabot/cargo/clap-4.5.36
build(deps): bump clap from 4.5.35 to 4.5.36
2025-04-14 09:17:23 +00:00
dependabot[bot] 2884a448be build(deps): bump clap from 4.5.35 to 4.5.36
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.35 to 4.5.36.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.35...clap_complete-v4.5.36)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.36
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-04-14 09:17:09 +00:00
github-actions[bot] 6ec01bb154 Merge pull request #776 from bee-san/dependabot/cargo/tokio-1.44.2
build(deps): bump tokio from 1.43.0 to 1.44.2
2025-04-08 02:14:53 +00:00
dependabot[bot] c09baef4f8 build(deps): bump tokio from 1.43.0 to 1.44.2
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.43.0 to 1.44.2.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.43.0...tokio-1.44.2)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.44.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-04-08 02:14:42 +00:00
github-actions[bot] cf426427ed Merge pull request #774 from bee-san/dependabot/cargo/env_logger-0.11.8
build(deps): bump env_logger from 0.11.7 to 0.11.8
2025-04-02 09:49:49 +00:00
github-actions[bot] 293056ef19 Merge pull request #773 from bee-san/dependabot/cargo/clap-4.5.35
build(deps): bump clap from 4.5.34 to 4.5.35
2025-04-02 09:49:45 +00:00
dependabot[bot] 2888336657 build(deps): bump env_logger from 0.11.7 to 0.11.8
Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.7 to 0.11.8.
- [Release notes](https://github.com/rust-cli/env_logger/releases)
- [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/env_logger/compare/v0.11.7...v0.11.8)

---
updated-dependencies:
- dependency-name: env_logger
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-04-02 09:49:38 +00:00
dependabot[bot] 800cb3ba39 build(deps): bump clap from 4.5.34 to 4.5.35
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.34 to 4.5.35.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.34...clap_complete-v4.5.35)

---
updated-dependencies:
- dependency-name: clap
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-04-02 09:49:31 +00:00
github-actions[bot] 096ebbf73d Merge pull request #772 from bee-san/dependabot/cargo/once_cell-1.21.3
build(deps): bump once_cell from 1.21.2 to 1.21.3
2025-03-31 10:00:28 +00:00
dependabot[bot] 5e6cc410e7 build(deps): bump once_cell from 1.21.2 to 1.21.3
Bumps [once_cell](https://github.com/matklad/once_cell) from 1.21.2 to 1.21.3.
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.21.2...v1.21.3)

---
updated-dependencies:
- dependency-name: once_cell
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-31 10:00:16 +00:00
github-actions[bot] 5739c6175e Merge pull request #771 from bee-san/dependabot/cargo/once_cell-1.21.2
build(deps): bump once_cell from 1.21.1 to 1.21.2
2025-03-28 09:43:12 +00:00
dependabot[bot] c134747788 build(deps): bump once_cell from 1.21.1 to 1.21.2
Bumps [once_cell](https://github.com/matklad/once_cell) from 1.21.1 to 1.21.2.
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.21.1...v1.21.2)

---
updated-dependencies:
- dependency-name: once_cell
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-28 09:42:57 +00:00
Roger G. Coram 2c2a55723a Merge pull request #769 from druil0or/master
Update README.md
2025-03-28 08:36:08 +00:00
dunno c5fb1b006c Update README.md
Edited Wiki link in links-2
2025-03-27 13:45:46 +01:00
github-actions[bot] 083a20269f Merge pull request #768 from bee-san/dependabot/cargo/clap-4.5.34
build(deps): bump clap from 4.5.32 to 4.5.34
2025-03-27 09:40:56 +00:00
dependabot[bot] 815b4b5e05 build(deps): bump clap from 4.5.32 to 4.5.34
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.32 to 4.5.34.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.32...clap_complete-v4.5.34)

---
updated-dependencies:
- dependency-name: clap
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-27 09:40:43 +00:00
github-actions[bot] b7734da3a7 Merge pull request #767 from bee-san/dependabot/cargo/log-0.4.27
build(deps): bump log from 0.4.26 to 0.4.27
2025-03-24 09:47:55 +00:00
dependabot[bot] 36d71894b9 build(deps): bump log from 0.4.26 to 0.4.27
Bumps [log](https://github.com/rust-lang/log) from 0.4.26 to 0.4.27.
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.26...0.4.27)

---
updated-dependencies:
- dependency-name: log
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-24 09:47:43 +00:00
github-actions[bot] 8a6334dfe8 Merge pull request #766 from bee-san/dependabot/cargo/async-std-1.13.1
build(deps): bump async-std from 1.13.0 to 1.13.1
2025-03-17 09:39:31 +00:00
dependabot[bot] e7c4205ec5 build(deps): bump async-std from 1.13.0 to 1.13.1
Bumps [async-std](https://github.com/async-rs/async-std) from 1.13.0 to 1.13.1.
- [Release notes](https://github.com/async-rs/async-std/releases)
- [Changelog](https://github.com/async-rs/async-std/blob/main/CHANGELOG.md)
- [Commits](https://github.com/async-rs/async-std/compare/v1.13.0...v1.13.1)

---
updated-dependencies:
- dependency-name: async-std
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-17 09:39:20 +00:00
Roger G. Coram 1553e58cf7 Merge pull request #764 from bee-san/dependabot/cargo/env_logger-0.11.7
build(deps): bump env_logger from 0.11.6 to 0.11.7
2025-03-16 09:30:12 +00:00
github-actions[bot] 2e5e5fd71e Merge pull request #765 from bee-san/dependabot/cargo/once_cell-1.21.1
build(deps): bump once_cell from 1.21.0 to 1.21.1
2025-03-14 09:20:53 +00:00
dependabot[bot] ea2e8f6fbe build(deps): bump once_cell from 1.21.0 to 1.21.1
Bumps [once_cell](https://github.com/matklad/once_cell) from 1.21.0 to 1.21.1.
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.21.0...v1.21.1)

---
updated-dependencies:
- dependency-name: once_cell
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-14 09:20:41 +00:00
github-actions[bot] d6bc7e7219 Merge pull request #763 from bee-san/dependabot/cargo/once_cell-1.21.0
build(deps): bump once_cell from 1.20.3 to 1.21.0
2025-03-11 09:33:47 +00:00
dependabot[bot] 5d66529231 build(deps): bump env_logger from 0.11.6 to 0.11.7
Bumps [env_logger](https://github.com/rust-cli/env_logger) from 0.11.6 to 0.11.7.
- [Release notes](https://github.com/rust-cli/env_logger/releases)
- [Changelog](https://github.com/rust-cli/env_logger/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/env_logger/compare/v0.11.6...v0.11.7)

---
updated-dependencies:
- dependency-name: env_logger
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-11 09:33:38 +00:00
github-actions[bot] 9e3c547615 Merge pull request #762 from bee-san/dependabot/cargo/clap-4.5.32
build(deps): bump clap from 4.5.31 to 4.5.32
2025-03-11 09:33:38 +00:00
dependabot[bot] a8c723fc68 build(deps): bump once_cell from 1.20.3 to 1.21.0
Bumps [once_cell](https://github.com/matklad/once_cell) from 1.20.3 to 1.21.0.
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.20.3...v1.21.0)

---
updated-dependencies:
- dependency-name: once_cell
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-11 09:33:31 +00:00
dependabot[bot] bbb8237d19 build(deps): bump clap from 4.5.31 to 4.5.32
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.31 to 4.5.32.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/v4.5.31...clap_complete-v4.5.32)

---
updated-dependencies:
- dependency-name: clap
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-11 09:33:24 +00:00
G. 5f8e015686 Add funny quotes (#759) 2025-03-09 10:02:24 +00:00
github-actions[bot] 9d6bca1119 Merge pull request #760 from bee-san/dependabot/cargo/ring-0.17.13
build(deps): bump ring from 0.17.11 to 0.17.13
2025-03-07 17:05:19 +00:00
dependabot[bot] 4168bf612e build(deps): bump ring from 0.17.11 to 0.17.13
Bumps [ring](https://github.com/briansmith/ring) from 0.17.11 to 0.17.13.
- [Changelog](https://github.com/briansmith/ring/blob/main/RELEASES.md)
- [Commits](https://github.com/briansmith/ring/commits)

---
updated-dependencies:
- dependency-name: ring
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-03-07 17:00:06 +00:00
Autumn (Bee)andbee 6a1b49e7a3 Add custom script directory (#758)
* Add custom script directory

* run just

---------

Co-authored-by: bee <[email protected]>
2025-03-05 09:28:57 +00:00
github-actions[bot] c123457734 Merge pull request #756 from RustScan/dependabot/cargo/clap-4.5.31
build(deps): bump clap from 4.5.30 to 4.5.31
2025-02-25 09:57:21 +00:00
dependabot[bot] 727a64d934 build(deps): bump clap from 4.5.30 to 4.5.31
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.30 to 4.5.31.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.30...v4.5.31)

---
updated-dependencies:
- dependency-name: clap
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2025-02-25 09:57:05 +00:00
bee b1f008a9c9 2.4.1 2025-02-23 15:03:57 +00:00
44 changed files with 9750 additions and 1973 deletions

No files matched your search

@@ -0,0 +1,57 @@
"""Provision TCP memory for repeated scans on a disposable macOS CI runner."""
import ctypes
import json
import os
import subprocess
class Statistics(ctypes.Structure):
# XNU's bsd/sys/mem_acct_private.h, struct memacct_statistics.
_fields_ = [("peak", ctypes.c_uint64), ("allocated", ctypes.c_int64),
("softlimit", ctypes.c_uint64), ("hardlimit", ctypes.c_uint64),
("name", ctypes.c_char * 16)]
def tcp_memory(new_limit: int | None = None) -> dict:
"""Read the TCP accounting object, optionally raising its hard limit."""
libc = ctypes.CDLL(None, use_errno=True)
libc.sysctlnametomib.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_size_t)]
libc.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint,
ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t),
ctypes.c_void_p, ctypes.c_size_t]
mib = (ctypes.c_int * 24)()
count = ctypes.c_size_t(22)
if libc.sysctlnametomib(b"kern.memacct", mib, ctypes.byref(count)):
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
def query(operation: int, index: int, output, replacement=None):
mib[count.value], mib[count.value + 1] = operation, index
length = ctypes.c_size_t(ctypes.sizeof(output))
if libc.sysctl(mib, count.value + 2, ctypes.byref(output), ctypes.byref(length),
ctypes.byref(replacement) if replacement is not None else None,
ctypes.sizeof(replacement) if replacement is not None else 0):
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
return length.value
names = ctypes.create_string_buffer(8 * 16)
length = query(5, 0, names) # MEM_ACCT_SUBSYSTEMS
index = next(i for i in range(length // 16)
if names.raw[i * 16:(i + 1) * 16].rstrip(b"\0") == b"TCP")
stats = Statistics()
query(6, index, stats) # MEM_ACCT_ALL_SUBSYSTEM_STATISTICS
previous_limit = stats.hardlimit
if new_limit is not None and 0 < previous_limit < new_limit:
query(3, index, ctypes.c_uint64(), ctypes.c_uint64(new_limit))
query(6, index, stats)
return {"previous_hardlimit": previous_limit,
**{name: getattr(stats, name) for name in ("peak", "allocated", "softlimit", "hardlimit")}}
if __name__ == "__main__":
# The default TCP budget is 1/32 of physical memory. Repeated 10,000-socket
# batches hit ENOBUFS on Darwin 25.6 even on master. Give both builds 1/8
# before timing; retain every listener check and performance threshold.
memory = int(subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True))
print(json.dumps(tcp_memory(memory // 8)))
+984
View File
@@ -0,0 +1,984 @@
#!/usr/bin/env python3
"""Compare RustScan builds by scanning listeners on the loopback interface.
CI-only tooling for ``.github/workflows/scan-benchmark.yml``; it is not part
of the test suite. It port-scans 127.0.0.1, so only run it on a disposable CI
runner, never on a developer machine or a shared host.
``run`` starts ``serve`` (TCP listeners and UDP responders on 127.0.0.1) in a
separate process, runs every scenario with every build (interleaving the
builds so that drift on the runner affects them all alike), checks that all
builds report the same open ports, and writes the raw measurements as JSON
plus a Markdown summary of the medians. On Linux the workflow also sets up a
host behind an artificial delay (a network namespace on the runner, with its
own ``serve``), so that answers arrive after a round trip, as they do from a
remote host, instead of while the probe is being sent.
Measured per run:
* scan time: RustScan's own "Portscan" timer (``RUST_LOG=rustscan=info``),
reported as throughput (sockets per second);
* wall time of the whole process, and the time until the first ``Open`` line
appears on stdout (latency to the first result);
* peak RSS and CPU time (user + system) of the process;
* peak number of open file descriptors (Windows: handles) and of threads,
sampled every few milliseconds while the scan runs.
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import re
import selectors
import signal
import socket
import statistics
import subprocess
import sys
import threading
import time
from dataclasses import asdict, dataclass, field
LOOPBACK = "127.0.0.1"
# Ports `serve` listens on. They stay below 32768, i.e. below the ephemeral
# port ranges of Linux (32768-60999), macOS and Windows (49152-65535): a scan
# of a port inside the ephemeral range can occasionally connect a socket to
# itself and report a bogus open port.
TCP_PORTS = tuple(range(1024, 32768, 101)) # 315 open TCP ports
UDP_PORTS = tuple(range(1031, 32768, 997)) # 32 open UDP ports
SWEEP = (1, 32767)
SAMPLE_INTERVAL = 0.005 # seconds between fd/thread samples
RUN_TIMEOUT = 300 # seconds before a run is killed and counted as failed
# --------------------------------------------------------------------------
# Listener process
# --------------------------------------------------------------------------
def serve(addresses: list[str]) -> int:
"""Accepts (and closes) TCP connections and answers every UDP datagram."""
selector = selectors.DefaultSelector()
bound: dict[str, dict[str, list[int]]] = {"tcp": {}, "udp": {}, "busy": {}}
for address in addresses:
for kind, ports in (("tcp", TCP_PORTS), ("udp", UDP_PORTS)):
sock_type = socket.SOCK_STREAM if kind == "tcp" else socket.SOCK_DGRAM
for port in ports:
sock = socket.socket(socket.AF_INET, sock_type)
if os.name != "nt":
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((address, port))
except OSError:
sock.close()
bound["busy"].setdefault(f"{kind} {address}", []).append(port)
continue
if kind == "tcp":
sock.listen(1024)
sock.setblocking(False)
selector.register(sock, selectors.EVENT_READ, kind)
bound[kind].setdefault(address, []).append(port)
print(json.dumps(bound), flush=True)
while True:
for key, _ in selector.select(timeout=1.0):
sock = key.fileobj
if key.data == "tcp":
while True:
try:
conn, _ = sock.accept()
except OSError: # BlockingIOError once the queue is empty
break
conn.close()
else:
while True:
try:
_, peer = sock.recvfrom(4096)
except ConnectionResetError:
# Windows reports an earlier ICMP error here; skip it.
continue
except OSError: # BlockingIOError once the queue is empty
break
try:
sock.sendto(b"rustscan-benchmark\n", peer)
except OSError:
pass
def start_server(addresses: list[str]) -> tuple[subprocess.Popen, dict]:
args = [arg for address in addresses for arg in ("--address", address)]
proc = subprocess.Popen(
[sys.executable, os.path.abspath(__file__), "serve", *args],
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
text=True,
)
line = proc.stdout.readline()
if not line:
raise SystemExit("the listener process exited before it was ready")
return proc, json.loads(line)
# --------------------------------------------------------------------------
# Scenarios
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Scenario:
name: str
title: str
addresses: tuple[str, ...]
ranges: tuple[tuple[int, int], ...] = ()
ports: tuple[int, ...] = ()
batch: int = 4500
timeout_ms: int = 1500
udp: bool = False
repeat: int = 1 # extra samples for very short scenarios
excluded_ports: tuple[int, ...] = ()
@property
def sockets(self) -> int:
span = sum(end - start + 1 for start, end in self.ranges)
excluded = sum(self.covers(port) for port in set(self.excluded_ports))
return len(self.addresses) * (len(self.ports) + span - excluded)
def covers(self, port: int) -> bool:
return port in self.ports or any(s <= port <= e for s, e in self.ranges)
def args(self) -> list[str]:
args = ["-a", ",".join(self.addresses)]
if self.ports:
args += ["-p", ",".join(map(str, self.ports))]
else:
args += ["-r", ",".join(f"{s}-{e}" for s, e in self.ranges)]
args += ["-b", str(self.batch), "-t", str(self.timeout_ms)]
if self.udp:
args.append("--udp")
if self.excluded_ports:
args += ["--exclude-ports", ",".join(map(str, self.excluded_ports))]
return args
def expected(self, listeners: dict) -> set[str]:
by_address = listeners["udp" if self.udp else "tcp"]
return {
f"{address}:{port}"
for address in self.addresses
for port in by_address.get(address, [])
if self.covers(port) and port not in self.excluded_ports
}
def describe(self) -> str:
hosts = f"{len(self.addresses)} addresses" if len(self.addresses) > 1 else self.addresses[0]
return f"{self.title}: `{' '.join(self.args()[2:])}` on {hosts}"
def build_scenarios(
system: str, filtered: tuple[int, int] | None, delayed: str | None, delay_ms: int
) -> list[Scenario]:
lo = (LOOPBACK,)
if system == "Windows":
# Windows retries a refused connection for about a second, so every
# closed port there takes as long as a filtered one: sweep fewer.
sweep, small_sweep = ((1, 8191),), ((1, 2047),)
else:
sweep = small_sweep = (SWEEP,)
def ports(ranges: tuple[tuple[int, int], ...]) -> str:
return f"{sum(e - s + 1 for s, e in ranges):,} ports"
scenarios = [
Scenario("tcp-1-port", "TCP, 1 open port", lo, ports=(TCP_PORTS[0],), repeat=3),
Scenario("tcp-open", "TCP, open listeners only", lo, ports=TCP_PORTS, repeat=3),
Scenario("tcp-sweep", f"TCP, {ports(sweep)}, default batch", lo, sweep),
Scenario(
"tcp-sweep-excluded",
f"TCP, {ports(sweep)}, 1,024 excluded ports",
lo,
sweep,
excluded_ports=tuple(range(1024, 2048)),
),
Scenario(
"tcp-sweep-b500", f"TCP, {ports(small_sweep)}, small batch", lo, small_sweep, batch=500
),
Scenario("tcp-sweep-b10000", f"TCP, {ports(sweep)}, large batch", lo, sweep, batch=10000),
]
if system == "Linux":
# Linux answers on all of 127.0.0.0/8; other systems only on 127.0.0.1.
hosts = tuple(f"127.0.0.{i}" for i in range(1, 9))
scenarios.append(
Scenario("tcp-8-hosts", "TCP, 8 hosts x 4,096 ports", hosts, ((1, 4096),))
)
if delayed:
scenarios.append(
Scenario("tcp-delayed", f"TCP, {ports(sweep)}, {delay_ms} ms away", (delayed,), sweep)
)
if filtered:
dropped = (filtered,)
scenarios += [
Scenario(
"tcp-filtered", "TCP, 2,000 filtered ports", lo, dropped, batch=500, timeout_ms=250
),
Scenario(
"tcp-mixed",
f"TCP, {ports(sweep)} + 2,000 filtered ports",
lo,
sweep + dropped,
timeout_ms=500,
),
]
scenarios.append(
Scenario("udp-sweep", f"UDP, {ports(sweep)}", lo, sweep, timeout_ms=500, udp=True)
)
scenarios.append(
Scenario(
"udp-open", "UDP, echo responders only", lo, ports=UDP_PORTS,
timeout_ms=500, udp=True, repeat=9,
)
)
if delayed:
scenarios.append(
Scenario(
"udp-delayed",
f"UDP, {ports(sweep)}, {delay_ms} ms away",
(delayed,),
sweep,
timeout_ms=500,
udp=True,
)
)
if filtered:
scenarios.append(
Scenario(
"udp-filtered",
"UDP, 2,000 filtered ports",
lo,
(filtered,),
batch=500,
timeout_ms=250,
udp=True,
)
)
return scenarios
# --------------------------------------------------------------------------
# Running one scan
# --------------------------------------------------------------------------
@dataclass
class RunResult:
build: str
scenario: str
sockets: int = 0
ok: bool = True
error: str = ""
returncode: int | None = None
wall_s: float | None = None
scan_s: float | None = None
first_open_s: float | None = None
rss_kb: int | None = None
cpu_s: float | None = None
peak_fds: int | None = None
peak_threads: int | None = None
open: list[str] = field(default_factory=list)
@property
def rate(self) -> float | None:
return self.sockets / self.scan_s if self.scan_s else None
SCAN_TIME = re.compile(r"Portscan\s*\|\s*([0-9.]+)\s*s")
OPEN_LINE = re.compile(r"^Open (\S+)$")
SUMMARY_LINE = re.compile(r"^(\S+) -> \[([0-9,]*)\]$")
class Sampler(threading.Thread):
"""Samples the open descriptors and the threads of a running process."""
def __init__(self, proc: subprocess.Popen):
super().__init__(daemon=True)
self.proc = proc
self.done = threading.Event()
self.peak_fds: int | None = None
self.peak_threads: int | None = None
self.system = platform.system()
self.libproc = darwin_libproc() if self.system == "Darwin" else None
def sample(self) -> tuple[int | None, int | None]:
pid = self.proc.pid
if self.system == "Linux":
fds = len(os.listdir(f"/proc/{pid}/fd"))
threads = None
with open(f"/proc/{pid}/status", encoding="ascii", errors="replace") as status:
for line in status:
if line.startswith("Threads:"):
threads = int(line.split()[1])
break
return fds, threads
if self.libproc is not None:
return darwin_fds_and_threads(self.libproc, pid)
if self.system == "Windows":
return windows_handle_count(self.proc), None
return None, None
def run(self) -> None:
while not self.done.is_set():
try:
fds, threads = self.sample()
except (OSError, ValueError):
return # the process is gone
if fds is not None:
self.peak_fds = max(self.peak_fds or 0, fds)
if threads is not None:
self.peak_threads = max(self.peak_threads or 0, threads)
self.done.wait(SAMPLE_INTERVAL)
def darwin_libproc():
import ctypes
try:
libproc = ctypes.CDLL("/usr/lib/libproc.dylib")
except OSError:
return None
libproc.proc_pidinfo.argtypes = [
ctypes.c_int,
ctypes.c_int,
ctypes.c_uint64,
ctypes.c_void_p,
ctypes.c_int,
]
libproc.proc_pidinfo.restype = ctypes.c_int
return libproc
def darwin_fds_and_threads(libproc, pid: int) -> tuple[int | None, int | None]:
import ctypes
proc_pidlistfds, proc_pidtaskinfo = 1, 4
size = libproc.proc_pidinfo(pid, proc_pidlistfds, 0, None, 0)
if size <= 0:
raise OSError("process is gone")
buf = ctypes.create_string_buffer(size)
used = libproc.proc_pidinfo(pid, proc_pidlistfds, 0, ctypes.addressof(buf), size)
if used <= 0:
raise OSError("process is gone")
fds = used // 8 # sizeof(struct proc_fdinfo)
class TaskInfo(ctypes.Structure): # struct proc_taskinfo
_fields_ = [
*((name, ctypes.c_uint64) for name in ("vsize", "rsize", "user", "system", "tu", "ts")),
*(
(name, ctypes.c_int32)
for name in (
"policy",
"faults",
"pageins",
"cow_faults",
"messages_sent",
"messages_received",
"syscalls_mach",
"syscalls_unix",
"csw",
"threadnum",
"numrunning",
"priority",
)
),
]
info = TaskInfo()
got = libproc.proc_pidinfo(
pid, proc_pidtaskinfo, 0, ctypes.addressof(info), ctypes.sizeof(info)
)
return fds, (info.threadnum if got == ctypes.sizeof(info) else None)
def windows_handle_count(proc: subprocess.Popen) -> int | None:
import ctypes
from ctypes import wintypes
count = wintypes.DWORD()
handle = wintypes.HANDLE(int(proc._handle)) # Popen keeps the handle open
if not ctypes.windll.kernel32.GetProcessHandleCount(handle, ctypes.byref(count)):
raise OSError("process is gone")
return count.value
def windows_memory_and_cpu(proc: subprocess.Popen) -> tuple[int | None, float | None]:
import ctypes
from ctypes import wintypes
class Counters(ctypes.Structure): # PROCESS_MEMORY_COUNTERS
_fields_ = [
("cb", wintypes.DWORD),
("PageFaultCount", wintypes.DWORD),
("PeakWorkingSetSize", ctypes.c_size_t),
("WorkingSetSize", ctypes.c_size_t),
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
("QuotaPagedPoolUsage", ctypes.c_size_t),
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
("PagefileUsage", ctypes.c_size_t),
("PeakPagefileUsage", ctypes.c_size_t),
]
handle = wintypes.HANDLE(int(proc._handle))
counters = Counters()
counters.cb = ctypes.sizeof(counters)
rss_kb = None
if ctypes.windll.psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb):
rss_kb = counters.PeakWorkingSetSize // 1024
times = [wintypes.FILETIME() for _ in range(4)] # creation, exit, kernel, user
cpu_s = None
if ctypes.windll.kernel32.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)):
ticks = sum((t.dwHighDateTime << 32) | t.dwLowDateTime for t in times[2:])
cpu_s = ticks / 1e7 # 100 ns units
return rss_kb, cpu_s
def run_once(name: str, binary: str, scenario: Scenario) -> RunResult:
result = RunResult(build=name, scenario=scenario.name, sockets=scenario.sockets)
cmd = [
binary,
*scenario.args(),
"--scripts",
"none",
"--accessible",
"--no-banner",
"--no-config",
]
env = dict(os.environ, RUST_LOG="rustscan=info")
stdout_lines: list[str] = []
stderr_chunks: list[bytes] = []
start = time.perf_counter()
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, env=env
)
def read_stdout() -> None:
for raw in proc.stdout:
line = raw.decode("utf-8", "replace").rstrip("\r\n")
if result.first_open_s is None and line.startswith("Open "):
result.first_open_s = time.perf_counter() - start
stdout_lines.append(line)
readers = [
threading.Thread(target=read_stdout, daemon=True),
threading.Thread(target=lambda: stderr_chunks.append(proc.stderr.read()), daemon=True),
]
for reader in readers:
reader.start()
sampler = Sampler(proc)
sampler.start()
# On Unix the child is reaped with os.wait4 below, so the watchdog must
# not go through Popen (which would try to reap it as well).
kill = proc.kill if os.name == "nt" else lambda: os.kill(proc.pid, signal.SIGKILL)
watchdog = threading.Timer(RUN_TIMEOUT, kill)
watchdog.start()
try:
if hasattr(os, "wait4"):
_, status, usage = os.wait4(proc.pid, 0)
result.wall_s = time.perf_counter() - start
proc.returncode = os.waitstatus_to_exitcode(status)
maxrss = usage.ru_maxrss # KiB on Linux, bytes on macOS
result.rss_kb = maxrss // 1024 if platform.system() == "Darwin" else maxrss
result.cpu_s = usage.ru_utime + usage.ru_stime
else:
proc.wait()
result.wall_s = time.perf_counter() - start
result.rss_kb, result.cpu_s = windows_memory_and_cpu(proc)
finally:
watchdog.cancel()
sampler.done.set()
sampler.join()
for reader in readers:
reader.join()
result.returncode = proc.returncode
result.peak_fds = sampler.peak_fds
result.peak_threads = sampler.peak_threads
stderr = b"".join(stderr_chunks).decode("utf-8", "replace")
opened: set[str] = set()
summarised: set[str] = set()
for line in stdout_lines:
if match := OPEN_LINE.match(line):
opened.add(match.group(1))
elif match := SUMMARY_LINE.match(line):
summarised.update(f"{match.group(1)}:{p}" for p in match.group(2).split(",") if p)
result.open = sorted(opened)
if scan_time := SCAN_TIME.search(stderr):
result.scan_s = float(scan_time.group(1))
problems = []
if proc.returncode != 0:
problems.append(f"exit code {proc.returncode}")
if result.scan_s is None:
problems.append("no 'Portscan' timer in the log")
if summarised != opened:
problems.append(f"'Open' lines and the summary disagree: {sorted(summarised ^ opened)[:5]}")
if problems:
result.ok = False
tail = "\n".join(stderr.strip().splitlines()[-15:])
result.error = "; ".join(problems) + (f"\nstderr tail:\n{tail}" if tail else "")
return result
# --------------------------------------------------------------------------
# Environment
# --------------------------------------------------------------------------
def sysctl(*names: str) -> list[str]:
return subprocess.run(
["sysctl", "-n", *names], capture_output=True, text=True, check=True
).stdout.split("\n")
def ephemeral_range() -> tuple[int, int] | None:
system = platform.system()
try:
if system == "Linux":
with open("/proc/sys/net/ipv4/ip_local_port_range", encoding="ascii") as f:
low, high = map(int, f.read().split())
return low, high
if system == "Darwin":
first, last = sysctl("net.inet.ip.portrange.first", "net.inet.ip.portrange.last")[:2]
return int(first), int(last)
except (OSError, ValueError, subprocess.CalledProcessError):
return None
return (49152, 65535) if system == "Windows" else None
def diagnose_tcp(binary: str, scenario: Scenario, listeners: dict) -> dict:
"""Untimed failure capture on a disposable macOS runner, using debug logs."""
from macos_tcp_memory import tcp_memory
def states() -> dict[str, int]:
snapshot = subprocess.run(
["netstat", "-an", "-p", "tcp"], capture_output=True, text=True, check=True
)
counts: dict[str, int] = {}
for line in snapshot.stdout.splitlines():
if line.startswith("tcp"):
state = line.split()[-1]
counts[state] = counts.get(state, 0) + 1
return counts
before = states()
memory_before = tcp_memory()
probe = subprocess.run(
[binary, *scenario.args(), "--scripts", "none", "--accessible", "--no-banner", "--no-config"],
env=dict(os.environ, RUST_LOG="rustscan=info,rustscan::scanner=debug"),
capture_output=True, text=True, check=True,
)
opened = {
match.group(1) for line in probe.stdout.splitlines()
if (match := OPEN_LINE.match(line))
}
return {
"before": before, "after": states(),
"memory_before": memory_before, "memory_after": tcp_memory(),
"missing": sorted(scenario.expected(listeners) - opened),
"errors": [line for line in probe.stderr.splitlines()
if "Typical socket connection errors" in line],
}
def environment() -> dict[str, object]:
system = platform.system()
info: dict[str, object] = {
"system": system,
"release": platform.release(),
"machine": platform.machine(),
"cpus": os.cpu_count(),
"cpu": platform.processor(),
"python": platform.python_version(),
"ephemeral_ports": ephemeral_range(),
}
try:
if system == "Linux":
with open("/proc/cpuinfo", encoding="ascii", errors="replace") as f:
for line in f:
if line.lower().startswith(("model name", "cpu model")):
info["cpu"] = line.split(":", 1)[1].strip()
break
elif system == "Darwin":
info["cpu"] = sysctl("machdep.cpu.brand_string")[0].strip()
except (OSError, subprocess.CalledProcessError):
pass
try:
import resource
info["open_file_limit"] = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
except (ImportError, OSError, ValueError):
pass
return info
# --------------------------------------------------------------------------
# Analysis and reporting
# --------------------------------------------------------------------------
Results = dict[str, dict[str, list[RunResult]]]
# key, title, formatter, higher is better
METRICS = (
("rate", "Throughput (sockets/s, from RustScan's scan timer)", lambda v: f"{v:,.0f}", True),
("wall_s", "Wall time of the whole process", lambda v: f"{v * 1000:,.0f} ms", False),
("first_open_s", "Time to the first open port", lambda v: f"{v * 1000:,.1f} ms", False),
("rss_kb", "Peak RSS", lambda v: f"{v / 1024:,.1f} MiB", False),
("cpu_s", "CPU time (user + system)", lambda v: f"{v * 1000:,.0f} ms", False),
("peak_fds", "Peak open descriptors (sampled; Windows: handles)", lambda v: f"{v:,.0f}", False),
("peak_threads", "Peak threads (sampled)", lambda v: f"{v:,.0f}", False),
)
FLAG_WORSE = 10.0 # percent
def metric_values(runs: list[RunResult], key: str) -> list[float]:
return [float(getattr(r, key)) for r in runs if r.ok and getattr(r, key) is not None]
def median(values: list[float]) -> float | None:
return statistics.median(values) if values else None
def worse_by(value: float, base: float, higher_is_better: bool) -> float:
"""How much worse `value` is than `base`, in percent (negative: better)."""
pct = (value / base - 1.0) * 100.0
return -pct if higher_is_better else pct
def check_open_ports(
results: Results, scenarios: list[Scenario], listeners: dict, baseline: str
) -> tuple[list[str], list[str], list[str]]:
"""Returns (failed runs, open-port mismatches, notes).
No build may miss more listeners than the baseline: none at all if the
baseline found every listener in every run, or barely more if it did not
either (a runner too slow for the scenario's timeout). Ports that are
open beyond the listeners (services already running on the runner) must
match what the baseline found: a build may not miss a port the baseline
found in every run, nor report one the baseline never found.
"""
failures, mismatches, notes = [], [], []
for sc in scenarios:
expected = sc.expected(listeners)
per_build = results[sc.name]
def missed(runs: list[RunResult]) -> int:
return sum(len(expected - set(r.open)) for r in runs if r.ok)
base_missed = missed(per_build[baseline])
allowed = base_missed + max(3, base_missed // 4) if base_missed else 0
if base_missed:
notes.append(
f"`{sc.name}`: `{baseline}` itself missed {base_missed} of "
f"{len(expected) * len(per_build[baseline])} listener answers"
)
base_extra = [set(r.open) - expected for r in per_build[baseline] if r.ok]
always = set.intersection(*base_extra) if base_extra else set()
ever = set.union(*base_extra) if base_extra else set()
for name, runs in per_build.items():
for index, res in enumerate(runs, start=1):
if not res.ok:
failures.append(f"`{sc.name}` `{name}` run {index}: {res.error.splitlines()[0]}")
if name == baseline:
continue
if (count := missed(runs)) > allowed:
mismatches.append(
f"`{sc.name}` `{name}`: missed {count} listener answers "
f"(`{baseline}`: {base_missed})"
)
for index, res in enumerate(runs, start=1):
if not res.ok:
continue
found = set(res.open)
where = f"`{sc.name}` `{name}` run {index}"
if lost := always - found:
mismatches.append(f"{where}: missed ports `{baseline}` always found {sorted(lost)[:5]}")
if bogus := (found - expected) - ever:
mismatches.append(f"{where}: reported ports `{baseline}` never found {sorted(bogus)[:5]}")
return failures, mismatches, notes
def gate(
results: Results,
scenarios: list[Scenario],
baseline: str,
candidate: str,
max_slowdown: float,
max_memory: float,
) -> list[str]:
"""Scenarios where `candidate` is clearly worse than `baseline`."""
problems = []
for sc in scenarios:
checks = [("wall_s", False, max_slowdown, 0.002), ("rss_kb", False, max_memory, 1024)]
if sc.sockets >= 1000: # throughput is meaningless for a handful of sockets
checks.append(("rate", True, max_slowdown, 0))
for key, higher, limit, floor in checks:
base = median(metric_values(results[sc.name][baseline], key))
value = median(metric_values(results[sc.name][candidate], key))
if not base or value is None or abs(value - base) <= floor:
continue
if (worse := worse_by(value, base, higher)) > limit:
problems.append(f"{sc.name}: {key} {worse:+.1f}% worse than {baseline} (limit {limit}%)")
return problems
def render_markdown(report: dict, results: Results, scenarios: list[Scenario]) -> str:
builds = report["builds"]
baseline = report["baseline"]
env = report["environment"]
out = [f"### Scan benchmark: {env['system']} {env['machine']}", ""]
out.append(
"; ".join(f"`{b['name']}`: {b['label']}" for b in builds)
+ f". {report['runs']} interleaved runs per build and scenario (`tcp-1-port`: x3), medians."
f" Runner: {env.get('cpu') or '?'}, {env['cpus']} CPUs, {env['system']} {env['release']},"
f" open-file limit {env.get('open_file_limit', 'n/a')}."
)
out.append("")
problems = report["failures"] + report["mismatches"]
tcp = sum(len(ports) for ports in report["listeners"]["tcp"].values())
udp = sum(len(ports) for ports in report["listeners"]["udp"].values())
if not problems:
out.append(
f"✅ Every run of every build exited cleanly; no build missed more of the {tcp} TCP "
f"and {udp} UDP listeners than `{baseline}`, or disagreed with it about other ports."
)
else:
out.append(f"❌ {len(report['failures'])} failed runs, {len(report['mismatches'])} open-port mismatches:")
out += [f"- {p}" for p in problems[:25]]
if report["notes"]:
out.append("")
out += [f"- ⚠️ {note}" for note in report["notes"]]
out.append("")
out.append("| Scenario | What is scanned |")
out.append("|---|---|")
out += [f"| `{sc.name}` | {sc.describe()} ({sc.sockets:,} sockets) |" for sc in scenarios]
out.append("")
out.append(f"Changes are relative to `{baseline}`; ❗ marks a median more than {FLAG_WORSE:.0f}% worse.")
out.append("")
for key, title, fmt, higher in METRICS:
rows = []
for sc in scenarios:
base_value = median(metric_values(results[sc.name][baseline], key))
cells = []
for build in builds:
value = median(metric_values(results[sc.name][build["name"]], key))
if value is None:
cells.append("–")
continue
cell = fmt(value)
if build["name"] != baseline and base_value:
pct = (value / base_value - 1.0) * 100.0
flag = " ❗" if worse_by(value, base_value, higher) > FLAG_WORSE else ""
cell += f" ({pct:+.1f}%{flag})"
cells.append(cell)
if any(c != "–" for c in cells):
rows.append(f"| `{sc.name}` | " + " | ".join(cells) + " |")
out.append(f"**{title}**")
out.append("")
if rows:
out.append("| Scenario | " + " | ".join(f"`{b['name']}`" for b in builds) + " |")
out.append("|---|" + "---:|" * len(builds))
out += rows
else:
out.append("_Not measured on this platform._")
out.append("")
return "\n".join(out)
# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def parse_pairs(values: list[str], option: str) -> dict[str, str]:
pairs = {}
for value in values:
name, sep, rest = value.partition("=")
if not sep or not name or not rest:
raise SystemExit(f"--{option} expects NAME=VALUE, got {value!r}")
pairs[name] = rest
return pairs
def run(args: argparse.Namespace) -> int:
builds = parse_pairs(args.build, "build")
labels = parse_pairs(args.label, "label")
baseline = args.baseline or next(iter(builds))
if baseline not in builds:
raise SystemExit(f"unknown baseline build {baseline!r}")
for name, path in builds.items():
if not os.path.isfile(path):
raise SystemExit(f"build {name!r}: {path} does not exist")
if args.gate and args.gate not in builds:
raise SystemExit(f"unknown --gate build {args.gate!r}")
if args.delayed_host and not args.delayed_listeners:
raise SystemExit("--delayed-host needs --delayed-listeners")
filtered = None
if args.filtered:
low, _, high = args.filtered.partition("-")
filtered = (int(low), int(high))
scenarios = build_scenarios(platform.system(), filtered, args.delayed_host, args.delay_ms)
if args.only:
wanted = set(args.only.split(","))
scenarios = [sc for sc in scenarios if sc.name in wanted]
if ephemeral := ephemeral_range():
low, high = ephemeral
for sc in scenarios:
for start, end in sc.ranges:
if start <= high and end >= low:
raise SystemExit(
f"scenario {sc.name} scans {start}-{end}, which overlaps the ephemeral "
f"port range {low}-{high}: a scan could connect a socket to itself"
)
server, listeners = start_server([LOOPBACK])
if args.delayed_host:
# The workflow runs a second `serve` on the delayed host (inside a
# network namespace) and stores its first line in this file.
with open(args.delayed_listeners, encoding="utf-8") as f:
far = json.loads(f.readline())
for kind in ("tcp", "udp"):
listeners[kind][args.delayed_host] = far[kind].get(args.delayed_host, [])
for kind in ("tcp", "udp"):
counts = {address: len(ports) for address, ports in listeners[kind].items()}
print(f"{kind} listeners: {counts}", flush=True)
if listeners["busy"]:
print(f"already in use: {listeners['busy']}", flush=True)
names = list(builds)
results: Results = {sc.name: {name: [] for name in names} for sc in scenarios}
diagnostics = []
try:
warmup = next((sc for sc in scenarios if sc.name == "tcp-sweep"), scenarios[0])
for name in names:
run_once(name, builds[name], warmup)
rounds = args.runs * max(sc.repeat for sc in scenarios)
for index in range(rounds):
shift = index % len(names)
order = names[shift:] + names[:shift]
for sc in scenarios:
if index >= args.runs * sc.repeat:
continue
for name in order:
res = run_once(name, builds[name], sc)
results[sc.name][name].append(res)
print(
f"[{index + 1}/{rounds}] {sc.name:<17} {name:<14} ok={res.ok} "
f"scan={res.scan_s} wall={res.wall_s} first={res.first_open_s} "
f"rss={res.rss_kb} cpu={res.cpu_s} fds={res.peak_fds} "
f"threads={res.peak_threads} open={len(res.open)}",
flush=True,
)
if not res.ok:
print(f" {res.error}", flush=True)
if platform.system() == "Darwin" and not diagnostics and any(
sc.expected(listeners) - set(results[sc.name][name][-1].open) for name in names
):
# A failed pair cannot support a performance claim. Capture
# its OS errors immediately, outside both measured scans.
for name in names:
record = {"build": name, "scenario": sc.name,
**diagnose_tcp(builds[name], sc, listeners)}
diagnostics.append(record)
print("TCP diagnosis: " + json.dumps(record), flush=True)
finally:
server.kill()
server.wait()
failures, mismatches, notes = check_open_ports(results, scenarios, listeners, baseline)
report = {
"environment": environment(),
"baseline": baseline,
"builds": [{"name": n, "label": labels.get(n, n), "path": builds[n]} for n in names],
"runs": args.runs,
"listeners": listeners,
"scenarios": [asdict(sc) | {"sockets": sc.sockets} for sc in scenarios],
"results": {
sc: {n: [asdict(r) | {"rate": r.rate} for r in runs] for n, runs in per.items()}
for sc, per in results.items()
},
"failures": failures,
"mismatches": mismatches,
"notes": notes,
"diagnostics": diagnostics,
}
markdown = render_markdown(report, results, scenarios)
if args.json:
with open(args.json, "w", encoding="utf-8") as f:
json.dump(report, f, indent=1)
if args.markdown:
with open(args.markdown, "w", encoding="utf-8") as f:
f.write(markdown + "\n")
print(markdown)
status = 1 if failures or mismatches else 0
if args.gate:
for problem in gate(results, scenarios, baseline, args.gate, args.max_slowdown, args.max_memory):
print(f"::error::performance regression: {problem}", flush=True)
status = 1
return status
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
sub = parser.add_subparsers(dest="command", required=True)
listen = sub.add_parser("serve", help="run the loopback listeners (started by `run`)")
listen.add_argument("--address", action="append", default=[], help="address to listen on")
bench = sub.add_parser("run", help="benchmark the given builds")
bench.add_argument("--build", action="append", required=True, metavar="NAME=PATH")
bench.add_argument("--label", action="append", default=[], metavar="NAME=TEXT")
bench.add_argument("--baseline", help="build the others are compared with (default: the first)")
bench.add_argument("--runs", type=int, default=9, help="interleaved runs per build and scenario")
bench.add_argument(
"--filtered", metavar="START-END", help="ports the runner drops; enables filtered scenarios"
)
bench.add_argument(
"--delayed-host",
metavar="ADDRESS",
help="address behind an artificial delay, with its own `serve`; enables delayed scenarios",
)
bench.add_argument(
"--delayed-listeners", metavar="FILE", help="first output line of that host's `serve`"
)
bench.add_argument("--delay-ms", type=int, default=5, help="that delay, for the summary")
bench.add_argument("--only", metavar="NAMES", help="comma-separated scenarios to run")
bench.add_argument("--json", help="write the raw results to this file")
bench.add_argument("--markdown", help="write the Markdown summary to this file")
bench.add_argument("--gate", metavar="NAME", help="fail if this build regresses against the baseline")
bench.add_argument("--max-slowdown", type=float, default=15.0, help="percent, throughput and wall time")
bench.add_argument("--max-memory", type=float, default=25.0, help="percent, peak RSS")
args = parser.parse_args()
# The summary contains emoji; Windows runners default to a legacy code page.
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
return serve(args.address or [LOOPBACK]) if args.command == "serve" else run(args)
if __name__ == "__main__":
sys.exit(main())
+83 -61
View File
@@ -1,13 +1,22 @@
name: CD Pipeline
on: [push]
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]
@@ -37,40 +46,35 @@ jobs:
path: target/aarch64-unknown-linux-gnu/release/rustscan
pkg_config_path: /usr/lib/x86_64-linux-gnu/pkgconfig
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Build binary
uses: houseabsolute/actions-rust-cross@v0
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
with:
command: build
target: ${{ matrix.target }}
args: "--locked --release"
strip: true
toolchain: stable
- 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
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
with:
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
executable-name: rustscan
target: ${{ matrix.target }}
archive-name: ${{ matrix.name }}
changes-file: ""
build-deb:
needs: [build-nix]
runs-on: ubuntu-latest
env:
IN_PIPELINE: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install cargo-deb
run: cargo install -f cargo-deb
- uses: awalsh128/cache-apt-pkgs-action@v1
- uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: musl-tools # provides musl-gcc
version: 1.0
@@ -79,7 +83,7 @@ jobs:
- name: Deb Build
run: cargo deb --target=x86_64-unknown-linux-musl
- name: Upload Deb Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: rustscan.deb
path: ./target/x86_64-unknown-linux-musl/debian/*
@@ -88,65 +92,54 @@ jobs:
env:
IN_PIPELINE: true
runs-on: macos-latest
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Build binary
uses: houseabsolute/actions-rust-cross@v0
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
with:
command: build
target: x86_64-apple-darwin
args: "--locked --release"
strip: true
toolchain: stable
- 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
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
with:
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
executable-name: rustscan
target: x86_64-apple-darwin
archive-name: x86_64-macos-rustscan
changes-file: ""
build-macos-aarch64:
env:
IN_PIPELINE: true
runs-on: macos-latest
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Build binary
uses: houseabsolute/actions-rust-cross@v0
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
with:
command: build
target: aarch64-apple-darwin
args: "--locked --release"
strip: true
toolchain: stable
- 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
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
with:
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
executable-name: rustscan
target: aarch64-apple-darwin
archive-name: aarch64-macos-rustscan
changes-file: ""
build-windows:
env:
IN_PIPELINE: true
runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/master'
strategy:
matrix:
type: [windows-x64, windows-x86]
@@ -154,26 +147,55 @@ jobs:
- type: windows-x64
os: windows-latest
target: x86_64-pc-windows-msvc
name: x86_64-windows-rustscan.exe
path: target\x86_64-pc-windows-msvc\release\rustscan.exe
name: x86_64-windows-rustscan
- type: windows-x86
os: windows-latest
target: i686-pc-windows-msvc
name: x86-windows-rustscan.exe
path: target\i686-pc-windows-msvc\release\rustscan.exe
name: x86-windows-rustscan
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Cache cargo & target directories
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Build binary
uses: houseabsolute/actions-rust-cross@v0
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
with:
command: build
target: ${{ matrix.target }}
target: ${{ matrix.target }}
args: "--locked --release"
strip: true
toolchain: stable
- uses: actions/upload-artifact@v4
- name: Package and upload artifacts
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
with:
name: ${{ matrix.name }}
path: ${{ matrix.path }}
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
-19
View File
@@ -1,19 +0,0 @@
name: Dependabot Auto-Merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
dependabot:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- name: Enable auto-merge for Dependabot PRs
run: |
gh pr review --approve "$PR_URL"
gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-21
View File
@@ -1,21 +0,0 @@
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
@@ -0,0 +1,256 @@
name: Scan benchmark
# Compares the scanning speed and resource use of a change with its base.
#
# Each run builds both versions in release mode and runs
# .github/scan-benchmark/scan_bench.py, which scans listeners it opens on
# 127.0.0.1 inside the runner (plus closed and, on Linux, filtered ports) and
# reports medians over interleaved runs. Nothing outside the runner is
# scanned. The unit tests deliberately never open sockets; this workflow is
# where scanning behaviour and performance are measured.
#
# It only runs for pull requests that touch the scanner, its runtime or its
# dependencies, or when started by hand, so it does not slow down normal CI.
on:
pull_request:
branches: [master]
paths:
- "src/scanner/**"
- "src/main.rs"
- "src/lib.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/scan-benchmark/**"
- ".github/workflows/scan-benchmark.yml"
workflow_dispatch:
inputs:
base:
description: "Branch, tag or commit to compare against"
default: "master"
runs:
description: "Interleaved runs per build and scenario"
default: "9"
extra:
description: "More builds to compare, as space-separated NAME=REF pairs"
default: ""
permissions:
contents: read
concurrency:
group: scan-benchmark-${{ github.ref }}
cancel-in-progress: true
jobs:
benchmark:
name: Benchmark (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
defaults:
run:
shell: bash
env:
# Ports the Linux runners drop, to measure filtered ports (timeouts).
# They lie above Linux's ephemeral port range (32768-60999).
FILTERED_PORTS: "61000-62999"
# The Linux runners' host behind a 5 ms delay (see "Set up a host 5 ms away").
DELAYED_HOST: "10.200.0.2"
RUNS: ${{ inputs.runs || '9' }}
EXTRA: ${{ inputs.extra }}
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Check the benchmark script
run: python -m py_compile .github/scan-benchmark/scan_bench.py
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
toolchain: stable
# build.rs formats the generated payload table with rustfmt.
components: rustfmt
# Caching is handled by Swatinem/rust-cache below.
cache: false
rustflags: ""
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Build the change
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
exe=""
if [ "$RUNNER_OS" = Windows ]; then exe=".exe"; fi
echo "EXE=$exe" >> "$GITHUB_ENV"
mkdir -p "$RUNNER_TEMP/bin"
cargo build --release --locked
cp "target/release/rustscan$exe" "$RUNNER_TEMP/bin/head$exe"
if [ -n "$PR_NUMBER" ]; then
label="#$PR_NUMBER at ${PR_HEAD::7}, merged into its base"
else
label="$(git log -1 --format='%h %s' HEAD)"
fi
echo "HEAD_LABEL=$label" >> "$GITHUB_ENV"
- name: Build the base and any extra builds
env:
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
INPUT_BASE: ${{ inputs.base }}
run: |
build_ref() {
local name="$1" ref="$2"
if [[ ! "$name" =~ ^[A-Za-z0-9._+-]+$ ]]; then
echo "::error::invalid build name '$name'"
exit 1
fi
git fetch --no-tags --depth=1 origin "$ref"
git worktree add --detach "$RUNNER_TEMP/src-$name" FETCH_HEAD
cargo build --release --locked \
--manifest-path "$RUNNER_TEMP/src-$name/Cargo.toml" --target-dir target
cp "target/release/rustscan$EXE" "$RUNNER_TEMP/bin/$name$EXE"
echo "$name=$(git log -1 --format='%h %s' FETCH_HEAD)" >> "$RUNNER_TEMP/labels"
}
if [ "$EVENT" = pull_request ]; then base="$PR_BASE"; else base="${INPUT_BASE:-master}"; fi
build_ref base "$base"
for spec in $EXTRA; do
build_ref "${spec%%=*}" "${spec#*=}"
done
cat "$RUNNER_TEMP/labels"
- name: Drop traffic to the filtered ports
if: runner.os == 'Linux'
run: |
if ! command -v iptables >/dev/null; then
echo "::warning::iptables is not available; skipping the filtered-port scenarios"
exit 0
fi
for proto in tcp udp; do
sudo iptables -I INPUT -i lo -p "$proto" --dport "${FILTERED_PORTS/-/:}" -j DROP
done
sudo iptables -S INPUT
echo "FILTERED_OK=1" >> "$GITHUB_ENV"
- name: Set up a host 5 ms away
if: runner.os == 'Linux'
run: |
# A network namespace on the runner, reached over a veth pair whose
# outbound side delays every packet. Its answers (SYN-ACKs, resets,
# ICMP errors, UDP replies) arrive a round trip after the probe, as
# from a remote host, instead of while the probe is being sent.
skip() {
echo "::warning::$1; skipping the delayed-host scenarios"
exit 0
}
sudo ip netns add far || skip "cannot create a network namespace"
sudo ip link add rs-near type veth peer name rs-far || skip "cannot create a veth pair"
sudo ip link set rs-far netns far || skip "cannot move the veth end"
sudo ip addr add 10.200.0.1/30 dev rs-near || skip "cannot address the veth pair"
sudo ip link set rs-near up || skip "cannot bring up the veth pair"
sudo ip netns exec far ip addr add "$DELAYED_HOST/30" dev rs-far || skip "cannot address the namespace"
sudo ip netns exec far ip link set rs-far up || skip "cannot bring up the namespace"
sudo ip netns exec far ip link set lo up || true
# Answer every probe to a closed UDP port, like the loopback
# interface does (ICMP errors are rate limited everywhere else).
for setting in icmp_ratelimit=0 icmp_msgs_per_sec=100000000 icmp_msgs_burst=100000000; do
sudo ip netns exec far sysctl -qw "net.ipv4.$setting" || true
done
sudo tc qdisc add dev rs-near root netem delay 5ms limit 1000000 || skip "netem is not available"
tc qdisc show dev rs-near
sudo ip netns exec far "$(command -v python)" .github/scan-benchmark/scan_bench.py \
serve --address "$DELAYED_HOST" > "$RUNNER_TEMP/far.json" 2> "$RUNNER_TEMP/far.log" &
for _ in $(seq 100); do
if [ -s "$RUNNER_TEMP/far.json" ]; then break; fi
sleep 0.1
done
if [ ! -s "$RUNNER_TEMP/far.json" ]; then
cat "$RUNNER_TEMP/far.log" || true
skip "the delayed host's listeners did not start"
fi
head -c 200 "$RUNNER_TEMP/far.json"; echo
echo "DELAYED_OK=1" >> "$GITHUB_ENV"
- name: Provision macOS TCP memory for repeated socket batches
if: runner.os == 'macOS'
run: |
# XNU's TCP accounting hard limit otherwise makes both builds miss
# listeners with ENOBUFS during repeated sweeps. Apply one budget to
# both builds before timing; keep all input sizes and gates intact.
sudo "$(command -v python)" .github/scan-benchmark/macos_tcp_memory.py
- name: Describe the runner
continue-on-error: true
run: |
uname -a
if [ "$RUNNER_OS" = Linux ]; then
nproc
grep -m1 'model name' /proc/cpuinfo || true
sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse net.core.somaxconn || true
ss -Hltnu | sort -k5 | head -n 50 || true
elif [ "$RUNNER_OS" = macOS ]; then
for oid in hw.ncpu machdep.cpu.brand_string kern.maxfilesperproc \
net.inet.ip.portrange.first net.inet.ip.portrange.last \
kern.ipc.maxsockbuf kern.ipc.nmbclusters; do
sysctl "$oid" || true
done
fi
- name: Run the benchmark
run: |
# Allow the largest batch size without RustScan shrinking it to fit
# the descriptor limit (macOS defaults to 256, and caps processes at
# kern.maxfilesperproc, 10240 on its runners).
if [ "$RUNNER_OS" != Windows ]; then
for limit in 65536 24576 10240; do
ulimit -n "$limit" 2>/dev/null && break
done
echo "open-file limit: $(ulimit -n)"
fi
# Fail on wrong results, or if the change is clearly slower or
# bigger than its base (see --max-slowdown/--max-memory).
args=(--runs "$RUNS" --gate head --json bench-results.json --markdown bench-results.md)
if [ -n "${FILTERED_OK:-}" ]; then args+=(--filtered "$FILTERED_PORTS"); fi
if [ -n "${DELAYED_OK:-}" ]; then
args+=(--delayed-host "$DELAYED_HOST" --delayed-listeners "$RUNNER_TEMP/far.json")
fi
args+=(--build "base=$RUNNER_TEMP/bin/base$EXE")
args+=(--build "head=$RUNNER_TEMP/bin/head$EXE" --label "head=$HEAD_LABEL")
while IFS= read -r line; do
name="${line%%=*}"
args+=(--label "$line")
if [ "$name" != base ]; then
args+=(--build "$name=$RUNNER_TEMP/bin/$name$EXE")
fi
done < "$RUNNER_TEMP/labels"
python .github/scan-benchmark/scan_bench.py run "${args[@]}"
- name: Publish the summary
if: always()
run: |
if [ -f bench-results.md ]; then cat bench-results.md >> "$GITHUB_STEP_SUMMARY"; fi
- name: Upload the results
if: always()
uses: actions/upload-artifact@v7
with:
name: scan-benchmark-${{ matrix.os }}
path: |
bench-results.json
bench-results.md
if-no-files-found: ignore
+19 -8
View File
@@ -1,34 +1,45 @@
name: Test
on:
pull_request:
branches: [master]
push:
branches:
- master
permissions:
contents: read
jobs:
test:
name: Test Suite
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
# 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]
rust: [stable]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout sources
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
profile: minimal
toolchain: ${{ matrix.rust }}
override: true
components: clippy, rustfmt
# Caching is handled by Swatinem/rust-cache below.
cache: false
# Keep RUSTFLAGS unset; `just test` already denies Clippy warnings.
rustflags: ""
- uses: taiki-e/install-action@nextest
- uses: Swatinem/rust-cache@v2
- name: Install Just
uses: extractions/setup-just@v2
uses: extractions/setup-just@v4
- name: Run just
- name: Run tests
run: just test
-5
View File
@@ -17,12 +17,7 @@ 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
+672 -726
View File
File diff suppressed because it is too large. Load diff
+26 -18
View File
@@ -1,6 +1,6 @@
[package]
name = "rustscan"
version = "2.3.0"
version = "2.4.1"
authors = ["Autumn <[email protected]>"]
edition = "2018"
description = "Faster Nmap Scanning with Rust"
@@ -17,33 +17,41 @@ exclude = [
]
[dependencies]
clap = { version = "4.5.13", features = ["derive", "wrap_help"] }
colored = "3.0.0"
async-std = "1.13.0"
futures = "0.3"
rlimit = "0.10.2"
log = "0.4.25"
env_logger = "0.11.6"
anstream = "=0.6.18"
dirs = "6.0.0"
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"
gcd = "2.0.1"
rand = "0.9.0"
rand = "0.10.1"
colorful = "0.3.2"
ansi_term = "0.12.1"
toml = "0.8.20"
toml = "1.1.2"
serde = "1.0.124"
serde_derive = "1.0.116"
cidr-utils = "0.6.1"
itertools = "0.14.0"
cidr-utils = "0.7.1"
itertools = "0.15.0"
hickory-resolver = { version = "0.24.3", features = ["dns-over-rustls"] }
anyhow = "1.0.40"
text_placeholder = { version = "0.5", features = ["struct_context"] }
once_cell = "1.20.3"
once_cell = "1.21.4"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock"] }
[dev-dependencies]
parameterized = "2.0.0"
wait-timeout = "0.2"
criterion = { version = "0.5", features = ["html_reports"] }
criterion = { version = "0.8", features = ["html_reports"] }
[package.metadata.deb]
depends = "$auto, nmap"
@@ -63,5 +71,5 @@ path = "src/main.rs"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tarpaulin_include)"] }
[[bench]]
name = "benchmark_portscan"
name = "benchmark_helpers"
harness = false
+5 -3
View File
@@ -40,7 +40,7 @@ MacOS:
Arch:
```
yay rustscan
pacman -S rustscan
```
# ✨ Features
@@ -55,12 +55,14 @@ Arch:
| <!--Installation Guide--> | <!--Documentation--> | <!--Discord--> |
| :----------------------------------------: | :------------------------------------: | :-------------------------: |
| :book: [Installation Guide][links-table-1] | :books: [Documentation][links-table-2] | :parrot: [Discord][discord] |
| :book: [Installation Guide][toc-install] | :books: [Documentation][links-table-2] | :parrot: [Discord][discord] |
| | :penguin: [ARM Support](docs/arm-support.md) | |
## 🙋 Table of Contents
- 📖 [Installation Guide][toc-install]
- 🐋 [Docker Usage][toc-docker-usage]
- 🐧 [ARM Support](docs/arm-support.md)
- 🦜 [Discord][discord]
- 🤸 [Usage][usage-1]
@@ -209,7 +211,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://rustscan.github.io/RustScan/ "Rustscan"
[links-table-2]: https://github.com/bee-san/RustScan/wiki "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"
+133
View File
@@ -0,0 +1,133 @@
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);
-80
View File
@@ -1,80 +0,0 @@
use async_std::task::block_on;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use rustscan::input::{PortRange, ScanOrder};
use rustscan::port_strategy::PortStrategy;
use rustscan::scanner::Scanner;
use std::net::IpAddr;
use std::time::Duration;
fn portscan_tcp(scanner: &Scanner) {
let _scan_result = block_on(scanner.run());
}
fn portscan_udp(scanner: &Scanner) {
let _scan_result = block_on(scanner.run());
}
fn bench_address() {
let _addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
}
fn bench_port_strategy() {
let range = PortRange {
start: 1,
end: 1_000,
};
let _strategy = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
}
fn criterion_benchmark(c: &mut Criterion) {
let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
let range = PortRange {
start: 1,
end: 1_000,
};
let strategy_tcp = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
let strategy_udp = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
let scanner_tcp = Scanner::new(
&addrs,
10,
Duration::from_millis(10),
1,
false,
strategy_tcp,
true,
vec![],
false,
);
c.bench_function("portscan tcp", |b| {
b.iter(|| portscan_tcp(black_box(&scanner_tcp)))
});
let scanner_udp = Scanner::new(
&addrs,
10,
Duration::from_millis(10),
1,
false,
strategy_udp,
true,
vec![],
true,
);
let mut udp_group = c.benchmark_group("portscan udp");
udp_group.measurement_time(Duration::from_secs(20));
udp_group.bench_function("portscan udp", |b| {
b.iter(|| portscan_udp(black_box(&scanner_udp)))
});
udp_group.finish();
// Benching helper functions
c.bench_function("parse address", |b| b.iter(bench_address));
c.bench_function("port strategy", |b| b.iter(bench_port_strategy));
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
+91 -19
View File
@@ -25,6 +25,10 @@ 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;
}
@@ -141,7 +145,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}"),
}
}
}
@@ -167,38 +171,43 @@ 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");
let payloads = &data[start + 1..];
payb_linenr.insert(line_nr, parser(payloads.trim()));
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()));
}
}
payb_linenr
}
/// Converts a hexadecimal string to a Vec<u8>
/// Converts a quoted Nmap payload block to bytes.
///
/// # Arguments
///
/// * `payload` - A string slice containing the hexadecimal payload
/// * `payload` - The raw block text starting at its first opening quote:
/// one or more `"..."` segments separated by whitespace
///
/// # Returns
///
/// A vector of bytes representing the decoded payload
/// The decoded probe bytes: segments are decoded with [`decode_segment`]
/// and concatenated with no separators, so multi-line probes reassemble
/// exactly.
fn parser(payload: &str) -> Vec<u8> {
let payload = payload.trim_matches('"');
let mut tmp_str = String::new();
let mut bytes: Vec<u8> = Vec::new();
for (idx, char) in payload.chars().enumerate() {
if char == '\\' && payload.chars().nth(idx + 1) == Some('x') {
continue;
} else if char.is_ascii_hexdigit() {
tmp_str.push(char);
if tmp_str.len() == 2 {
bytes.push(u8::from_str_radix(&tmp_str, 16).unwrap());
tmp_str.clear();
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;
}
}
}
@@ -206,6 +215,69 @@ 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 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
# Install rustfmt for code formatting and clippy for linting.
RUN rustup component add rustfmt clippy
+16 -2
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 are now ready to use RustScan:
You can check the command-line interface without starting a scan:
```bash
root@container:/rustscan# cargo run -- -b 2000 -t 5000 -a 127.0.0.1
root@container:/rustscan# cargo run -- --help
```
You can also format, lint with `clippy` and test the code with the following commands:
@@ -67,3 +67,17 @@ 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
@@ -0,0 +1,38 @@
# 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
@@ -0,0 +1,151 @@
{
"metadata": {
"base": "67c08a33100362c54b56fde784b0fd234f70fa11",
"candidate": "a05592a8a88b77ad85d961137943dfadfe98b612",
"environment": "Linux-7.2.6-1-cachyos-x86_64-with-glibc2.44",
"rust": "rustc 1.98.1 (48a229cea 2026-09-01) (Arch Linux rust 1:1.98.1-1.1)",
"hosts_bytes": 2760051,
"profile": "release with LTO",
"network_namespace": "unshare --user --map-root-user --net",
"common_args": [
"--no-config",
"--config-path",
"/dev/null",
"--no-banner",
"--scripts",
"none",
"--greppable",
"--ports",
"80",
"--exclude-ports",
"80"
],
"workloads": {
"literal-ipv4": [
"-a",
"127.0.0.1"
],
"mixed-cidrs": [
"-a",
"127.0.0.1,192.0.2.0/30,2001:db8::/126",
"--exclude-addresses",
"192.0.2.1,2001:db8::1"
]
},
"warmups_each": 2,
"samples_each": 20,
"binary_cpus": "0-3",
"harness_cpus": "8-9",
"binary_sha256": {
"base": "4d6b090218ff36249ed95f14ec1a82626e8ea33854f3823f19a6de247b1524b8",
"dns": "a9d6bf949f56c53d18cc85bc9b53b21bd85a63bcdd04ad78992d392c675c77ac"
}
},
"summary": {
"literal-ipv4": {
"base_ms": 168.23068849999999,
"dns_ms": 2.248762,
"samples_each": 20
},
"mixed-cidrs": {
"base_ms": 178.0855925,
"dns_ms": 2.4539600000000004,
"samples_each": 20
}
},
"samples": {
"literal-ipv4": {
"base": [
143.391226,
146.562804,
149.615525,
151.188597,
152.269193,
159.499601,
164.926958,
164.347529,
167.208931,
165.433184,
172.246939,
169.252446,
172.777899,
170.729249,
170.092681,
171.863255,
169.612842,
170.857396,
173.117403,
175.206345
],
"dns": [
2.241793,
1.817341,
2.39956,
2.048986,
3.123401,
1.820017,
3.615094,
2.165312,
2.675535,
2.103261,
2.531392,
2.215141,
2.572939,
2.063803,
2.671431,
2.059846,
2.598858,
2.230916,
2.678259,
2.255731
]
},
"mixed-cidrs": {
"base": [
175.504767,
178.797842,
186.183728,
176.885958,
177.989816,
180.028568,
177.66299,
174.757221,
175.468485,
175.328023,
177.639639,
175.637593,
177.922435,
185.394396,
182.587204,
180.58311,
178.961137,
178.293186,
180.482419,
178.181369
],
"dns": [
3.275193,
2.114074,
2.700771,
2.183317,
2.606538,
2.368046,
2.599702,
2.238105,
2.683892,
2.292249,
2.528366,
2.114478,
2.668921,
2.103271,
3.275514,
2.27974,
3.103311,
2.265298,
2.580743,
2.379554
]
}
},
"invocations": 88
}
+48
View File
@@ -0,0 +1,48 @@
# Lazy DNS initialization benchmark
Compared current Tokio master `67c08a33100362c54b56fde784b0fd234f70fa11` with the lazy-resolver implementation
at `a05592a8a88b77ad85d961137943dfadfe98b612` on 2026-10-02.
## Method
Both binaries are release builds with LTO, using Rust 1.98.1 on an Intel Core
Ultra 7 165U running Linux 7.2.6-1-cachyos. Each workload used twenty measured
runs and two warmups per binary, alternating A/B and B/A order. Binaries used
CPUs 0-3; the harness used CPUs 8-9. No builds ran during measurement.
The harness ran in a private network namespace. Every invocation excluded
its only requested port, so no scan traffic was sent. Configuration and
scripts were disabled. Timing spans process launch through exit, including
the same `taskset` affinity launcher for both builds.
Common arguments:
```sh
--no-config --config-path /dev/null --no-banner --scripts none --greppable \
--ports 80 --exclude-ports 80
```
The literal case adds `-a 127.0.0.1`. The mixed case adds
`-a 127.0.0.1,192.0.2.0/30,2001:db8::/126` and
`--exclude-addresses 192.0.2.1,2001:db8::1`. Both use `RUST_LOG=rustscan=info`.
## Results
| Workload | Master median | Lazy DNS median | Time saved |
| --- | ---: | ---: | ---: |
| Literal IPv4 | 168.23 ms | 2.25 ms | 165.98 ms (98.66%) |
| IPv4/IPv6 CIDRs and exclusions | 178.09 ms | 2.45 ms | 175.63 ms (98.62%) |
All 88 invocations exited successfully with the expected empty scan output.
A separate syscall trace of the optimized literal-IP invocation recorded no
reads of `/etc/hosts` or `/etc/resolv.conf` and no network connects or sends.
This host has a 2,760,051-byte hosts file. Eager Hickory initialization parses
that file even for numeric targets. Systems with smaller hosts files will
save less; this measures CLI startup, not socket throughput. Hostnames that
need fallback DNS still pay resolver initialization once, and parsing order,
file handling, deduplication and exclusions keep their existing behavior.
[Raw samples, setup and binary hashes](lazy-dns-benchmark-results.json) are
included. Network-free regression tests verify that literal IPv4/IPv6 targets,
CIDRs, exclusions, duplicates and empty inputs do not construct the resolver.
File diff suppressed because it is too large. Load diff
+114
View File
@@ -0,0 +1,114 @@
# Port exclusion preparation benchmark
Compared Tokio master `b7119f7f3eb1601e81126ef3572699ed7b3b7c0b` with
implementation `270561d553688acbd99f61c460cdfa7c76c1423e` on 2026-10-02.
## Method
Release builds with LTO, Rust 1.98.1, Intel Core Ultra 7 165U, Linux
7.2.6-1-cachyos. Both versions use the same lockfile and identical
`benches/benchmark_helpers.rs`; copy the final benchmark harness into the
baseline worktree before building it. The benchmark runs the production
`Scanner::run_with_status` with **zero target addresses**, so it opens no
sockets and does no DNS or scripting. It includes generating/cloning the
port list, filtering and empty scan setup. Scanner construction and the
current-thread Tokio runtime construction are outside the timer.
Manual ports are `1..=port_count`; exclusions are
`(i * 13 % 65536) as u16` for `i` in `0..excluded_count`. Each case has two
independent twenty-sample Criterion runs per build, a 500 ms warmup and a
2 s target measurement. Cases run in A/B then B/A order on CPU 0. No builds
or other local checks ran during timing. CPU frequency was not fixed on
this shared laptop, so the table retains both repetitions rather than
selecting one result. Values are Criterion's mean point estimates.
Build command for each worktree:
```sh
cargo bench --locked --bench benchmark_helpers --no-run
```
The saved benchmark executables are then run serially, for each case:
```sh
CARGO_TARGET_DIR=/tmp/preparation-results taskset -c 0 /path/to/benchmark_helpers \
--bench 'port preparation/65535 ports, 4096 exclusions' --save-baseline base-a
```
Repeat with the candidate binary as `head-a`, then the candidate as
`head-b` and the baseline as `base-b`. To run the group normally without
comparing builds:
```sh
cargo bench --locked --bench benchmark_helpers -- 'port preparation'
```
## Results
Times are microseconds; A and B are separate matched repetitions.
| Ports | Exclusions | Master µs A / B | Optimized µs A / B | Time saved A / B |
| ---: | ---: | ---: | ---: | ---: |
| 1 | 1 | 0.289 / 0.286 | 0.261 / 0.271 | 9.7% / 5.2% |
| 16 | 4 | 0.260 / 0.268 | 0.232 / 0.237 | 10.6% / 11.6% |
| 64 | 1 | 0.433 / 0.437 | 0.237 / 0.239 | 45.2% / 45.4% |
| 64 | 4,096 | 6.231 / 10.093 | 2.484 / 3.038 | 60.1% / 69.9% |
| 4,096 | 0 | 10.889 / 10.037 | 0.335 / 0.397 | 96.9% / 96.0% |
| 4,096 | 64 | 20.680 / 20.363 | 2.930 / 3.064 | 85.8% / 85.0% |
| 4,096 | 4,096 | 524.223 / 519.670 | 6.805 / 6.710 | 98.7% / 98.7% |
| 65,535 | 0 | 166.113 / 115.686 | 3.655 / 2.333 | 97.8% / 98.0% |
| 65,535 | 4 | 175.717 / 181.343 | 29.769 / 30.230 | 83.1% / 83.3% |
| 65,535 | 64 | 207.617 / 206.910 | 31.595 / 31.462 | 84.8% / 84.8% |
| 65,535 | 1,024 | 2018.463 / 3271.564 | 33.403 / 33.520 | 98.3% / 99.0% |
| 65,535 | 4,096 | 8811.234 / 8469.706 | 59.853 / 63.211 | 99.3% / 99.3% |
All twelve cases improved in both repetitions. This measures **preparation,
not end-to-end network scan speed**: many scans are dominated by socket I/O,
timeouts and reporting. In particular, the default no-exclusion saving is
only a fraction of a millisecond.
## Implementation and correctness
With no exclusions, keep the already generated vector. For fewer than 64
ports, filter in place with linear membership checks; this avoids bitmap
setup and a second allocation. Larger lists use an 8 KiB bitmap covering
all 65,536 values representable by `u16`, then retain ports in place. This
changes the large-list filtering cost from ports × exclusions to ports +
exclusions, while preserving order, duplicate ports and boundary values.
The helper is kept out of line: release assembly showed an inlined bitmap
reserved 8,424 bytes in every async poll, whereas the final poll frame uses
344 bytes and the helper reserves its bitmap only during preparation.
Exploration found that in-place linear membership slows large cases. A
first production candidate also slowed 64 ports with 4,096 exclusions;
using the bitmap for that case corrected the regression before the final
matched runs. The cutoff is a performance choice, not a limit on input.
One focused network-free regression test compares the implementation with
an independent membership oracle for empty inputs, endpoints 0/65535,
duplicate ports/exclusions, all ports, and a frozen random order.
`cargo test --locked` passes 74 tests with one pre-existing ignored doctest.
All-target Clippy, formatting, documentation and diff checks pass. The
CI-only scan harness adds an excluded-port sweep using the existing
listeners, checking expected results and using the actual attempted socket
count for throughput.
[Raw Criterion samples, confidence intervals, revisions and binary hashes](port-exclusion-benchmark-results.json)
are included. Hosted correctness/performance results are linked in the PR.
## macOS runner setup
Repeated hosted sweeps on Darwin 25.6 returned `ENOBUFS` (error 55) on
both master and the candidate, missing fixture listeners. Such runs are
invalid for performance claims. The workflow provisions the TCP memory
budget once before either build is measured, raising it from the default
1/32 to 1/8 of physical memory when necessary, and prints the actual limit
and allocation counters. It retains every scenario, repeat, listener
comparison and performance threshold. An immediate untimed diagnostic
scan records errors and kernel counters if a measured pair misses a
listener; that failed dataset is not used to claim a speedup.
This uses XNU's [TCP budget initialization](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/netinet/tcp_subr.c)
and [memory accounting interface](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/mem_acct_private.h).
It changes the disposable benchmark environment, not RustScan's behavior
or the operating system configuration on users' machines.
+4
View File
@@ -0,0 +1,4 @@
tags = ["core_approved", "example"]
ports = ["80", "443", "8080"]
developer = ["example", "https://example.org"]
directory = "fixtures/.rustscan_scripts"
-23
View File
@@ -1,23 +0,0 @@
#!/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
@@ -1,13 +0,0 @@
#!/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
@@ -1,12 +0,0 @@
#!/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 $@
+3 -5
View File
@@ -1,9 +1,7 @@
#!intentional_blank_line
Metadata-only test fixture; not executable.
#tags = ["core_approved", "example"]
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "nmap -vvv -p {{port}} {{ip}}"
#call_format = "fixture {{ip}} {{port}}"
# 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"
The parser reads only the metadata above. This file contains no script body.
@@ -1,7 +1,7 @@
#!intentional_blank_line
Metadata-only test fixture; not executable.
#tags = "core_approved"
#developer = [ "example", "https://example.org" ]
#ports_separator = ","
#call_format = "nmap -vvv -p {{port}} {{ip}}"
#call_format = "fixture {{ip}} {{port}}"
# 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
@@ -1,6 +0,0 @@
127.0.0.1
google.com
example.com
66666666666666.666666666666666666.66666666666666666.6666666666
....
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.radiatorFixtures
+1 -1
View File
@@ -1,5 +1,5 @@
test:
cargo nextest run
cargo test
cargo clippy -- --deny warnings
cargo clippy --tests -- --deny warnings
cargo fmt --check
+283 -126
View File
@@ -1,4 +1,5 @@
//! Provides functions to parse input IP addresses, CIDRs or files.
use std::cell::LazyCell;
use std::collections::BTreeSet;
use std::fs::{self, File};
use std::io::{prelude::*, BufReader};
@@ -6,7 +7,7 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::path::Path;
use std::str::FromStr;
use cidr_utils::cidr::IpCidr;
use cidr_utils::cidr::{IpCidr, IpInet};
use hickory_resolver::{
config::{NameServerConfig, Protocol, ResolverConfig, ResolverOpts},
Resolver,
@@ -30,13 +31,32 @@ use crate::warning;
/// ```
///
/// Finally, any duplicates are removed to avoid excessive scans.
///
/// # Blocking
///
/// This function blocks: it reads files and resolves host names, and
/// hickory's synchronous [`Resolver`] runs a Tokio runtime of its own, which
/// Tokio refuses to drop inside an asynchronous context. Call it before you
/// start your runtime (as the `rustscan` binary does) or from
/// `tokio::task::spawn_blocking`, not directly from async code.
pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
parse_addresses_with_resolver(input, || get_resolver(&input.resolver))
}
fn parse_addresses_with_resolver(
input: &Opts,
create_resolver: impl FnOnce() -> Resolver,
) -> Vec<IpAddr> {
let mut ips: Vec<IpAddr> = Vec::new();
let mut unresolved_addresses: Vec<&str> = Vec::new();
let backup_resolver = get_resolver(&input.resolver);
// Most scans use literal IPs or CIDRs. Defer the resolver's runtime and
// hosts-file loading until a DNS fallback actually needs them, then reuse
// that resolver for the rest of the targets and exclusions.
let backup_resolver = LazyCell::new(create_resolver);
let resolver = || &*backup_resolver;
for address in &input.addresses {
let parsed_ips = parse_address(address, &backup_resolver);
let parsed_ips = parse_address_with_resolver(address, &resolver);
if !parsed_ips.is_empty() {
ips.extend(parsed_ips);
} else {
@@ -58,7 +78,7 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
continue;
}
if let Ok(x) = read_ips_from_file(file_path, &backup_resolver) {
if let Ok(x) = read_ips_from_file(file_path, &resolver) {
ips.extend(x);
} else {
warning!(
@@ -69,17 +89,11 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
}
}
// Finally, craft a list of addresses to be excluded from the scan.
let mut excluded_ips: BTreeSet<IpAddr> = BTreeSet::new();
if let Some(exclude_addresses) = &input.exclude_addresses {
for addr in exclude_addresses {
excluded_ips.extend(parse_address(addr, &backup_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_ips.contains(ip));
ips.retain(|ip| seen.insert(*ip) && !excluded_cidrs.iter().any(|cidr| cidr.contains(ip)));
ips
}
@@ -98,33 +112,98 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
/// let ips = parse_address("127.0.0.1", &Resolver::default().unwrap());
/// ```
pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
IpCidr::from_str(address)
.map(|cidr| cidr.iter().map(|c| c.address()).collect())
.ok()
.or_else(|| {
format!("{}:80", &address)
.to_socket_addrs()
.ok()
.map(|mut iter| vec![iter.next().unwrap().ip()])
})
.unwrap_or_else(|| resolve_ips_from_host(address, resolver))
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),
}
}
}
/// Uses DNS to get the IPS associated with host
fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr> {
fn resolve_ips_from_host<'a>(
source: &str,
backup_resolver: &impl Fn() -> &'a Resolver,
) -> Vec<IpAddr> {
let mut ips: Vec<IpAddr> = Vec::new();
if let Ok(addrs) = source.to_socket_addrs() {
for ip in addrs {
ips.push(ip.ip());
}
} else if let Ok(addrs) = backup_resolver.lookup_ip(source) {
} else if let Ok(addrs) = backup_resolver().lookup_ip(source) {
ips.extend(addrs.iter());
}
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:
@@ -152,17 +231,56 @@ fn get_resolver(resolver: &Option<String>) -> Resolver {
Protocol::Udp,
));
}
Resolver::new(config, ResolverOpts::default()).unwrap()
Resolver::new(config, resolver_opts()).unwrap()
}
None => match Resolver::from_system_conf() {
None => match system_resolver() {
Ok(resolver) => resolver,
Err(_) => {
Resolver::new(ResolverConfig::cloudflare_tls(), ResolverOpts::default()).unwrap()
}
Err(_) => Resolver::new(ResolverConfig::cloudflare_tls(), resolver_opts()).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)?
@@ -175,9 +293,9 @@ fn read_resolver_from_file(path: &str) -> Result<Vec<IpAddr>, std::io::Error> {
#[cfg(not(tarpaulin_include))]
/// Parses an input file of IPs and uses those
fn read_ips_from_file(
fn read_ips_from_file<'a>(
ips: &std::path::Path,
backup_resolver: &Resolver,
backup_resolver: &impl Fn() -> &'a Resolver,
) -> Result<Vec<IpAddr>, std::io::Error> {
let file = File::open(ips)?;
let reader = BufReader::new(file);
@@ -186,7 +304,7 @@ fn read_ips_from_file(
for address_line in reader.lines() {
if let Ok(address) = address_line {
ips.extend(parse_address(&address, backup_resolver));
ips.extend(parse_address_with_resolver(&address, backup_resolver));
} else {
debug!("Line in file is not valid");
}
@@ -197,8 +315,47 @@ fn read_ips_from_file(
#[cfg(test)]
mod tests {
use super::{get_resolver, parse_addresses, Opts};
use std::net::Ipv4Addr;
use super::{parse_addresses, parse_addresses_with_resolver, Opts};
use std::net::{IpAddr, Ipv4Addr};
#[test]
fn literal_targets_and_exclusions_do_not_initialize_a_resolver() {
let opts = Opts {
addresses: vec![
"192.0.2.0/30".to_owned(),
"192.0.2.2".to_owned(),
"2001:db8::/126".to_owned(),
"2001:db8::3".to_owned(),
],
exclude_addresses: Some(vec![
"192.0.2.0/31".to_owned(),
"192.0.2.3".to_owned(),
"2001:db8::/127".to_owned(),
"2001:db8::3".to_owned(),
]),
..Default::default()
};
let ips = parse_addresses_with_resolver(&opts, || {
panic!("literal targets and exclusions must not initialize DNS")
});
assert_eq!(
ips,
[
"192.0.2.2".parse::<IpAddr>().unwrap(),
"2001:db8::2".parse::<IpAddr>().unwrap(),
]
);
}
#[test]
fn empty_targets_do_not_initialize_a_resolver() {
assert!(parse_addresses_with_resolver(&Opts::default(), || {
panic!("empty targets must not initialize DNS")
})
.is_empty());
}
#[test]
fn parse_correct_addresses() {
@@ -279,81 +436,6 @@ mod tests {
);
}
#[test]
fn parse_correct_host_addresses() {
let opts = Opts {
addresses: vec!["google.com".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(ips.len(), 1);
}
#[test]
fn parse_correct_and_incorrect_addresses() {
let opts = Opts {
addresses: vec!["127.0.0.1".to_owned(), "im_wrong".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(ips, [Ipv4Addr::new(127, 0, 0, 1),]);
}
#[test]
fn parse_incorrect_addresses() {
let opts = Opts {
addresses: vec!["im_wrong".to_owned(), "300.10.1.1".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert!(ips.is_empty());
}
#[test]
fn parse_hosts_file_and_incorrect_hosts() {
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
let opts = Opts {
addresses: vec!["fixtures/hosts.txt".to_owned()],
..Default::default()
};
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 opts = Opts {
addresses: vec!["fixtures/empty_hosts.txt".to_owned()],
..Default::default()
};
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 opts = Opts {
addresses: vec!["fixtures/naughty_string.txt".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(ips.len(), 0);
}
#[test]
fn parse_duplicate_cidrs() {
let opts = Opts {
@@ -367,27 +449,102 @@ mod tests {
}
#[test]
fn resolver_default_cloudflare() {
let opts = Opts::default();
let resolver = get_resolver(&opts.resolver);
let lookup = resolver.lookup_ip("www.example.com.").unwrap();
assert!(opts.resolver.is_none());
assert!(lookup.iter().next().is_some());
}
#[test]
fn resolver_args_google_dns() {
// https://developers.google.com/speed/public-dns
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 {
resolver: Some("8.8.8.8,8.8.4.4".to_owned()),
addresses: vec!["192.128.1.1/24".to_owned()],
..Default::default()
};
let resolver = get_resolver(&opts.resolver);
let lookup = resolver.lookup_ip("www.example.com.").unwrap();
let ips = parse_addresses(&opts);
assert!(lookup.iter().next().is_some());
assert_eq!(ips.len(), 256);
}
#[test]
fn parse_non_canonical_cidr_mid_block() {
// 192.168.1.13/29: .13 = 0000 1101, mask clears last 3 bits → .8 = 0000 1000
// network is 192.168.1.8/29, spanning .8 through .15
let opts = Opts {
addresses: vec!["192.168.1.13/29".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(192, 168, 1, 8),
Ipv4Addr::new(192, 168, 1, 9),
Ipv4Addr::new(192, 168, 1, 10),
Ipv4Addr::new(192, 168, 1, 11),
Ipv4Addr::new(192, 168, 1, 12),
Ipv4Addr::new(192, 168, 1, 13),
Ipv4Addr::new(192, 168, 1, 14),
Ipv4Addr::new(192, 168, 1, 15),
]
);
}
#[test]
fn parse_non_canonical_cidr_last_in_block() {
// 192.168.1.15/29: last address in the block, should still resolve to same .8–.15 network
let opts = Opts {
addresses: vec!["192.168.1.15/29".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(192, 168, 1, 8),
Ipv4Addr::new(192, 168, 1, 9),
Ipv4Addr::new(192, 168, 1, 10),
Ipv4Addr::new(192, 168, 1, 11),
Ipv4Addr::new(192, 168, 1, 12),
Ipv4Addr::new(192, 168, 1, 13),
Ipv4Addr::new(192, 168, 1, 14),
Ipv4Addr::new(192, 168, 1, 15),
]
);
}
#[test]
fn parse_non_canonical_cidr_crosses_third_octet() {
// 192.168.1.5/23: host bits span into the third octet
// .1.5 in 23-bit context → network is 192.168.0.0/23, spanning .0.0 through .1.255 (512 addresses)
let opts = Opts {
addresses: vec!["192.168.1.5/23".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips.first(),
Some(&IpAddr::V4(Ipv4Addr::new(192, 168, 0, 0)))
);
assert_eq!(
ips.last(),
Some(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 255)))
);
assert_eq!(ips.len(), 512);
}
#[test]
fn parse_non_canonical_cidr_slash30() {
// 10.0.0.7/30: .7 = 0000 0111, mask clears last 2 bits → .4 = 0000 0100
// network is 10.0.0.4/30, spanning .4 through .7
let opts = Opts {
addresses: vec!["10.0.0.7/30".to_owned()],
..Default::default()
};
let ips = parse_addresses(&opts);
assert_eq!(
ips,
[
Ipv4Addr::new(10, 0, 0, 4),
Ipv4Addr::new(10, 0, 0, 5),
Ipv4Addr::new(10, 0, 0, 6),
Ipv4Addr::new(10, 0, 0, 7),
]
);
}
}
+5 -7
View File
@@ -42,13 +42,11 @@ impl Benchmark {
let mut summary = String::from("\nRustScan Benchmark Summary");
for timer in &self.named_timers {
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));
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));
}
}
}
summary
+455 -58
View File
@@ -1,7 +1,9 @@
//! Provides a means to read, parse and hold configuration options for scans.
use clap::{Parser, ValueEnum};
use clap::parser::ValueSource;
use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum};
use serde_derive::Deserialize;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
@@ -28,35 +30,94 @@ pub enum ScriptsRequired {
Custom,
}
/// Represents the range of ports to be scanned.
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct PortRange {
pub start: u16,
pub end: u16,
/// 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))
}
}
#[cfg(not(tarpaulin_include))]
fn parse_range(input: &str) -> Result<PortRange, String> {
let range = input
.split('-')
.map(str::parse)
.collect::<Result<Vec<u16>, std::num::ParseIntError>>();
/// 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()?;
if range.is_err() {
return Err(String::from(
"the range format must be 'start-end'. Example: 1-1000.",
));
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());
}
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.",
)),
}
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)
}
#[derive(Parser, Debug, Clone)]
@@ -81,9 +142,9 @@ pub struct Opts {
#[arg(short, long, value_delimiter = ',')]
pub ports: Option<Vec<u16>>,
/// 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>,
/// 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>,
/// Whether to ignore the configuration file or not.
#[arg(short, long)]
@@ -114,7 +175,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: u16,
pub batch_size: usize,
/// The timeout in milliseconds before a port is assumed to be closed.
#[arg(short, long, default_value = "1500")]
@@ -125,9 +186,10 @@ pub struct Opts {
#[arg(long, default_value = "1")]
pub tries: u8,
/// Automatically ups the ULIMIT with the value you provided.
/// Automatically increases the Unix file-descriptor limit.
#[cfg_attr(not(unix), arg(hide = true))]
#[arg(short, long)]
pub ulimit: Option<u64>,
pub ulimit: Option<usize>,
/// The order of scanning to be performed. The "serial" option will
/// scan ports in ascending order while the "random" option will scan
@@ -162,25 +224,84 @@ pub struct Opts {
/// UDP scanning mode, finds UDP ports that send back responses
#[arg(long)]
pub udp: bool,
/// Also list TCP ports that actively refused the connection (closed).
/// Closed ports are only printed; scripts are never run against them.
#[arg(long)]
pub closed: bool,
/// Milliseconds to wait after scanning a port (on every address) before
/// scanning the next one, for slow, low-noise scans. 0 disables the delay.
#[arg(long, default_value = "0", value_name = "MS")]
pub interval: u64,
/// Ids of the options given explicitly on the command line. The
/// configuration file never overrides these (see [`Opts::merge`]).
#[arg(skip)]
pub cli_args: HashSet<String>,
}
#[cfg(not(tarpaulin_include))]
impl Opts {
pub fn read() -> Self {
let mut opts = Opts::parse();
Self::try_read_from(std::env::args_os()).unwrap_or_else(|e| e.exit())
}
/// Parses `args` like [`Opts::read`] does, returning an error instead of
/// exiting.
fn try_read_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let mut command = Self::command();
let mut matches = command.try_get_matches_from_mut(args)?;
// clap fills in default values, so the parsed struct alone cannot
// tell `-b 4500` apart from no `-b` at all. Record what the user
// actually typed so that `merge` lets it win over the config file.
let cli_args = command
.get_arguments()
.map(|arg| arg.get_id().as_str())
.filter(|id| matches.value_source(id) == Some(ValueSource::CommandLine))
.map(str::to_owned)
.collect();
let mut opts =
Self::from_arg_matches_mut(&mut matches).map_err(|e| e.format(&mut command))?;
opts.cli_args = cli_args;
if opts.ports.is_none() && opts.range.is_none() {
opts.range = Some(PortRange {
start: LOWEST_PORT_NUMBER,
end: TOP_PORT_NUMBER,
});
opts.range = Some(PortRanges(vec![(LOWEST_PORT_NUMBER, TOP_PORT_NUMBER)]));
}
opts
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(())
}
/// Reads the command line arguments into an Opts struct and merge
/// values found within the user configuration file.
/// values found within the user configuration file. Options given on the
/// command line take precedence over the configuration file.
pub fn merge(&mut self, config: &Config) {
if !self.no_config {
self.merge_required(config);
@@ -193,7 +314,9 @@ impl Opts {
($($field: ident),+) => {
$(
if let Some(e) = &config.$field {
self.$field = e.clone();
if !self.cli_args.contains(stringify!($field)) {
self.$field = e.clone();
}
}
)+
}
@@ -201,7 +324,7 @@ impl Opts {
merge_required!(
addresses, greppable, accessible, batch_size, timeout, tries, scan_order, scripts,
command, udp
command, udp, no_banner, closed, interval
);
}
@@ -209,7 +332,7 @@ impl Opts {
macro_rules! merge_optional {
($($field: ident),+) => {
$(
if config.$field.is_some() {
if config.$field.is_some() && !self.cli_args.contains(stringify!($field)) {
self.$field = config.$field.clone();
}
)+
@@ -218,11 +341,7 @@ impl Opts {
// Only use top ports when the user asks for them
if self.top && config.ports.is_some() {
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);
self.ports = config.ports.clone();
}
merge_optional!(range, resolver, ulimit, exclude_ports, exclude_addresses);
@@ -252,6 +371,9 @@ impl Default for Opts {
exclude_ports: None,
exclude_addresses: None,
udp: false,
closed: false,
interval: 0,
cli_args: HashSet::new(),
}
}
}
@@ -263,14 +385,14 @@ impl Default for Opts {
#[derive(Debug, Deserialize)]
pub struct Config {
addresses: Option<Vec<String>>,
ports: Option<HashMap<String, u16>>,
range: Option<PortRange>,
ports: Option<Vec<u16>>,
range: Option<PortRanges>,
greppable: Option<bool>,
accessible: Option<bool>,
batch_size: Option<u16>,
batch_size: Option<usize>,
timeout: Option<u32>,
tries: Option<u8>,
ulimit: Option<u64>,
ulimit: Option<usize>,
resolver: Option<String>,
scan_order: Option<ScanOrder>,
command: Option<Vec<String>>,
@@ -278,6 +400,9 @@ pub struct Config {
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))]
@@ -295,10 +420,26 @@ impl Config {
/// scan_order = "Serial"
/// exclude_ports = [8080, 9090, 80]
/// udp = false
/// closed = false
/// interval = 0
///
pub fn read(custom_config_path: Option<PathBuf>) -> Self {
let mut content = String::new();
let config_path = custom_config_path.unwrap_or_else(default_config_path);
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()
});
if config_path.exists() {
content = match fs::read_to_string(config_path) {
Ok(content) => content,
@@ -309,7 +450,9 @@ impl Config {
let config: Config = match toml::from_str(&content) {
Ok(config) => config,
Err(e) => {
println!("Found {e} in configuration file.\nAborting scan.\n");
crate::tui::println_safe(format_args!(
"Found {e} in configuration file.\nAborting scan.\n"
));
std::process::exit(1);
}
};
@@ -318,8 +461,30 @@ impl Config {
}
}
/// Constructs default path to config toml
/// 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.
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.");
};
@@ -331,8 +496,9 @@ pub fn default_config_path() -> PathBuf {
mod tests {
use clap::{CommandFactory, Parser};
use parameterized::parameterized;
use std::collections::HashSet;
use super::{Config, Opts, PortRange, ScanOrder, ScriptsRequired};
use super::{Config, Opts, PortRanges, ScanOrder, ScriptsRequired};
impl Config {
fn default() -> Self {
@@ -353,6 +519,9 @@ mod tests {
exclude_ports: None,
exclude_addresses: None,
udp: Some(false),
no_banner: None,
closed: Some(false),
interval: None,
}
}
}
@@ -382,6 +551,82 @@ 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();
@@ -417,10 +662,7 @@ mod tests {
fn opts_merge_optional_arguments() {
let mut opts = Opts::default();
let mut config = Config::default();
config.range = Some(PortRange {
start: 1,
end: 1_000,
});
config.range = Some(PortRanges(vec![(1, 1_000)]));
config.ulimit = Some(1_000);
config.resolver = Some("1.1.1.1".to_owned());
@@ -430,4 +672,159 @@ 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 -9
View File
@@ -5,22 +5,26 @@
//!
//! 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).
//!
//! ```rust
//! use async_std::task::block_on;
//! 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
//! use std::{net::IpAddr, time::Duration};
//!
//! use rustscan::input::{PortRange, ScanOrder};
//! use rustscan::input::{PortRanges, ScanOrder};
//! use rustscan::port_strategy::PortStrategy;
//! use rustscan::scanner::Scanner;
//!
//! fn main() {
//! let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
//! let range = PortRange {
//! start: 1,
//! end: 1_000,
//! };
//! 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 scanner = Scanner::new(
//! &addrs, // the addresses to scan
@@ -34,7 +38,13 @@
//! false, // is this a UDP scan?
//! );
//!
//! let scan_result = block_on(scanner.run());
//! // 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());
//!
//! println!("{:?}", scan_result);
//! }
+162 -49
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::Scanner;
use rustscan::scanner::{PortStatus, 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,9 +23,10 @@ extern crate dirs;
// Average value for Ubuntu
#[cfg(unix)]
const DEFAULT_FILE_DESCRIPTORS_LIMIT: u64 = 8000;
const DEFAULT_FILE_DESCRIPTORS_LIMIT: usize = 8000;
// Safest batch size based on experimentation
const AVERAGE_BATCH_SIZE: u16 = 3000;
#[cfg(unix)]
const AVERAGE_BATCH_SIZE: usize = 3000;
#[macro_use]
extern crate log;
@@ -46,7 +47,12 @@ fn main() {
let config = Config::read(opts.config_path.clone());
opts.merge(&config);
debug!("Main() `opts` arguments are {:?}", opts);
if let Err(message) = opts.validate_platform() {
eprintln!("error: {message}");
std::process::exit(2);
}
debug!("Main() `opts` arguments are {opts:?}");
let scripts_to_run: Vec<ScriptFile> = match init_scripts(&opts.scripts) {
Ok(scripts_to_run) => scripts_to_run,
@@ -60,7 +66,7 @@ fn main() {
}
};
debug!("Scripts initialized {:?}", &scripts_to_run);
debug!("Scripts initialized {scripts_to_run:?}");
if !opts.greppable && !opts.accessible && !opts.no_banner {
print_opening(&opts);
@@ -77,13 +83,10 @@ fn main() {
std::process::exit(1);
}
#[cfg(unix)]
let batch_size: u16 = infer_batch_size(&opts, adjust_ulimit_size(&opts));
let batch_size = effective_batch_size(&opts);
debug!("Effective batch size: {batch_size}");
#[cfg(not(unix))]
let batch_size: u16 = AVERAGE_BATCH_SIZE;
let scanner = Scanner::new(
let mut scanner = Scanner::new(
&ips,
batch_size,
Duration::from_millis(opts.timeout.into()),
@@ -93,21 +96,36 @@ fn main() {
opts.accessible,
opts.exclude_ports.unwrap_or_default(),
opts.udp,
);
debug!("Scanner finished building: {:?}", scanner);
)
.with_open_port_output();
if opts.closed {
scanner = scanner.with_closed_ports();
}
if opts.interval > 0 {
scanner = scanner.with_interval(Duration::from_millis(opts.interval));
}
debug!("Scanner finished building: {scanner:?}");
let mut portscan_bench = NamedTimer::start("Portscan");
let scan_result = block_on(scanner.run());
let scan_result = match run_scan(&scanner) {
Ok(scan_result) => scan_result,
Err(e) => {
eprintln!("error: could not start the scan: {e}");
std::process::exit(1);
}
};
portscan_bench.end();
benchmarks.push(portscan_bench);
let mut ports_per_ip = HashMap::new();
let mut ports_per_ip: HashMap<IpAddr, Vec<u16>> = HashMap::new();
let mut closed_ports_per_ip: HashMap<IpAddr, Vec<u16>> = HashMap::new();
for socket in scan_result {
ports_per_ip
.entry(socket.ip())
.or_insert_with(Vec::new)
.push(socket.port());
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 ip in ips {
@@ -122,7 +140,7 @@ fn main() {
\n*I used {} batch size, consider lowering it with {} or a comfortable number for your system.
\n Alternatively, increase the timeout if your ping is high. Rustscan -t 2000 for 2000 milliseconds (2s) timeout.\n",
ip,
opts.batch_size,
batch_size,
"'rustscan -b <batch_size> -a <ip address>'");
warning!(x, opts.greppable, opts.accessible);
}
@@ -136,17 +154,19 @@ fn main() {
// if option scripts is none, no script will be spawned
if opts.greppable || opts.scripts == ScriptsRequired::None {
println!("{} -> [{}]", &ip, ports_str);
println_safe(format_args!("{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 mut script_f in scripts_to_run.clone() {
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();
// 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(' ');
@@ -156,7 +176,7 @@ fn main() {
opts.greppable,
opts.accessible
);
debug!("Call format {}", call_f);
debug!("Call format {call_f}");
script_f.call_format = Some(call_f);
}
}
@@ -173,7 +193,7 @@ fn main() {
);
match script.run() {
Ok(script_result) => {
detail!(script_result.to_string(), opts.greppable, opts.accessible);
detail!(script_result.clone(), opts.greppable, opts.accessible);
}
Err(e) => {
warning!(&format!("Error {e}"), opts.greppable, opts.accessible);
@@ -182,15 +202,64 @@ 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());
}
/// 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) {
@@ -201,12 +270,12 @@ fn print_opening(opts: &Opts) {
`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'
The Modern Day Port Scanner."#;
println!("{}", s.gradient(Color::Green).bold());
println_safe(format_args!("{}", s.gradient(Color::Green).bold()));
let info = r#"________________________________________
: http://discord.skerritt.blog :
: https://github.com/RustScan/RustScan :
--------------------------------------"#;
println!("{}", info.gradient(Color::Yellow).bold());
println_safe(format_args!("{}", info.gradient(Color::Yellow).bold()));
funny_opening!();
let config_path = opts
@@ -219,13 +288,26 @@ 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) -> u64 {
fn adjust_ulimit_size(opts: &Opts) -> usize {
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}."),
@@ -242,14 +324,15 @@ fn adjust_ulimit_size(opts: &Opts) -> u64 {
}
let (soft, _) = Resource::NOFILE.get().unwrap();
soft
// 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)
}
#[cfg(unix)]
fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
use std::convert::TryInto;
let mut batch_size: u64 = opts.batch_size.into();
fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
let mut batch_size = opts.batch_size;
// Adjust the batch size when the ulimit value is lower than the desired batch size
if ulimit < batch_size {
@@ -260,8 +343,8 @@ fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
// 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.into() {
// ulimit is smaller than aveage batch size
if ulimit < AVERAGE_BATCH_SIZE {
// ulimit is smaller than average 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);
@@ -269,7 +352,7 @@ fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
batch_size = ulimit / 2;
} else if ulimit > DEFAULT_FILE_DESCRIPTORS_LIMIT {
info!("Batch size is now average batch size");
batch_size = AVERAGE_BATCH_SIZE.into();
batch_size = AVERAGE_BATCH_SIZE;
} else {
batch_size = ulimit - 100;
}
@@ -282,12 +365,12 @@ fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
}
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::{print_opening, Opts};
@@ -313,7 +396,7 @@ mod tests {
};
let batch_size = infer_batch_size(&opts, 9_000);
assert!(batch_size == 3_000);
assert_eq!(batch_size, 3_000);
}
#[test]
#[cfg(unix)]
@@ -326,7 +409,7 @@ mod tests {
};
let batch_size = infer_batch_size(&opts, 5_000);
assert!(batch_size == 4_900);
assert_eq!(batch_size, 4_900);
}
#[test]
#[cfg(unix)]
@@ -339,7 +422,7 @@ mod tests {
};
let batch_size = adjust_ulimit_size(&opts);
assert!(batch_size == 2_000);
assert_eq!(batch_size, 2_000);
}
#[test]
@@ -353,16 +436,46 @@ mod tests {
let batch_size = infer_batch_size(&opts, 1_000_000);
assert!(batch_size == opts.batch_size);
assert_eq!(batch_size, opts.batch_size);
}
#[test]
fn test_print_opening_no_panic() {
let opts = Opts {
ulimit: Some(2_000),
..Default::default()
};
let opts = Opts::default();
// print opening should not panic
print_opening(&opts);
}
#[test]
#[cfg(windows)]
fn windows_batch_size_uses_requested_value() {
let opts = Opts {
batch_size: 50,
..Default::default()
};
assert_eq!(effective_batch_size(&opts), 50);
}
#[test]
#[cfg(windows)]
fn windows_batch_size_preserves_large_requested_value() {
let opts = Opts {
batch_size: 12_345,
..Default::default()
};
assert_eq!(effective_batch_size(&opts), 12_345);
}
#[test]
#[cfg(windows)]
fn windows_batch_size_preserves_single_connection() {
let opts = Opts {
batch_size: 1,
..Default::default()
};
assert_eq!(effective_batch_size(&opts), 1);
}
}
+37 -27
View File
@@ -1,6 +1,6 @@
//! Provides a means to hold configuration options specifically for port scanning.
mod range_iterator;
use crate::input::{PortRange, ScanOrder};
use crate::input::{PortRanges, ScanOrder};
use rand::rng;
use rand::seq::SliceRandom;
use range_iterator::RangeIterator;
@@ -17,20 +17,18 @@ pub enum PortStrategy {
}
impl PortStrategy {
pub fn pick(range: &Option<PortRange>, ports: Option<Vec<u16>>, order: ScanOrder) -> Self {
pub fn pick(range: &Option<PortRanges>, ports: Option<Vec<u16>>, order: ScanOrder) -> Self {
match order {
ScanOrder::Serial if ports.is_none() => {
let range = range.as_ref().unwrap();
let port_ranges = range.as_ref().unwrap();
PortStrategy::Serial(SerialRange {
start: range.start,
end: range.end,
range: port_ranges.0.clone(),
})
}
ScanOrder::Random if ports.is_none() => {
let range = range.as_ref().unwrap();
let port_ranges = range.as_ref().unwrap();
PortStrategy::Random(RandomRange {
start: range.start,
end: range.end,
range: port_ranges.0.clone(),
})
}
ScanOrder::Serial => PortStrategy::Manual(ports.unwrap()),
@@ -62,13 +60,12 @@ trait RangeOrder {
/// ascending order.
#[derive(Debug)]
pub struct SerialRange {
start: u16,
end: u16,
range: Vec<(u16, u16)>,
}
impl RangeOrder for SerialRange {
fn generate(&self) -> Vec<u16> {
(self.start..=self.end).collect()
RangeIterator::new_serial(&self.range).collect()
}
}
@@ -76,8 +73,7 @@ impl RangeOrder for SerialRange {
/// a random order. This vector is built following the LCG algorithm.
#[derive(Debug)]
pub struct RandomRange {
start: u16,
end: u16,
range: Vec<(u16, u16)>,
}
impl RangeOrder for RandomRange {
@@ -91,33 +87,47 @@ 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(self.start.into(), self.end.into()).collect()
RangeIterator::new_random(&self.range).collect()
}
}
#[cfg(test)]
mod tests {
use super::PortStrategy;
use crate::input::{PortRange, ScanOrder};
use crate::input::{PortRanges, ScanOrder};
use std::collections::HashSet;
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 range = PortRange { start: 1, end: 100 };
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Serial);
let ranges = PortRanges(vec![(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)]);
let strategy = PortStrategy::pick(&Some(ranges.clone()), None, ScanOrder::Serial);
let result = strategy.order();
let expected_range = (1..=100).collect::<Vec<u16>>();
assert_eq!(expected_range, result);
let expected = expected_ports_from_ranges(&ranges.0);
assert_eq!(expected, result);
}
#[test]
fn random_strategy_with_range() {
let range = PortRange { start: 1, end: 100 };
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
let ranges = PortRanges(vec![(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)]);
let strategy = PortStrategy::pick(&Some(ranges.clone()), None, ScanOrder::Random);
let mut result = strategy.order();
let expected_range = (1..=100).collect::<Vec<u16>>();
assert_ne!(expected_range, result);
let expected = expected_ports_from_ranges(&ranges.0);
assert_ne!(expected, result);
result.sort_unstable();
assert_eq!(expected_range, result);
assert_eq!(expected, result);
}
#[test]
@@ -131,10 +141,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_range = (1..10).collect::<Vec<u16>>();
assert_ne!(expected_range, result);
let expected = (1..10).collect::<Vec<u16>>();
assert_ne!(expected, result);
result.sort_unstable();
assert_eq!(expected_range, result);
assert_eq!(expected, result);
}
}
+251 -57
View File
@@ -1,71 +1,196 @@
use gcd::Gcd;
use rand::Rng;
use rand::RngExt;
use std::convert::TryInto;
/// Yields ports produced from a collection of (possibly overlapping)
/// inclusive `u16` ranges in two modes:
///
/// **Randomized** — created by `RangeIterator::new_random`:
/// - The algorithm generates a permutation of indices `0..N-1` using the
/// additive congruential step `x_{i+1} = (x_i + step) % N`.
/// - `step` is chosen so `gcd(step, N) == 1` to ensure the sequence is a
/// full-length cycle (visits each index exactly once).
/// - `x_0` (the seed stored as `normalized_first_pick`) is chosen uniformly
/// in `0..N`.
///
/// For more information: <https://en.wikipedia.org/wiki/Linear_congruential_generator>
///
/// **Serial** — `RangeIterator::new_serial`:
/// Iterates the input ranges in the **original input order** and yields
/// each port the first time it is encountered. Duplicate ports (from
/// overlapping ranges) are skipped using a 65_536-entry "seen" table.
pub struct RangeIterator {
active: bool,
normalized_end: u32,
total: 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 {
/// 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.
/// Construct a randomized iterator (LCG permutation).
///
/// 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);
/// 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();
// Randomly choose a number within the range to be the first
// and assign it as a pick.
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 normalized_first_pick = rng.random_range(0..normalized_end);
let first = rng.random_range(0..total);
Self {
active: true,
normalized_end,
total,
normalized_first_pick: first,
normalized_pick: first,
step,
normalized_first_pick,
normalized_pick: normalized_first_pick,
actual_start: start,
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),
}
}
}
impl Iterator for RangeIterator {
type Item = 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.
/// 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`.
///
fn next(&mut self) -> Option<Self::Item> {
if !self.active {
return None;
}
let current_pick = self.normalized_pick;
let next_pick = (current_pick + self.step) % self.normalized_end;
// 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;
}
// 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 {
// 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 {
self.active = false;
}
self.normalized_pick = next_pick;
Some(
(self.actual_start + current_pick)
.try_into()
.expect("Could not convert u32 to u16"),
)
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)
}
}
@@ -98,36 +223,105 @@ fn pick_random_coprime(end: u32) -> u32 {
#[cfg(test)]
mod tests {
use super::RangeIterator;
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
}
#[test]
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);
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);
let result = generate_sorted_range(1, 100);
let expected_range = (1..=100).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, 1000);
let expected_range = (1..=1000).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, 65_535);
let expected_range = (1..=65_535).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(1000, 2000);
let expected_range = (1000..=2000).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);
}
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();
#[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.
result
// 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);
}
}
+163
View File
@@ -0,0 +1,163 @@
use std::{collections::HashSet, fmt, io, net::IpAddr};
/// Keep the OS error and its target intact until diagnostics need formatting.
#[derive(Debug)]
struct ScanError {
ip: IpAddr,
error: io::Error,
}
impl fmt::Display for ScanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.error, self.ip)
}
}
impl std::error::Error for ScanError {}
/// Preserve the normal I/O result layout; only diagnostics need target context.
pub(super) fn diagnostic_error(error: io::Error, ip: IpAddr, enabled: bool) -> io::Error {
if enabled {
io::Error::other(ScanError { ip, error })
} else {
error
}
}
pub(super) struct ScanErrors {
enabled: bool,
limit: usize,
messages: HashSet<String>,
}
impl ScanErrors {
pub(super) fn new(enabled: bool, limit: usize) -> Self {
Self {
enabled,
limit,
messages: HashSet::new(),
}
}
pub(super) fn record(&mut self, failure: io::Error) {
if self.enabled && self.messages.len() < self.limit {
self.messages.insert(failure.to_string());
}
}
pub(super) fn messages(&self) -> &HashSet<String> {
&self.messages
}
}
/// Detect descriptor exhaustion without allocating or interpreting OS messages.
#[cfg(unix)]
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
matches!(error.raw_os_error(), Some(libc::EMFILE | libc::ENFILE))
}
#[cfg(windows)]
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
error.raw_os_error() == Some(windows_sys::Win32::Networking::WinSock::WSAEMFILE)
}
// Retain the previous fallback for targets without Unix or Winsock error codes.
#[cfg(not(any(unix, windows)))]
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
error
.to_string()
.to_lowercase()
.contains("too many open files")
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fmt, net::SocketAddr};
#[derive(Debug)]
struct MustNotFormat;
impl fmt::Display for MustNotFormat {
fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
panic!("discarded diagnostics must not format errors")
}
}
impl std::error::Error for MustNotFormat {}
fn failure(error: io::Error) -> io::Error {
diagnostic_error(error, "127.0.0.1".parse().unwrap(), true)
}
#[test]
fn disabled_diagnostics_do_not_format_errors() {
let mut errors = ScanErrors::new(false, 1000);
errors.record(diagnostic_error(
io::Error::other(MustNotFormat),
"127.0.0.1".parse().unwrap(),
false,
));
assert!(errors.messages().is_empty());
}
#[test]
fn disabled_diagnostics_preserve_the_original_os_error() {
let original = io::Error::from_raw_os_error(123);
let error = diagnostic_error(original, "127.0.0.1".parse().unwrap(), false);
assert_eq!(error.raw_os_error(), Some(123));
}
#[test]
fn full_diagnostics_do_not_format_more_errors() {
let mut errors = ScanErrors::new(true, 1);
errors.record(failure(io::ErrorKind::ConnectionRefused.into()));
errors.record(failure(io::Error::other(MustNotFormat)));
assert_eq!(errors.messages().len(), 1);
}
#[test]
fn enabled_diagnostics_keep_error_details_and_deduplicate_per_ip() {
let mut errors = ScanErrors::new(true, 1000);
for port in [80, 443] {
errors.record(diagnostic_error(
io::Error::other("test socket error"),
SocketAddr::from(([127, 0, 0, 1], port)).ip(),
true,
));
}
assert_eq!(errors.messages().len(), 1);
assert!(errors.messages().contains("test socket error 127.0.0.1"));
}
#[cfg(unix)]
#[test]
fn detects_unix_descriptor_limits_by_code() {
for code in [libc::EMFILE, libc::ENFILE] {
assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error(
code
)));
}
assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error(
libc::ECONNREFUSED
)));
}
#[cfg(windows)]
#[test]
fn detects_winsock_descriptor_limits_by_code() {
use windows_sys::Win32::Networking::WinSock::{WSAECONNREFUSED, WSAEMFILE};
assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error(
WSAEMFILE
)));
assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error(
WSAECONNREFUSED
)));
}
#[cfg(any(unix, windows))]
#[test]
fn descriptor_check_never_formats_an_error() {
assert!(!is_descriptor_exhaustion(&io::Error::other(MustNotFormat)));
}
}
+569 -318
View File
File diff suppressed because it is too large. Load diff
+101 -29
View File
@@ -1,55 +1,78 @@
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> {
// 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>>>,
// 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>>,
}
/// 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 {
product_it: iproduct!(ports_it, ips_it),
// `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()),
}
}
}
#[allow(clippy::doc_link_with_quotes)]
impl Iterator for SocketIterator<'_> {
type Item = SocketAddr;
/// 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
/// Returns the next socket, or `None` when all combinations are exhausted.
/// Every IP is paired with the same port until the port advances.
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;
@@ -72,4 +95,53 @@ 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);
}
}
}
+68 -88
View File
@@ -46,12 +46,9 @@
//!
//! - `fixtures/test_rustscan_scripts.toml`
//!
//! Script file examples:
//! Metadata-only test fixture:
//!
//! - `fixtures/test_script.py`
//! - `fixtures/test_script.pl`
//! - `fixtures/test_script.sh`
//! - `fixtures/test_script.txt`
//! - `fixtures/.rustscan_scripts/test_script.txt`
//!
//! `call_format` in script files can be of 2 variants:
//!
@@ -108,16 +105,20 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
scripts_to_run.push(default_script);
}
ScriptsRequired::Custom => {
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 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 parsed_scripts = parse_scripts(script_paths);
debug!("Scripts parsed \n{:?}", parsed_scripts);
let script_config = ScriptConfig::read_config()?;
debug!("Script config \n{:?}", script_config);
debug!("Scripts parsed \n{parsed_scripts:?}");
// Only Scripts that contain all the tags found in ScriptConfig will be selected.
if let Some(config_hashset) = script_config.tags {
@@ -131,14 +132,14 @@ 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:?}");
}
}
@@ -148,7 +149,7 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
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);
}
@@ -220,7 +221,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());
@@ -265,14 +266,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")
@@ -310,16 +311,15 @@ fn execute_script(script: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
Err(error) => {
debug!("Command error {}", error.to_string());
debug!("Command error {error}",);
Err(anyhow!(error.to_string()))
}
}
}
pub fn find_scripts(mut path: PathBuf) -> Result<Vec<PathBuf>> {
path.push(".rustscan_scripts");
pub fn find_scripts(path: PathBuf) -> Result<Vec<PathBuf>> {
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?;
@@ -357,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.to_string());
debug!("Failed to parse ScriptFile headers {e}");
None
}
}
@@ -382,6 +382,7 @@ 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))]
@@ -400,27 +401,13 @@ impl ScriptConfig {
#[cfg(test)]
mod tests {
use super::{find_scripts, parse_scripts, Script, ScriptFile};
// 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,
)
}
use super::*;
#[test]
fn find_and_parse_scripts() {
let scripts = find_scripts("fixtures/".into()).unwrap();
let scripts = find_scripts("fixtures/.rustscan_scripts".into()).unwrap();
let scripts = parse_scripts(scripts);
assert_eq!(scripts.len(), 4);
assert_eq!(scripts.len(), 1);
}
#[test]
@@ -436,26 +423,6 @@ 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() {
@@ -480,39 +447,52 @@ mod tests {
assert_eq!(script_f.ports_separator, Some(",".to_string()));
assert_eq!(
script_f.call_format,
Some("nmap -vvv -p {{port}} {{ip}}".to_string())
Some("fixture {{ip}} {{port}}".to_string())
);
}
#[test]
#[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");
}
fn test_custom_directory_config() {
// Create test config
let config_str = r#"
tags = ["core_approved", "example"]
directory = "fixtures/.rustscan_scripts"
"#;
#[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
let config: ScriptConfig = toml::from_str(config_str).unwrap();
assert_eq!(
output.trim(),
"Python script ran with arguments ['fixtures/.rustscan_scripts/test_script.py', '127.0.0.1', '80,8080']"
config.directory,
Some("fixtures/.rustscan_scripts".to_string())
);
// 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]
#[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");
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());
}
}
+57 -13
View File
@@ -1,20 +1,55 @@
//! 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) => {
println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name);
$crate::tui::println_safe(format_args!(
"{} {}",
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
println!("{}", $name);
$crate::tui::println_safe(format_args!("{}", $name));
} else {
println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name);
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Red.bold().paint("[!]"),
$name
));
}
}
};
@@ -23,16 +58,24 @@ macro_rules! warning {
#[macro_export]
macro_rules! detail {
($name:expr) => {
println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name);
$crate::tui::println_safe(format_args!(
"{} {}",
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
println!("{}", $name);
$crate::tui::println_safe(format_args!("{}", $name));
} else {
println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name);
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::Blue.bold().paint("[~]"),
$name
));
}
}
};
@@ -41,24 +84,24 @@ macro_rules! detail {
#[macro_export]
macro_rules! output {
($name:expr) => {
println!(
$crate::tui::println_safe(format_args!(
"{} {}",
RGansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
$name
);
));
};
($name:expr, $greppable:expr, $accessible:expr) => {
// if not greppable then print, otherwise no else statement so do not print.
if !$greppable {
if $accessible {
// Don't print the ascii art
println!("{}", $name);
$crate::tui::println_safe(format_args!("{}", $name));
} else {
println!(
$crate::tui::println_safe(format_args!(
"{} {}",
ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
$name
);
));
}
}
};
@@ -97,9 +140,10 @@ 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();
println!("{}\n", random_quote);
$crate::tui::println_safe(format_args!("{}\n", random_quote));
};
}
-163
View File
@@ -1,163 +0,0 @@
/*
* 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
@@ -0,0 +1,32 @@
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");
}