Skip to main content

gossan_classify/
classifier.rs

1//! Public-facing banner classifier.
2//!
3//! [`BannerClassifier`] is the thin facade callers reach for. Internally
4//! it just owns a [`CpuMatcher`] over the [`builtin_rules`] set, so the
5//! classify crate can grow a GPU backend later without rewriting the
6//! callers (gossan-portscan, gossan-cli, gossan-correlation, etc.).
7
8use crate::matcher::CpuMatcher;
9use crate::rules::{builtin_rules, ServiceMatch, ServiceRule};
10
11/// Top-level classifier — drop a banner in, get a ranked list of
12/// service matches out.
13pub struct BannerClassifier {
14    matcher: CpuMatcher,
15}
16
17impl BannerClassifier {
18    /// Build a classifier seeded with [`builtin_rules`].
19    #[must_use]
20    pub fn new() -> Self {
21        Self {
22            matcher: CpuMatcher::new(builtin_rules()),
23        }
24    }
25
26    /// Build a classifier from a custom rule set. Callers wiring in
27    /// community-contributed TOML rule packs should use this.
28    #[must_use]
29    pub fn with_rules(rules: Vec<ServiceRule>) -> Self {
30        Self {
31            matcher: CpuMatcher::new(rules),
32        }
33    }
34
35    /// Classify a single banner. Returns matches sorted by priority
36    /// (highest first); empty vec when nothing fires.
37    #[must_use]
38    pub fn classify(&self, banner: &str) -> Vec<ServiceMatch> {
39        self.matcher.match_banner(banner)
40    }
41
42    /// Classify a batch of banners. Mirrors `CpuMatcher::match_batch`.
43    /// The returned outer vec has one entry per input banner; inner
44    /// vecs follow `classify`'s ordering.
45    #[must_use]
46    pub fn classify_batch(&self, banners: &[&str]) -> Vec<Vec<ServiceMatch>> {
47        self.matcher.match_batch(banners)
48    }
49
50    /// First match for a banner, if any. Useful when callers only
51    /// want the top service identification and don't care about
52    /// alternative rule hits.
53    #[must_use]
54    pub fn classify_top(&self, banner: &str) -> Option<ServiceMatch> {
55        self.matcher.match_banner(banner).into_iter().next()
56    }
57}
58
59impl Default for BannerClassifier {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn classifier_loads_builtin_rules_without_panic() {
71        let c = BannerClassifier::new();
72        let _ = c.classify("Server: nginx/1.25.3\r\n");
73    }
74
75    #[test]
76    fn classify_top_returns_none_for_garbage() {
77        let c = BannerClassifier::new();
78        assert!(c.classify_top("\x00\x00\x00\x00").is_none());
79    }
80
81    #[test]
82    fn classify_batch_preserves_ordering() {
83        let c = BannerClassifier::new();
84        let banners = ["Server: nginx", "SSH-2.0-OpenSSH_8.9", "garbage"];
85        let out = c.classify_batch(&banners);
86        assert_eq!(out.len(), banners.len(), "one result vec per input banner");
87    }
88
89    #[test]
90    fn with_rules_uses_caller_rule_set() {
91        // Rules vec deliberately empty — classifier must accept it
92        // and return empty matches for every banner.
93        let c = BannerClassifier::with_rules(vec![]);
94        assert!(c.classify("Server: nginx/1.25.3").is_empty());
95        assert!(c.classify_top("anything").is_none());
96    }
97}