use std::ops::Range;
use std::sync::LazyLock;
use bathy_types::confidence::Confidence;
use regex::Regex;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Specificity {
ProductAndVersion,
ProductOnly,
ProtocolOnly,
Weak,
}
impl Specificity {
pub const ALL: [Self; 4] = [
Self::ProductAndVersion,
Self::ProductOnly,
Self::ProtocolOnly,
Self::Weak,
];
const fn value(self) -> f64 {
match self {
Self::ProductAndVersion => 0.95,
Self::ProductOnly => 0.85,
Self::ProtocolOnly => 0.70,
Self::Weak => 0.50,
}
}
pub fn confidence(self) -> Confidence {
#[allow(
clippy::expect_used,
reason = "four in-range literals; enforced by every_rung_of_the_ladder_is_a_valid_confidence"
)]
Confidence::new(self.value()).expect("ladder values are in range")
}
}
pub struct RuleDoc {
pub id: &'static str,
pub service: &'static str,
pub specificity: Specificity,
pub rationale: &'static str,
pub source: &'static str,
}
pub(crate) struct Hit {
pub product: Option<String>,
pub version: Option<String>,
pub specificity: Specificity,
pub span: Range<usize>,
}
pub(crate) struct Rule {
pub probe_id: &'static str,
pub doc: RuleDoc,
pub matcher: fn(&[u8]) -> Option<Hit>,
}
pub(crate) fn rules_for(probe_id: &str) -> impl Iterator<Item = &'static Rule> {
ALL_RULES.iter().filter(move |r| r.probe_id == probe_id)
}
pub fn all_rules() -> impl Iterator<Item = &'static RuleDoc> {
ALL_RULES.iter().map(|r| &r.doc)
}
pub fn explain(rule_id: &str) -> Option<&'static RuleDoc> {
ALL_RULES.iter().map(|r| &r.doc).find(|d| d.id == rule_id)
}
pub fn known_probe_ids() -> impl Iterator<Item = &'static str> {
let mut ids: Vec<&'static str> = ALL_RULES.iter().map(|r| r.probe_id).collect();
ids.sort_unstable();
ids.dedup();
ids.into_iter()
}
fn utf8_lines(bytes: &[u8]) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut start = 0usize;
for (i, &b) in bytes.iter().enumerate() {
if b == b'\n' {
if let Some(Ok(s)) = bytes.get(start..i).map(std::str::from_utf8) {
out.push((start, s));
}
let Some(next) = i.checked_add(1) else {
return out;
};
start = next;
}
}
if start < bytes.len()
&& let Some(Ok(s)) = bytes.get(start..).map(std::str::from_utf8)
{
out.push((start, s));
}
out
}
fn absolute_span(line_start: usize, m: ®ex::Match<'_>) -> Option<Range<usize>> {
Some(line_start.checked_add(m.start())?..line_start.checked_add(m.end())?)
}
#[allow(
clippy::expect_used,
reason = "compile-time-constant patterns only; enforced by every_static_regex_in_this_module_compiles"
)]
fn static_regex(pattern: &'static str) -> Regex {
Regex::new(pattern).expect("a pattern literal in this module does not compile")
}
fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
let s: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?;
Some(u16::from_be_bytes(s))
}
fn http_status_line(bytes: &[u8]) -> Option<(usize, &str)> {
let (start, first) = *utf8_lines(bytes).first()?;
if first.starts_with("HTTP/") {
Some((start, first))
} else {
None
}
}
static NGINX_SERVER_RE: LazyLock<Regex> =
LazyLock::new(|| static_regex(r"(?i)^Server:[ \t]*nginx(?:/([0-9][0-9A-Za-z.\-]*))?"));
fn http_nginx(bytes: &[u8]) -> Option<Hit> {
http_status_line(bytes)?;
for (line_start, line) in utf8_lines(bytes) {
let Some(caps) = NGINX_SERVER_RE.captures(line) else {
continue;
};
let m = caps.get(0)?;
let version = caps.get(1).map(|v| v.as_str().to_owned());
let specificity = if version.is_some() {
Specificity::ProductAndVersion
} else {
Specificity::ProductOnly
};
return Some(Hit {
product: Some("nginx".to_owned()),
version,
specificity,
span: absolute_span(line_start, &m)?,
});
}
None
}
fn http_bare_protocol(bytes: &[u8]) -> Option<Hit> {
let (start, first) = http_status_line(bytes)?;
Some(Hit {
product: None,
version: None,
specificity: Specificity::ProtocolOnly,
span: start..start.checked_add(first.len())?,
})
}
static SSH_OPENSSH_RE: LazyLock<Regex> =
LazyLock::new(|| static_regex(r"^SSH-\d\.\d+-OpenSSH_(\S+)"));
static SSH_BANNER_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^SSH-\d\.\d+-"));
fn ssh_openssh(bytes: &[u8]) -> Option<Hit> {
for (line_start, line) in utf8_lines(bytes) {
let Some(caps) = SSH_OPENSSH_RE.captures(line) else {
continue;
};
let m = caps.get(0)?;
let version = caps.get(1)?.as_str().to_owned();
return Some(Hit {
product: Some("OpenSSH".to_owned()),
version: Some(version),
specificity: Specificity::ProductAndVersion,
span: absolute_span(line_start, &m)?,
});
}
None
}
fn ssh_bare_protocol(bytes: &[u8]) -> Option<Hit> {
for (line_start, line) in utf8_lines(bytes) {
if let Some(m) = SSH_BANNER_RE.find(line) {
return Some(Hit {
product: None,
version: None,
specificity: Specificity::ProtocolOnly,
span: absolute_span(line_start, &m)?,
});
}
}
None
}
fn postgres_ssl_accepted(bytes: &[u8]) -> Option<Hit> {
if bytes == b"S" {
Some(Hit {
product: None,
version: None,
specificity: Specificity::ProtocolOnly,
span: 0..1,
})
} else {
None
}
}
fn postgres_ssl_declined(bytes: &[u8]) -> Option<Hit> {
if bytes == b"N" {
Some(Hit {
product: None,
version: None,
specificity: Specificity::ProtocolOnly,
span: 0..1,
})
} else {
None
}
}
fn redis_pong(bytes: &[u8]) -> Option<Hit> {
let prefix = b"+PONG";
if bytes.starts_with(prefix) {
Some(Hit {
product: None,
version: None,
specificity: Specificity::ProtocolOnly,
span: 0..prefix.len(),
})
} else {
None
}
}
fn redis_resp_shaped_reply(bytes: &[u8]) -> Option<Hit> {
let sigil = *bytes.first()?;
if !matches!(sigil, b'+' | b'-' | b':' | b'$' | b'*') {
return None;
}
let crlf_at = bytes.windows(2).position(|w| w == b"\r\n")?;
Some(Hit {
product: None,
version: None,
specificity: Specificity::Weak,
span: 0..crlf_at.checked_add(2)?,
})
}
fn mysql_handshake_v10(bytes: &[u8]) -> Option<Hit> {
const PROTOCOL_VERSION_OFFSET: usize = 4;
const VERSION_STRING_START: usize = 5;
if *bytes.get(PROTOCOL_VERSION_OFFSET)? != 0x0a {
return None;
}
let rest = bytes.get(VERSION_STRING_START..)?;
let nul = rest.iter().position(|&b| b == 0)?;
if nul == 0 {
return None; }
let version_end = VERSION_STRING_START.checked_add(nul)?;
let version = std::str::from_utf8(bytes.get(VERSION_STRING_START..version_end)?).ok()?;
Some(Hit {
product: Some("MySQL".to_owned()),
version: Some(version.to_owned()),
specificity: Specificity::ProductAndVersion,
span: VERSION_STRING_START..version_end,
})
}
fn dns_skip_name(bytes: &[u8], mut at: usize) -> Option<usize> {
loop {
let len = *bytes.get(at)?;
if len == 0 {
return at.checked_add(1);
}
if len & 0xC0 == 0xC0 {
bytes.get(at.checked_add(1)?)?;
return at.checked_add(2);
}
at = at.checked_add(1)?.checked_add(len as usize)?;
}
}
fn dns_bind_version(bytes: &[u8]) -> Option<Hit> {
let msg_len = u16_at(bytes, 0)? as usize;
let msg_start = 2usize;
let msg_end = msg_start.checked_add(msg_len)?;
if msg_end > bytes.len() {
return None;
}
let flags = u16_at(bytes, msg_start.checked_add(2)?)?;
if flags & 0x8000 == 0 {
return None; }
let qdcount = u16_at(bytes, msg_start.checked_add(4)?)?;
let ancount = u16_at(bytes, msg_start.checked_add(6)?)?;
if ancount == 0 {
return None;
}
let mut at = msg_start.checked_add(12)?; for _ in 0..qdcount {
at = dns_skip_name(bytes, at)?;
at = at.checked_add(4)?; if at > msg_end {
return None;
}
}
for _ in 0..ancount {
at = dns_skip_name(bytes, at)?;
let rtype = u16_at(bytes, at)?;
let rclass = u16_at(bytes, at.checked_add(2)?)?;
let rdlength = u16_at(bytes, at.checked_add(8)?)? as usize; let rdata_start = at.checked_add(10)?;
let rdata_end = rdata_start.checked_add(rdlength)?;
if rdata_end > msg_end || rdata_end > bytes.len() {
return None;
}
if rtype == 16 && rclass == 3 {
let txt_len = *bytes.get(rdata_start)? as usize;
let txt_start = rdata_start.checked_add(1)?;
let txt_end = txt_start.checked_add(txt_len)?;
if txt_end > rdata_end {
return None;
}
let version = std::str::from_utf8(bytes.get(txt_start..txt_end)?).ok()?;
if version.is_empty() {
return None;
}
return Some(Hit {
product: Some("BIND".to_owned()),
version: Some(version.to_owned()),
specificity: Specificity::ProductAndVersion,
span: txt_start..txt_end,
});
}
at = rdata_end;
}
None
}
static SMTP_GREETING_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^220[ -]"));
static SMTP_POSTFIX_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^220[ -].*\bPostfix\b"));
fn smtp_postfix(bytes: &[u8]) -> Option<Hit> {
for (line_start, line) in utf8_lines(bytes) {
if let Some(m) = SMTP_POSTFIX_RE.find(line) {
return Some(Hit {
product: Some("Postfix".to_owned()),
version: None,
specificity: Specificity::ProductOnly,
span: absolute_span(line_start, &m)?,
});
}
}
None
}
fn smtp_bare_protocol(bytes: &[u8]) -> Option<Hit> {
let (start, first) = *utf8_lines(bytes).first()?;
let m = SMTP_GREETING_RE.find(first)?;
Some(Hit {
product: None,
version: None,
specificity: Specificity::ProtocolOnly,
span: absolute_span(start, &m)?,
})
}
fn tls_server_hello(bytes: &[u8]) -> Option<Hit> {
const CONTENT_TYPE_HANDSHAKE: u8 = 0x16;
const HANDSHAKE_TYPE_SERVER_HELLO: u8 = 0x02;
const HEADER_LEN: usize = 6; let &[
CONTENT_TYPE_HANDSHAKE,
_,
_,
_,
_,
HANDSHAKE_TYPE_SERVER_HELLO,
] = bytes.get(0..HEADER_LEN)?
else {
return None;
};
Some(Hit {
product: None,
version: None,
specificity: Specificity::ProtocolOnly,
span: 0..HEADER_LEN,
})
}
static ALL_RULES: &[Rule] = &[
Rule {
probe_id: "http-get-v1",
doc: RuleDoc {
id: "http.server.nginx.v1",
service: "http",
specificity: Specificity::ProductAndVersion,
rationale: "The `Server` response header declared `nginx`, optionally followed by a version.",
source: "RFC 9112 §4 (\"Status Line\"), RFC 9110 §10.2.4 (`Server`); capture from \
nginx:1.27-alpine (digest sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10), \
M4 Task 2 report",
},
matcher: http_nginx,
},
Rule {
probe_id: "http-get-v1",
doc: RuleDoc {
id: "http.protocol.bare.v1",
service: "http",
specificity: Specificity::ProtocolOnly,
rationale: "The response's first line is a well-formed HTTP status line, but no \
`Server` header matched any known product.",
source: "RFC 9112 §4 (\"Status Line\": `status-line = HTTP-version SP status-code SP \
[ reason-phrase ]`)",
},
matcher: http_bare_protocol,
},
Rule {
probe_id: "ssh-banner-v1",
doc: RuleDoc {
id: "ssh.banner.openssh.v1",
service: "ssh",
specificity: Specificity::ProductAndVersion,
rationale: "The SSH identification string named the OpenSSH software version, per \
the `SSH-protoversion-softwareversion` format.",
source: "RFC 4253 §4.2 (\"Protocol Version Exchange\"); capture from \
linuxserver/openssh-server:latest \
(digest sha256:96b9a4d3b5106746d08d43a6911650d4d21f7d5c7f2ac9660e792bdb5e63157c), \
M4 Task 2 report",
},
matcher: ssh_openssh,
},
Rule {
probe_id: "ssh-banner-v1",
doc: RuleDoc {
id: "ssh.protocol.bare.v1",
service: "ssh",
specificity: Specificity::ProtocolOnly,
rationale: "The response is a well-formed SSH identification string, but the \
software field did not match any known product.",
source: "RFC 4253 §4.2 (\"Protocol Version Exchange\": SSH-protoversion-softwareversion)",
},
matcher: ssh_bare_protocol,
},
Rule {
probe_id: "postgres-startup-v1",
doc: RuleDoc {
id: "postgres.sslrequest.accepted.v1",
service: "postgresql",
specificity: Specificity::ProtocolOnly,
rationale: "The server replied with the single byte `S`, PostgreSQL's documented \
SSLRequest reply meaning it will negotiate SSL.",
source: "PostgreSQL \"Message Flow\" §54.2.10 (\"SSL Session Encryption\": \"The \
server then responds with a single byte containing S or N, indicating \
that it is willing or unwilling to perform SSL, respectively.\" -- \
postgresql.org/docs/current/protocol-flow.html); capture from \
postgres:16-alpine (digest sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777), \
M4 Task 2 report",
},
matcher: postgres_ssl_accepted,
},
Rule {
probe_id: "postgres-startup-v1",
doc: RuleDoc {
id: "postgres.sslrequest.declined.v1",
service: "postgresql",
specificity: Specificity::ProtocolOnly,
rationale: "The server replied with the single byte `N`, PostgreSQL's documented \
SSLRequest reply meaning it will not negotiate SSL.",
source: "PostgreSQL \"Message Flow\" §54.2.10 (\"SSL Session Encryption\": \"The \
server then responds with a single byte containing S or N, indicating \
that it is willing or unwilling to perform SSL, respectively.\" -- \
postgresql.org/docs/current/protocol-flow.html); capture from \
postgres:16-alpine (digest sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777), \
M4 Task 2 report -- the container itself replied `N`",
},
matcher: postgres_ssl_declined,
},
Rule {
probe_id: "redis-ping-v1",
doc: RuleDoc {
id: "redis.ping.pong.v1",
service: "redis",
specificity: Specificity::ProtocolOnly,
rationale: "The server replied `+PONG`, RESP's documented reply to the `PING` command.",
source: "Redis RESP protocol specification, \"Simple strings\" (a `+`-prefixed \
reply \"terminated by CRLF\") plus its \"Inline commands\" example, which \
shows `C: PING` answered by `S: +PONG` \
(redis.io/docs/latest/develop/reference/protocol-spec/); capture from \
redis:7-alpine (digest sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2), \
M4 Task 2 report",
},
matcher: redis_pong,
},
Rule {
probe_id: "redis-ping-v1",
doc: RuleDoc {
id: "redis.protocol.resp_shaped.v1",
service: "redis",
specificity: Specificity::Weak,
rationale: "The reply began with a valid RESP type sigil and carried a proper CRLF \
line terminator, but was not the literal `+PONG` a real Redis server \
sends -- consistent with a RESP-compatible service, not a confirmed \
product.",
source: "Redis RESP protocol specification, \"Simple strings\" (a reply is \
\"terminated by CRLF\") \
(redis.io/docs/latest/develop/reference/protocol-spec/), structural only",
},
matcher: redis_resp_shaped_reply,
},
Rule {
probe_id: "mysql-greeting-v1",
doc: RuleDoc {
id: "mysql.handshake.v10.v1",
service: "mysql",
specificity: Specificity::ProductAndVersion,
rationale: "The greeting's protocol-version byte was 0x0a (HandshakeV10), followed \
by a NUL-terminated server-version string.",
source: "MySQL \"Protocol::HandshakeV10\" field-layout table (protocol_version: \
int<1>, \"Always 10\"; immediately followed by server_version: \
string<NUL>) -- dev.mysql.com/doc/dev/mysql-server/latest/\
page_protocol_connection_phase_packets_protocol_handshake_v10.html; \
capture from mysql:8.4 \
(digest sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb), \
M4 Task 2 report",
},
matcher: mysql_handshake_v10,
},
Rule {
probe_id: "dns-version-bind-v1",
doc: RuleDoc {
id: "dns.version_bind.txt_chaos.v1",
service: "dns",
specificity: Specificity::ProductAndVersion,
rationale: "The reply's answer section carried a TXT/CH record -- the documented \
response to a `version.bind` query -- containing a version string.",
source: "RFC 1035 §4.1.1 (header), §4.1.2 (question), §3.2.2 (TXT), §3.2.4 (CH), \
§4.2.2 (TCP length prefix), §3.3.14 (TXT RDATA); BIND manual, \"Built-in \
Server Information Zones\" (bind9.readthedocs.io/en/latest/reference.html); \
capture from internetsystemsconsortium/bind9:9.18 \
(digest sha256:1ffb29c718ee2540c5643c1e8166629a07bbd505f99107baae535e9f86eb7eef), \
M4 Task 2 report",
},
matcher: dns_bind_version,
},
Rule {
probe_id: "smtp-banner-v1",
doc: RuleDoc {
id: "smtp.banner.postfix.v1",
service: "smtp",
specificity: Specificity::ProductOnly,
rationale: "The 220 greeting named Postfix. Postfix's greeting does not carry a \
version number, so no version can be extracted.",
source: "RFC 5321 §4.2 (\"SMTP Replies\": the `nnn-`/`nnn ` multiline reply ABNF \
this rule's regex scans every line for a match against); capture from \
boky/postfix:latest \
(digest sha256:aafc772384232497bed875e1eb66b4d3e54ba1ebc86e2e185a6dc1dbc48182ef), \
M4 Task 2 report",
},
matcher: smtp_postfix,
},
Rule {
probe_id: "smtp-banner-v1",
doc: RuleDoc {
id: "smtp.protocol.bare.v1",
service: "smtp",
specificity: Specificity::ProtocolOnly,
rationale: "The response is a well-formed 220 SMTP greeting, but no product name in \
it matched any known rule.",
source: "RFC 5321 §4.3.1 (\"Sequencing Overview\": \"Normally, a receiver will send \
a 220 'Service ready' reply\" -- descriptive, not a MUST; §3.1 explicitly \
permits a 554 reply instead), §4.2 (\"SMTP Replies\": `nnn-`/`nnn ` \
multiline reply ABNF)",
},
matcher: smtp_bare_protocol,
},
Rule {
probe_id: "tls-v1",
doc: RuleDoc {
id: "tls.serverhello.structural.v1",
service: "tls",
specificity: Specificity::ProtocolOnly,
rationale: "The reply's record layer carried content type 0x16 (handshake) with an \
inner handshake type of 0x02 (ServerHello) -- confirms a TLS server \
answered, but (for TLS 1.3) the certificate is encrypted, so no product \
or version can be read from these bytes.",
source: "RFC 8446 §5.1 (\"Record Layer\": `ContentType ... handshake(22)`, i.e. \
0x16), §4 (\"Handshake Protocol\", which defines `enum { ... \
server_hello(2), ... } HandshakeType`, i.e. 0x02) -- the IANA \"TLS \
HandshakeType\" registry itself is §11, not §4, as §4's own text says \
(\"New handshake message types are assigned by IANA as described in \
Section 11\"); capture from \
nginx:1.27-alpine (same digest as http.server.nginx.v1) terminating TLS \
1.3 with a locally generated self-signed certificate, M4 Task 2 report",
},
matcher: tls_server_hello,
},
];
#[cfg(test)]
mod tests {
use super::*;
use crate::interpret;
use bathy_types::{ProbeCapture, Transport};
#[test]
fn every_rung_of_the_ladder_is_a_valid_confidence() {
for rung in Specificity::ALL {
assert!(
Confidence::new(rung.value()).is_ok(),
"rung {rung:?} has value {} , which Confidence rejects",
rung.value()
);
}
}
#[test]
fn the_ladder_array_lists_every_variant() {
for rung in Specificity::ALL {
match rung {
Specificity::ProductAndVersion
| Specificity::ProductOnly
| Specificity::ProtocolOnly
| Specificity::Weak => {}
}
}
let mut seen: Vec<f64> = Specificity::ALL.iter().map(|s| s.value()).collect();
seen.sort_by(f64::total_cmp);
seen.dedup();
assert_eq!(
seen.len(),
Specificity::ALL.len(),
"two rungs of the ladder carry the same confidence, so one of them is not a rung"
);
}
#[test]
fn every_static_regex_in_this_module_compiles() {
let _ = NGINX_SERVER_RE.as_str();
let _ = SSH_OPENSSH_RE.as_str();
let _ = SSH_BANNER_RE.as_str();
let _ = SMTP_GREETING_RE.as_str();
let _ = SMTP_POSTFIX_RE.as_str();
}
#[test]
fn every_rule_has_its_static_regex_forced() {
let source = include_str!("rules.rs");
let declared = source
.lines()
.filter(|l| l.contains("LazyLock<Regex>") && l.trim_start().starts_with("static "))
.count();
assert_eq!(
declared, 5,
"this file declares {declared} `LazyLock<Regex>` statics, not 5; add the new one to \
every_static_regex_in_this_module_compiles and update this count"
);
}
#[test]
fn known_probe_ids_lists_every_probe_this_crate_has_rules_for_deduped_and_sorted() {
let ids: Vec<&str> = known_probe_ids().collect();
assert_eq!(
ids,
vec![
"dns-version-bind-v1",
"http-get-v1",
"mysql-greeting-v1",
"postgres-startup-v1",
"redis-ping-v1",
"smtp-banner-v1",
"ssh-banner-v1",
"tls-v1",
]
);
}
#[test]
fn rules_for_returns_only_rules_belonging_to_the_probe_it_was_asked_about() {
for id in known_probe_ids() {
let selected: Vec<&str> = rules_for(id).map(|r| r.doc.id).collect();
assert!(
!selected.is_empty(),
"{id} is a known probe id, so it must select at least one rule"
);
for r in rules_for(id) {
assert_eq!(
r.probe_id, id,
"rules_for({id:?}) offered rule {:?}, which belongs to probe {:?} -- \
a capture from one probe must never be matched against another \
probe's rules",
r.doc.id, r.probe_id
);
}
assert!(
selected.len() < ALL_RULES.len(),
"rules_for({id:?}) returned every rule in the registry ({selected:?}); \
dispatch is not filtering by probe id at all"
);
}
}
#[test]
fn rules_for_partitions_the_registry_leaving_no_rule_unreachable_and_none_duplicated() {
let mut total = 0usize;
let mut seen: Vec<&str> = Vec::new();
for id in known_probe_ids() {
for r in rules_for(id) {
total += 1;
seen.push(r.doc.id);
}
}
seen.sort_unstable();
let mut deduped = seen.clone();
deduped.dedup();
assert_eq!(
seen, deduped,
"a rule was offered by two different probe ids"
);
assert_eq!(
total,
ALL_RULES.len(),
"the union of rules_for() over every known probe id must be exactly the registry"
);
}
#[test]
fn a_rule_is_never_offered_a_capture_from_a_different_probe() {
let recognized: &[(&'static str, &[u8])] = &[
(
"http-get-v1",
b"HTTP/1.1 200 OK\r\nServer: nginx/1.26.0\r\n\r\n",
),
("ssh-banner-v1", b"SSH-2.0-OpenSSH_10.3\r\n"),
("smtp-banner-v1", b"220 mail.example.com ESMTP Postfix\r\n"),
("redis-ping-v1", b"+PONG\r\n"),
("tls-v1", &[0x16, 0x03, 0x03, 0x00, 0x02, 0x02, 0x00]),
("postgres-startup-v1", b"S"),
];
for &(owner, bytes) in recognized {
let own = interpret(&ProbeCapture {
probe_id: owner,
transport: Transport::Tcp,
port: 0,
request: None,
response: bytes.to_vec(),
elapsed_micros: 0,
truncated: false,
});
assert!(
!own.is_empty(),
"test fixture sanity: {owner} must recognize its own bytes, or the \
cross-feeding below proves nothing"
);
for other in known_probe_ids().filter(|&id| id != owner) {
let cross = interpret(&ProbeCapture {
probe_id: other,
transport: Transport::Tcp,
port: 0,
request: None,
response: bytes.to_vec(),
elapsed_micros: 0,
truncated: false,
});
assert!(
cross.is_empty(),
"bytes only {owner} can produce were interpreted as {:?} when \
delivered under probe id {other}",
cross.iter().map(|i| i.rule_id).collect::<Vec<_>>()
);
}
}
}
#[test]
fn ladder_orders_product_and_version_above_product_only_above_protocol_only_above_weak() {
assert!(
Specificity::ProductAndVersion.confidence().get()
> Specificity::ProductOnly.confidence().get()
);
assert!(
Specificity::ProductOnly.confidence().get()
> Specificity::ProtocolOnly.confidence().get()
);
assert!(
Specificity::ProtocolOnly.confidence().get() > Specificity::Weak.confidence().get()
);
}
#[test]
fn utf8_lines_splits_on_newline_and_reports_correct_offsets() {
let bytes = b"HTTP/1.1 200 OK\r\nServer: nginx\r\n\r\n";
let lines = utf8_lines(bytes);
assert_eq!(lines[0], (0, "HTTP/1.1 200 OK\r"));
assert_eq!(lines[1].0, 17);
assert!(lines[1].1.starts_with("Server: nginx"));
}
#[test]
fn utf8_lines_skips_a_line_that_is_not_valid_utf8_but_keeps_earlier_and_later_lines() {
let mut bytes = b"clean line one\n".to_vec();
bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); bytes.extend_from_slice(b"clean line three\n");
let lines = utf8_lines(&bytes);
let texts: Vec<&str> = lines.iter().map(|(_, s)| *s).collect();
assert_eq!(texts, vec!["clean line one", "clean line three"]);
}
#[test]
fn utf8_lines_never_panics_on_empty_input() {
assert!(utf8_lines(&[]).is_empty());
}
#[test]
fn http_nginx_extracts_product_and_version() {
let bytes = b"HTTP/1.1 200 OK\r\nServer: nginx/1.27.5\r\n\r\n";
let hit = http_nginx(bytes).unwrap();
assert_eq!(hit.product.as_deref(), Some("nginx"));
assert_eq!(hit.version.as_deref(), Some("1.27.5"));
assert_eq!(hit.specificity, Specificity::ProductAndVersion);
assert_eq!(&bytes[hit.span.clone()], b"Server: nginx/1.27.5");
}
#[test]
fn http_nginx_without_a_version_is_product_only() {
let hit = http_nginx(b"HTTP/1.1 200 OK\r\nServer: nginx\r\n\r\n").unwrap();
assert!(hit.version.is_none());
assert_eq!(hit.specificity, Specificity::ProductOnly);
}
#[test]
fn the_bytes_a_tls_terminator_returns_to_a_plaintext_request_name_its_product() {
let observed: &[u8] = b"HTTP/1.1 400 Bad Request\r\nServer: nginx/1.29.8\r\n\
Date: Tue, 04 Aug 2026 17:29:44 GMT\r\n\
Content-Type: text/html\r\nContent-Length: 255\r\n\
Connection: close\r\n\r\n<html>\r\n\
<head><title>400 The plain HTTP request was sent to \
HTTPS port</title></head>\r\n";
let hit = http_nginx(observed).expect(
"the `Server` header is in cleartext ahead of any handshake; if this stops \
matching, lab/ground-truth.json's claim at 10.30.0.17:443 has lost its basis",
);
assert_eq!(hit.product.as_deref(), Some("nginx"));
assert_eq!(hit.version.as_deref(), Some("1.29.8"));
assert_eq!(
hit.specificity,
Specificity::ProductAndVersion,
"a 4xx status is still an HTTP response and still names its server"
);
}
#[test]
fn http_nginx_does_not_match_a_non_http_response() {
assert!(http_nginx(b"Server: nginx/1.27.5\r\n").is_none());
}
#[test]
fn http_nginx_does_not_match_a_different_server_header() {
assert!(http_nginx(b"HTTP/1.1 200 OK\r\nServer: Apache/2.4.62\r\n\r\n").is_none());
}
#[test]
fn http_nginx_cites_the_correct_bytes_when_invalid_utf8_precedes_the_match() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"HTTP/1.1 200 OK\r\n");
bytes.extend_from_slice(b"X-Bad: \x80\r\n");
bytes.extend_from_slice(b"Server: nginx/1.26.0\r\n");
bytes.extend_from_slice(b"\r\n");
let hit = http_nginx(&bytes).unwrap();
assert_eq!(hit.version.as_deref(), Some("1.26.0"));
assert_eq!(
&bytes[hit.span.clone()],
b"Server: nginx/1.26.0",
"span must index the real bytes even with invalid UTF-8 earlier in the response"
);
}
#[test]
fn http_nginx_span_stays_in_bounds_when_the_match_ends_at_the_last_byte() {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"HTTP/1.1 200 OK\r\n");
bytes.extend_from_slice(b"X-Bad: \x80\r\n");
bytes.extend_from_slice(b"Server: nginx/1.26.0"); let hit = http_nginx(&bytes).unwrap();
assert_eq!(
hit.span.end,
bytes.len(),
"the match ends exactly at the buffer's end"
);
assert_eq!(&bytes[hit.span.clone()], b"Server: nginx/1.26.0");
}
#[test]
fn http_bare_protocol_matches_any_status_line() {
let bytes = b"HTTP/1.0 404 Not Found\r\n\r\n";
let hit = http_bare_protocol(bytes).unwrap();
assert_eq!(
&bytes[hit.span.clone()],
b"HTTP/1.0 404 Not Found\r",
"span must be exactly the status line, not the whole response"
);
}
#[test]
fn http_bare_protocol_cites_the_correct_bytes_when_invalid_utf8_precedes_the_status_line() {
let bytes = b"\x80garbage\nHTTP/1.1 200 OK\r\n\r\n";
let hit = http_bare_protocol(bytes).unwrap();
assert_eq!(
hit.span,
9..25,
"the status line begins after the skipped invalid line, not at byte 0"
);
assert_eq!(
&bytes[hit.span.clone()],
b"HTTP/1.1 200 OK\r",
"span must cite the status line's real bytes; dropping the line-start offset \
cites b\"\\x80garbage\\nHTTP/1.\" instead -- the wrong bytes entirely, and \
still a perfectly valid range, so no bounds check would ever notice"
);
}
#[test]
fn smtp_bare_protocol_cites_the_correct_bytes_when_invalid_utf8_precedes_the_greeting() {
let bytes = b"\x80junk\n220 mail.example.com ESMTP Sendmail\r\n";
let hit = smtp_bare_protocol(bytes).unwrap();
assert_eq!(
hit.span,
6..10,
"the greeting begins after the skipped invalid line, not at byte 0"
);
assert_eq!(
&bytes[hit.span.clone()],
b"220 ",
"span must cite the greeting's real bytes, not b\"\\x80jun\""
);
}
#[test]
fn ssh_openssh_extracts_version_and_ignores_the_trailing_comment() {
let bytes = b"SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13\r\n";
let hit = ssh_openssh(bytes).unwrap();
assert_eq!(hit.product.as_deref(), Some("OpenSSH"));
assert_eq!(hit.version.as_deref(), Some("9.6p1"));
assert_eq!(&bytes[hit.span.clone()], b"SSH-2.0-OpenSSH_9.6p1");
}
#[test]
fn ssh_openssh_matches_the_real_captured_banner_with_no_comment() {
let hit = ssh_openssh(b"SSH-2.0-OpenSSH_10.3\r\n").unwrap();
assert_eq!(hit.version.as_deref(), Some("10.3"));
}
#[test]
fn ssh_openssh_does_not_match_a_non_openssh_banner() {
assert!(ssh_openssh(b"SSH-2.0-libssh_0.9.6\r\n").is_none());
}
#[test]
fn ssh_openssh_finds_the_identification_line_after_a_preamble_line() {
let bytes = b"Some preamble the server sent first\r\nSSH-2.0-OpenSSH_9.6p1\r\n";
let hit = ssh_openssh(bytes).unwrap();
assert_eq!(hit.version.as_deref(), Some("9.6p1"));
assert_eq!(&bytes[hit.span.clone()], b"SSH-2.0-OpenSSH_9.6p1");
assert!(
hit.span.start > 0,
"the identification line is not at offset 0 here, so this also proves the \
line-offset term in Hit::span is real, not dead code"
);
}
#[test]
fn ssh_bare_protocol_matches_any_ssh_banner() {
let bytes = b"SSH-2.0-libssh_0.9.6\r\n";
let hit = ssh_bare_protocol(bytes).unwrap();
assert_eq!(&bytes[hit.span.clone()], b"SSH-2.0-");
}
#[test]
fn ssh_bare_protocol_finds_the_identification_line_after_a_preamble_line() {
let bytes = b"Some preamble the server sent first\r\nSSH-2.0-libssh_0.9.6\r\n";
let hit = ssh_bare_protocol(bytes).unwrap();
assert_eq!(&bytes[hit.span.clone()], b"SSH-2.0-");
assert!(hit.span.start > 0);
}
#[test]
fn postgres_ssl_accepted_matches_exactly_s() {
let hit = postgres_ssl_accepted(b"S").unwrap();
assert_eq!(&b"S"[hit.span.clone()], b"S");
assert!(postgres_ssl_accepted(b"N").is_none());
assert!(postgres_ssl_accepted(b"SS").is_none());
}
#[test]
fn postgres_ssl_declined_matches_the_real_captured_reply() {
let hit = postgres_ssl_declined(b"N").unwrap();
assert_eq!(&b"N"[hit.span.clone()], b"N");
assert!(postgres_ssl_declined(b"S").is_none());
}
#[test]
fn redis_pong_matches_the_real_captured_reply() {
let bytes = b"+PONG\r\n";
let hit = redis_pong(bytes).unwrap();
assert_eq!(&bytes[hit.span.clone()], b"+PONG");
}
#[test]
fn redis_resp_shaped_reply_is_weak_for_a_non_pong_resp_value() {
let bytes = b"-ERR unknown command\r\n";
let hit = redis_resp_shaped_reply(bytes).unwrap();
assert_eq!(hit.specificity, Specificity::Weak);
assert_eq!(&bytes[hit.span.clone()], b"-ERR unknown command\r\n");
}
#[test]
fn redis_resp_shaped_reply_does_not_match_non_resp_bytes() {
assert!(redis_resp_shaped_reply(b"HTTP/1.1 200 OK\r\n").is_none());
assert!(redis_resp_shaped_reply(b"").is_none());
}
#[test]
fn redis_resp_shaped_reply_rejects_a_lone_sigil_byte_with_no_crlf() {
assert!(redis_resp_shaped_reply(b"+").is_none());
assert!(redis_resp_shaped_reply(b"+X").is_none());
assert!(redis_resp_shaped_reply(b"+no terminator here").is_none());
}
const MYSQL_GREETING_HEX: &str = "4a0000000a382e342e3131000800000062215a7649740441\
00ffffff0200ffdf1500000000000000000000441e1b514b\
4e6e53084a5c270063616368696e675f736861325f706173\
73776f726400";
fn hex_to_bytes(hex: &str) -> Vec<u8> {
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect()
}
#[test]
fn mysql_handshake_v10_extracts_the_real_captured_version() {
let bytes = hex_to_bytes(MYSQL_GREETING_HEX);
let hit = mysql_handshake_v10(&bytes).unwrap();
assert_eq!(hit.product.as_deref(), Some("MySQL"));
assert_eq!(hit.version.as_deref(), Some("8.4.11"));
assert_eq!(&bytes[hit.span.clone()], b"8.4.11");
}
#[test]
fn mysql_handshake_v10_rejects_a_short_packet() {
assert!(mysql_handshake_v10(b"\x0a\x00").is_none());
}
#[test]
fn mysql_handshake_v10_rejects_a_non_handshake_v10_protocol_byte() {
let mut bytes = hex_to_bytes(MYSQL_GREETING_HEX);
bytes[4] = 0x09; assert!(mysql_handshake_v10(&bytes).is_none());
}
#[test]
fn mysql_handshake_v10_rejects_a_missing_nul_terminator() {
let bytes = vec![0u8, 0, 0, 0, 0x0a, b'8', b'.', b'4']; assert!(mysql_handshake_v10(&bytes).is_none());
}
const BIND_REPLY_HEX: &str = "00405344840000010001000100000776657273696f6e0462696e6400001000\
03c00c0010000300000000000807392e31382e3530c00c00020003000000000\
002c00c";
#[test]
fn dns_bind_version_extracts_the_real_captured_version() {
let bytes = hex_to_bytes(BIND_REPLY_HEX);
let hit = dns_bind_version(&bytes).unwrap();
assert_eq!(hit.product.as_deref(), Some("BIND"));
assert_eq!(hit.version.as_deref(), Some("9.18.50"));
assert_eq!(&bytes[hit.span.clone()], b"9.18.50");
}
#[test]
fn dns_bind_version_rejects_a_query_not_a_response() {
let query: Vec<u8> = [
0x00, 0x1e, 0x53, 0x44, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 7,
b'v', b'e', b'r', b's', b'i', b'o', b'n', 4, b'b', b'i', b'n', b'd', 0, 0x00, 0x10,
0x00, 0x03,
]
.to_vec();
assert!(dns_bind_version(&query).is_none());
}
#[test]
fn dns_bind_version_rejects_truncated_bytes_without_panicking() {
let full = hex_to_bytes(BIND_REPLY_HEX);
for cut in 0..full.len() {
assert!(dns_bind_version(&full[..cut]).is_none());
}
}
#[test]
fn dns_skip_name_rejects_a_label_length_that_runs_past_the_end() {
assert!(dns_skip_name(&[63, 1, 2], 0).is_none());
}
#[test]
fn dns_skip_name_treats_a_top_bits_set_byte_as_a_two_byte_compression_pointer() {
assert_eq!(dns_skip_name(&[200, 1, 2], 0), Some(2));
}
#[test]
fn smtp_postfix_matches_the_real_captured_greeting() {
let bytes = b"220 mail.example.com ESMTP Postfix\r\n";
let hit = smtp_postfix(bytes).unwrap();
assert_eq!(hit.product.as_deref(), Some("Postfix"));
assert!(hit.version.is_none());
assert_eq!(
&bytes[hit.span.clone()],
b"220 mail.example.com ESMTP Postfix"
);
}
#[test]
fn smtp_postfix_does_not_match_a_non_postfix_greeting() {
assert!(smtp_postfix(b"220 mail.example.com ESMTP Sendmail\r\n").is_none());
}
#[test]
fn smtp_postfix_finds_the_product_on_a_continuation_line() {
let bytes = b"220-mail.example.com ESMTP\r\n220 Postfix ready\r\n";
let hit = smtp_postfix(bytes).unwrap();
assert_eq!(hit.product.as_deref(), Some("Postfix"));
assert!(
hit.span.start > 0,
"the matching line is not at offset 0 here"
);
assert_eq!(&bytes[hit.span.clone()], b"220 Postfix");
}
#[test]
fn smtp_bare_protocol_matches_any_220_greeting() {
let bytes = b"220 mail.example.com ESMTP Sendmail\r\n";
let hit = smtp_bare_protocol(bytes).unwrap();
assert_eq!(&bytes[hit.span.clone()], b"220 ");
}
#[test]
fn tls_server_hello_matches_the_structural_header() {
let reply: &[u8] = &[0x16, 0x03, 0x03, 0x00, 0x02, 0x02, 0x00];
let hit = tls_server_hello(reply).unwrap();
assert_eq!(
&reply[hit.span.clone()],
&[0x16, 0x03, 0x03, 0x00, 0x02, 0x02]
);
}
#[test]
fn tls_server_hello_does_not_match_a_non_handshake_record() {
let alert: &[u8] = &[0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x00];
assert!(tls_server_hello(alert).is_none());
}
#[test]
fn tls_server_hello_rejects_a_too_short_buffer_without_panicking() {
for len in 0..6 {
assert!(tls_server_hello(&vec![0x16; len]).is_none());
}
}
}