bathy_interpret/rules.rs
1//! The confidence ladder, the rule registry, and every protocol rule.
2//!
3//! # The ladder (AC-4.11)
4//!
5//! [`Specificity`] is the *only* place a confidence number is written down.
6//! Every rule below declares a rung, never a literal `f64` -- so a `0.95`
7//! anywhere means the same thing ("product and version both extracted from
8//! a self-identifying banner") regardless of which protocol produced it,
9//! and the whole table is auditable in one place rather than sprinkled as
10//! magic numbers through match arms.
11//!
12//! # Byte safety (verification beyond the brief)
13//!
14//! Every matcher below is a plain `fn(&[u8]) -> Option<Hit>` operating on
15//! attacker-controlled bytes. None of them may panic, and every
16//! [`Hit::span`] they produce must be a valid range into the slice they
17//! were handed. Two disciplines make that provable rather than merely
18//! asserted:
19//!
20//! - Text-shaped rules (HTTP, SSH, SMTP) never call
21//! `String::from_utf8_lossy` over the *whole* response and then reuse
22//! `regex`'s match offsets as indices into the original bytes. That
23//! combination is unsound: a lossy conversion can change the byte length
24//! of anything after the first invalid byte (a single invalid byte
25//! becomes a 3-byte U+FFFD), so an offset computed against the lossy
26//! `String` does not necessarily land on the same byte -- or even inside
27//! bounds -- of the original slice. **This is a real defect in this
28//! task's own brief**: its worked `HTTP_NGINX` example does exactly this
29//! (`String::from_utf8_lossy(bytes)` over the whole response, then reuses
30//! `caps.get(0)`'s offsets as `matched_span` into the original `bytes`).
31//! It is also, independently, a second defect against this task's own
32//! dispatch instruction ("compile every regex once via `LazyLock`, not
33//! per call"): the brief's example constructs a fresh `regex::Regex` on
34//! every single invocation of the matcher closure. Both are fixed here:
35//! [`utf8_lines`] validates each line's bytes with `std::str::from_utf8`
36//! (strict, not lossy) *before* that line's bytes are ever used to
37//! compute an offset, skipping a line that isn't valid UTF-8 rather than
38//! guessing at where it ends; and every regex below is a module-level
39//! `LazyLock<Regex>`, compiled once for the life of the process. A
40//! response with a binary body after clean text headers (an ordinary
41//! HTTP reply with an image body, for instance) still gets its headers
42//! matched correctly under this scheme, because invalidity in one line
43//! never poisons another line's offsets.
44//! - Binary-shaped rules (Postgres, MySQL, DNS, TLS) use only checked
45//! arithmetic (`checked_add`, slice `.get`) and never index past a bound
46//! they have not just verified, so a truncated, malformed, or hostile
47//! packet yields `None` rather than a panic or an out-of-range span.
48//!
49//! `crate::interpret::tests` property-tests both disciplines' end result
50//! (span validity) over arbitrary bytes for the whole rule set at once, not
51//! just per protocol.
52//!
53//! **Both disciplines were prose until the M7 panic-lint round.** The
54//! sentence above about binary-shaped rules using "only checked arithmetic
55//! ... and never index past a bound" was *false when written*: `utf8_lines`
56//! sliced `bytes[start..i]`, `u16_at` indexed `s[0]`/`s[1]`,
57//! `mysql_handshake_v10` sliced `bytes[VERSION_STRING_START..version_end]`
58//! after an unchecked `+`, `dns_bind_version` sliced
59//! `bytes[txt_start..txt_end]`, `tls_server_hello` indexed `header[0]` and
60//! `header[5]`, and every text-shaped rule computed its span with a bare
61//! `line_start + m.start()`. Each was in fact in bounds, and none was
62//! *checked* by anything except a reader's attention -- which is precisely
63//! how the `from_utf8_lossy` offset defect this module's own comment
64//! describes got written in the first place, and how seven span-corrupting
65//! mutants survived into three review rounds. The Global Constraint that
66//! claimed `unwrap`/`expect`/indexing panics were "denied by lint" in this
67//! crate was aspirational from M1 until then; `src/lib.rs` now carries the
68//! lint that makes it true, and the arithmetic in this file is checked or
69//! it does not compile. See [`absolute_span`], which is the one place the
70//! offset arithmetic those seven mutants attacked now lives.
71//!
72//! # Provenance
73//!
74//! Every rule's `source` names an RFC section, a vendor's own protocol
75//! documentation, or a capture this project ran itself in Task 2 of this
76//! milestone (image, digest, and observed bytes -- see that task's report,
77//! `.superpowers/sdd/2026-07-31-bathy-m4-probes-interpret/task-2-report.md`).
78//! `nmap` and `nmap-service-probes`, both present on this development
79//! machine, were never opened or consulted while writing any rule below --
80//! confirmed structurally, not just by this comment, by
81//! `crate::tests::every_rule_documents_its_non_nmap_source`.
82
83use std::ops::Range;
84use std::sync::LazyLock;
85
86use bathy_types::confidence::Confidence;
87use regex::Regex;
88
89/// The confidence ladder. Every rule declares which rung it sits on, so
90/// scores across protocols mean the same thing and are auditable in one
91/// table rather than sprinkled as magic numbers through match arms
92/// (AC-4.11).
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum Specificity {
95 /// Product and version both extracted from a self-identifying banner.
96 ProductAndVersion,
97 /// Product identified, version absent or unparseable.
98 ProductOnly,
99 /// Protocol confirmed by structure, product unknown.
100 ProtocolOnly,
101 /// Consistent with the service but not conclusive.
102 Weak,
103}
104
105impl Specificity {
106 /// Every rung, in ladder order.
107 ///
108 /// Exists so the exhaustiveness of the ladder is testable rather than
109 /// eyeballed: `tests::every_rung_of_the_ladder_is_a_valid_confidence`
110 /// walks this array, and `tests::the_ladder_array_lists_every_variant`
111 /// matches on each variant exhaustively, so adding a fifth rung without
112 /// adding it here fails to compile.
113 pub const ALL: [Self; 4] = [
114 Self::ProductAndVersion,
115 Self::ProductOnly,
116 Self::ProtocolOnly,
117 Self::Weak,
118 ];
119
120 /// The `f64` this rung means. Split out from [`Self::confidence`] so the
121 /// four numbers can be range-checked by a test without going through the
122 /// fallible constructor.
123 const fn value(self) -> f64 {
124 match self {
125 Self::ProductAndVersion => 0.95,
126 Self::ProductOnly => 0.85,
127 Self::ProtocolOnly => 0.70,
128 Self::Weak => 0.50,
129 }
130 }
131
132 pub fn confidence(self) -> Confidence {
133 // NARROW ALLOW (M7 panic-lint round). `expect` is denied crate-wide
134 // in non-test builds because this crate parses attacker-controlled
135 // bytes; this call site takes no bytes at all. Its argument is
136 // `self.value()`, a `match` over a four-variant enum returning one
137 // of four literals, every one of them inside `Confidence`'s 0.0..=1.0
138 // domain -- so the `Err` arm is unreachable for every value the type
139 // system permits. `Confidence`'s field is private, so there is no
140 // infallible constructor to reach for, and the alternatives are both
141 // worse: `unwrap_or(..)` would silently substitute a confidence
142 // nobody wrote, and returning `Result` would push an error nobody can
143 // trigger through every rule in the file.
144 //
145 // The reasoning is enforced, not asserted:
146 // `tests::every_rung_of_the_ladder_is_a_valid_confidence` walks
147 // `Specificity::ALL` and fails if any rung's value leaves the domain,
148 // and `tests::the_ladder_array_lists_every_variant` fails to compile
149 // if a rung is added without joining `ALL`.
150 #[allow(
151 clippy::expect_used,
152 reason = "four in-range literals; enforced by every_rung_of_the_ladder_is_a_valid_confidence"
153 )]
154 Confidence::new(self.value()).expect("ladder values are in range")
155 }
156}
157
158/// Documentation for one rule, surfaced verbatim by [`explain`] (the
159/// `fingerprint.explain` tool's data source in M5).
160pub struct RuleDoc {
161 pub id: &'static str,
162 pub service: &'static str,
163 pub specificity: Specificity,
164 /// Human-readable explanation of what pattern justified the claim.
165 pub rationale: &'static str,
166 /// Provenance of this rule. Must cite an RFC, vendor documentation, or
167 /// a capture from software run in this project's own lab. Never Nmap.
168 pub source: &'static str,
169}
170
171/// What a rule's matcher found, before it is wrapped into a public
172/// [`crate::interpret::Interpretation`].
173pub(crate) struct Hit {
174 pub product: Option<String>,
175 pub version: Option<String>,
176 /// Overrides the rule's own `doc.specificity` when a rule's confidence
177 /// genuinely depends on what was found (e.g. product-with-version vs.
178 /// product-without-version from the same regex). Rules whose rung never
179 /// varies just echo `doc.specificity` here.
180 pub specificity: Specificity,
181 /// Byte range within the response that justified the claim.
182 pub span: Range<usize>,
183}
184
185/// One interpretation rule: which probe it applies to, its documentation,
186/// and the pure function that decides whether a response matches it.
187pub(crate) struct Rule {
188 pub probe_id: &'static str,
189 pub doc: RuleDoc,
190 pub matcher: fn(&[u8]) -> Option<Hit>,
191}
192
193/// Every rule applicable to a given probe, in registration order (the order
194/// `interpret` iterates them in before its own sort makes order
195/// irrelevant).
196pub(crate) fn rules_for(probe_id: &str) -> impl Iterator<Item = &'static Rule> {
197 ALL_RULES.iter().filter(move |r| r.probe_id == probe_id)
198}
199
200/// Every rule's documentation, for exhaustive checks like "no rule cites
201/// Nmap" (AC-4.16) and for tools that want to list what this crate can
202/// recognize at all.
203pub fn all_rules() -> impl Iterator<Item = &'static RuleDoc> {
204 ALL_RULES.iter().map(|r| &r.doc)
205}
206
207/// Documentation for one rule by id, surfaced by the `fingerprint.explain`
208/// tool in M5 (AC-4.12: every rule that can fire must be explainable).
209pub fn explain(rule_id: &str) -> Option<&'static RuleDoc> {
210 ALL_RULES.iter().map(|r| &r.doc).find(|d| d.id == rule_id)
211}
212
213/// Every distinct probe id this crate has at least one rule for -- the
214/// "registry" M4 Task 4's replay corpus (`crates/bathy-interpret/tests/replay.rs`)
215/// checks each fixture's `probe_id` against, closing that task's own "the
216/// corpus is data, so test the data" requirement.
217///
218/// Deliberately *not* `bathy_probe::framework::ProbeRegistry`'s own id list:
219/// depending on `bathy-probe` from this crate, even as a dev-dependency,
220/// would contradict this crate's own `src/lib.rs` doc comment (this crate
221/// sits *below* `bathy-probe` in the workspace layer order specifically so
222/// its tests need no upward dependency at all) and would fail
223/// `xtask check-deps`, which inspects a package's dev-dependencies too, not
224/// only its normal ones (`find_violations` in `xtask/src/main.rs` does not
225/// filter `cargo metadata`'s dependency list by kind). This crate's own rule
226/// registry is the authoritative "what probe ids do I know how to interpret"
227/// answer from *inside* this crate, which is the only registry `interpret`
228/// itself actually consults (see [`rules_for`]) -- a fixture naming a probe
229/// id this function doesn't return could never produce a real rule match
230/// regardless of what `bathy-probe` itself knows about, so it is exactly the
231/// right check for a corpus that exists to regression-test `interpret`.
232pub fn known_probe_ids() -> impl Iterator<Item = &'static str> {
233 let mut ids: Vec<&'static str> = ALL_RULES.iter().map(|r| r.probe_id).collect();
234 ids.sort_unstable();
235 ids.dedup();
236 ids.into_iter()
237}
238
239/// Splits `bytes` on `\n` and returns each line's starting byte offset
240/// (relative to `bytes`) together with its content as `&str` -- but only
241/// for lines that are themselves valid UTF-8. See this module's doc
242/// comment ("Byte safety") for why per-line validation, not a single
243/// whole-response `String::from_utf8_lossy`, is what keeps a match's byte
244/// offsets valid indices into `bytes` itself.
245fn utf8_lines(bytes: &[u8]) -> Vec<(usize, &str)> {
246 let mut out = Vec::new();
247 let mut start = 0usize;
248 for (i, &b) in bytes.iter().enumerate() {
249 if b == b'\n' {
250 if let Some(Ok(s)) = bytes.get(start..i).map(std::str::from_utf8) {
251 out.push((start, s));
252 }
253 // `i < bytes.len()`, so this cannot overflow -- but it is
254 // written checked anyway, because that is exactly the kind of
255 // "obviously fine" offset arithmetic this module's own history
256 // is about. A `None` here would mean `start` stops advancing,
257 // so bail rather than loop on a stale offset.
258 let Some(next) = i.checked_add(1) else {
259 return out;
260 };
261 start = next;
262 }
263 }
264 if start < bytes.len()
265 && let Some(Ok(s)) = bytes.get(start..).map(std::str::from_utf8)
266 {
267 out.push((start, s));
268 }
269 out
270}
271
272/// An absolute byte range into the response, from a regex match against one
273/// of [`utf8_lines`]'s lines plus that line's own start offset.
274///
275/// The single home for the `line_start + m.start()` arithmetic that every
276/// text-shaped rule below needs. It is one function rather than six copies
277/// for the reason this module's "Byte safety" note gives: this exact
278/// expression is what the `from_utf8_lossy` defect corrupted and what seven
279/// span mutants attacked across three review rounds, and a checked add
280/// written six times is six chances to write the seventh unchecked.
281///
282/// Returns `None` on overflow rather than wrapping or saturating: a
283/// saturated span would be a *wrong* claim about which bytes justified an
284/// interpretation, and this crate's whole contract is that a span points at
285/// the evidence. No match, no claim.
286fn absolute_span(line_start: usize, m: ®ex::Match<'_>) -> Option<Range<usize>> {
287 Some(line_start.checked_add(m.start())?..line_start.checked_add(m.end())?)
288}
289
290/// Compiles one of this module's own literal, compile-time-constant regex
291/// patterns.
292///
293/// # Why this panics, and why that is the right behaviour
294///
295/// `expect` is denied crate-wide in non-test builds (see `src/lib.rs`)
296/// because this crate parses attacker-controlled bytes. This function takes
297/// no bytes: its only callers pass a `&'static str` literal written in this
298/// file, so whether it compiles is decided when the source is written, not
299/// by anything a scanned peer sends. There is no input that reaches this
300/// `Err` arm.
301///
302/// The alternative -- returning `Option<Regex>` and having the rule quietly
303/// not fire -- is strictly worse than a panic: it would turn a typo in a
304/// pattern into a rule that silently recognizes nothing, which is the
305/// "reaches nothing while reading as coverage" failure this project has
306/// already measured once in a property-test strategy. A `LazyLock` that
307/// dies loudly on first use is a bug you find; a rule that never matches is
308/// a bug you ship.
309///
310/// The reasoning is enforced rather than asserted:
311/// `tests::every_static_regex_in_this_module_compiles` forces every
312/// `LazyLock` below, so a bad pattern fails `cargo test` rather than a scan.
313#[allow(
314 clippy::expect_used,
315 reason = "compile-time-constant patterns only; enforced by every_static_regex_in_this_module_compiles"
316)]
317fn static_regex(pattern: &'static str) -> Regex {
318 Regex::new(pattern).expect("a pattern literal in this module does not compile")
319}
320
321/// Reads a big-endian `u16` at `bytes[at..at+2]`, or `None` if that range
322/// runs off the end of `bytes`. The one primitive every binary-shaped
323/// matcher below builds its bounds-checked parsing on.
324fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
325 let s: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?;
326 Some(u16::from_be_bytes(s))
327}
328
329// =====================================================================
330// HTTP -- source: RFC 9112 §4 ("Status Line": `status-line = HTTP-version
331// SP status-code SP [ reason-phrase ]`), RFC 9110 §10.2.4 (`Server`).
332// (Root-cause fix, M4 Task 3 review round 1: this previously cited §3,
333// which is "Request Line" -- the ABNF for what a *client* sends, not a
334// server's response. §4 is the section that actually defines the
335// status-line shape these rules match against.) Corroborated against a
336// real server: `docker.io/library/nginx:1.27-alpine`, digest
337// `sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10`
338// (M4 Task 2 report), which replied `HTTP/1.1 200 OK\r\nServer:
339// nginx/1.27.5\r\n...`.
340// =====================================================================
341
342/// The response's first line that is valid UTF-8 -- not unconditionally its
343/// literal first line. A well-formed HTTP status line is always plain
344/// ASCII and therefore always valid UTF-8, so for any real HTTP response
345/// this distinction is moot: the first valid-UTF-8 line *is* byte 0.
346/// [`utf8_lines`] is still what supplies the "valid UTF-8" half of that
347/// guarantee, which is why this function is phrased in terms of it rather
348/// than indexing `bytes` directly.
349fn http_status_line(bytes: &[u8]) -> Option<(usize, &str)> {
350 let (start, first) = *utf8_lines(bytes).first()?;
351 if first.starts_with("HTTP/") {
352 Some((start, first))
353 } else {
354 None
355 }
356}
357
358static NGINX_SERVER_RE: LazyLock<Regex> =
359 LazyLock::new(|| static_regex(r"(?i)^Server:[ \t]*nginx(?:/([0-9][0-9A-Za-z.\-]*))?"));
360
361fn http_nginx(bytes: &[u8]) -> Option<Hit> {
362 http_status_line(bytes)?;
363 for (line_start, line) in utf8_lines(bytes) {
364 let Some(caps) = NGINX_SERVER_RE.captures(line) else {
365 continue;
366 };
367 let m = caps.get(0)?;
368 let version = caps.get(1).map(|v| v.as_str().to_owned());
369 let specificity = if version.is_some() {
370 Specificity::ProductAndVersion
371 } else {
372 Specificity::ProductOnly
373 };
374 return Some(Hit {
375 product: Some("nginx".to_owned()),
376 version,
377 specificity,
378 span: absolute_span(line_start, &m)?,
379 });
380 }
381 None
382}
383
384fn http_bare_protocol(bytes: &[u8]) -> Option<Hit> {
385 let (start, first) = http_status_line(bytes)?;
386 Some(Hit {
387 product: None,
388 version: None,
389 specificity: Specificity::ProtocolOnly,
390 span: start..start.checked_add(first.len())?,
391 })
392}
393
394// =====================================================================
395// SSH -- source: RFC 4253 §4.2 ("Protocol Version Exchange"). Corroborated
396// against `docker.io/linuxserver/openssh-server:latest`, digest
397// `sha256:96b9a4d3b5106746d08d43a6911650d4d21f7d5c7f2ac9660e792bdb5e63157c`
398// (M4 Task 2 report), which sent `SSH-2.0-OpenSSH_10.3\r\n` unprompted.
399//
400// Both matchers below scan *every* line, not just the first, and stop at
401// the first one that matches. This is not defensive-for-its-own-sake:
402// §4.2 itself says "The server MAY send other lines of data before
403// sending the version string... Such lines MUST NOT begin with 'SSH-'...
404// Clients MUST be able to process such lines." A matcher that only ever
405// looked at line 0 would false-negative on exactly this RFC-sanctioned
406// case -- a real, spec-compliant server whose banner isn't byte 0. (Root-
407// cause fix, M4 Task 3 review round 1: an earlier version of both
408// functions here called `utf8_lines(bytes).first()`, which is *always*
409// offset 0 by construction -- so it both missed this case and made the
410// `line_start + …` term in `Hit::span` provably dead code, indistinguishable
411// by any test from a version that dropped the offset entirely. See
412// `tests::ssh_openssh_finds_the_identification_line_after_a_preamble_line`.)
413// =====================================================================
414
415static SSH_OPENSSH_RE: LazyLock<Regex> =
416 LazyLock::new(|| static_regex(r"^SSH-\d\.\d+-OpenSSH_(\S+)"));
417
418static SSH_BANNER_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^SSH-\d\.\d+-"));
419
420fn ssh_openssh(bytes: &[u8]) -> Option<Hit> {
421 for (line_start, line) in utf8_lines(bytes) {
422 let Some(caps) = SSH_OPENSSH_RE.captures(line) else {
423 continue;
424 };
425 let m = caps.get(0)?;
426 let version = caps.get(1)?.as_str().to_owned();
427 return Some(Hit {
428 product: Some("OpenSSH".to_owned()),
429 version: Some(version),
430 specificity: Specificity::ProductAndVersion,
431 span: absolute_span(line_start, &m)?,
432 });
433 }
434 None
435}
436
437fn ssh_bare_protocol(bytes: &[u8]) -> Option<Hit> {
438 for (line_start, line) in utf8_lines(bytes) {
439 if let Some(m) = SSH_BANNER_RE.find(line) {
440 return Some(Hit {
441 product: None,
442 version: None,
443 specificity: Specificity::ProtocolOnly,
444 span: absolute_span(line_start, &m)?,
445 });
446 }
447 }
448 None
449}
450
451// =====================================================================
452// PostgreSQL -- source: PostgreSQL's own Frontend/Backend Protocol
453// documentation, split across two pages of the same doc set, not one:
454//
455// - The *request* bytes (an 8-byte message: length 8, then the fixed
456// SSLRequest code 80877103) are "Message Formats" §SSLRequest
457// (<https://www.postgresql.org/docs/current/protocol-message-formats.html>).
458// That page defines what the client sends; it does not document the
459// server's reply at all.
460// - The *reply*'s meaning is documented separately, in "Message Flow"
461// §54.2.10 ("SSL Session Encryption" -- "Message Flow" is the page's own
462// title; `protocol-flow` is only its URL slug, corrected in the M4
463// whole-branch fix wave's citation sweep,
464// <https://www.postgresql.org/docs/current/protocol-flow.html>): "The
465// server then responds with a single byte containing S or N, indicating
466// that it is willing or unwilling to perform SSL, respectively." (Root-
467// cause fix, M4 Task 3 review round 1: both rules below previously cited
468// only the request-format page for this fact too -- verified against the
469// live page, which covers the request shape only.)
470//
471// Corroborated against `docker.io/library/postgres:16-alpine`, digest
472// `sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777`
473// (M4 Task 2 report), which replied `N` (run without SSL configured) to
474// exactly the 8 bytes `postgres-startup-v1` sends.
475// =====================================================================
476
477fn postgres_ssl_accepted(bytes: &[u8]) -> Option<Hit> {
478 if bytes == b"S" {
479 Some(Hit {
480 product: None,
481 version: None,
482 specificity: Specificity::ProtocolOnly,
483 span: 0..1,
484 })
485 } else {
486 None
487 }
488}
489
490fn postgres_ssl_declined(bytes: &[u8]) -> Option<Hit> {
491 if bytes == b"N" {
492 Some(Hit {
493 product: None,
494 version: None,
495 specificity: Specificity::ProtocolOnly,
496 span: 0..1,
497 })
498 } else {
499 None
500 }
501}
502
503// =====================================================================
504// Redis -- source: Redis's own RESP protocol specification
505// (<https://redis.io/docs/latest/develop/reference/protocol-spec/>): a
506// simple string reply is `+<text>\r\n`. Corroborated against
507// `docker.io/library/redis:7-alpine`, digest
508// `sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2`
509// (M4 Task 2 report), which replied `+PONG\r\n` to exactly the RESP `PING`
510// `redis-ping-v1` sends.
511// =====================================================================
512
513fn redis_pong(bytes: &[u8]) -> Option<Hit> {
514 let prefix = b"+PONG";
515 if bytes.starts_with(prefix) {
516 Some(Hit {
517 product: None,
518 version: None,
519 specificity: Specificity::ProtocolOnly,
520 span: 0..prefix.len(),
521 })
522 } else {
523 None
524 }
525}
526
527/// Weak-tier fallback: *some* RESP-shaped reply came back (one of the five
528/// type sigils RESP defines -- simple string, error, integer, bulk string,
529/// array), but not literally `+PONG`. Genuinely weaker evidence than
530/// [`redis_pong`]: several Redis-protocol-compatible servers (e.g. KeyDB,
531/// Dragonfly) reply to `PING` with a valid but non-identical RESP value, so
532/// this recognizes the *wire format*, not the product -- exactly
533/// [`Specificity::Weak`]'s definition ("consistent with the service but not
534/// conclusive"), not a guess that it is Redis itself.
535///
536/// Requires an actual `\r\n` terminator (RESP's own line terminator, per
537/// the RESP protocol specification's "Simple strings" section: "terminated
538/// by CRLF") after the sigil, not just a matching first byte. (Root-cause
539/// fix, M4 Task 3 review round 1: a single stray byte from an arbitrary
540/// binary protocol -- `0x2b` alone, say -- happens to equal `+` and
541/// previously matched on its own; requiring the terminator this rule's own
542/// rationale claims to have found is what makes "RESP-shaped" an honest
543/// description rather than a one-byte coincidence.)
544fn redis_resp_shaped_reply(bytes: &[u8]) -> Option<Hit> {
545 let sigil = *bytes.first()?;
546 if !matches!(sigil, b'+' | b'-' | b':' | b'$' | b'*') {
547 return None;
548 }
549 let crlf_at = bytes.windows(2).position(|w| w == b"\r\n")?;
550 Some(Hit {
551 product: None,
552 version: None,
553 specificity: Specificity::Weak,
554 span: 0..crlf_at.checked_add(2)?,
555 })
556}
557
558// =====================================================================
559// MySQL -- source: MySQL's own "Protocol::HandshakeV10" *packet-layout*
560// page
561// (<https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_handshake_v10.html>)
562// -- not the "Connection Phase" overview page this previously cited, which
563// only links to the packet layout without itself listing the fields
564// (verified against the live page; root-cause fix, M4 Task 3 review round
565// 1). The layout page's own field table lists `protocol_version` as
566// `int<1>`, "Always 10", as the first field, with `server_version` --
567// `string<NUL>` -- immediately after it. Byte 4 of the packet is therefore
568// `protocol_version` (0x0a for HandshakeV10), followed immediately by the
569// NUL-terminated `server_version` string. Corroborated against
570// `docker.io/library/mysql:8.4`, digest
571// `sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb`
572// (M4 Task 2 report), whose captured `HandshakeV10` packet's version string
573// reads `8.4.11` -- the exact bytes reused as this rule's own test fixture
574// below.
575// =====================================================================
576
577fn mysql_handshake_v10(bytes: &[u8]) -> Option<Hit> {
578 const PROTOCOL_VERSION_OFFSET: usize = 4;
579 const VERSION_STRING_START: usize = 5;
580 if *bytes.get(PROTOCOL_VERSION_OFFSET)? != 0x0a {
581 return None;
582 }
583 let rest = bytes.get(VERSION_STRING_START..)?;
584 let nul = rest.iter().position(|&b| b == 0)?;
585 if nul == 0 {
586 return None; // empty version string: nothing to report
587 }
588 let version_end = VERSION_STRING_START.checked_add(nul)?;
589 let version = std::str::from_utf8(bytes.get(VERSION_STRING_START..version_end)?).ok()?;
590 Some(Hit {
591 product: Some("MySQL".to_owned()),
592 version: Some(version.to_owned()),
593 specificity: Specificity::ProductAndVersion,
594 span: VERSION_STRING_START..version_end,
595 })
596}
597
598// =====================================================================
599// DNS (version.bind/TXT/CHAOS) -- source: RFC 1035 §4.1.1 (header),
600// §4.1.2 (question section), §3.2.2 (TXT, type 16), §3.2.4 (CH/Chaos,
601// class 3), §4.2.2 (TCP's 2-byte length prefix), §3.3.14 (TXT RDATA is a
602// sequence of length-prefixed character-strings); the `version.bind`
603// convention itself is documented by BIND's own manual
604// (<https://bind9.readthedocs.io/en/latest/reference.html>, "Built-in
605// Server Information Zones"). Corroborated against
606// `docker.io/internetsystemsconsortium/bind9:9.18`, digest
607// `sha256:1ffb29c718ee2540c5643c1e8166629a07bbd505f99107baae535e9f86eb7eef`
608// (M4 Task 2 report), whose captured reply carries a TXT record reading
609// `9.18.50` -- the exact bytes reused as this rule's own test fixture
610// below.
611// =====================================================================
612
613/// Skips one DNS name starting at `at` (a run of length-prefixed labels
614/// terminated by a zero-length label, or a two-byte compression pointer --
615/// RFC 1035 §4.1.4), returning the offset just past it, or `None` if the
616/// name runs off the end of `bytes`.
617fn dns_skip_name(bytes: &[u8], mut at: usize) -> Option<usize> {
618 loop {
619 let len = *bytes.get(at)?;
620 if len == 0 {
621 return at.checked_add(1);
622 }
623 if len & 0xC0 == 0xC0 {
624 // Compression pointer: exactly 2 bytes, does not recurse into
625 // the name it points at -- not needed for this rule's purpose.
626 bytes.get(at.checked_add(1)?)?;
627 return at.checked_add(2);
628 }
629 at = at.checked_add(1)?.checked_add(len as usize)?;
630 }
631}
632
633fn dns_bind_version(bytes: &[u8]) -> Option<Hit> {
634 let msg_len = u16_at(bytes, 0)? as usize;
635 let msg_start = 2usize;
636 let msg_end = msg_start.checked_add(msg_len)?;
637 if msg_end > bytes.len() {
638 return None;
639 }
640
641 let flags = u16_at(bytes, msg_start.checked_add(2)?)?;
642 if flags & 0x8000 == 0 {
643 return None; // QR bit: must be a response, not a query
644 }
645 let qdcount = u16_at(bytes, msg_start.checked_add(4)?)?;
646 let ancount = u16_at(bytes, msg_start.checked_add(6)?)?;
647 if ancount == 0 {
648 return None;
649 }
650
651 let mut at = msg_start.checked_add(12)?; // past the fixed 12-byte header
652 for _ in 0..qdcount {
653 at = dns_skip_name(bytes, at)?;
654 at = at.checked_add(4)?; // QTYPE + QCLASS
655 if at > msg_end {
656 return None;
657 }
658 }
659
660 for _ in 0..ancount {
661 at = dns_skip_name(bytes, at)?;
662 let rtype = u16_at(bytes, at)?;
663 let rclass = u16_at(bytes, at.checked_add(2)?)?;
664 let rdlength = u16_at(bytes, at.checked_add(8)?)? as usize; // TYPE+CLASS+TTL = 8
665 let rdata_start = at.checked_add(10)?;
666 let rdata_end = rdata_start.checked_add(rdlength)?;
667 if rdata_end > msg_end || rdata_end > bytes.len() {
668 return None;
669 }
670
671 if rtype == 16 && rclass == 3 {
672 // TXT/CH: RDATA is a length-prefixed character-string.
673 let txt_len = *bytes.get(rdata_start)? as usize;
674 let txt_start = rdata_start.checked_add(1)?;
675 let txt_end = txt_start.checked_add(txt_len)?;
676 if txt_end > rdata_end {
677 return None;
678 }
679 let version = std::str::from_utf8(bytes.get(txt_start..txt_end)?).ok()?;
680 if version.is_empty() {
681 return None;
682 }
683 return Some(Hit {
684 product: Some("BIND".to_owned()),
685 version: Some(version.to_owned()),
686 specificity: Specificity::ProductAndVersion,
687 span: txt_start..txt_end,
688 });
689 }
690 at = rdata_end;
691 }
692 None
693}
694
695// =====================================================================
696// SMTP -- source: RFC 5321 §3.1 ("Session Initiation": "An SMTP session is
697// initiated when a client opens a connection to a server and the server
698// responds with an opening message" -- §3.1 itself permits a 554 reply
699// here instead of 220, so this is a description of the usual case, not a
700// promise about wording); §4.3.1 ("Sequencing Overview": "Normally, a
701// receiver will send a 220 'Service ready' reply" -- likewise descriptive);
702// §4.2 ("SMTP Replies": the `nnn-`/`nnn ` multiline reply ABNF these
703// rules' regexes depend on). (Root-cause fix, M4 Task 3 review round 1:
704// this previously attributed the quotation "the SMTP server MUST send a
705// 220 'Service ready' reply" to §3.1 -- that sentence does not appear
706// anywhere in §3.1, RFC 5321 makes no MUST-level promise about the
707// greeting at all, and the real "Normally... will send" sentence is in
708// §4.3.1, not §3.1. The multiline-reply ABNF was also miscited to §4.3.1
709// -- it is in §4.2, "SMTP Replies". Verified against the live RFC text for
710// this fix, not re-derived from the earlier, uncorroborated citation.)
711// Corroborated against `docker.io/boky/postfix:latest`,
712// digest `sha256:aafc772384232497bed875e1eb66b4d3e54ba1ebc86e2e185a6dc1dbc48182ef`
713// (M4 Task 2 report), which replied `220 <host> ESMTP Postfix (Debian)\r\n`.
714// =====================================================================
715
716static SMTP_GREETING_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^220[ -]"));
717
718static SMTP_POSTFIX_RE: LazyLock<Regex> = LazyLock::new(|| static_regex(r"^220[ -].*\bPostfix\b"));
719
720/// Scans every line, not just the first: RFC 5321 §4.2's own ABNF for the
721/// `Greeting` allows a *multiline* 220 reply -- verbatim: `( "220-"
722/// (Domain / address-literal) [ SP textstring ] CRLF *( "220-" [
723/// textstring ] CRLF ) "220" [ SP textstring ] CRLF )` -- so the text
724/// naming a product may legitimately be on a continuation line rather than
725/// the very first one. (An earlier version of this comment transcribed the
726/// final line as `"220" SP [text] CRLF`, moving the `SP` outside the
727/// optional group and so making it mandatory. It is not: a conformant
728/// final line may be a bare `220\r\n`, which `SMTP_GREETING_RE` -- `^220[
729/// -]` -- does not match. That is a real, if narrow, false negative, left
730/// as-is here deliberately: widening the regex is a behaviour change to a
731/// matcher, which belongs in a rule change with its own corpus fixture,
732/// not in a citation correction. M4 whole-branch fix wave citation sweep.) (Root-cause fix, M4 Task 3 review round 1: an earlier
733/// version only checked `utf8_lines(bytes).first()`, which is always
734/// offset 0 -- making `Hit::span`'s offset term dead code for every
735/// realistic single-line-greeting test, the same issue fixed in
736/// `ssh_openssh` above. See
737/// `tests::smtp_postfix_finds_the_product_on_a_continuation_line`.)
738fn smtp_postfix(bytes: &[u8]) -> Option<Hit> {
739 for (line_start, line) in utf8_lines(bytes) {
740 if let Some(m) = SMTP_POSTFIX_RE.find(line) {
741 return Some(Hit {
742 product: Some("Postfix".to_owned()),
743 version: None,
744 specificity: Specificity::ProductOnly,
745 span: absolute_span(line_start, &m)?,
746 });
747 }
748 }
749 None
750}
751
752fn smtp_bare_protocol(bytes: &[u8]) -> Option<Hit> {
753 let (start, first) = *utf8_lines(bytes).first()?;
754 let m = SMTP_GREETING_RE.find(first)?;
755 Some(Hit {
756 product: None,
757 version: None,
758 specificity: Specificity::ProtocolOnly,
759 span: absolute_span(start, &m)?,
760 })
761}
762
763// =====================================================================
764// TLS -- source: RFC 8446 §5.1 ("Record Layer": `ContentType ...
765// handshake(22)`, i.e. content type `0x16`) and §4 ("Handshake Protocol",
766// which defines `enum { ... server_hello(2), ... } HandshakeType`, i.e.
767// `0x02`). §4 is NOT the handshake type registry -- that is §11, as §4's
768// own text says: "New handshake message types are assigned by IANA as
769// described in Section 11." An earlier version of this comment and of the
770// rule's own `source` string below called §4 the registry (M4 whole-branch
771// review, MINOR-3). Corroborated
772// against `docker.io/library/nginx:1.27-alpine` (same digest as the HTTP
773// rule above) terminating TLS 1.3 with a locally generated self-signed
774// certificate (M4 Task 2 report): sending `tls-v1`'s `ClientHello` elicited
775// a real `ServerHello` with exactly this record/handshake-type header.
776// Structural only, deliberately: RFC 8446 §4.4 moves `Certificate` into the
777// encrypted handshake flight for TLS 1.3, so no product or version can be
778// read from these bytes without first decrypting them, which this probe
779// (and this rule) never does -- see `bathy_probe::probes::tls`'s own doc
780// comment for the same point made about the probe side.
781// =====================================================================
782
783fn tls_server_hello(bytes: &[u8]) -> Option<Hit> {
784 const CONTENT_TYPE_HANDSHAKE: u8 = 0x16;
785 const HANDSHAKE_TYPE_SERVER_HELLO: u8 = 0x02;
786 const HEADER_LEN: usize = 6; // 5-byte record header + 1-byte handshake type
787 // Destructured, not indexed: the array pattern is what makes the
788 // six-byte length requirement and the two field positions one check the
789 // compiler sees, rather than a `get(0..6)` whose result is then indexed
790 // on the reader's word that six is bigger than five.
791 let &[
792 CONTENT_TYPE_HANDSHAKE,
793 _,
794 _,
795 _,
796 _,
797 HANDSHAKE_TYPE_SERVER_HELLO,
798 ] = bytes.get(0..HEADER_LEN)?
799 else {
800 return None;
801 };
802 Some(Hit {
803 product: None,
804 version: None,
805 specificity: Specificity::ProtocolOnly,
806 span: 0..HEADER_LEN,
807 })
808}
809
810// =====================================================================
811// The registry.
812// =====================================================================
813
814static ALL_RULES: &[Rule] = &[
815 Rule {
816 probe_id: "http-get-v1",
817 doc: RuleDoc {
818 id: "http.server.nginx.v1",
819 service: "http",
820 specificity: Specificity::ProductAndVersion,
821 rationale: "The `Server` response header declared `nginx`, optionally followed by a version.",
822 source: "RFC 9112 §4 (\"Status Line\"), RFC 9110 §10.2.4 (`Server`); capture from \
823 nginx:1.27-alpine (digest sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10), \
824 M4 Task 2 report",
825 },
826 matcher: http_nginx,
827 },
828 Rule {
829 probe_id: "http-get-v1",
830 doc: RuleDoc {
831 id: "http.protocol.bare.v1",
832 service: "http",
833 specificity: Specificity::ProtocolOnly,
834 rationale: "The response's first line is a well-formed HTTP status line, but no \
835 `Server` header matched any known product.",
836 source: "RFC 9112 §4 (\"Status Line\": `status-line = HTTP-version SP status-code SP \
837 [ reason-phrase ]`)",
838 },
839 matcher: http_bare_protocol,
840 },
841 Rule {
842 probe_id: "ssh-banner-v1",
843 doc: RuleDoc {
844 id: "ssh.banner.openssh.v1",
845 service: "ssh",
846 specificity: Specificity::ProductAndVersion,
847 rationale: "The SSH identification string named the OpenSSH software version, per \
848 the `SSH-protoversion-softwareversion` format.",
849 source: "RFC 4253 §4.2 (\"Protocol Version Exchange\"); capture from \
850 linuxserver/openssh-server:latest \
851 (digest sha256:96b9a4d3b5106746d08d43a6911650d4d21f7d5c7f2ac9660e792bdb5e63157c), \
852 M4 Task 2 report",
853 },
854 matcher: ssh_openssh,
855 },
856 Rule {
857 probe_id: "ssh-banner-v1",
858 doc: RuleDoc {
859 id: "ssh.protocol.bare.v1",
860 service: "ssh",
861 specificity: Specificity::ProtocolOnly,
862 rationale: "The response is a well-formed SSH identification string, but the \
863 software field did not match any known product.",
864 source: "RFC 4253 §4.2 (\"Protocol Version Exchange\": SSH-protoversion-softwareversion)",
865 },
866 matcher: ssh_bare_protocol,
867 },
868 Rule {
869 probe_id: "postgres-startup-v1",
870 doc: RuleDoc {
871 id: "postgres.sslrequest.accepted.v1",
872 service: "postgresql",
873 specificity: Specificity::ProtocolOnly,
874 rationale: "The server replied with the single byte `S`, PostgreSQL's documented \
875 SSLRequest reply meaning it will negotiate SSL.",
876 source: "PostgreSQL \"Message Flow\" §54.2.10 (\"SSL Session Encryption\": \"The \
877 server then responds with a single byte containing S or N, indicating \
878 that it is willing or unwilling to perform SSL, respectively.\" -- \
879 postgresql.org/docs/current/protocol-flow.html); capture from \
880 postgres:16-alpine (digest sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777), \
881 M4 Task 2 report",
882 },
883 matcher: postgres_ssl_accepted,
884 },
885 Rule {
886 probe_id: "postgres-startup-v1",
887 doc: RuleDoc {
888 id: "postgres.sslrequest.declined.v1",
889 service: "postgresql",
890 specificity: Specificity::ProtocolOnly,
891 rationale: "The server replied with the single byte `N`, PostgreSQL's documented \
892 SSLRequest reply meaning it will not negotiate SSL.",
893 source: "PostgreSQL \"Message Flow\" §54.2.10 (\"SSL Session Encryption\": \"The \
894 server then responds with a single byte containing S or N, indicating \
895 that it is willing or unwilling to perform SSL, respectively.\" -- \
896 postgresql.org/docs/current/protocol-flow.html); capture from \
897 postgres:16-alpine (digest sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777), \
898 M4 Task 2 report -- the container itself replied `N`",
899 },
900 matcher: postgres_ssl_declined,
901 },
902 Rule {
903 probe_id: "redis-ping-v1",
904 doc: RuleDoc {
905 id: "redis.ping.pong.v1",
906 service: "redis",
907 specificity: Specificity::ProtocolOnly,
908 rationale: "The server replied `+PONG`, RESP's documented reply to the `PING` command.",
909 source: "Redis RESP protocol specification, \"Simple strings\" (a `+`-prefixed \
910 reply \"terminated by CRLF\") plus its \"Inline commands\" example, which \
911 shows `C: PING` answered by `S: +PONG` \
912 (redis.io/docs/latest/develop/reference/protocol-spec/); capture from \
913 redis:7-alpine (digest sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2), \
914 M4 Task 2 report",
915 },
916 matcher: redis_pong,
917 },
918 Rule {
919 probe_id: "redis-ping-v1",
920 doc: RuleDoc {
921 id: "redis.protocol.resp_shaped.v1",
922 service: "redis",
923 specificity: Specificity::Weak,
924 rationale: "The reply began with a valid RESP type sigil and carried a proper CRLF \
925 line terminator, but was not the literal `+PONG` a real Redis server \
926 sends -- consistent with a RESP-compatible service, not a confirmed \
927 product.",
928 source: "Redis RESP protocol specification, \"Simple strings\" (a reply is \
929 \"terminated by CRLF\") \
930 (redis.io/docs/latest/develop/reference/protocol-spec/), structural only",
931 },
932 matcher: redis_resp_shaped_reply,
933 },
934 Rule {
935 probe_id: "mysql-greeting-v1",
936 doc: RuleDoc {
937 id: "mysql.handshake.v10.v1",
938 service: "mysql",
939 specificity: Specificity::ProductAndVersion,
940 rationale: "The greeting's protocol-version byte was 0x0a (HandshakeV10), followed \
941 by a NUL-terminated server-version string.",
942 source: "MySQL \"Protocol::HandshakeV10\" field-layout table (protocol_version: \
943 int<1>, \"Always 10\"; immediately followed by server_version: \
944 string<NUL>) -- dev.mysql.com/doc/dev/mysql-server/latest/\
945 page_protocol_connection_phase_packets_protocol_handshake_v10.html; \
946 capture from mysql:8.4 \
947 (digest sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb), \
948 M4 Task 2 report",
949 },
950 matcher: mysql_handshake_v10,
951 },
952 Rule {
953 probe_id: "dns-version-bind-v1",
954 doc: RuleDoc {
955 id: "dns.version_bind.txt_chaos.v1",
956 service: "dns",
957 specificity: Specificity::ProductAndVersion,
958 rationale: "The reply's answer section carried a TXT/CH record -- the documented \
959 response to a `version.bind` query -- containing a version string.",
960 source: "RFC 1035 §4.1.1 (header), §4.1.2 (question), §3.2.2 (TXT), §3.2.4 (CH), \
961 §4.2.2 (TCP length prefix), §3.3.14 (TXT RDATA); BIND manual, \"Built-in \
962 Server Information Zones\" (bind9.readthedocs.io/en/latest/reference.html); \
963 capture from internetsystemsconsortium/bind9:9.18 \
964 (digest sha256:1ffb29c718ee2540c5643c1e8166629a07bbd505f99107baae535e9f86eb7eef), \
965 M4 Task 2 report",
966 },
967 matcher: dns_bind_version,
968 },
969 Rule {
970 probe_id: "smtp-banner-v1",
971 doc: RuleDoc {
972 id: "smtp.banner.postfix.v1",
973 service: "smtp",
974 specificity: Specificity::ProductOnly,
975 rationale: "The 220 greeting named Postfix. Postfix's greeting does not carry a \
976 version number, so no version can be extracted.",
977 source: "RFC 5321 §4.2 (\"SMTP Replies\": the `nnn-`/`nnn ` multiline reply ABNF \
978 this rule's regex scans every line for a match against); capture from \
979 boky/postfix:latest \
980 (digest sha256:aafc772384232497bed875e1eb66b4d3e54ba1ebc86e2e185a6dc1dbc48182ef), \
981 M4 Task 2 report",
982 },
983 matcher: smtp_postfix,
984 },
985 Rule {
986 probe_id: "smtp-banner-v1",
987 doc: RuleDoc {
988 id: "smtp.protocol.bare.v1",
989 service: "smtp",
990 specificity: Specificity::ProtocolOnly,
991 rationale: "The response is a well-formed 220 SMTP greeting, but no product name in \
992 it matched any known rule.",
993 source: "RFC 5321 §4.3.1 (\"Sequencing Overview\": \"Normally, a receiver will send \
994 a 220 'Service ready' reply\" -- descriptive, not a MUST; §3.1 explicitly \
995 permits a 554 reply instead), §4.2 (\"SMTP Replies\": `nnn-`/`nnn ` \
996 multiline reply ABNF)",
997 },
998 matcher: smtp_bare_protocol,
999 },
1000 Rule {
1001 probe_id: "tls-v1",
1002 doc: RuleDoc {
1003 id: "tls.serverhello.structural.v1",
1004 service: "tls",
1005 specificity: Specificity::ProtocolOnly,
1006 rationale: "The reply's record layer carried content type 0x16 (handshake) with an \
1007 inner handshake type of 0x02 (ServerHello) -- confirms a TLS server \
1008 answered, but (for TLS 1.3) the certificate is encrypted, so no product \
1009 or version can be read from these bytes.",
1010 source: "RFC 8446 §5.1 (\"Record Layer\": `ContentType ... handshake(22)`, i.e. \
1011 0x16), §4 (\"Handshake Protocol\", which defines `enum { ... \
1012 server_hello(2), ... } HandshakeType`, i.e. 0x02) -- the IANA \"TLS \
1013 HandshakeType\" registry itself is §11, not §4, as §4's own text says \
1014 (\"New handshake message types are assigned by IANA as described in \
1015 Section 11\"); capture from \
1016 nginx:1.27-alpine (same digest as http.server.nginx.v1) terminating TLS \
1017 1.3 with a locally generated self-signed certificate, M4 Task 2 report",
1018 },
1019 matcher: tls_server_hello,
1020 },
1021];
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026 // The behavioural half of the dispatch tests below drives the crate's
1027 // own public entry point rather than `rules_for` directly.
1028 use crate::interpret;
1029 use bathy_types::{ProbeCapture, Transport};
1030
1031 // --- the two narrow `#[allow(clippy::expect_used)]`s in this file ---
1032 //
1033 // Both allows in this module claim their `Err` arm is unreachable. These
1034 // three tests are what make that a checked claim rather than a comment;
1035 // deleting one re-opens exactly the hole the M7 panic-lint round closed.
1036
1037 #[test]
1038 fn every_rung_of_the_ladder_is_a_valid_confidence() {
1039 // `Specificity::confidence`'s `expect` is allowed because every rung
1040 // is one of four literals inside `Confidence`'s domain. This is the
1041 // check on that: a rung edited to 1.5 or -0.1 fails here rather than
1042 // panicking in the middle of a scan.
1043 for rung in Specificity::ALL {
1044 assert!(
1045 Confidence::new(rung.value()).is_ok(),
1046 "rung {rung:?} has value {} , which Confidence rejects",
1047 rung.value()
1048 );
1049 }
1050 }
1051
1052 #[test]
1053 fn the_ladder_array_lists_every_variant() {
1054 // Exhaustiveness, checked by the compiler rather than by counting:
1055 // a fifth `Specificity` variant makes this `match` fail to build, and
1056 // the `assert_eq!` catches a variant dropped from `ALL` instead.
1057 for rung in Specificity::ALL {
1058 match rung {
1059 Specificity::ProductAndVersion
1060 | Specificity::ProductOnly
1061 | Specificity::ProtocolOnly
1062 | Specificity::Weak => {}
1063 }
1064 }
1065 let mut seen: Vec<f64> = Specificity::ALL.iter().map(|s| s.value()).collect();
1066 seen.sort_by(f64::total_cmp);
1067 seen.dedup();
1068 assert_eq!(
1069 seen.len(),
1070 Specificity::ALL.len(),
1071 "two rungs of the ladder carry the same confidence, so one of them is not a rung"
1072 );
1073 }
1074
1075 #[test]
1076 fn every_static_regex_in_this_module_compiles() {
1077 // `static_regex`'s `expect` is allowed because its arguments are
1078 // literals in this file. This forces every one of those `LazyLock`s,
1079 // so a bad pattern is a red test rather than a panic on the first
1080 // response that reaches the rule. A regex added below without a line
1081 // here is caught by `every_rule_has_its_static_regex_forced`.
1082 let _ = NGINX_SERVER_RE.as_str();
1083 let _ = SSH_OPENSSH_RE.as_str();
1084 let _ = SSH_BANNER_RE.as_str();
1085 let _ = SMTP_GREETING_RE.as_str();
1086 let _ = SMTP_POSTFIX_RE.as_str();
1087 }
1088
1089 #[test]
1090 fn every_rule_has_its_static_regex_forced() {
1091 // The test above is a hand-written list, which is the shape this
1092 // project has repeatedly watched go stale. This is the check on the
1093 // list: it counts `LazyLock<Regex>` declarations in this file's own
1094 // source and fails if one was added without joining the test.
1095 let source = include_str!("rules.rs");
1096 let declared = source
1097 .lines()
1098 .filter(|l| l.contains("LazyLock<Regex>") && l.trim_start().starts_with("static "))
1099 .count();
1100 assert_eq!(
1101 declared, 5,
1102 "this file declares {declared} `LazyLock<Regex>` statics, not 5; add the new one to \
1103 every_static_regex_in_this_module_compiles and update this count"
1104 );
1105 }
1106
1107 // --- known_probe_ids ---
1108
1109 #[test]
1110 fn known_probe_ids_lists_every_probe_this_crate_has_rules_for_deduped_and_sorted() {
1111 // Pinned against M4 Task 2's eight real probe ids by name -- a
1112 // change here (an id added, removed, or renamed) is exactly the
1113 // kind of thing M4 Task 4's replay corpus depends on staying in
1114 // sync with the fixtures under `testdata/captures/`.
1115 let ids: Vec<&str> = known_probe_ids().collect();
1116 assert_eq!(
1117 ids,
1118 vec![
1119 "dns-version-bind-v1",
1120 "http-get-v1",
1121 "mysql-greeting-v1",
1122 "postgres-startup-v1",
1123 "redis-ping-v1",
1124 "smtp-banner-v1",
1125 "ssh-banner-v1",
1126 "tls-v1",
1127 ]
1128 );
1129 }
1130
1131 // --- rules_for: the dispatch itself (M4 whole-branch review,
1132 // IMPORTANT-5).
1133 //
1134 // `rules_for` is this crate's entire routing mechanism -- the single
1135 // `r.probe_id == probe_id` comparison that decides which rules a
1136 // capture is even offered to -- and nothing tested it. Making it ignore
1137 // its argument outright, so every rule ran against every capture
1138 // regardless of which probe produced it, survived all 69 tests: the
1139 // corpus's inputs are mutually exclusive enough that no rule
1140 // false-positives on another protocol's bytes today.
1141 //
1142 // "Today" is the problem. That property is a coincidence of the current
1143 // thirteen rules, not an invariant: a TLS `ServerHello` is a
1144 // 6-byte structural header, a MySQL greeting is `int<1> = 10`, and a
1145 // redis `+...CRLF` is one leading byte -- all short, all structural,
1146 // and any new rule that widened one of them would start silently
1147 // claiming another protocol's captures with no test objecting.
1148 // Dispatch is what makes that impossible, so dispatch is what gets
1149 // asserted, in both a structural and a behavioural form. Together with
1150 // `bathy-engine`'s `tests/probe_rule_seam.rs` (CRITICAL-1) this closes
1151 // the id-equality seam at both of its ends: that the ids agree across
1152 // the two crates, and that this crate actually routes by them. ---
1153
1154 #[test]
1155 fn rules_for_returns_only_rules_belonging_to_the_probe_it_was_asked_about() {
1156 for id in known_probe_ids() {
1157 let selected: Vec<&str> = rules_for(id).map(|r| r.doc.id).collect();
1158 assert!(
1159 !selected.is_empty(),
1160 "{id} is a known probe id, so it must select at least one rule"
1161 );
1162 for r in rules_for(id) {
1163 assert_eq!(
1164 r.probe_id, id,
1165 "rules_for({id:?}) offered rule {:?}, which belongs to probe {:?} -- \
1166 a capture from one probe must never be matched against another \
1167 probe's rules",
1168 r.doc.id, r.probe_id
1169 );
1170 }
1171 assert!(
1172 selected.len() < ALL_RULES.len(),
1173 "rules_for({id:?}) returned every rule in the registry ({selected:?}); \
1174 dispatch is not filtering by probe id at all"
1175 );
1176 }
1177 }
1178
1179 #[test]
1180 fn rules_for_partitions_the_registry_leaving_no_rule_unreachable_and_none_duplicated() {
1181 // Every rule is selected by exactly one probe id: the union over
1182 // all known ids is the whole registry (no rule is unreachable) and
1183 // the counts sum without overlap (no rule is offered twice).
1184 let mut total = 0usize;
1185 let mut seen: Vec<&str> = Vec::new();
1186 for id in known_probe_ids() {
1187 for r in rules_for(id) {
1188 total += 1;
1189 seen.push(r.doc.id);
1190 }
1191 }
1192 seen.sort_unstable();
1193 let mut deduped = seen.clone();
1194 deduped.dedup();
1195 assert_eq!(
1196 seen, deduped,
1197 "a rule was offered by two different probe ids"
1198 );
1199 assert_eq!(
1200 total,
1201 ALL_RULES.len(),
1202 "the union of rules_for() over every known probe id must be exactly the registry"
1203 );
1204 }
1205
1206 #[test]
1207 fn a_rule_is_never_offered_a_capture_from_a_different_probe() {
1208 // The behavioural half, through the crate's own public entry point
1209 // rather than through `rules_for` directly: each of these byte
1210 // strings is one this crate genuinely recognizes under its OWN
1211 // probe id (every one of them is asserted to match in a test
1212 // elsewhere in this module). Delivered under any OTHER probe id,
1213 // `interpret` must return nothing at all -- not a lower-confidence
1214 // guess, nothing. This is what "the probe that produced these bytes
1215 // is part of the evidence" means in practice.
1216 let recognized: &[(&'static str, &[u8])] = &[
1217 (
1218 "http-get-v1",
1219 b"HTTP/1.1 200 OK\r\nServer: nginx/1.26.0\r\n\r\n",
1220 ),
1221 ("ssh-banner-v1", b"SSH-2.0-OpenSSH_10.3\r\n"),
1222 ("smtp-banner-v1", b"220 mail.example.com ESMTP Postfix\r\n"),
1223 ("redis-ping-v1", b"+PONG\r\n"),
1224 ("tls-v1", &[0x16, 0x03, 0x03, 0x00, 0x02, 0x02, 0x00]),
1225 ("postgres-startup-v1", b"S"),
1226 ];
1227 for &(owner, bytes) in recognized {
1228 let own = interpret(&ProbeCapture {
1229 probe_id: owner,
1230 transport: Transport::Tcp,
1231 port: 0,
1232 request: None,
1233 response: bytes.to_vec(),
1234 elapsed_micros: 0,
1235 truncated: false,
1236 });
1237 assert!(
1238 !own.is_empty(),
1239 "test fixture sanity: {owner} must recognize its own bytes, or the \
1240 cross-feeding below proves nothing"
1241 );
1242 for other in known_probe_ids().filter(|&id| id != owner) {
1243 let cross = interpret(&ProbeCapture {
1244 probe_id: other,
1245 transport: Transport::Tcp,
1246 port: 0,
1247 request: None,
1248 response: bytes.to_vec(),
1249 elapsed_micros: 0,
1250 truncated: false,
1251 });
1252 assert!(
1253 cross.is_empty(),
1254 "bytes only {owner} can produce were interpreted as {:?} when \
1255 delivered under probe id {other}",
1256 cross.iter().map(|i| i.rule_id).collect::<Vec<_>>()
1257 );
1258 }
1259 }
1260 }
1261
1262 // --- The ladder ---
1263
1264 #[test]
1265 fn ladder_orders_product_and_version_above_product_only_above_protocol_only_above_weak() {
1266 assert!(
1267 Specificity::ProductAndVersion.confidence().get()
1268 > Specificity::ProductOnly.confidence().get()
1269 );
1270 assert!(
1271 Specificity::ProductOnly.confidence().get()
1272 > Specificity::ProtocolOnly.confidence().get()
1273 );
1274 assert!(
1275 Specificity::ProtocolOnly.confidence().get() > Specificity::Weak.confidence().get()
1276 );
1277 }
1278
1279 // --- utf8_lines ---
1280
1281 #[test]
1282 fn utf8_lines_splits_on_newline_and_reports_correct_offsets() {
1283 let bytes = b"HTTP/1.1 200 OK\r\nServer: nginx\r\n\r\n";
1284 let lines = utf8_lines(bytes);
1285 assert_eq!(lines[0], (0, "HTTP/1.1 200 OK\r"));
1286 assert_eq!(lines[1].0, 17);
1287 assert!(lines[1].1.starts_with("Server: nginx"));
1288 }
1289
1290 #[test]
1291 fn utf8_lines_skips_a_line_that_is_not_valid_utf8_but_keeps_earlier_and_later_lines() {
1292 let mut bytes = b"clean line one\n".to_vec();
1293 bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); // not valid UTF-8
1294 bytes.extend_from_slice(b"clean line three\n");
1295 let lines = utf8_lines(&bytes);
1296 let texts: Vec<&str> = lines.iter().map(|(_, s)| *s).collect();
1297 assert_eq!(texts, vec!["clean line one", "clean line three"]);
1298 }
1299
1300 #[test]
1301 fn utf8_lines_never_panics_on_empty_input() {
1302 assert!(utf8_lines(&[]).is_empty());
1303 }
1304
1305 // --- HTTP ---
1306
1307 #[test]
1308 fn http_nginx_extracts_product_and_version() {
1309 let bytes = b"HTTP/1.1 200 OK\r\nServer: nginx/1.27.5\r\n\r\n";
1310 let hit = http_nginx(bytes).unwrap();
1311 assert_eq!(hit.product.as_deref(), Some("nginx"));
1312 assert_eq!(hit.version.as_deref(), Some("1.27.5"));
1313 assert_eq!(hit.specificity, Specificity::ProductAndVersion);
1314 assert_eq!(&bytes[hit.span.clone()], b"Server: nginx/1.27.5");
1315 }
1316
1317 #[test]
1318 fn http_nginx_without_a_version_is_product_only() {
1319 let hit = http_nginx(b"HTTP/1.1 200 OK\r\nServer: nginx\r\n\r\n").unwrap();
1320 assert!(hit.version.is_none());
1321 assert_eq!(hit.specificity, Specificity::ProductOnly);
1322 }
1323
1324 /// The exact response `10.30.0.17:443` -- the lab's TLS-only nginx --
1325 /// returns to a plaintext request, byte for byte as `lab/run.sh verify`
1326 /// read it. This is the named test `lab/ground-truth.json`'s
1327 /// `identification_gap` at that endpoint points at.
1328 ///
1329 /// It exists to locate the gap precisely, because "bathy does not identify
1330 /// nginx behind TLS" has two possible causes and only one of them is true.
1331 /// It is NOT that the rules cannot read these bytes: they can, at full
1332 /// `ProductAndVersion` specificity, as this asserts. It is that
1333 /// `Scheduler::detect_service` stops at the first probe whose capture
1334 /// interprets to anything, and on 443 that is `tls-v1`, which is
1335 /// protocol-only by construction -- RFC 8446 §4.4 moves the certificate
1336 /// into the encrypted flight -- so `http-get-v1` is never reached and
1337 /// these bytes are never captured.
1338 ///
1339 /// If that policy changes, AC-7.5's conformance test is what goes red and
1340 /// demands the ground truth's `identification_gap` key be deleted. This
1341 /// test is what says the rule side was never the problem, and it runs with
1342 /// no lab, no Docker and no network.
1343 #[test]
1344 fn the_bytes_a_tls_terminator_returns_to_a_plaintext_request_name_its_product() {
1345 let observed: &[u8] = b"HTTP/1.1 400 Bad Request\r\nServer: nginx/1.29.8\r\n\
1346 Date: Tue, 04 Aug 2026 17:29:44 GMT\r\n\
1347 Content-Type: text/html\r\nContent-Length: 255\r\n\
1348 Connection: close\r\n\r\n<html>\r\n\
1349 <head><title>400 The plain HTTP request was sent to \
1350 HTTPS port</title></head>\r\n";
1351 let hit = http_nginx(observed).expect(
1352 "the `Server` header is in cleartext ahead of any handshake; if this stops \
1353 matching, lab/ground-truth.json's claim at 10.30.0.17:443 has lost its basis",
1354 );
1355 assert_eq!(hit.product.as_deref(), Some("nginx"));
1356 assert_eq!(hit.version.as_deref(), Some("1.29.8"));
1357 assert_eq!(
1358 hit.specificity,
1359 Specificity::ProductAndVersion,
1360 "a 4xx status is still an HTTP response and still names its server"
1361 );
1362 }
1363
1364 #[test]
1365 fn http_nginx_does_not_match_a_non_http_response() {
1366 assert!(http_nginx(b"Server: nginx/1.27.5\r\n").is_none());
1367 }
1368
1369 #[test]
1370 fn http_nginx_does_not_match_a_different_server_header() {
1371 assert!(http_nginx(b"HTTP/1.1 200 OK\r\nServer: Apache/2.4.62\r\n\r\n").is_none());
1372 }
1373
1374 // --- Soundness regression: the brief's own worked example reused
1375 // `String::from_utf8_lossy` offsets as indices into the original
1376 // bytes, which silently cites the wrong bytes (or panics) once
1377 // anything before the match isn't valid UTF-8. These two tests are
1378 // built to fail under that unsound version specifically -- reverting
1379 // `http_nginx` to a whole-response-lossy-conversion implementation and
1380 // running the suite is this task's own review-round-1 finding; see the
1381 // fix report for the reproduced failure. ---
1382
1383 #[test]
1384 fn http_nginx_cites_the_correct_bytes_when_invalid_utf8_precedes_the_match() {
1385 // A stray 0x80 (a lone UTF-8 continuation byte, invalid on its own)
1386 // sits on an earlier header line, strictly before the `Server:`
1387 // line that actually matches. `utf8_lines` skips only that one
1388 // invalid line; `http_nginx` must still report a span pointing at
1389 // the real `Server:` bytes, at their real offset in the original
1390 // buffer -- not an offset computed against a lossy re-encoding of
1391 // the whole response (which would grow by 2 bytes at the one
1392 // invalid byte, a `String::from_utf8_lossy` implementation would
1393 // silently misalign every offset after it).
1394 let mut bytes = Vec::new();
1395 bytes.extend_from_slice(b"HTTP/1.1 200 OK\r\n");
1396 bytes.extend_from_slice(b"X-Bad: \x80\r\n");
1397 bytes.extend_from_slice(b"Server: nginx/1.26.0\r\n");
1398 bytes.extend_from_slice(b"\r\n");
1399 let hit = http_nginx(&bytes).unwrap();
1400 assert_eq!(hit.version.as_deref(), Some("1.26.0"));
1401 assert_eq!(
1402 &bytes[hit.span.clone()],
1403 b"Server: nginx/1.26.0",
1404 "span must index the real bytes even with invalid UTF-8 earlier in the response"
1405 );
1406 }
1407
1408 #[test]
1409 fn http_nginx_span_stays_in_bounds_when_the_match_ends_at_the_last_byte() {
1410 // No trailing CRLF at all: the `Server:` line is both the match
1411 // and the literal last byte of the buffer. A lossy-offset
1412 // implementation whose earlier invalid byte inflated every
1413 // downstream offset by 2 would push `span.end` past
1414 // `bytes.len()`, which panics on the slice index below rather than
1415 // merely being wrong.
1416 let mut bytes = Vec::new();
1417 bytes.extend_from_slice(b"HTTP/1.1 200 OK\r\n");
1418 bytes.extend_from_slice(b"X-Bad: \x80\r\n");
1419 bytes.extend_from_slice(b"Server: nginx/1.26.0"); // ends the buffer, no CRLF
1420 let hit = http_nginx(&bytes).unwrap();
1421 assert_eq!(
1422 hit.span.end,
1423 bytes.len(),
1424 "the match ends exactly at the buffer's end"
1425 );
1426 assert_eq!(&bytes[hit.span.clone()], b"Server: nginx/1.26.0");
1427 }
1428
1429 #[test]
1430 fn http_bare_protocol_matches_any_status_line() {
1431 let bytes = b"HTTP/1.0 404 Not Found\r\n\r\n";
1432 let hit = http_bare_protocol(bytes).unwrap();
1433 assert_eq!(
1434 &bytes[hit.span.clone()],
1435 b"HTTP/1.0 404 Not Found\r",
1436 "span must be exactly the status line, not the whole response"
1437 );
1438 }
1439
1440 // --- The `*_bare_protocol` line-offset gap (M4 whole-branch review,
1441 // IMPORTANT-4). `http_bare_protocol` and `smtp_bare_protocol` are the
1442 // only two matchers that take `utf8_lines(bytes).first()` rather than
1443 // iterating, so in every test above their line offset happens to be 0
1444 // and the `start +` term in their span is indistinguishable from a
1445 // literal `0` -- dropping it survived the whole suite for both. The
1446 // offset is NOT always 0: `utf8_lines` skips lines that are not valid
1447 // UTF-8, so `first()` is the first VALID line, which is at a non-zero
1448 // offset whenever anything invalid precedes it. That is reachable from
1449 // a hostile or merely broken peer, which is exactly the case
1450 // `matched_span` -- the "which bytes justified this claim" contract --
1451 // must not get wrong.
1452 //
1453 // The audit that produced these two tests mutated the offset term out
1454 // of every one of the ten span constructions in this file, not just the
1455 // one reported: `http_nginx`, `ssh_openssh`, `ssh_bare_protocol`,
1456 // `smtp_postfix`, `mysql_handshake`, `dns_bind_version`, both redis
1457 // matchers and `tls_server_hello` all die already. These two were the
1458 // only survivors, and they share one shape. See the fix-wave report. ---
1459
1460 #[test]
1461 fn http_bare_protocol_cites_the_correct_bytes_when_invalid_utf8_precedes_the_status_line() {
1462 // A lone 0x80 (an unaccompanied UTF-8 continuation byte, invalid on
1463 // its own) makes line 0 undecodable, so `utf8_lines` skips it and
1464 // the status line -- the real match -- starts at byte 9, not 0.
1465 let bytes = b"\x80garbage\nHTTP/1.1 200 OK\r\n\r\n";
1466 let hit = http_bare_protocol(bytes).unwrap();
1467 assert_eq!(
1468 hit.span,
1469 9..25,
1470 "the status line begins after the skipped invalid line, not at byte 0"
1471 );
1472 assert_eq!(
1473 &bytes[hit.span.clone()],
1474 b"HTTP/1.1 200 OK\r",
1475 "span must cite the status line's real bytes; dropping the line-start offset \
1476 cites b\"\\x80garbage\\nHTTP/1.\" instead -- the wrong bytes entirely, and \
1477 still a perfectly valid range, so no bounds check would ever notice"
1478 );
1479 }
1480
1481 #[test]
1482 fn smtp_bare_protocol_cites_the_correct_bytes_when_invalid_utf8_precedes_the_greeting() {
1483 // Same shape as the HTTP case above, and the same defect: this is
1484 // the second of the two matchers that read only the first VALID
1485 // line. A real SMTP peer that emits a non-UTF-8 byte before its
1486 // greeting is unusual; a hostile one that does it deliberately, to
1487 // make bathy cite bytes it did not send, is the threat this
1488 // matters for.
1489 let bytes = b"\x80junk\n220 mail.example.com ESMTP Sendmail\r\n";
1490 let hit = smtp_bare_protocol(bytes).unwrap();
1491 assert_eq!(
1492 hit.span,
1493 6..10,
1494 "the greeting begins after the skipped invalid line, not at byte 0"
1495 );
1496 assert_eq!(
1497 &bytes[hit.span.clone()],
1498 b"220 ",
1499 "span must cite the greeting's real bytes, not b\"\\x80jun\""
1500 );
1501 }
1502
1503 // --- SSH ---
1504
1505 #[test]
1506 fn ssh_openssh_extracts_version_and_ignores_the_trailing_comment() {
1507 let bytes = b"SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13\r\n";
1508 let hit = ssh_openssh(bytes).unwrap();
1509 assert_eq!(hit.product.as_deref(), Some("OpenSSH"));
1510 assert_eq!(hit.version.as_deref(), Some("9.6p1"));
1511 assert_eq!(&bytes[hit.span.clone()], b"SSH-2.0-OpenSSH_9.6p1");
1512 }
1513
1514 #[test]
1515 fn ssh_openssh_matches_the_real_captured_banner_with_no_comment() {
1516 // The exact banner captured from linuxserver/openssh-server:latest
1517 // in M4 Task 2 (see this module's source note).
1518 let hit = ssh_openssh(b"SSH-2.0-OpenSSH_10.3\r\n").unwrap();
1519 assert_eq!(hit.version.as_deref(), Some("10.3"));
1520 }
1521
1522 #[test]
1523 fn ssh_openssh_does_not_match_a_non_openssh_banner() {
1524 assert!(ssh_openssh(b"SSH-2.0-libssh_0.9.6\r\n").is_none());
1525 }
1526
1527 // RFC 4253 §4.2: "The server MAY send other lines of data before
1528 // sending the version string... Clients MUST be able to process such
1529 // lines." A matcher that only ever looked at line 0 would false-
1530 // negative here; it would also make `Hit::span`'s line-offset term
1531 // untestable (offset 0 either way). This is both the false-negative
1532 // fix and its own regression test, together.
1533 #[test]
1534 fn ssh_openssh_finds_the_identification_line_after_a_preamble_line() {
1535 let bytes = b"Some preamble the server sent first\r\nSSH-2.0-OpenSSH_9.6p1\r\n";
1536 let hit = ssh_openssh(bytes).unwrap();
1537 assert_eq!(hit.version.as_deref(), Some("9.6p1"));
1538 assert_eq!(&bytes[hit.span.clone()], b"SSH-2.0-OpenSSH_9.6p1");
1539 assert!(
1540 hit.span.start > 0,
1541 "the identification line is not at offset 0 here, so this also proves the \
1542 line-offset term in Hit::span is real, not dead code"
1543 );
1544 }
1545
1546 #[test]
1547 fn ssh_bare_protocol_matches_any_ssh_banner() {
1548 let bytes = b"SSH-2.0-libssh_0.9.6\r\n";
1549 let hit = ssh_bare_protocol(bytes).unwrap();
1550 assert_eq!(&bytes[hit.span.clone()], b"SSH-2.0-");
1551 }
1552
1553 #[test]
1554 fn ssh_bare_protocol_finds_the_identification_line_after_a_preamble_line() {
1555 let bytes = b"Some preamble the server sent first\r\nSSH-2.0-libssh_0.9.6\r\n";
1556 let hit = ssh_bare_protocol(bytes).unwrap();
1557 assert_eq!(&bytes[hit.span.clone()], b"SSH-2.0-");
1558 assert!(hit.span.start > 0);
1559 }
1560
1561 // --- Postgres ---
1562
1563 #[test]
1564 fn postgres_ssl_accepted_matches_exactly_s() {
1565 let hit = postgres_ssl_accepted(b"S").unwrap();
1566 assert_eq!(&b"S"[hit.span.clone()], b"S");
1567 assert!(postgres_ssl_accepted(b"N").is_none());
1568 assert!(postgres_ssl_accepted(b"SS").is_none());
1569 }
1570
1571 #[test]
1572 fn postgres_ssl_declined_matches_the_real_captured_reply() {
1573 // postgres:16-alpine (M4 Task 2 report) replied exactly `N`.
1574 let hit = postgres_ssl_declined(b"N").unwrap();
1575 assert_eq!(&b"N"[hit.span.clone()], b"N");
1576 assert!(postgres_ssl_declined(b"S").is_none());
1577 }
1578
1579 // --- Redis ---
1580
1581 #[test]
1582 fn redis_pong_matches_the_real_captured_reply() {
1583 let bytes = b"+PONG\r\n";
1584 let hit = redis_pong(bytes).unwrap();
1585 assert_eq!(&bytes[hit.span.clone()], b"+PONG");
1586 }
1587
1588 #[test]
1589 fn redis_resp_shaped_reply_is_weak_for_a_non_pong_resp_value() {
1590 let bytes = b"-ERR unknown command\r\n";
1591 let hit = redis_resp_shaped_reply(bytes).unwrap();
1592 assert_eq!(hit.specificity, Specificity::Weak);
1593 assert_eq!(&bytes[hit.span.clone()], b"-ERR unknown command\r\n");
1594 }
1595
1596 #[test]
1597 fn redis_resp_shaped_reply_does_not_match_non_resp_bytes() {
1598 assert!(redis_resp_shaped_reply(b"HTTP/1.1 200 OK\r\n").is_none());
1599 assert!(redis_resp_shaped_reply(b"").is_none());
1600 }
1601
1602 // --- Root-cause fix, M4 Task 3 review round 1: a single stray sigil
1603 // byte with no CRLF terminator used to match on its own -- one byte
1604 // from an arbitrary binary protocol is not meaningful RESP evidence.
1605 #[test]
1606 fn redis_resp_shaped_reply_rejects_a_lone_sigil_byte_with_no_crlf() {
1607 assert!(redis_resp_shaped_reply(b"+").is_none());
1608 assert!(redis_resp_shaped_reply(b"+X").is_none());
1609 assert!(redis_resp_shaped_reply(b"+no terminator here").is_none());
1610 }
1611
1612 // --- MySQL ---
1613
1614 // The real handshake packet captured from `mysql:8.4` (M4 Task 2
1615 // report), reused verbatim here as this rule's own fixture.
1616 const MYSQL_GREETING_HEX: &str = "4a0000000a382e342e3131000800000062215a7649740441\
1617 00ffffff0200ffdf1500000000000000000000441e1b514b\
1618 4e6e53084a5c270063616368696e675f736861325f706173\
1619 73776f726400";
1620
1621 fn hex_to_bytes(hex: &str) -> Vec<u8> {
1622 (0..hex.len())
1623 .step_by(2)
1624 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
1625 .collect()
1626 }
1627
1628 #[test]
1629 fn mysql_handshake_v10_extracts_the_real_captured_version() {
1630 let bytes = hex_to_bytes(MYSQL_GREETING_HEX);
1631 let hit = mysql_handshake_v10(&bytes).unwrap();
1632 assert_eq!(hit.product.as_deref(), Some("MySQL"));
1633 assert_eq!(hit.version.as_deref(), Some("8.4.11"));
1634 // Asserted independently of `hit.version` above: `version` and
1635 // `span` are computed from the same offsets in the real code, but
1636 // a mutant that shifts only `Hit::span` (leaving the string
1637 // extraction that produces `.version` untouched) would pass the
1638 // assertion above while citing the wrong bytes. Re-deriving the
1639 // expected text from `hit.span` itself is what catches that.
1640 assert_eq!(&bytes[hit.span.clone()], b"8.4.11");
1641 }
1642
1643 #[test]
1644 fn mysql_handshake_v10_rejects_a_short_packet() {
1645 assert!(mysql_handshake_v10(b"\x0a\x00").is_none());
1646 }
1647
1648 #[test]
1649 fn mysql_handshake_v10_rejects_a_non_handshake_v10_protocol_byte() {
1650 let mut bytes = hex_to_bytes(MYSQL_GREETING_HEX);
1651 bytes[4] = 0x09; // not HandshakeV10
1652 assert!(mysql_handshake_v10(&bytes).is_none());
1653 }
1654
1655 #[test]
1656 fn mysql_handshake_v10_rejects_a_missing_nul_terminator() {
1657 let bytes = vec![0u8, 0, 0, 0, 0x0a, b'8', b'.', b'4']; // no trailing NUL
1658 assert!(mysql_handshake_v10(&bytes).is_none());
1659 }
1660
1661 // --- DNS ---
1662
1663 // The real reply captured from `internetsystemsconsortium/bind9:9.18`
1664 // (M4 Task 2 report), reused verbatim as this rule's own fixture.
1665 const BIND_REPLY_HEX: &str = "00405344840000010001000100000776657273696f6e0462696e6400001000\
1666 03c00c0010000300000000000807392e31382e3530c00c00020003000000000\
1667 002c00c";
1668
1669 #[test]
1670 fn dns_bind_version_extracts_the_real_captured_version() {
1671 let bytes = hex_to_bytes(BIND_REPLY_HEX);
1672 let hit = dns_bind_version(&bytes).unwrap();
1673 assert_eq!(hit.product.as_deref(), Some("BIND"));
1674 assert_eq!(hit.version.as_deref(), Some("9.18.50"));
1675 // See the identical comment on the MySQL test above: re-derive the
1676 // expected text from `hit.span` itself, independent of whatever
1677 // internal offsets produced `.version`, so a span-only shift is
1678 // caught even when `.version` still happens to be correct.
1679 assert_eq!(&bytes[hit.span.clone()], b"9.18.50");
1680 }
1681
1682 #[test]
1683 fn dns_bind_version_rejects_a_query_not_a_response() {
1684 // Same shape as the real query bathy_probe::probes::dns::build_query
1685 // sends (QR bit clear).
1686 let query: Vec<u8> = [
1687 0x00, 0x1e, 0x53, 0x44, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 7,
1688 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,
1689 0x00, 0x03,
1690 ]
1691 .to_vec();
1692 assert!(dns_bind_version(&query).is_none());
1693 }
1694
1695 #[test]
1696 fn dns_bind_version_rejects_truncated_bytes_without_panicking() {
1697 let full = hex_to_bytes(BIND_REPLY_HEX);
1698 for cut in 0..full.len() {
1699 assert!(dns_bind_version(&full[..cut]).is_none());
1700 }
1701 }
1702
1703 #[test]
1704 fn dns_skip_name_rejects_a_label_length_that_runs_past_the_end() {
1705 // A label byte of 63 (top two bits clear, so a genuine label
1706 // length -- not a compression pointer, which needs the top two
1707 // bits set per RFC 1035 §4.1.4) claiming 63 more bytes in a
1708 // 3-byte buffer must not panic or wrap; it must simply fail to
1709 // resolve.
1710 assert!(dns_skip_name(&[63, 1, 2], 0).is_none());
1711 }
1712
1713 #[test]
1714 fn dns_skip_name_treats_a_top_bits_set_byte_as_a_two_byte_compression_pointer() {
1715 // 200 = 0b1100_1000: top two bits set, so RFC 1035 §4.1.4 defines
1716 // this as a compression pointer, not a 200-byte label -- it
1717 // consumes exactly 2 bytes regardless of what follows.
1718 assert_eq!(dns_skip_name(&[200, 1, 2], 0), Some(2));
1719 }
1720
1721 // --- SMTP ---
1722
1723 #[test]
1724 fn smtp_postfix_matches_the_real_captured_greeting() {
1725 let bytes = b"220 mail.example.com ESMTP Postfix\r\n";
1726 let hit = smtp_postfix(bytes).unwrap();
1727 assert_eq!(hit.product.as_deref(), Some("Postfix"));
1728 assert!(hit.version.is_none());
1729 assert_eq!(
1730 &bytes[hit.span.clone()],
1731 b"220 mail.example.com ESMTP Postfix"
1732 );
1733 }
1734
1735 #[test]
1736 fn smtp_postfix_does_not_match_a_non_postfix_greeting() {
1737 assert!(smtp_postfix(b"220 mail.example.com ESMTP Sendmail\r\n").is_none());
1738 }
1739
1740 // RFC 5321 §4.2's own `Greeting` ABNF allows a multiline 220 reply
1741 // (`"220-" Domain [SP text] CRLF *("220-" [text] CRLF) "220" SP [text]
1742 // CRLF`); the product name may legitimately be on a continuation line,
1743 // not the first one. This also forces `line_start > 0`, which is what
1744 // makes the offset term in `Hit::span` observable at all -- see the
1745 // identical point made on the SSH tests above.
1746 #[test]
1747 fn smtp_postfix_finds_the_product_on_a_continuation_line() {
1748 let bytes = b"220-mail.example.com ESMTP\r\n220 Postfix ready\r\n";
1749 let hit = smtp_postfix(bytes).unwrap();
1750 assert_eq!(hit.product.as_deref(), Some("Postfix"));
1751 assert!(
1752 hit.span.start > 0,
1753 "the matching line is not at offset 0 here"
1754 );
1755 assert_eq!(&bytes[hit.span.clone()], b"220 Postfix");
1756 }
1757
1758 #[test]
1759 fn smtp_bare_protocol_matches_any_220_greeting() {
1760 let bytes = b"220 mail.example.com ESMTP Sendmail\r\n";
1761 let hit = smtp_bare_protocol(bytes).unwrap();
1762 assert_eq!(&bytes[hit.span.clone()], b"220 ");
1763 }
1764
1765 // --- TLS ---
1766
1767 #[test]
1768 fn tls_server_hello_matches_the_structural_header() {
1769 let reply: &[u8] = &[0x16, 0x03, 0x03, 0x00, 0x02, 0x02, 0x00];
1770 let hit = tls_server_hello(reply).unwrap();
1771 assert_eq!(
1772 &reply[hit.span.clone()],
1773 &[0x16, 0x03, 0x03, 0x00, 0x02, 0x02]
1774 );
1775 }
1776
1777 #[test]
1778 fn tls_server_hello_does_not_match_a_non_handshake_record() {
1779 let alert: &[u8] = &[0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x00];
1780 assert!(tls_server_hello(alert).is_none());
1781 }
1782
1783 #[test]
1784 fn tls_server_hello_rejects_a_too_short_buffer_without_panicking() {
1785 for len in 0..6 {
1786 assert!(tls_server_hello(&vec![0x16; len]).is_none());
1787 }
1788 }
1789}