Skip to main content

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: &regex::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}