use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use anyhow::{Context, Result};
use futures::stream::{self, StreamExt};
use regex::bytes::{Captures, Regex};
use rustls::ClientConfig;
use rustls_pki_types::ServerName;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, UdpSocket};
use tokio::time::timeout;
use tokio_rustls::TlsConnector;
#[derive(Debug)]
pub struct ServiceMatch {
pub service_name: String,
pub regex: Regex,
pub product_tpl: Option<String>,
pub version_tpl: Option<String>,
pub info_tpl: Option<String>,
pub os_tpl: Option<String>,
pub device_tpl: Option<String>,
pub cpe_tpl: Vec<String>,
pub soft: bool,
}
pub type PortRanges = Vec<(u16, u16)>;
#[derive(Debug)]
pub struct TcpProbe {
pub name: String,
pub payload: Vec<u8>,
pub totalwait_ms: u64,
pub rarity: u8,
pub ports: Option<PortRanges>,
pub sslports: Option<PortRanges>,
pub matches: Vec<ServiceMatch>,
}
#[derive(Debug)]
pub struct UdpProbe {
pub name: String,
pub payload: Vec<u8>,
pub totalwait_ms: u64,
pub rarity: u8,
pub ports: Option<PortRanges>,
pub matches: Vec<ServiceMatch>,
}
#[derive(Debug, Default)]
pub struct ServiceProbes {
pub tcp: Vec<TcpProbe>,
pub udp: Vec<UdpProbe>,
}
fn tls_client_config() -> Arc<ClientConfig> {
static CFG: OnceLock<Arc<ClientConfig>> = OnceLock::new();
CFG.get_or_init(|| {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
Arc::new(
ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
)
})
.clone()
}
fn server_name(host: IpAddr) -> ServerName<'static> {
match host {
IpAddr::V4(a) => ServerName::IpAddress(a.into()),
IpAddr::V6(a) => ServerName::IpAddress(a.into()),
}
}
pub fn load_service_probes(path: &Path) -> Result<ServiceProbes> {
let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
parse_probes(&text).context("parse nmap-service-probes")
}
pub fn load_tcp_probes(path: &Path) -> Result<Vec<TcpProbe>> {
Ok(load_service_probes(path)?.tcp)
}
fn parse_probes(text: &str) -> Result<ServiceProbes> {
let mut out = ServiceProbes::default();
let mut cur_tcp: Option<TcpProbe> = None;
let mut cur_udp: Option<UdpProbe> = None;
for raw in text.lines() {
let line = raw.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with("Exclude ") {
continue;
}
if let Some(rest) = line.strip_prefix("Probe TCP ") {
if let Some(p) = cur_udp.take() {
out.udp.push(p);
}
if let Some(p) = cur_tcp.take() {
out.tcp.push(p);
}
let (name, payload) = parse_probe_tcp_line(rest)?;
cur_tcp = Some(TcpProbe {
name,
payload,
totalwait_ms: 6000,
rarity: 5,
ports: None,
sslports: None,
matches: Vec::new(),
});
continue;
}
if let Some(rest) = line.strip_prefix("Probe UDP ") {
if let Some(p) = cur_tcp.take() {
out.tcp.push(p);
}
if let Some(p) = cur_udp.take() {
out.udp.push(p);
}
let (name, payload) = parse_probe_udp_line(rest)?;
cur_udp = Some(UdpProbe {
name,
payload,
totalwait_ms: 6000,
rarity: 5,
ports: None,
matches: Vec::new(),
});
continue;
}
if let Some(p) = cur_tcp.as_mut() {
if apply_probe_line_tcp(line, p)? {
continue;
}
}
if let Some(p) = cur_udp.as_mut() {
if apply_probe_line_udp(line, p)? {
continue;
}
}
}
if let Some(p) = cur_udp.take() {
out.udp.push(p);
}
if let Some(p) = cur_tcp.take() {
out.tcp.push(p);
}
Ok(out)
}
fn apply_probe_line_tcp(line: &str, p: &mut TcpProbe) -> Result<bool> {
if let Some(ms) = line.strip_prefix("totalwaitms ") {
if let Ok(n) = ms.trim().parse::<u64>() {
p.totalwait_ms = n;
}
return Ok(true);
}
if let Some(r) = line.strip_prefix("rarity ") {
if let Ok(n) = r.trim().parse::<u8>() {
p.rarity = n;
}
return Ok(true);
}
if let Some(rest) = line.strip_prefix("ports ") {
p.ports = parse_port_ranges_list(rest);
return Ok(true);
}
if let Some(rest) = line.strip_prefix("sslports ") {
p.sslports = parse_port_ranges_list(rest);
return Ok(true);
}
if line.starts_with("match ") || line.starts_with("softmatch ") {
if let Some(m) = parse_match_line(line)? {
p.matches.push(m);
}
return Ok(true);
}
Ok(false)
}
fn apply_probe_line_udp(line: &str, p: &mut UdpProbe) -> Result<bool> {
if let Some(ms) = line.strip_prefix("totalwaitms ") {
if let Ok(n) = ms.trim().parse::<u64>() {
p.totalwait_ms = n;
}
return Ok(true);
}
if let Some(r) = line.strip_prefix("rarity ") {
if let Ok(n) = r.trim().parse::<u8>() {
p.rarity = n;
}
return Ok(true);
}
if let Some(rest) = line.strip_prefix("ports ") {
p.ports = parse_port_ranges_list(rest);
return Ok(true);
}
if line.starts_with("match ") || line.starts_with("softmatch ") {
if let Some(m) = parse_match_line(line)? {
p.matches.push(m);
}
return Ok(true);
}
Ok(false)
}
fn parse_port_ranges_list(s: &str) -> Option<PortRanges> {
let mut out = PortRanges::new();
for part in s.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some((a, b)) = part.split_once('-') {
let lo: u16 = a.trim().parse().ok()?;
let hi: u16 = b.trim().parse().ok()?;
out.push((lo.min(hi), lo.max(hi)));
} else {
let p: u16 = part.parse().ok()?;
out.push((p, p));
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn port_in_ranges(port: u16, ranges: &PortRanges) -> bool {
ranges.iter().any(|&(lo, hi)| port >= lo && port <= hi)
}
fn probe_ports_ok(port: u16, spec: &Option<PortRanges>) -> bool {
spec.as_ref().is_none_or(|r| port_in_ranges(port, r))
}
fn use_tls_for_tcp(port: u16, probe: &TcpProbe) -> bool {
probe
.sslports
.as_ref()
.is_some_and(|r| port_in_ranges(port, r))
}
fn parse_probe_tcp_line(rest: &str) -> Result<(String, Vec<u8>)> {
let rest = rest.trim_start();
let name_end = rest
.find(char::is_whitespace)
.ok_or_else(|| anyhow::anyhow!("Probe TCP: missing probe name"))?;
let name = rest[..name_end].to_string();
let qpart = rest[name_end..].trim_start();
let payload = parse_q_field(qpart).unwrap_or_default();
Ok((name, payload))
}
fn parse_probe_udp_line(rest: &str) -> Result<(String, Vec<u8>)> {
let rest = rest.trim_start();
let name_end = rest
.find(char::is_whitespace)
.ok_or_else(|| anyhow::anyhow!("Probe UDP: missing probe name"))?;
let name = rest[..name_end].to_string();
let qpart = rest[name_end..].trim_start();
let payload = parse_q_field(qpart).unwrap_or_default();
Ok((name, payload))
}
fn parse_q_field(s: &str) -> Option<Vec<u8>> {
let s = s.trim_start();
let rest = s.strip_prefix('q')?;
let delim = rest.chars().next()?;
let inner = rest.get(delim.len_utf8()..)?;
let end = inner.find(delim)?;
Some(decode_nmap_escape_bytes(&inner[..end]))
}
fn hex_nibble(c: char) -> Option<u8> {
match c {
'0'..='9' => Some(c as u8 - b'0'),
'a'..='f' => Some(c as u8 - b'a' + 10),
'A'..='F' => Some(c as u8 - b'A' + 10),
_ => None,
}
}
fn decode_nmap_escape_bytes(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len());
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
if c != '\\' {
out.push(c as u8);
continue;
}
match it.next() {
Some('x') | Some('X') => {
let a = it.next().unwrap_or('0');
let b = it.next().unwrap_or('0');
if let (Some(hi), Some(lo)) = (hex_nibble(a), hex_nibble(b)) {
out.push(hi << 4 | lo);
}
}
Some('0') => out.push(0),
Some('n') => out.push(b'\n'),
Some('r') => out.push(b'\r'),
Some('t') => out.push(b'\t'),
Some('\\') => out.push(b'\\'),
Some(o) => out.push(o as u8),
None => {}
}
}
out
}
fn parse_match_line(line: &str) -> Result<Option<ServiceMatch>> {
let soft = line.starts_with("softmatch ");
let rest = if soft {
&line["softmatch ".len()..]
} else {
&line["match ".len()..]
};
let (service_token, after_svc) = split_first_token(rest);
if service_token.is_empty() {
return Ok(None);
}
let after_svc = after_svc.trim_start();
let (pattern_src, tail) = match extract_m_delimited(after_svc) {
Some(x) => x,
None => return Ok(None),
};
let dotall = tail.split("p/").next().unwrap_or("").contains("|s");
let mut pat = String::new();
if dotall {
pat.push_str("(?s)");
}
pat.push_str(pattern_src);
let regex = match Regex::new(&pat) {
Ok(r) => r,
Err(_) => return Ok(None),
};
let (product_tpl, version_tpl, info_tpl, os_tpl, device_tpl, cpe_tpl) =
extract_p_v_templates(tail);
Ok(Some(ServiceMatch {
service_name: service_token.to_string(),
regex,
product_tpl,
version_tpl,
info_tpl,
os_tpl,
device_tpl,
cpe_tpl,
soft,
}))
}
fn split_first_token(s: &str) -> (&str, &str) {
let s = s.trim_start();
let end = s.find(char::is_whitespace).unwrap_or(s.len());
(&s[..end], &s[end..])
}
fn extract_m_delimited(rest: &str) -> Option<(&str, &str)> {
let b = rest.as_bytes();
if b.first().copied()? != b'm' {
return None;
}
let delim = b.get(1).copied()? as char;
let mut i = 2usize;
let mut escaped = false;
while i < b.len() {
let c = b[i];
if escaped {
escaped = false;
i += 1;
continue;
}
if c == b'\\' {
escaped = true;
i += 1;
continue;
}
if c == delim as u8 {
let pattern = std::str::from_utf8(&b[2..i]).ok()?;
let tail = std::str::from_utf8(&b[i + 1..]).ok()?;
return Some((pattern, tail));
}
i += 1;
}
None
}
type PVTemplates = (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
Vec<String>,
);
fn extract_p_v_templates(tail: &str) -> PVTemplates {
let p = find_slash_field(tail, "p/");
let v = find_slash_field(tail, "v/");
let i = find_slash_field(tail, "i/");
let o = find_slash_field(tail, "o/");
let d = find_slash_field(tail, "d/");
let cpe = find_all_slash_fields(tail, "cpe:/");
(p, v, i, o, d, cpe)
}
fn find_all_slash_fields(s: &str, needle: &str) -> Vec<String> {
let mut out = Vec::new();
let mut search = s;
while let Some(idx) = search.find(needle) {
let rest = &search[idx + needle.len()..];
if let Some(end) = rest.find('/') {
out.push(format!("cpe:/{}", &rest[..end]));
}
search = &search[idx + needle.len()..];
}
out
}
fn find_slash_field(s: &str, needle: &str) -> Option<String> {
let i = s.find(needle)?;
let rest = &s[i + needle.len()..];
let end = rest.find('/')?;
Some(rest[..end].to_string())
}
fn apply_template(tpl: &str, caps: &Captures) -> String {
let mut out = String::with_capacity(tpl.len() + 16);
let mut chars = tpl.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' && chars.peek() == Some(&'$') {
out.push('$');
chars.next();
continue;
}
if c == '$' {
let mut n: usize = 0;
let mut any = false;
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
any = true;
n = n * 10 + (d as u8 - b'0') as usize;
chars.next();
} else {
break;
}
}
if any {
if let Some(m) = caps.get(n) {
out.push_str(&String::from_utf8_lossy(m.as_bytes()));
}
} else {
out.push('$');
}
} else {
out.push(c);
}
}
out
}
fn format_match(m: &ServiceMatch, caps: &Captures) -> String {
let prod = m
.product_tpl
.as_ref()
.map(|t| apply_template(t, caps))
.unwrap_or_default();
let ver = m
.version_tpl
.as_ref()
.map(|t| apply_template(t, caps))
.unwrap_or_default();
let info = m
.info_tpl
.as_ref()
.map(|t| apply_template(t, caps))
.unwrap_or_default();
let os = m
.os_tpl
.as_ref()
.map(|t| apply_template(t, caps))
.unwrap_or_default();
let device = m
.device_tpl
.as_ref()
.map(|t| apply_template(t, caps))
.unwrap_or_default();
let cpe_strs: Vec<String> = m.cpe_tpl.iter().map(|t| apply_template(t, caps)).collect();
let prod = prod.trim();
let ver = ver.trim();
let info = info.trim();
let os = os.trim();
let device = device.trim();
let mut parts = Vec::new();
if !prod.is_empty() {
parts.push(prod.to_string());
}
if !ver.is_empty() {
parts.push(ver.to_string());
}
if !info.is_empty() {
parts.push(format!("({})", info));
}
if !os.is_empty() {
parts.push(format!("[{}]", os));
}
if !device.is_empty() {
parts.push(format!("{{{}}}", device));
}
for c in &cpe_strs {
let c = c.trim();
if !c.is_empty() {
parts.push(c.to_string());
}
}
if parts.is_empty() {
m.service_name.clone()
} else {
parts.join(" ")
}
}
pub async fn run_tcp_version_scan(
open_tcp: Vec<(IpAddr, u16)>,
probes: Arc<Vec<TcpProbe>>,
intensity: u8,
connect_timeout: Duration,
concurrency: usize,
) -> HashMap<(IpAddr, u16), String> {
let mut out = HashMap::new();
if probes.is_empty() || open_tcp.is_empty() {
return out;
}
let c = concurrency.max(1);
let results: Vec<_> = stream::iter(open_tcp)
.map(|(host, port)| {
let probes = Arc::clone(&probes);
async move {
let s = probe_one_tcp_port(host, port, &probes, intensity, connect_timeout).await;
((host, port), s)
}
})
.buffer_unordered(c)
.collect()
.await;
for ((h, p), s) in results {
if let Some(text) = s {
out.insert((h, p), text);
}
}
out
}
pub async fn run_udp_version_scan(
open_udp: Vec<(IpAddr, u16)>,
probes: Arc<Vec<UdpProbe>>,
intensity: u8,
connect_timeout: Duration,
concurrency: usize,
) -> HashMap<(IpAddr, u16), String> {
let mut out = HashMap::new();
if probes.is_empty() || open_udp.is_empty() {
return out;
}
let c = concurrency.max(1);
let results: Vec<_> = stream::iter(open_udp)
.map(|(host, port)| {
let probes = Arc::clone(&probes);
async move {
let s = probe_one_udp_port(host, port, &probes, intensity, connect_timeout).await;
((host, port), s)
}
})
.buffer_unordered(c)
.collect()
.await;
for ((h, p), s) in results {
if let Some(text) = s {
out.insert((h, p), text);
}
}
out
}
async fn read_tcp_banner(
host: IpAddr,
port: u16,
payload: &[u8],
read_to: Duration,
connect_timeout: Duration,
tls: bool,
) -> Option<Vec<u8>> {
let addr = SocketAddr::new(host, port);
let tcp = match timeout(connect_timeout, TcpStream::connect(addr)).await {
Ok(Ok(s)) => s,
_ => return None,
};
let mut buf = vec![0u8; 65_536];
let n = if tls {
let cfg = tls_client_config();
let connector = TlsConnector::from(cfg);
let dns = server_name(host);
let mut tls_stream = match timeout(connect_timeout, connector.connect(dns, tcp)).await {
Ok(Ok(s)) => s,
_ => return None,
};
if !payload.is_empty() {
tls_stream.write_all(payload).await.ok()?;
}
match timeout(read_to, tls_stream.read(&mut buf)).await {
Ok(Ok(n)) => n,
_ => 0,
}
} else {
let mut tcp = tcp;
if !payload.is_empty() {
tcp.write_all(payload).await.ok()?;
}
match timeout(read_to, tcp.read(&mut buf)).await {
Ok(Ok(n)) => n,
_ => 0,
}
};
Some(buf[..n].to_vec())
}
async fn probe_one_tcp_port(
host: IpAddr,
port: u16,
probes: &[TcpProbe],
intensity: u8,
connect_timeout: Duration,
) -> Option<String> {
let mut best_soft: Option<String> = None;
for probe in probes {
if probe.rarity > intensity {
continue;
}
if !probe_ports_ok(port, &probe.ports) {
continue;
}
if probe.matches.is_empty() {
continue;
}
let read_to = Duration::from_millis(probe.totalwait_ms.clamp(1, 30_000));
let tls = use_tls_for_tcp(port, probe);
let banner = match read_tcp_banner(
host,
port,
&probe.payload,
read_to,
connect_timeout,
tls,
)
.await
{
Some(b) => b,
None => continue,
};
for m in &probe.matches {
if m.soft {
continue;
}
if let Some(caps) = m.regex.captures(&banner) {
return Some(format_match(m, &caps));
}
}
for m in &probe.matches {
if !m.soft {
continue;
}
if let Some(caps) = m.regex.captures(&banner) {
best_soft = Some(format_match(m, &caps));
break;
}
}
}
best_soft
}
async fn probe_one_udp_port(
host: IpAddr,
port: u16,
probes: &[UdpProbe],
intensity: u8,
_connect_timeout: Duration,
) -> Option<String> {
let mut best_soft: Option<String> = None;
for probe in probes {
if probe.rarity > intensity {
continue;
}
if !probe_ports_ok(port, &probe.ports) {
continue;
}
if probe.matches.is_empty() {
continue;
}
let read_to = Duration::from_millis(probe.totalwait_ms.clamp(1, 30_000));
let bind_addr: SocketAddr = match host {
IpAddr::V4(_) => "0.0.0.0:0".parse().unwrap(),
IpAddr::V6(_) => "[::]:0".parse().unwrap(),
};
let dst = SocketAddr::new(host, port);
let socket = UdpSocket::bind(bind_addr).await.ok()?;
if !probe.payload.is_empty() {
socket.send_to(&probe.payload, dst).await.ok()?;
}
let mut buf = vec![0u8; 65_536];
let n = match timeout(read_to, socket.recv_from(&mut buf)).await {
Ok(Ok((n, _))) => n,
_ => 0,
};
if n == 0 && !probe.payload.is_empty() {
continue;
}
let banner = &buf[..n];
for m in &probe.matches {
if m.soft {
continue;
}
if let Some(caps) = m.regex.captures(banner) {
return Some(format_match(m, &caps));
}
}
for m in &probe.matches {
if !m.soft {
continue;
}
if let Some(caps) = m.regex.captures(banner) {
best_soft = Some(format_match(m, &caps));
break;
}
}
}
best_soft
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_fixture() {
let fixture = r#"
Probe TCP NULL q||
rarity 1
match ssh m|^SSH-([\d.]+)-([^\r\n]+)| p/OpenSSH/ v/$1/
Probe TCP GetRequest q|GET / HTTP/1.0\r\n\r\n|
rarity 1
ports 80,443
match http m|^HTTP/1\.[01]\s\d\d\d| p/HTTP server/
"#;
let sp = parse_probes(fixture).expect("parse");
assert_eq!(sp.tcp.len(), 2);
assert_eq!(sp.tcp[0].name, "NULL");
assert!(sp.tcp[0].payload.is_empty());
assert!(sp.tcp[1].ports.is_some());
}
#[test]
fn q_field_decodes_hex_escape() {
let p = parse_q_field("q|\\x00\\x01SSH|").expect("q");
assert_eq!(p, vec![0, 1, b'S', b'S', b'H']);
}
#[test]
fn parses_port_ranges() {
let r = parse_port_ranges_list("1,3-5,443").expect("ranges");
assert!(port_in_ranges(1, &r));
assert!(port_in_ranges(4, &r));
assert!(!port_in_ranges(2, &r));
}
#[test]
fn parses_port_ranges_reversed_pair_normalizes() {
let r = parse_port_ranges_list("9-3").expect("ranges");
assert!(port_in_ranges(5, &r));
assert!(!port_in_ranges(2, &r));
}
#[test]
fn parses_udp_probe_and_totalwaitms() {
let fixture = r#"
Probe UDP DNSVersionBindReq q|\x12\x34\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00|
totalwaitms 3000
rarity 2
match dns m|^[^\x00]+\x00\x00\x01\x00\x01| p/DNS server/
"#;
let sp = parse_probes(fixture).expect("parse");
assert_eq!(sp.tcp.len(), 0);
assert_eq!(sp.udp.len(), 1);
let u = &sp.udp[0];
assert_eq!(u.name, "DNSVersionBindReq");
assert_eq!(u.totalwait_ms, 3000);
assert_eq!(u.rarity, 2);
assert!(!u.payload.is_empty());
assert_eq!(u.matches.len(), 1);
assert!(!u.matches[0].soft);
}
#[test]
fn softmatch_flag_parsed() {
let fixture = r#"
Probe TCP NULL q||
softmatch unknown m|^.| p/Guess/
"#;
let sp = parse_probes(fixture).expect("parse");
assert!(sp.tcp[0].matches[0].soft);
}
#[test]
fn decode_nmap_escape_backslash_and_zero() {
let p = parse_q_field(r"q|\\\0\x41|").expect("q");
assert_eq!(p, vec![b'\\', 0, b'A']);
}
#[test]
fn decode_nmap_escape_whitespace_controls() {
assert_eq!(
decode_nmap_escape_bytes(r"\n\r\t"),
vec![b'\n', b'\r', b'\t']
);
}
#[test]
fn decode_nmap_escape_plain_chars_after_backslash() {
assert_eq!(decode_nmap_escape_bytes(r"\Q"), vec![b'Q']);
}
#[test]
fn parse_q_field_custom_pipe_delimiter() {
let p = parse_q_field("q|GET / HTTP/1.0\\r\\n\\r\\n|").expect("q");
assert!(p.starts_with(b"GET / HTTP/1.0"));
}
#[test]
fn parse_q_field_missing_delimiter_is_none() {
assert!(parse_q_field("not-a-q-field").is_none());
}
#[test]
fn parse_port_ranges_single_port() {
let r = parse_port_ranges_list("8080").expect("ranges");
assert_eq!(r, vec![(8080, 8080)]);
}
#[test]
fn parse_port_ranges_mixed_singles_and_ranges() {
let r = parse_port_ranges_list("22,80,443,8000-8010").expect("ranges");
assert!(port_in_ranges(22, &r));
assert!(port_in_ranges(80, &r));
assert!(port_in_ranges(443, &r));
assert!(port_in_ranges(8005, &r));
assert!(!port_in_ranges(8011, &r));
}
#[test]
fn parse_port_ranges_whitespace_tolerant() {
let r = parse_port_ranges_list(" 80 , 443 , 1000 - 2000 ").expect("ranges");
assert!(port_in_ranges(80, &r));
assert!(port_in_ranges(1500, &r));
}
#[test]
fn parse_port_ranges_empty_input_returns_none() {
assert!(parse_port_ranges_list("").is_none());
assert!(parse_port_ranges_list(" ").is_none());
assert!(parse_port_ranges_list(",,").is_none());
}
#[test]
fn parse_port_ranges_invalid_returns_none() {
assert!(parse_port_ranges_list("abc").is_none());
assert!(parse_port_ranges_list("80,abc,443").is_none());
assert!(parse_port_ranges_list("99999").is_none());
}
#[test]
fn port_in_ranges_boundary_inclusive() {
let r: PortRanges = vec![(100, 200)];
assert!(port_in_ranges(100, &r));
assert!(port_in_ranges(200, &r));
assert!(!port_in_ranges(99, &r));
assert!(!port_in_ranges(201, &r));
}
#[test]
fn port_in_ranges_inclusive_endpoints() {
let r = parse_port_ranges_list("80-443").unwrap();
assert!(port_in_ranges(80, &r));
assert!(port_in_ranges(443, &r));
assert!(!port_in_ranges(79, &r));
assert!(!port_in_ranges(444, &r));
}
#[test]
fn probe_ports_ok_with_no_spec_allows_any_port() {
assert!(probe_ports_ok(22, &None));
assert!(probe_ports_ok(8080, &None));
assert!(probe_ports_ok(65535, &None));
}
#[test]
fn probe_ports_ok_with_spec_restricts_to_listed() {
let spec = Some(vec![(80, 80), (443, 443)]);
assert!(probe_ports_ok(80, &spec));
assert!(probe_ports_ok(443, &spec));
assert!(!probe_ports_ok(22, &spec));
assert!(!probe_ports_ok(8080, &spec));
}
#[test]
fn probe_ports_ok_some_restricts_to_list() {
let spec = parse_port_ranges_list("22,443");
assert!(probe_ports_ok(22, &spec));
assert!(probe_ports_ok(443, &spec));
assert!(!probe_ports_ok(80, &spec));
}
#[test]
fn hex_nibble_decimal_digits() {
assert_eq!(hex_nibble('0'), Some(0));
assert_eq!(hex_nibble('5'), Some(5));
assert_eq!(hex_nibble('9'), Some(9));
}
#[test]
fn hex_nibble_lowercase_letters() {
assert_eq!(hex_nibble('a'), Some(10));
assert_eq!(hex_nibble('f'), Some(15));
}
#[test]
fn hex_nibble_uppercase_letters() {
assert_eq!(hex_nibble('A'), Some(10));
assert_eq!(hex_nibble('F'), Some(15));
}
#[test]
fn hex_nibble_invalid_returns_none() {
assert_eq!(hex_nibble('g'), None);
assert_eq!(hex_nibble('G'), None);
assert_eq!(hex_nibble('Z'), None);
assert_eq!(hex_nibble(' '), None);
assert_eq!(hex_nibble('-'), None);
}
#[test]
fn decode_plain_ascii_passes_through() {
assert_eq!(decode_nmap_escape_bytes("hello"), b"hello");
assert_eq!(decode_nmap_escape_bytes(""), b"");
}
#[test]
fn decode_known_letter_escapes() {
assert_eq!(decode_nmap_escape_bytes(r"\n"), b"\n");
assert_eq!(decode_nmap_escape_bytes(r"\r"), b"\r");
assert_eq!(decode_nmap_escape_bytes(r"\t"), b"\t");
assert_eq!(decode_nmap_escape_bytes(r"\\"), b"\\");
assert_eq!(decode_nmap_escape_bytes(r"\0"), &[0u8]);
}
#[test]
fn decode_hex_byte_lowercase_and_upper_prefix() {
assert_eq!(decode_nmap_escape_bytes(r"\x41"), b"A");
assert_eq!(decode_nmap_escape_bytes(r"\X41"), b"A");
assert_eq!(decode_nmap_escape_bytes(r"\xff"), &[0xffu8]);
assert_eq!(decode_nmap_escape_bytes(r"\x00"), &[0u8]);
}
#[test]
fn decode_unknown_escape_takes_following_char_verbatim() {
assert_eq!(decode_nmap_escape_bytes(r"\q"), b"q");
assert_eq!(decode_nmap_escape_bytes(r"\Z"), b"Z");
}
#[test]
fn decode_trailing_backslash_dropped() {
let out = decode_nmap_escape_bytes("hi\\");
assert_eq!(out, b"hi");
}
#[test]
fn decode_mixed_escapes_in_sequence() {
assert_eq!(decode_nmap_escape_bytes(r"a\nb\tc\\d"), b"a\nb\tc\\d");
assert_eq!(decode_nmap_escape_bytes(r"GET /\r\n\r\n"), b"GET /\r\n\r\n");
}
#[test]
fn parse_q_field_with_non_pipe_delimiter() {
let p = parse_q_field("q/hello/").expect("q with /");
assert_eq!(p, b"hello");
}
#[test]
fn parse_q_field_missing_closing_delimiter_returns_none() {
assert!(parse_q_field("q|unterminated").is_none());
}
#[test]
fn parse_q_field_empty_payload() {
let p = parse_q_field("q||").expect("q||");
assert!(p.is_empty());
}
#[test]
fn parse_q_field_without_q_prefix_returns_none() {
assert!(parse_q_field("|hello|").is_none());
assert!(parse_q_field("hello").is_none());
}
#[test]
fn use_tls_only_when_port_on_sslports() {
let mut probe = TcpProbe {
name: "X".into(),
payload: vec![],
totalwait_ms: 1000,
rarity: 1,
ports: None,
sslports: parse_port_ranges_list("443,8443"),
matches: vec![],
};
assert!(use_tls_for_tcp(443, &probe));
assert!(use_tls_for_tcp(8443, &probe));
assert!(!use_tls_for_tcp(80, &probe));
probe.sslports = None;
assert!(!use_tls_for_tcp(443, &probe));
}
#[test]
fn parse_match_line_extracts_product_and_version_templates() {
let m = parse_match_line("match ssh m|^SSH-([\\d.]+)| p/OpenSSH/ v/$1/").unwrap();
let m = m.expect("match");
assert_eq!(m.service_name, "ssh");
assert_eq!(m.product_tpl.as_deref(), Some("OpenSSH"));
assert_eq!(m.version_tpl.as_deref(), Some("$1"));
assert!(!m.soft);
}
#[test]
fn parse_match_line_softmatch_flag() {
let m = parse_match_line("softmatch guess m|^.| p/?/")
.unwrap()
.unwrap();
assert!(m.soft);
}
#[test]
fn parse_match_line_invalid_regex_returns_none() {
assert!(parse_match_line("match bad m|[unclosed| p/x/")
.unwrap()
.is_none());
}
#[test]
fn apply_template_substitutes_capture_groups() {
let re = Regex::new(r"^SSH-(.+)-(.+)$").unwrap();
let caps = re.captures(b"SSH-8.2p1-Ubuntu").unwrap();
assert_eq!(apply_template("$1 on $2", &caps), "8.2p1 on Ubuntu");
}
#[test]
fn apply_template_dollar_dollar_is_literal() {
let re = Regex::new(r"x").unwrap();
let caps = re.captures(b"x").unwrap();
assert_eq!(apply_template("cost is $$5", &caps), "cost is $5");
}
#[test]
fn format_match_joins_product_version_and_info() {
let re = Regex::new(r"^HTTP/1\.1").unwrap();
let caps = re.captures(b"HTTP/1.1 200 OK").unwrap();
let m = ServiceMatch {
service_name: "http".into(),
regex: re,
product_tpl: Some("Apache".into()),
version_tpl: Some("2.4".into()),
info_tpl: Some("protocol 2.0".into()),
os_tpl: None,
device_tpl: None,
cpe_tpl: vec![],
soft: false,
};
let s = format_match(&m, &caps);
assert!(s.contains("Apache"));
assert!(s.contains("2.4"));
assert!(s.contains("(protocol 2.0)"));
}
#[test]
fn format_match_empty_templates_falls_back_to_service_name() {
let re = Regex::new(r".").unwrap();
let caps = re.captures(b"x").unwrap();
let m = ServiceMatch {
service_name: "unknown".into(),
regex: re,
product_tpl: None,
version_tpl: None,
info_tpl: None,
os_tpl: None,
device_tpl: None,
cpe_tpl: vec![],
soft: false,
};
assert_eq!(format_match(&m, &caps), "unknown");
}
#[test]
fn parses_sslports_and_rarity_on_tcp_probe() {
let fixture = r#"
Probe TCP NULL q||
sslports 443,8443
rarity 3
match x m|^.| p/x/
"#;
let sp = parse_probes(fixture).unwrap();
let p = &sp.tcp[0];
assert_eq!(p.rarity, 3);
assert!(use_tls_for_tcp(443, p));
}
#[test]
fn hash_comment_and_exclude_lines_skipped() {
let fixture = r#"
# comment line
Exclude 1-65535
Probe TCP NULL q||
match x m|^.| p/x/
"#;
let sp = parse_probes(fixture).unwrap();
assert_eq!(sp.tcp.len(), 1);
}
#[test]
fn consecutive_tcp_probes_both_parsed() {
let fixture = r#"
Probe TCP A q|A|
Probe TCP B q|B|
"#;
let sp = parse_probes(fixture).unwrap();
assert_eq!(sp.tcp.len(), 2);
assert_eq!(sp.tcp[0].name, "A");
assert_eq!(sp.tcp[1].name, "B");
}
#[test]
fn find_slash_field_extracts_info_template() {
let tail = " p/Apache/ v/2.4/ i/protocol 2.0/ o/Linux/";
assert_eq!(find_slash_field(tail, "i/"), Some("protocol 2.0".into()));
assert_eq!(find_slash_field(tail, "o/"), Some("Linux".into()));
}
#[test]
fn find_all_slash_fields_collects_cpe_entries() {
let tail = " p/x/ cpe:/a:vendor:product:1.0/ cpe:/b:other:thing/";
let cpe = find_all_slash_fields(tail, "cpe:/");
assert_eq!(cpe.len(), 2);
assert!(cpe[0].starts_with("cpe:/"));
}
#[test]
fn match_line_with_os_and_device_templates() {
let m = parse_match_line("match svc m|^ok| p/Px/o/FreeBSD/d/router/").unwrap();
let m = m.unwrap();
assert_eq!(m.os_tpl.as_deref(), Some("FreeBSD"));
assert_eq!(m.device_tpl.as_deref(), Some("router"));
}
#[test]
fn tcp_probe_then_udp_probe_flushes_tcp() {
let fixture = r#"
Probe TCP T q|t|
Probe UDP U q|u|
"#;
let sp = parse_probes(fixture).unwrap();
assert_eq!(sp.tcp.len(), 1);
assert_eq!(sp.udp.len(), 1);
assert_eq!(sp.tcp[0].name, "T");
assert_eq!(sp.udp[0].name, "U");
}
#[test]
fn format_match_includes_os_and_device_brackets() {
let re = Regex::new(r"^x").unwrap();
let caps = re.captures(b"x").unwrap();
let m = ServiceMatch {
service_name: "svc".into(),
regex: re,
product_tpl: None,
version_tpl: None,
info_tpl: None,
os_tpl: Some("Linux".into()),
device_tpl: Some("switch".into()),
cpe_tpl: vec![],
soft: false,
};
let s = format_match(&m, &caps);
assert!(s.contains("[Linux]"));
assert!(s.contains("{switch}"));
}
#[test]
fn parse_match_line_cpe_template() {
let m = parse_match_line("match http m|^HTTP| p/Apache/ cpe:/a:apache:http_server/")
.unwrap()
.unwrap();
assert_eq!(m.cpe_tpl.len(), 1);
assert!(m.cpe_tpl[0].contains("apache"));
}
#[test]
fn probe_ports_ok_empty_ranges_some() {
let spec = parse_port_ranges_list("8080");
assert!(probe_ports_ok(8080, &spec));
assert!(!probe_ports_ok(8081, &spec));
}
#[test]
fn parse_probes_default_totalwaitms_six_seconds() {
let fixture = "Probe TCP NULL q||\n";
let sp = parse_probes(fixture).unwrap();
assert_eq!(sp.tcp[0].totalwait_ms, 6000);
}
#[test]
fn parse_probes_default_rarity_five() {
let fixture = "Probe UDP X q||\n";
let sp = parse_probes(fixture).unwrap();
assert_eq!(sp.udp[0].rarity, 5);
}
#[test]
fn apply_template_missing_group_is_empty() {
let re = Regex::new(r"^x").unwrap();
let caps = re.captures(b"x").unwrap();
assert_eq!(apply_template("$9", &caps), "");
}
#[test]
fn parse_match_line_pipe_delimited_regex() {
let m = parse_match_line("match ssh m|^SSH-| p/OpenSSH/")
.unwrap()
.unwrap();
assert_eq!(m.service_name, "ssh");
}
#[test]
fn parse_match_line_escaped_delimiter_in_pattern() {
let m = parse_match_line(r"match x m|^foo\|bar| p/X/")
.unwrap()
.unwrap();
assert_eq!(m.service_name, "x");
}
#[test]
fn apply_template_multi_digit_group_index() {
let re = Regex::new(r"^(.)(.)(.)").unwrap();
let caps = re.captures(b"abc").unwrap();
assert_eq!(apply_template("$1$2$3", &caps), "abc");
}
#[test]
fn apply_template_group_ten_when_present() {
let re = Regex::new(r"^(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)").unwrap();
let caps = re.captures(b"abcdefghij").unwrap();
assert_eq!(apply_template("$10", &caps), "j");
}
#[test]
fn parse_probe_tcp_line_decodes_payload() {
let (name, payload) =
super::parse_probe_tcp_line("NULL q|GET / HTTP/1.0\\r\\n\\r\\n|").unwrap();
assert_eq!(name, "NULL");
assert!(payload.starts_with(b"GET"));
}
#[test]
fn parse_probe_udp_line_decodes_payload() {
let (name, payload) = super::parse_probe_udp_line("DNS q|\\0\\x00\\x01|").unwrap();
assert_eq!(name, "DNS");
assert!(!payload.is_empty());
}
#[test]
fn parse_probes_tcp_ports_line_restricts_probe() {
let fixture = "Probe TCP T q||\nports 22,80\n";
let sp = parse_probes(fixture).unwrap();
assert!(probe_ports_ok(22, &sp.tcp[0].ports));
assert!(!probe_ports_ok(8080, &sp.tcp[0].ports));
}
#[test]
fn parse_probes_skips_exclude_directive_line() {
let fixture = "Exclude T\nProbe TCP T q||\n";
let sp = parse_probes(fixture).unwrap();
assert_eq!(sp.tcp.len(), 1);
assert_eq!(sp.tcp[0].name, "T");
}
#[test]
fn split_first_token_via_match_line_service_name() {
let m = parse_match_line("match my-service m|^x| p/X/")
.unwrap()
.unwrap();
assert_eq!(m.service_name, "my-service");
}
#[test]
fn find_slash_field_info_template() {
let tail = " p/Apache/ i/(Ubuntu)/ v/2.4/";
assert_eq!(super::find_slash_field(tail, "i/"), Some("(Ubuntu)".into()));
}
#[test]
fn format_match_includes_cpe_when_present() {
let re = Regex::new(r"^HTTP").unwrap();
let caps = re.captures(b"HTTP/1.1").unwrap();
let m = ServiceMatch {
service_name: "http".into(),
regex: re,
product_tpl: Some("Apache".into()),
version_tpl: None,
info_tpl: None,
os_tpl: None,
device_tpl: None,
cpe_tpl: vec!["cpe:/a:apache:http_server".into()],
soft: false,
};
let s = format_match(&m, &caps);
assert!(s.contains("cpe:/a:apache:http_server"));
}
#[test]
fn split_first_token_single_word() {
let (a, b) = super::split_first_token("hello");
assert_eq!(a, "hello");
assert_eq!(b, "");
}
#[test]
fn split_first_token_two_words() {
let (a, b) = super::split_first_token("ssh m|^x|");
assert_eq!(a, "ssh");
assert_eq!(b, " m|^x|");
}
#[test]
fn extract_m_delimited_simple_pattern() {
let (pat, tail) = super::extract_m_delimited("m|^SSH-| p/OpenSSH/").unwrap();
assert_eq!(pat, "^SSH-");
assert!(tail.contains("p/"));
}
#[test]
fn extract_p_v_templates_product_only() {
let (p, v, _, _, _, _) = super::extract_p_v_templates(" p/Apache/ ");
assert_eq!(p.as_deref(), Some("Apache"));
assert!(v.is_none());
}
#[test]
fn find_slash_field_missing_returns_none() {
assert!(super::find_slash_field(" p/x/ ", "z/").is_none());
}
#[test]
fn decode_nmap_escape_backslash_at_end_dropped() {
assert_eq!(decode_nmap_escape_bytes(r"test\"), b"test");
}
#[test]
fn parse_probes_udp_only_fixture() {
let fixture = "Probe UDP X q||\n";
let sp = parse_probes(fixture).unwrap();
assert_eq!(sp.tcp.len(), 0);
assert_eq!(sp.udp.len(), 1);
}
#[test]
fn parse_match_line_no_m_field_returns_none() {
assert!(parse_match_line("match ssh p/OpenSSH/").unwrap().is_none());
}
}