Skip to main content

bathy_interpret/
lib.rs

1#![forbid(unsafe_code)]
2#![cfg_attr(
3    not(test),
4    deny(
5        clippy::unwrap_used,
6        clippy::expect_used,
7        clippy::indexing_slicing,
8        clippy::panic,
9        clippy::arithmetic_side_effects
10    )
11)]
12//! `bathy-interpret`: the pure interpretation layer.
13//!
14//! This crate turns the raw bytes a [`bathy_types::ProbeCapture`] recorded
15//! into zero or more [`Interpretation`]s -- structured claims about what
16//! service, product, and version a peer's response is evidence for. It is
17//! the reason findings in this project are explainable and replayable:
18//!
19//! - **Explainable.** Every [`Interpretation`] names the exact `rule_id`
20//!   that produced it and the exact byte range of the response that
21//!   justified it (`matched_span`). [`explain`] resolves any rule id this
22//!   crate can produce back to human-readable documentation and its
23//!   provenance. M5's `fingerprint.explain` tool is built directly on this.
24//! - **Replayable.** [`interpret`] is a pure function: no I/O, no clock, no
25//!   randomness, no async runtime. Feed it the same bytes years later and
26//!   it makes the same claim, because nothing except those bytes and this
27//!   crate's own source code ever fed the decision. M4 Task 4's replay
28//!   corpus depends on this holding exactly, with the network interface
29//!   down; M7's fuzz target depends on it never panicking regardless.
30//!
31//! # Purity is enforced structurally, not just by convention
32//!
33//! `bathy-interpret` depends on exactly two crates: `bathy-types` (for
34//! [`bathy_types::ProbeCapture`], [`bathy_types::event::Observation`], and
35//! [`bathy_types::confidence::Confidence`] -- the shapes this crate
36//! consumes and produces) and `regex` (for matching text-shaped protocol
37//! banners). This crate's own code never touches tokio, the filesystem, a
38//! clock, or a random-number generator -- `cargo tree -p bathy-interpret
39//! --edges normal` is asserted in CI to show only `bathy-types`, `regex`,
40//! and their own transitive dependencies (AC-4.10), and no matcher in
41//! `rules.rs` calls anything from either crate except `regex::Regex`
42//! itself and plain byte/string operations.
43//!
44//! (Narrowed claim, M4 Task 3 review round 1: an earlier version of this
45//! paragraph said "no randomness anywhere in ... its dependency graph,"
46//! which overstates what AC-4.10 actually checks. `bathy-types` itself
47//! depends on `ulid`, which depends on `rand`/`getrandom` -- `getrandom`
48//! *is* present in this crate's dependency tree; `bathy_types::clock` is
49//! the sanctioned, sole call site for it in this workspace, and this
50//! crate's own code never calls it. That inheritance is unavoidable and
51//! judged acceptable: it is linkable, not callable, from here, and
52//! removing it would not actually shrink the built artifact -- Cargo's
53//! feature unification means every other crate in the workspace that
54//! depends on `bathy-types` already pulls the same dependency in, so it is
55//! compiled into any binary that links this crate either way.)
56//!
57//! This crate also sits *below* `bathy-probe` in this workspace's layer
58//! order (`xtask`'s `LAYERS`), specifically so `ProbeCapture` fixtures can
59//! be built by hand in a test or fuzz target with no socket anywhere in
60//! the dependency graph at all.
61//!
62//! # No panics on the byte path (Global Constraint), and how it is scoped
63//!
64//! The `#![cfg_attr(not(test), deny(...))]` above is the executable form of
65//! the overview's "No panics in parsing paths" constraint. That constraint
66//! said `unwrap()`/`expect()`/indexing-slice panics were "denied by lint" in
67//! this crate and in `bathy-probe` **from M1**, and no such lint existed
68//! anywhere in the tree until the M7 verification round. It was an
69//! aspiration written in the indicative mood for six milestones, in the one
70//! crate whose entire input is bytes a scanned peer chose.
71//!
72//! It found real hits here, and they are not stylistic: `utf8_lines` sliced
73//! `bytes[start..i]`, `u16_at` indexed a two-byte window it had just
74//! `get`-checked, `mysql_handshake_v10` and `dns_bind_version` sliced with
75//! offsets built by unchecked `+`, `tls_server_hello` indexed a header, and
76//! every text-shaped rule computed its `matched_span` as a bare `line_start
77//! + m.start()`. That last expression is the one the `from_utf8_lossy`
78//! defect corrupted and the one seven span mutants attacked across three
79//! review rounds; it now lives in exactly one place, `rules::absolute_span`,
80//! and it is checked. See `rules.rs`'s own "Byte safety" note.
81//!
82//! **How test code is exempt.** `cfg_attr(not(test), ...)`, not a bare
83//! `deny`. Under `cargo clippy --all-targets` the library is compiled twice:
84//! once as the lib, where `cfg(test)` is off and the deny is live over every
85//! line of production code, and once as the unit-test harness, where
86//! `cfg(test)` is on and the deny is absent -- so `#[cfg(test)] mod tests`
87//! keeps `unwrap()`. `tests/replay.rs`, `tests/span_edge_corpus.rs` and
88//! `benches/interpret.rs` are separate crates that never see the attribute.
89//!
90//! **A crate-level `#![allow]` of any of these would reproduce the exact
91//! defect being closed**, so the two exceptions in this crate
92//! (`Specificity::confidence` and `rules::static_regex`, both over
93//! compile-time-constant inputs) are site-level, carry a `reason`, and are
94//! each backed by a test that fails if the reasoning stops being true.
95//! `cargo run -p xtask -- check-panics` enforces that shape, and
96//! additionally holds the overview's constraint text to the set of crates
97//! that actually carry the attribute.
98//!
99//! # Never guess (AC-4.13)
100//!
101//! [`interpret`] returns an empty vector when nothing in its rule set
102//! recognizes the input. A scanner that invents a service from bytes that
103//! do not structurally support the claim is worse than one that reports
104//! nothing -- see `interpret::tests::unrecognized_bytes_yield_no_observation_rather_than_a_guess`.
105
106mod interpret;
107mod rules;
108
109pub use interpret::{Interpretation, interpret};
110pub use rules::{RuleDoc, Specificity, all_rules, explain, known_probe_ids};
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn every_rule_documents_its_non_nmap_source() {
118        for rule in all_rules() {
119            assert!(!rule.source.is_empty(), "rule {} has no source", rule.id);
120            let lower = rule.source.to_lowercase();
121            assert!(!lower.contains("nmap"), "rule {} cites Nmap", rule.id);
122        }
123    }
124
125    #[test]
126    fn every_rule_documents_a_non_empty_rationale() {
127        for rule in all_rules() {
128            assert!(
129                !rule.rationale.is_empty(),
130                "rule {} has no rationale",
131                rule.id
132            );
133        }
134    }
135
136    #[test]
137    fn every_rule_id_is_unique() {
138        let mut ids: Vec<&str> = all_rules().map(|r| r.id).collect();
139        let before = ids.len();
140        ids.sort_unstable();
141        ids.dedup();
142        assert_eq!(ids.len(), before, "duplicate rule id in the registry");
143    }
144
145    #[test]
146    fn explain_resolves_every_rule_all_rules_can_produce() {
147        for rule in all_rules() {
148            assert!(
149                explain(rule.id).is_some(),
150                "explain() cannot resolve {}, which all_rules() lists",
151                rule.id
152            );
153        }
154    }
155
156    #[test]
157    fn explain_returns_none_for_an_unknown_rule_id() {
158        assert!(explain("no-such-rule-id").is_none());
159    }
160
161    #[test]
162    fn the_rule_set_is_non_empty() {
163        // A crate with zero rules would make every other guarantee here
164        // vacuous. Not a brief requirement, but the whole point of this
165        // crate existing.
166        assert!(all_rules().next().is_some());
167    }
168}