#![doc = env!("CARGO_PKG_DESCRIPTION")]
#![doc = ""]
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(
html_logo_url = "https://raw.githubusercontent.com/0xdea/singsing-rs/master/.img/logo_singsing.png"
)]
#![expect(
clippy::pub_use,
reason = "the crate's one `pub use` re-exports a foreign `ipnet` type that already appears \
in our public API (`TargetsError`), the deliberate exception this lint warns \
against as a module-layout anti-pattern; `use` items can't carry the attribute \
themselves, so it's set here instead"
)]
#[cfg(not(target_os = "linux"))]
compile_error!("singsing-rs only supports Linux (see the Compatibility section in README.md)");
use std::any::Any;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::error::Error;
use std::net::{IpAddr, Ipv4Addr};
use std::num::ParseIntError;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use std::{fs, io, thread};
use pnet::datalink;
use pnet::packet::ip::IpNextHeaderProtocols;
use pnet::packet::ipv4::{Ipv4Packet, MutableIpv4Packet, checksum};
use pnet::packet::tcp::{MutableTcpPacket, TcpFlags, TcpPacket, ipv4_checksum};
use pnet::packet::{MutablePacket as _, Packet as _};
use pnet::transport::{
TransportChannelType, TransportReceiver, ipv4_packet_iter, transport_channel,
};
const PACKET_LEN: usize = 40;
const RECEIVE_BUFFER_LEN: usize = 1 << 20;
const MAX_PROBES: usize = 16_777_214;
const MAX_TIMEOUT: Duration = Duration::from_hours(24);
const ONE_MINUTE: Duration = Duration::from_mins(1);
const TEN_MINUTES: Duration = Duration::from_mins(10);
const THIRTY_MINUTES: Duration = Duration::from_mins(30);
const ONE_HOUR: Duration = Duration::from_hours(1);
pub type Port = u16;
type SeqNum = u32;
type ExpectedResponses = HashMap<(Ipv4Addr, Port), SeqNum>;
pub type CallbackError = Box<dyn Error + Send + Sync>;
pub use ipnet::{AddrParseError, Ipv4Net};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum InterfaceError {
#[error("network interface {name:?} does not exist")]
NotFound {
name: String,
},
#[error("network interface {name:?} has no IPv4 address")]
NoIpv4 {
name: String,
},
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TargetsError {
#[error("invalid IPv4 network")]
InvalidNetwork(#[source] AddrParseError),
#[error("invalid IPv4 address")]
InvalidAddress(#[source] AddrParseError),
#[error("{network} contains more than {max} usable addresses; split networks larger than a /8")]
TooLarge {
network: Ipv4Net,
max: usize,
},
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PortsError {
#[error("empty port in {input:?}")]
EmptyItem {
input: String,
},
#[error("invalid port range {item:?}")]
InvalidRange {
item: String,
},
#[error("reversed port range {item:?}")]
ReversedRange {
item: String,
},
#[error("invalid TCP port {input:?}")]
InvalidPort {
input: String,
#[source]
source: ParseIntError,
},
#[error("TCP port zero is not supported")]
PortZero,
#[error("failed to read {}", path.display())]
ServicesFileRead {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("{} contains no TCP services", path.display())]
NoTcpServices {
path: PathBuf,
},
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ScanError {
#[error("at least one target and one port are required")]
EmptyScan,
#[error("bandwidth must be greater than zero")]
ZeroBandwidth,
#[error("bandwidth is too large")]
BandwidthOverflow,
#[error("timeout of {timeout:?} exceeds the maximum of {max:?}")]
TimeoutTooLarge {
timeout: Duration,
max: Duration,
},
#[error("scan size overflow")]
ScanSizeOverflow,
#[error(
"scan contains {probe_count} probes; maximum is {max} \
(one port on a /8 or all 65,535 ports on a /24); split larger scans"
)]
TooManyProbes {
probe_count: usize,
max: usize,
},
#[error("duplicate host/port pair {host}:{port}; ScanConfig targets and ports must be unique")]
DuplicatePair {
host: Ipv4Addr,
port: Port,
},
#[error("failed to create raw socket (run as root or grant CAP_NET_RAW)")]
SocketCreation(#[source] io::Error),
#[error("failed to receive raw packet")]
Receive(#[source] io::Error),
#[error("packet receiver thread panicked: {0}")]
ReceiverPanicked(String),
#[error(transparent)]
Incomplete(IncompleteScanError),
#[error("callback failed")]
Callback(#[source] CallbackError),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SendError {
#[error("failed to construct IPv4 packet")]
PacketConstruction,
#[error("failed to send SYN to {host}:{port}")]
Io {
host: Ipv4Addr,
port: Port,
#[source]
source: io::Error,
},
#[error("callback failed")]
Callback(#[source] CallbackError),
}
#[derive(Debug, thiserror::Error)]
#[error("scan stopped after sending {probes_sent} of {total_probes} probes")]
pub struct IncompleteScanError {
#[source]
source: SendError,
partial_results: Vec<ScanResult>,
probes_sent: usize,
total_probes: usize,
}
impl IncompleteScanError {
#[must_use]
pub fn partial_results(&self) -> &[ScanResult] {
&self.partial_results
}
#[must_use]
pub const fn probes_sent(&self) -> usize {
self.probes_sent
}
#[must_use]
pub const fn total_probes(&self) -> usize {
self.total_probes
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub struct ScanConfig {
pub targets: Vec<Ipv4Addr>,
pub ports: Vec<Port>,
pub source: Ipv4Addr,
pub bandwidth_kib: u64,
pub timeout: Duration,
pub show_closed: bool,
}
impl ScanConfig {
#[must_use]
pub const fn new(targets: Vec<Ipv4Addr>, ports: Vec<Port>, source: Ipv4Addr) -> Self {
Self {
targets,
ports,
source,
bandwidth_kib: 15,
timeout: Duration::from_secs(30),
show_closed: false,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub struct ScanProgress {
pub probes_sent: usize,
pub total_probes: usize,
pub elapsed: Duration,
}
impl ScanProgress {
#[must_use]
pub const fn new(probes_sent: usize, total_probes: usize, elapsed: Duration) -> Self {
Self {
probes_sent,
total_probes,
elapsed,
}
}
#[must_use]
pub const fn percent(self) -> usize {
if self.total_probes == 0 {
return 0;
}
self.probes_sent.saturating_mul(100) / self.total_probes
}
#[must_use]
pub fn estimated_remaining(self) -> Option<Duration> {
let sent = u32::try_from(self.probes_sent).ok()?;
let remaining = u32::try_from(self.total_probes.saturating_sub(self.probes_sent)).ok()?;
if sent == 0 {
return None;
}
self.elapsed.checked_mul(remaining)?.checked_div(sent)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum PortState {
Open,
Closed,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub struct ScanResult {
pub host: Ipv4Addr,
pub port: Port,
pub state: PortState,
}
impl ScanResult {
#[must_use]
pub const fn new(host: Ipv4Addr, port: Port, state: PortState) -> Self {
Self { host, port, state }
}
}
pub fn interface_ipv4(name: &str) -> Result<Ipv4Addr, InterfaceError> {
let interface = datalink::interfaces()
.into_iter()
.find(|interface| interface.name == name)
.ok_or_else(|| InterfaceError::NotFound {
name: name.to_owned(),
})?;
interface
.ips
.into_iter()
.find_map(|network| match network.ip() {
IpAddr::V4(address) => Some(address),
IpAddr::V6(_) => None,
})
.ok_or_else(|| InterfaceError::NoIpv4 {
name: name.to_owned(),
})
}
pub fn parse_targets(input: &str) -> Result<Vec<Ipv4Addr>, TargetsError> {
let network = if input.contains('/') {
input.parse().map_err(TargetsError::InvalidNetwork)?
} else {
format!("{input}/32")
.parse()
.map_err(TargetsError::InvalidAddress)?
};
if usable_target_count(network).is_none_or(|count| count > MAX_PROBES) {
return Err(TargetsError::TooLarge {
network,
max: MAX_PROBES,
});
}
Ok(network.hosts().collect())
}
pub fn parse_ports(input: &str) -> Result<Vec<Port>, PortsError> {
let mut ports = BTreeSet::new();
for item in input.split(',') {
if item.is_empty() {
return Err(PortsError::EmptyItem {
input: input.to_owned(),
});
}
let (start, end) = if let Some((start, end)) = item.split_once('-') {
if end.contains('-') {
return Err(PortsError::InvalidRange {
item: item.to_owned(),
});
}
(parse_port(start)?, parse_port(end)?)
} else {
let port = parse_port(item)?;
(port, port)
};
if start > end {
return Err(PortsError::ReversedRange {
item: item.to_owned(),
});
}
ports.extend(start..=end);
}
Ok(ports.into_iter().collect())
}
pub fn ports_from_services(path: impl AsRef<Path>) -> Result<Vec<Port>, PortsError> {
let contents =
fs::read_to_string(path.as_ref()).map_err(|source| PortsError::ServicesFileRead {
path: path.as_ref().to_path_buf(),
source,
})?;
let mut ports = BTreeSet::new();
for line in contents.lines() {
let mut fields = line
.split('#')
.next()
.unwrap_or_default()
.split_whitespace();
let _service = fields.next();
if let Some(port_protocol) = fields.next()
&& let Some((port, "tcp")) = port_protocol.split_once('/')
&& let Ok(port) = parse_port(port)
{
ports.insert(port);
}
}
if ports.is_empty() {
return Err(PortsError::NoTcpServices {
path: path.as_ref().to_path_buf(),
});
}
Ok(ports.into_iter().collect())
}
pub fn scan(config: &ScanConfig) -> Result<Vec<ScanResult>, ScanError> {
scan_with_callbacks(config, |_| Ok(()), |_| Ok(()))
}
pub fn scan_with_callback(
config: &ScanConfig,
on_result: impl FnMut(ScanResult) -> Result<(), CallbackError> + Send + 'static,
) -> Result<Vec<ScanResult>, ScanError> {
scan_with_callbacks(config, on_result, |_| Ok(()))
}
pub fn scan_with_callbacks(
config: &ScanConfig,
mut on_result: impl FnMut(ScanResult) -> Result<(), CallbackError> + Send + 'static,
mut on_progress: impl FnMut(ScanProgress) -> Result<(), CallbackError>,
) -> Result<Vec<ScanResult>, ScanError> {
let probe_count = validate_scan(config)?;
let source_port = source_port();
let nonce = nonce();
let expected = Arc::new(expected_responses(config, nonce, probe_count)?);
let protocol = TransportChannelType::Layer3(IpNextHeaderProtocols::Tcp);
let (mut sender, mut receiver) =
transport_channel(RECEIVE_BUFFER_LEN, protocol).map_err(ScanError::SocketCreation)?;
let done = Arc::new(AtomicBool::new(false));
let receiver_done = Arc::clone(&done);
let receiver_expected = Arc::clone(&expected);
let source = config.source;
let timeout = config.timeout;
let show_closed = config.show_closed;
let receive_thread = thread::spawn(move || {
let receive_config = ReceiveConfig {
expected: &receiver_expected,
source,
source_port,
show_closed,
done: &receiver_done,
timeout,
};
receive(&mut receiver, &receive_config, &mut on_result)
});
let bytes_per_second = config
.bandwidth_kib
.checked_mul(1024)
.ok_or(ScanError::BandwidthOverflow)?;
let packets_per_second = (bytes_per_second / 40).max(1);
let interval = Duration::from_nanos(1_000_000_000_u64 / packets_per_second);
let mut next_send = Instant::now();
let started = next_send;
let mut next_progress = ONE_MINUTE;
let mut probes_sent = 0;
let send_result = (|| -> Result<(), SendError> {
#[expect(
clippy::iter_over_hash_type,
reason = "randomized `HashMap` iteration order is deliberate; see README's Transmission order section"
)]
for (&(host, port), &sequence) in expected.iter() {
let packet = syn_packet(config.source, host, source_port, port, sequence);
let ipv4_packet =
MutableIpv4Packet::owned(packet).ok_or(SendError::PacketConstruction)?;
sender
.send_to(ipv4_packet, IpAddr::V4(host))
.map_err(|io_error| SendError::Io {
host,
port,
source: io_error,
})?;
probes_sent += 1;
next_send += interval;
if let Some(delay) = next_send.checked_duration_since(Instant::now()) {
thread::sleep(delay);
}
let now = Instant::now();
let elapsed = now.duration_since(started);
if elapsed >= next_progress {
on_progress(ScanProgress {
probes_sent,
total_probes: probe_count,
elapsed,
})
.map_err(SendError::Callback)?;
next_progress = advance_progress_deadline(next_progress, elapsed);
}
}
Ok(())
})();
done.store(true, Ordering::Release);
let mut results = receive_thread.join().map_err(|payload| {
ScanError::ReceiverPanicked(describe_panic_payload(&*payload).to_owned())
})??;
results.sort_unstable_by_key(|result| (u32::from(result.host), result.port));
if let Err(error) = send_result {
return Err(ScanError::Incomplete(IncompleteScanError {
source: error,
partial_results: results,
probes_sent,
total_probes: probe_count,
}));
}
Ok(results)
}
fn usable_target_count(network: Ipv4Net) -> Option<usize> {
let host_bits = 32_u32.checked_sub(u32::from(network.prefix_len()))?;
match host_bits {
0 => Some(1),
1 => Some(2),
bits => 1_usize.checked_shl(bits)?.checked_sub(2),
}
}
fn parse_port(input: &str) -> Result<Port, PortsError> {
let port = input.parse().map_err(|source| PortsError::InvalidPort {
input: input.to_owned(),
source,
})?;
if port == 0 {
return Err(PortsError::PortZero);
}
Ok(port)
}
fn validate_scan(config: &ScanConfig) -> Result<usize, ScanError> {
validate_probe_count(
config.targets.len(),
config.ports.len(),
config.bandwidth_kib,
config.timeout,
)
}
fn validate_probe_count(
target_count: usize,
port_count: usize,
bandwidth_kib: u64,
timeout: Duration,
) -> Result<usize, ScanError> {
if target_count == 0 || port_count == 0 {
return Err(ScanError::EmptyScan);
}
if bandwidth_kib == 0 {
return Err(ScanError::ZeroBandwidth);
}
if timeout > MAX_TIMEOUT {
return Err(ScanError::TimeoutTooLarge {
timeout,
max: MAX_TIMEOUT,
});
}
let probe_count = target_count
.checked_mul(port_count)
.ok_or(ScanError::ScanSizeOverflow)?;
if probe_count > MAX_PROBES {
return Err(ScanError::TooManyProbes {
probe_count,
max: MAX_PROBES,
});
}
Ok(probe_count)
}
#[expect(
clippy::as_conversions,
reason = "`nonce() % 16384` is always in `0..16384`, so it always fits in a `u16`"
)]
fn source_port() -> Port {
49152 + (nonce() % 16384) as u16
}
fn nonce() -> u32 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos()
}
fn expected_responses(
config: &ScanConfig,
nonce: u32,
probe_count: usize,
) -> Result<ExpectedResponses, ScanError> {
let mut expected = HashMap::with_capacity(probe_count);
for &host in &config.targets {
for &port in &config.ports {
if expected
.insert((host, port), sequence(host, port, nonce))
.is_some()
{
return Err(ScanError::DuplicatePair { host, port });
}
}
}
Ok(expected)
}
fn sequence(host: Ipv4Addr, port: Port, nonce: u32) -> SeqNum {
u32::from(host)
.rotate_left(13)
.wrapping_add(u32::from(port).rotate_left(3))
^ nonce
}
fn syn_packet(
source: Ipv4Addr,
destination: Ipv4Addr,
source_port: Port,
destination_port: Port,
sequence: SeqNum,
) -> Vec<u8> {
let mut bytes = vec![0_u8; PACKET_LEN];
#[expect(
clippy::expect_used,
reason = "`bytes` is exactly `PACKET_LEN`, sized to fit one IPv4 header and one TCP header, so packet construction cannot fail"
)]
let mut ipv4 = MutableIpv4Packet::new(&mut bytes).expect("fixed-size IPv4 packet");
ipv4.set_version(4);
ipv4.set_header_length(5);
ipv4.set_total_length(40);
#[expect(
clippy::as_conversions,
reason = "`sequence >> 16` keeps only the top 16 bits, so it always fits in a `u16`"
)]
ipv4.set_identification((sequence >> 16) as u16);
ipv4.set_ttl(64);
ipv4.set_next_level_protocol(IpNextHeaderProtocols::Tcp);
ipv4.set_source(source);
ipv4.set_destination(destination);
#[expect(
clippy::expect_used,
reason = "`bytes` is exactly `PACKET_LEN`, sized to fit one IPv4 header and one TCP header, so packet construction cannot fail"
)]
let mut tcp = MutableTcpPacket::new(ipv4.payload_mut()).expect("fixed-size TCP packet");
tcp.set_source(source_port);
tcp.set_destination(destination_port);
tcp.set_sequence(sequence);
tcp.set_data_offset(5);
tcp.set_flags(TcpFlags::SYN);
tcp.set_window(64240);
tcp.set_checksum(ipv4_checksum(&tcp.to_immutable(), &source, &destination));
ipv4.set_checksum(checksum(&ipv4.to_immutable()));
bytes
}
fn advance_progress_deadline(mut deadline: Duration, elapsed: Duration) -> Duration {
while deadline <= elapsed {
deadline = next_progress_deadline(deadline);
}
deadline
}
fn next_progress_deadline(previous: Duration) -> Duration {
let interval = if previous < TEN_MINUTES {
ONE_MINUTE
} else if previous < ONE_HOUR {
TEN_MINUTES
} else {
THIRTY_MINUTES
};
previous + interval
}
struct ReceiveConfig<'a> {
expected: &'a ExpectedResponses,
source: Ipv4Addr,
source_port: Port,
show_closed: bool,
done: &'a AtomicBool,
timeout: Duration,
}
fn receive(
receiver: &mut TransportReceiver,
config: &ReceiveConfig<'_>,
on_result: &mut impl FnMut(ScanResult) -> Result<(), CallbackError>,
) -> Result<Vec<ScanResult>, ScanError> {
let mut iterator = ipv4_packet_iter(receiver);
let mut results = Vec::new();
let mut seen = HashSet::new();
let mut deadline = None;
loop {
if config.done.load(Ordering::Acquire) && deadline.is_none() {
deadline = Some(Instant::now() + config.timeout);
}
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
break;
}
let wait = deadline
.and_then(|deadline| deadline.checked_duration_since(Instant::now()))
.unwrap_or(Duration::from_millis(100))
.min(Duration::from_millis(100));
let Some((ipv4, _)) = iterator
.next_with_timeout(wait)
.map_err(ScanError::Receive)?
else {
continue;
};
let Some(result) = classify_response(
&ipv4,
config.expected,
config.source,
config.source_port,
config.show_closed,
&mut seen,
) else {
continue;
};
on_result(result).map_err(ScanError::Callback)?;
results.push(result);
}
Ok(results)
}
fn classify_response(
ipv4: &Ipv4Packet<'_>,
expected: &ExpectedResponses,
source: Ipv4Addr,
source_port: Port,
show_closed: bool,
seen: &mut HashSet<(Ipv4Addr, Port)>,
) -> Option<ScanResult> {
if ipv4.get_destination() != source {
return None;
}
let tcp = TcpPacket::new(ipv4.payload())?;
let key = (ipv4.get_source(), tcp.get_source());
let (host, port) = key;
let sequence = expected.get(&key)?;
if tcp.get_destination() != source_port || tcp.get_acknowledgement() != sequence.wrapping_add(1)
{
return None;
}
let flags = tcp.get_flags();
let state = if flags == TcpFlags::SYN | TcpFlags::ACK {
PortState::Open
} else if show_closed && (flags == TcpFlags::RST || flags == TcpFlags::RST | TcpFlags::ACK) {
PortState::Closed
} else {
return None;
};
if !seen.insert(key) {
return None;
}
Some(ScanResult { host, port, state })
}
fn describe_panic_payload(payload: &(dyn Any + Send)) -> &str {
payload
.downcast_ref::<&str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("unknown panic payload")
}
#[cfg(test)]
#[expect(clippy::panic_in_result_fn, reason = "panics are allowed in test code")]
#[expect(clippy::unwrap_used, reason = "tests can use `unwrap`")]
mod tests {
use std::path::PathBuf;
use std::sync::atomic::AtomicUsize;
use std::{env, fs, io, process};
use super::*;
fn response_packet(
remote: Ipv4Addr,
local: Ipv4Addr,
remote_port: Port,
local_port: Port,
acknowledgement: SeqNum,
flags: u8,
) -> Vec<u8> {
let mut bytes = vec![0_u8; PACKET_LEN];
let mut ipv4 = MutableIpv4Packet::new(&mut bytes).unwrap();
ipv4.set_version(4);
ipv4.set_header_length(5);
ipv4.set_total_length(40);
ipv4.set_next_level_protocol(IpNextHeaderProtocols::Tcp);
ipv4.set_source(remote);
ipv4.set_destination(local);
let mut tcp = MutableTcpPacket::new(ipv4.payload_mut()).unwrap();
tcp.set_source(remote_port);
tcp.set_destination(local_port);
tcp.set_acknowledgement(acknowledgement);
tcp.set_data_offset(5);
tcp.set_flags(flags);
bytes
}
fn classify_packet(
bytes: &[u8],
expected: &ExpectedResponses,
source: Ipv4Addr,
source_port: Port,
show_closed: bool,
seen: &mut HashSet<(Ipv4Addr, Port)>,
) -> Option<ScanResult> {
let ipv4 = Ipv4Packet::new(bytes)?;
classify_response(&ipv4, expected, source, source_port, show_closed, seen)
}
fn services_path() -> PathBuf {
static NEXT_FILE: AtomicUsize = AtomicUsize::new(0);
let number = NEXT_FILE.fetch_add(1, Ordering::Relaxed);
env::temp_dir().join(format!("singsing-rs-services-{}-{number}", process::id()))
}
fn services_from(contents: &str) -> anyhow::Result<Vec<u16>> {
let path = services_path();
fs::write(&path, contents)?;
let result = ports_from_services(&path).map_err(anyhow::Error::from);
fs::remove_file(path)?;
result
}
#[test]
fn parses_ports_ranges_and_duplicates() {
assert_eq!(parse_ports("22,80,79-81").unwrap(), [22, 79, 80, 81]);
}
#[test]
fn rejects_invalid_ports() {
assert!(matches!(parse_ports("0"), Err(PortsError::PortZero)));
assert!(matches!(
parse_ports("80-79"),
Err(PortsError::ReversedRange { item }) if item == "80-79"
));
assert!(matches!(
parse_ports("65536"),
Err(PortsError::InvalidPort { input, .. }) if input == "65536"
));
assert!(matches!(
parse_ports("22,"),
Err(PortsError::EmptyItem { input }) if input == "22,"
));
assert!(matches!(
parse_ports("1-2-3"),
Err(PortsError::InvalidRange { item }) if item == "1-2-3"
));
}
#[test]
fn parses_host_and_network() {
assert_eq!(
parse_targets("192.168.2.9").unwrap(),
["192.168.2.9".parse::<Ipv4Addr>().unwrap()]
);
assert_eq!(
parse_targets("192.168.2.0/30").unwrap(),
[
"192.168.2.1".parse::<Ipv4Addr>().unwrap(),
"192.168.2.2".parse::<Ipv4Addr>().unwrap()
]
);
assert_eq!(
parse_targets("192.168.2.0/31").unwrap(),
[
"192.168.2.0".parse::<Ipv4Addr>().unwrap(),
"192.168.2.1".parse::<Ipv4Addr>().unwrap()
]
);
assert_eq!(
parse_targets("192.168.2.7/32").unwrap(),
["192.168.2.7".parse::<Ipv4Addr>().unwrap()]
);
}
#[test]
fn normalizes_host_bits_and_rejects_invalid_targets() {
assert_eq!(
parse_targets("192.168.2.7/30").unwrap(),
[
"192.168.2.5".parse::<Ipv4Addr>().unwrap(),
"192.168.2.6".parse::<Ipv4Addr>().unwrap()
]
);
assert!(matches!(
parse_targets(""),
Err(TargetsError::InvalidAddress(_))
));
assert!(matches!(
parse_targets("not-an-address"),
Err(TargetsError::InvalidAddress(_))
));
assert!(matches!(
parse_targets("192.168.2.1/33"),
Err(TargetsError::InvalidNetwork(_))
));
}
#[test]
fn rejects_oversized_cidr_before_expansion() {
let slash_8 = "10.0.0.0/8".parse::<Ipv4Net>().unwrap();
let slash_31 = "192.168.2.0/31".parse::<Ipv4Net>().unwrap();
let slash_32 = "192.168.2.1/32".parse::<Ipv4Net>().unwrap();
assert_eq!(usable_target_count(slash_8), Some(MAX_PROBES));
assert_eq!(usable_target_count(slash_31), Some(2));
assert_eq!(usable_target_count(slash_32), Some(1));
assert!(matches!(
parse_targets("10.0.0.0/7"),
Err(TargetsError::TooLarge { max, .. }) if max == MAX_PROBES
));
assert!(matches!(
parse_targets("0.0.0.0/0"),
Err(TargetsError::TooLarge { max, .. }) if max == MAX_PROBES
));
}
#[test]
fn resolves_loopback_interface_address() {
assert_eq!(interface_ipv4("lo").unwrap(), Ipv4Addr::LOCALHOST);
}
#[test]
fn rejects_unknown_interface() {
let name = "singsing-rs-interface-does-not-exist";
assert!(matches!(
interface_ipv4(name),
Err(InterfaceError::NotFound { name: n }) if n == name
));
}
#[test]
#[expect(
clippy::as_conversions,
reason = "`sequence >> 16` keeps only the top 16 bits, so it always fits in a `u16`"
)]
fn builds_valid_syn_packet() {
let source = "192.168.2.1".parse().unwrap();
let destination = "172.16.100.2".parse().unwrap();
let sequence = 0x1234_5678;
let bytes = syn_packet(source, destination, 50000, 443, sequence);
let ipv4 = Ipv4Packet::new(&bytes).unwrap();
let tcp = TcpPacket::new(ipv4.payload()).unwrap();
assert_eq!(bytes.len(), PACKET_LEN);
assert_eq!(ipv4.get_version(), 4);
assert_eq!(ipv4.get_header_length(), 5);
assert_eq!(ipv4.get_total_length(), 40);
assert_eq!(ipv4.get_identification(), (sequence >> 16) as u16);
assert_eq!(ipv4.get_ttl(), 64);
assert_eq!(ipv4.get_next_level_protocol(), IpNextHeaderProtocols::Tcp);
assert_eq!(ipv4.get_source(), source);
assert_eq!(ipv4.get_destination(), destination);
let mut ip_for_checksum = MutableIpv4Packet::owned(bytes.clone()).unwrap();
ip_for_checksum.set_checksum(0);
assert_eq!(
ipv4.get_checksum(),
checksum(&ip_for_checksum.to_immutable())
);
let mut tcp_for_checksum = MutableTcpPacket::owned(tcp.packet().to_vec()).unwrap();
tcp_for_checksum.set_checksum(0);
assert_eq!(
tcp.get_checksum(),
ipv4_checksum(&tcp_for_checksum.to_immutable(), &source, &destination)
);
assert_eq!(tcp.packet().len(), 20);
assert!(tcp.payload().is_empty());
assert_eq!(tcp.get_source(), 50000);
assert_eq!(tcp.get_destination(), 443);
assert_eq!(tcp.get_sequence(), sequence);
assert_eq!(tcp.get_acknowledgement(), 0);
assert_eq!(tcp.get_data_offset(), 5);
assert_eq!(tcp.get_flags(), TcpFlags::SYN);
assert_eq!(tcp.get_window(), 64240);
assert_eq!(tcp.get_urgent_ptr(), 0);
}
#[test]
fn accepts_open_response_once() {
let source = "192.168.2.1".parse().unwrap();
let target = "172.16.100.2".parse().unwrap();
let source_port = 50000;
let target_port = 443;
let sequence = 0x1234_5678_u32;
let expected = HashMap::from([((target, target_port), sequence)]);
let open = ScanResult {
host: target,
port: target_port,
state: PortState::Open,
};
let valid_open = response_packet(
target,
source,
target_port,
source_port,
sequence.wrapping_add(1),
TcpFlags::SYN | TcpFlags::ACK,
);
let mut seen = HashSet::new();
assert_eq!(
classify_packet(
&valid_open,
&expected,
source,
source_port,
false,
&mut seen
),
Some(open)
);
assert_eq!(
classify_packet(
&valid_open,
&expected,
source,
source_port,
false,
&mut seen
),
None
);
}
#[test]
fn rejects_uncorrelated_responses() {
let source = "192.168.2.1".parse().unwrap();
let target = "172.16.100.2".parse().unwrap();
let other_target = "172.16.100.3".parse().unwrap();
let source_port = 50000;
let target_port = 443;
let sequence = 0x1234_5678_u32;
let expected = HashMap::from([((target, target_port), sequence)]);
let invalid_packets = [
response_packet(
target,
"192.168.2.2".parse().unwrap(),
target_port,
source_port,
sequence.wrapping_add(1),
TcpFlags::SYN | TcpFlags::ACK,
),
response_packet(
other_target,
source,
target_port,
source_port,
sequence.wrapping_add(1),
TcpFlags::SYN | TcpFlags::ACK,
),
response_packet(
target,
source,
80,
source_port,
sequence.wrapping_add(1),
TcpFlags::SYN | TcpFlags::ACK,
),
response_packet(
target,
source,
target_port,
source_port + 1,
sequence.wrapping_add(1),
TcpFlags::SYN | TcpFlags::ACK,
),
response_packet(
target,
source,
target_port,
source_port,
sequence,
TcpFlags::SYN | TcpFlags::ACK,
),
];
for packet in invalid_packets {
assert_eq!(
classify_packet(
&packet,
&expected,
source,
source_port,
false,
&mut HashSet::new()
),
None
);
}
}
#[test]
fn reports_closed_responses_only_when_requested() {
let source = "192.168.2.1".parse().unwrap();
let target = "172.16.100.2".parse().unwrap();
let source_port = 50000;
let target_port = 443;
let sequence = 0x1234_5678_u32;
let expected = HashMap::from([((target, target_port), sequence)]);
let closed_packet = response_packet(
target,
source,
target_port,
source_port,
sequence.wrapping_add(1),
TcpFlags::RST | TcpFlags::ACK,
);
let mut closed_seen = HashSet::new();
assert_eq!(
classify_packet(
&closed_packet,
&expected,
source,
source_port,
false,
&mut closed_seen
),
None
);
assert_eq!(
classify_packet(
&closed_packet,
&expected,
source,
source_port,
true,
&mut closed_seen
),
Some(ScanResult {
host: target,
port: target_port,
state: PortState::Closed,
})
);
}
#[test]
fn ignores_truncated_and_unexpected_responses() {
let source = "192.168.2.1".parse().unwrap();
let target = "172.16.100.2".parse().unwrap();
let source_port = 50000;
let target_port = 443;
let sequence = 0x1234_5678_u32;
let expected = HashMap::from([((target, target_port), sequence)]);
let mut truncated = vec![0_u8; 20];
let mut ipv4 = MutableIpv4Packet::new(&mut truncated).unwrap();
ipv4.set_version(4);
ipv4.set_header_length(5);
ipv4.set_total_length(20);
ipv4.set_next_level_protocol(IpNextHeaderProtocols::Tcp);
ipv4.set_source(target);
ipv4.set_destination(source);
let mut seen = HashSet::new();
assert_eq!(
classify_packet(&truncated, &expected, source, source_port, false, &mut seen),
None
);
for flags in [TcpFlags::ACK, TcpFlags::SYN | TcpFlags::ACK | TcpFlags::RST] {
let packet = response_packet(
target,
source,
target_port,
source_port,
sequence.wrapping_add(1),
flags,
);
assert_eq!(
classify_packet(&packet, &expected, source, source_port, true, &mut seen),
None
);
}
let valid = response_packet(
target,
source,
target_port,
source_port,
sequence.wrapping_add(1),
TcpFlags::SYN | TcpFlags::ACK,
);
assert!(
classify_packet(&valid, &expected, source, source_port, false, &mut seen).is_some()
);
}
#[test]
fn accepts_wrapped_acknowledgement_number() {
let source = "192.168.2.1".parse().unwrap();
let target = "172.16.100.2".parse().unwrap();
let source_port = 50000;
let target_port = 443;
let expected = HashMap::from([((target, target_port), u32::MAX)]);
let response = response_packet(
target,
source,
target_port,
source_port,
0,
TcpFlags::SYN | TcpFlags::ACK,
);
assert!(
classify_packet(
&response,
&expected,
source,
source_port,
false,
&mut HashSet::new()
)
.is_some()
);
}
#[test]
fn validates_scan_limits_and_configuration() {
let timeout = Duration::from_secs(30);
assert_eq!(
validate_probe_count(254, 65_535, 15, timeout).unwrap(),
16_645_890
);
assert_eq!(
validate_probe_count(256, 65_535, 15, timeout).unwrap(),
16_776_960
);
assert_eq!(
validate_probe_count(MAX_PROBES, 1, 15, timeout).unwrap(),
MAX_PROBES
);
assert_eq!(validate_probe_count(1, 1, 15, MAX_TIMEOUT).unwrap(), 1);
assert!(matches!(
validate_probe_count(257, 65_535, 15, timeout),
Err(ScanError::TooManyProbes { probe_count: 16_842_495, max }) if max == MAX_PROBES
));
assert!(matches!(
validate_probe_count(MAX_PROBES + 1, 1, 15, timeout),
Err(ScanError::TooManyProbes { max, .. }) if max == MAX_PROBES
));
assert!(matches!(
validate_probe_count(usize::MAX, 2, 15, timeout),
Err(ScanError::ScanSizeOverflow)
));
assert!(matches!(
validate_probe_count(0, 1, 15, timeout),
Err(ScanError::EmptyScan)
));
assert!(matches!(
validate_probe_count(1, 0, 15, timeout),
Err(ScanError::EmptyScan)
));
assert!(matches!(
validate_probe_count(1, 1, 0, timeout),
Err(ScanError::ZeroBandwidth)
));
assert!(matches!(
validate_probe_count(1, 1, 15, MAX_TIMEOUT + Duration::from_secs(1)),
Err(ScanError::TimeoutTooLarge { max, .. }) if max == MAX_TIMEOUT
));
}
#[test]
fn timeout_too_large_reports_both_durations() {
let timeout = MAX_TIMEOUT + Duration::from_secs(1);
let error = validate_probe_count(1, 1, 15, timeout).unwrap_err();
assert_eq!(
error.to_string(),
format!("timeout of {timeout:?} exceeds the maximum of {MAX_TIMEOUT:?}")
);
}
#[test]
fn rejects_duplicate_scan_config_entries() {
let host = "192.168.2.1".parse().unwrap();
let duplicate_targets = ScanConfig::new(vec![host, host], vec![443], host);
let duplicate_ports = ScanConfig::new(vec![host], vec![443, 443], host);
let unique = ScanConfig::new(vec![host], vec![80, 443], host);
assert!(
expected_responses(&duplicate_targets, 1, 2)
.unwrap_err()
.to_string()
.contains("must be unique")
);
assert!(
expected_responses(&duplicate_ports, 1, 2)
.unwrap_err()
.to_string()
.contains("must be unique")
);
assert_eq!(expected_responses(&unique, 1, 2).unwrap().len(), 2);
}
#[test]
fn parses_tcp_services_and_ignores_other_entries() -> anyhow::Result<()> {
let ports = services_from(
"\
# comment
ssh 22/tcp
domain 53/udp
http 80/tcp www # inline comment
http-alt 80/tcp
malformed
invalid nope/tcp
zero 0/tcp
",
)?;
assert_eq!(ports, [22, 80]);
Ok(())
}
#[test]
fn rejects_services_file_without_tcp_ports() {
let path = services_path();
fs::write(&path, "domain 53/udp\n# comment\nmalformed\n").unwrap();
let error = ports_from_services(&path).unwrap_err();
fs::remove_file(&path).unwrap();
assert!(matches!(error, PortsError::NoTcpServices { path: p } if p == path));
}
#[test]
fn reports_missing_services_file_path() {
let path = services_path();
let error = ports_from_services(&path).unwrap_err();
assert!(matches!(
&error,
PortsError::ServicesFileRead { path: p, .. } if p == &path
));
assert!(format!("{error:#}").contains(&path.display().to_string()));
}
#[test]
fn incomplete_scan_error_preserves_context() {
let host = "172.16.100.2".parse().unwrap();
let partial_result = ScanResult {
host,
port: 443,
state: PortState::Open,
};
let incomplete = IncompleteScanError {
source: SendError::Io {
host,
port: 443,
source: io::Error::other("send failed"),
},
partial_results: vec![partial_result],
probes_sent: 7,
total_probes: 10,
};
assert_eq!(incomplete.partial_results(), [partial_result]);
assert_eq!(incomplete.probes_sent(), 7);
assert_eq!(incomplete.total_probes(), 10);
assert_eq!(
incomplete.to_string(),
"scan stopped after sending 7 of 10 probes"
);
assert_eq!(
Error::source(&incomplete).unwrap().to_string(),
"failed to send SYN to 172.16.100.2:443"
);
let error: anyhow::Error = incomplete.into();
assert!(error.downcast_ref::<IncompleteScanError>().is_some());
assert_eq!(
format!("{error:#}"),
"scan stopped after sending 7 of 10 probes: \
failed to send SYN to 172.16.100.2:443: send failed"
);
}
#[test]
fn describes_str_panic_payload() {
let payload: Box<dyn Any + Send> = Box::new("boom");
assert_eq!(describe_panic_payload(&*payload), "boom");
}
#[test]
fn describes_string_panic_payload() {
let payload: Box<dyn Any + Send> = Box::new(String::from("boom"));
assert_eq!(describe_panic_payload(&*payload), "boom");
}
#[test]
fn describes_unrecognized_panic_payload() {
let payload: Box<dyn Any + Send> = Box::new(42_i32);
assert_eq!(describe_panic_payload(&*payload), "unknown panic payload");
}
#[test]
fn estimates_scan_progress() {
let progress = ScanProgress {
probes_sent: 25,
total_probes: 100,
elapsed: Duration::from_secs(60),
};
assert_eq!(progress.percent(), 25);
assert_eq!(
progress.estimated_remaining(),
Some(Duration::from_secs(180))
);
}
#[test]
fn handles_scan_progress_boundaries() {
let no_probes = ScanProgress {
probes_sent: 0,
total_probes: 0,
elapsed: Duration::from_secs(60),
};
let not_started = ScanProgress {
total_probes: 100,
..no_probes
};
let complete = ScanProgress {
probes_sent: 100,
..not_started
};
let over_complete = ScanProgress {
probes_sent: 101,
..complete
};
assert_eq!(no_probes.percent(), 0);
assert_eq!(no_probes.estimated_remaining(), None);
assert_eq!(not_started.percent(), 0);
assert_eq!(not_started.estimated_remaining(), None);
assert_eq!(complete.percent(), 100);
assert_eq!(complete.estimated_remaining(), Some(Duration::ZERO));
assert_eq!(over_complete.estimated_remaining(), Some(Duration::ZERO));
}
#[test]
fn probe_limit_accommodates_single_port_slash_8() {
assert_eq!(MAX_PROBES, 16_777_214);
}
#[test]
fn progress_schedule_uses_increasing_intervals() {
assert_eq!(next_progress_deadline(Duration::from_mins(9)), TEN_MINUTES);
assert_eq!(next_progress_deadline(TEN_MINUTES), Duration::from_mins(20));
assert_eq!(next_progress_deadline(Duration::from_mins(50)), ONE_HOUR);
assert_eq!(next_progress_deadline(ONE_HOUR), Duration::from_mins(90));
}
#[test]
fn progress_schedule_skips_missed_deadlines() {
assert_eq!(
advance_progress_deadline(ONE_MINUTE, Duration::from_mins(35)),
Duration::from_mins(40)
);
}
}