Skip to main content

termivar_scanner/defense/
transition.rs

1//! Deterministic comparison of two observed defense states.
2//!
3//! A [`DefenseTransition`] is the difference between a control and a candidate
4//! observation of the same target — for example the baseline response versus a
5//! response to a strategy-derived candidate request. It is *evidence*, not a
6//! decision: a planner weighs a transition to decide whether to escalate,
7//! back off, or re-fingerprint, but this module never selects a payload.
8
9use super::fingerprint::DefenseProduct;
10use super::state::{DefensePosture, DefenseState};
11
12/// Direction of a posture change between two observations.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PostureShift {
15    /// The candidate posture matches the control posture.
16    Unchanged,
17    /// The candidate is more defensive than the control.
18    Escalated,
19    /// The candidate is less defensive than the control.
20    Deescalated,
21}
22
23/// Summary classification of a control-to-candidate defense transition.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum DefenseTransitionKind {
27    /// Neither the posture nor the observed signals changed.
28    NoChange,
29    /// The candidate is more defended than the control.
30    DefenseEngaged,
31    /// The candidate is less defended than the control.
32    DefenseRelaxed,
33    /// The posture level is unchanged but the observed signals differ
34    /// (a different product, status class, or rate-limit signal).
35    DefenseReconfigured,
36}
37
38/// The deterministic difference between a control and a candidate observation.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct DefenseTransition {
41    posture_shift: PostureShift,
42    newly_blocking: bool,
43    newly_rate_limited: bool,
44    status_changed: bool,
45    fingerprint_changed: bool,
46    control_product: Option<DefenseProduct>,
47    candidate_product: Option<DefenseProduct>,
48    kind: DefenseTransitionKind,
49}
50
51impl DefenseTransition {
52    /// Compares a control observation against a candidate observation.
53    ///
54    /// The result is a pure function of the two states, so identical inputs
55    /// always produce an equal transition.
56    pub fn between(control: &DefenseState, candidate: &DefenseState) -> Self {
57        let posture_shift = match candidate.posture().cmp(&control.posture()) {
58            std::cmp::Ordering::Greater => PostureShift::Escalated,
59            std::cmp::Ordering::Less => PostureShift::Deescalated,
60            std::cmp::Ordering::Equal => PostureShift::Unchanged,
61        };
62
63        let newly_blocking = candidate.posture() == DefensePosture::Blocking
64            && control.posture() != DefensePosture::Blocking;
65        let newly_rate_limited = candidate.is_rate_limited() && !control.is_rate_limited();
66        let status_changed = control.status_signal() != candidate.status_signal();
67
68        let control_product = control.fingerprint().map(|print| print.product());
69        let candidate_product = candidate.fingerprint().map(|print| print.product());
70        let fingerprint_changed = control_product != candidate_product;
71
72        let kind = match posture_shift {
73            PostureShift::Escalated => DefenseTransitionKind::DefenseEngaged,
74            PostureShift::Deescalated => DefenseTransitionKind::DefenseRelaxed,
75            PostureShift::Unchanged => {
76                if fingerprint_changed || status_changed || newly_rate_limited {
77                    DefenseTransitionKind::DefenseReconfigured
78                } else {
79                    DefenseTransitionKind::NoChange
80                }
81            },
82        };
83
84        Self {
85            posture_shift,
86            newly_blocking,
87            newly_rate_limited,
88            status_changed,
89            fingerprint_changed,
90            control_product,
91            candidate_product,
92            kind,
93        }
94    }
95
96    /// Returns the direction of the posture change.
97    pub const fn posture_shift(&self) -> PostureShift {
98        self.posture_shift
99    }
100
101    /// Returns the summary classification of the transition.
102    pub const fn kind(&self) -> DefenseTransitionKind {
103        self.kind
104    }
105
106    /// Returns whether the candidate is blocking while the control was not.
107    pub const fn is_newly_blocking(&self) -> bool {
108        self.newly_blocking
109    }
110
111    /// Returns whether the candidate is rate limited while the control was not.
112    pub const fn is_newly_rate_limited(&self) -> bool {
113        self.newly_rate_limited
114    }
115
116    /// Returns whether the coarse status class changed.
117    pub const fn status_changed(&self) -> bool {
118        self.status_changed
119    }
120
121    /// Returns whether the fingerprinted product changed (including appearing or
122    /// disappearing).
123    pub const fn fingerprint_changed(&self) -> bool {
124        self.fingerprint_changed
125    }
126
127    /// Returns the product fingerprinted on the control leg, if any.
128    pub const fn control_product(&self) -> Option<DefenseProduct> {
129        self.control_product
130    }
131
132    /// Returns the product fingerprinted on the candidate leg, if any.
133    pub const fn candidate_product(&self) -> Option<DefenseProduct> {
134        self.candidate_product
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn state(status: u16, headers: &[(&str, &str)], body: &str) -> DefenseState {
143        DefenseState::observe(status, headers, body)
144    }
145
146    #[test]
147    fn open_to_blocking_is_an_engaged_escalation() {
148        let control = state(200, &[], "ok");
149        let candidate = state(403, &[], "forbidden");
150        let transition = DefenseTransition::between(&control, &candidate);
151
152        assert_eq!(transition.posture_shift(), PostureShift::Escalated);
153        assert_eq!(transition.kind(), DefenseTransitionKind::DefenseEngaged);
154        assert!(transition.is_newly_blocking());
155        assert!(transition.status_changed());
156    }
157
158    #[test]
159    fn blocking_to_open_is_relaxed() {
160        let control = state(403, &[], "forbidden");
161        let candidate = state(200, &[], "ok");
162        let transition = DefenseTransition::between(&control, &candidate);
163
164        assert_eq!(transition.posture_shift(), PostureShift::Deescalated);
165        assert_eq!(transition.kind(), DefenseTransitionKind::DefenseRelaxed);
166        assert!(!transition.is_newly_blocking());
167    }
168
169    #[test]
170    fn identical_open_states_show_no_change() {
171        let control = state(200, &[("Server", "nginx")], "ok");
172        let candidate = state(200, &[("Server", "nginx")], "ok");
173        let transition = DefenseTransition::between(&control, &candidate);
174
175        assert_eq!(transition.posture_shift(), PostureShift::Unchanged);
176        assert_eq!(transition.kind(), DefenseTransitionKind::NoChange);
177        assert!(!transition.fingerprint_changed());
178    }
179
180    #[test]
181    fn same_posture_with_a_new_fingerprint_is_reconfigured() {
182        // Both suspected, but a product fingerprint appears on the candidate.
183        let control = state(200, &[("Retry-After", "5")], "slow down");
184        let candidate = state(200, &[("Retry-After", "5"), ("CF-RAY", "abc")], "slow down");
185        let transition = DefenseTransition::between(&control, &candidate);
186
187        assert_eq!(transition.posture_shift(), PostureShift::Unchanged);
188        assert_eq!(
189            transition.kind(),
190            DefenseTransitionKind::DefenseReconfigured
191        );
192        assert!(transition.fingerprint_changed());
193        assert_eq!(transition.control_product(), None);
194        assert_eq!(
195            transition.candidate_product(),
196            Some(super::DefenseProduct::Cloudflare)
197        );
198    }
199
200    #[test]
201    fn newly_rate_limited_without_a_block_is_reconfigured() {
202        let control = state(200, &[], "ok");
203        let candidate = state(429, &[], "slow down");
204        let transition = DefenseTransition::between(&control, &candidate);
205
206        // 200 (Open) -> 429 (Suspected) is an escalation.
207        assert_eq!(transition.posture_shift(), PostureShift::Escalated);
208        assert_eq!(transition.kind(), DefenseTransitionKind::DefenseEngaged);
209        assert!(transition.is_newly_rate_limited());
210    }
211
212    #[test]
213    fn transition_is_deterministic() {
214        let control = state(200, &[], "ok");
215        let candidate = state(403, &[("CF-RAY", "x")], "access denied");
216        let first = DefenseTransition::between(&control, &candidate);
217        let second = DefenseTransition::between(&control, &candidate);
218        assert_eq!(first, second);
219    }
220}