Skip to main content

parlov_core/
exchange.rs

1use bytes::Bytes;
2use http::{HeaderMap, StatusCode};
3use serde::{Deserialize, Serialize};
4
5use crate::{ProbeDefinition, ResponseClass, ResponseSurface, Technique};
6
7/// Request and response paired so analyzers always have the full context of what was sent.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ProbeExchange {
10    /// The request that was executed.
11    pub request: ProbeDefinition,
12    /// The response surface that came back.
13    pub response: ResponseSurface,
14}
15
16/// Baseline and probe exchange pairs with technique context for differential analysis.
17#[derive(Debug, Clone)]
18pub struct DifferentialSet {
19    /// Known-valid / control exchanges — establishes the expected response surface.
20    pub baseline: Vec<ProbeExchange>,
21    /// Suspect exchanges — compared against baseline to detect oracle signals.
22    pub probe: Vec<ProbeExchange>,
23    /// Optional canonical (unmutated) exchange for control-integrity verification.
24    ///
25    /// Populated by route-mutating strategies (`case_normalize`, `trailing_slash`) via the
26    /// runner's third dispatch. Consumed by `control_integrity` to detect when a path mutation
27    /// destroyed routing — canonical 2xx + mutated baseline non-2xx means the mutation broke the
28    /// route, and any resulting Contradictory is invalid.
29    pub canonical: Option<ProbeExchange>,
30    /// Technique that generated these probes — provides attribution and calibration context.
31    pub technique: Technique,
32}
33
34impl DifferentialSet {
35    /// Constructs a `DifferentialSet` with `canonical = None` (the common case).
36    ///
37    /// Only `case_normalize` and `trailing_slash` set `canonical` after construction.
38    #[must_use]
39    pub fn new(
40        baseline: Vec<ProbeExchange>,
41        probe: Vec<ProbeExchange>,
42        technique: Technique,
43    ) -> Self {
44        Self {
45            baseline,
46            probe,
47            canonical: None,
48            technique,
49        }
50    }
51
52    /// Returns `(status, headers)` from the first baseline exchange suitable for harvesting.
53    ///
54    /// Priority: 2xx first, then 3xx+Location, then 4xx with JSON content type
55    /// (`StructuredError` — needed by C5 problem-details producer).
56    #[must_use]
57    pub fn first_harvest_exchange(&self) -> Option<(StatusCode, HeaderMap)> {
58        self.first_harvest_exchange_with_body()
59            .map(|(s, h, _)| (s, h))
60    }
61
62    /// Returns `(status, headers, body)` from the first baseline exchange suitable for harvesting.
63    ///
64    /// Priority order:
65    /// 1. First 2xx — carries `ETag`, `Last-Modified`, `Content-Type`, and body for chain producers.
66    /// 2. First 3xx with a `Location` header — for redirect-diff chaining.
67    /// 3. First baseline exchange classified as `AuthChallenge`, `RateLimited`, or
68    ///    `StructuredError` — admits 401/407 (C8 WWW-Authenticate) and 4xx+JSON (C5 body).
69    #[must_use]
70    pub fn first_harvest_exchange_with_body(&self) -> Option<(StatusCode, HeaderMap, Bytes)> {
71        if let Some(ex) = self
72            .baseline
73            .iter()
74            .find(|ex| ex.response.status.is_success())
75        {
76            return Some((
77                ex.response.status,
78                ex.response.headers.clone(),
79                ex.response.body.clone(),
80            ));
81        }
82        if let Some(ex) = self.baseline.iter().find(|ex| {
83            ex.response.status.is_redirection()
84                && ex.response.headers.contains_key(http::header::LOCATION)
85        }) {
86            return Some((
87                ex.response.status,
88                ex.response.headers.clone(),
89                ex.response.body.clone(),
90            ));
91        }
92        self.baseline
93            .iter()
94            .find(|ex| {
95                matches!(
96                    ResponseClass::classify(ex.response.status, &ex.response.headers),
97                    ResponseClass::AuthChallenge
98                        | ResponseClass::RateLimited
99                        | ResponseClass::StructuredError
100                )
101            })
102            .map(|ex| {
103                (
104                    ex.response.status,
105                    ex.response.headers.clone(),
106                    ex.response.body.clone(),
107                )
108            })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use bytes::Bytes;
115    use http::{header, HeaderMap, HeaderValue, StatusCode};
116
117    use super::*;
118    use crate::{
119        always_applicable, NormativeStrength, OracleClass, ProbeDefinition, ResponseSurface,
120        SignalSurface, Vector,
121    };
122
123    fn technique() -> Technique {
124        Technique {
125            id: "test",
126            name: "Test",
127            oracle_class: OracleClass::Existence,
128            vector: Vector::StatusCodeDiff,
129            strength: NormativeStrength::Must,
130            normalization_weight: None,
131            inverted_signal_weight: None,
132            method_relevant: false,
133            parser_relevant: false,
134            applicability: always_applicable,
135            contradiction_surface: SignalSurface::Status,
136        }
137    }
138
139    fn make_exchange(status: StatusCode, headers: HeaderMap) -> ProbeExchange {
140        ProbeExchange {
141            request: ProbeDefinition {
142                url: "https://example.com/r/1".into(),
143                method: http::Method::GET,
144                headers: HeaderMap::new(),
145                body: None,
146            },
147            response: ResponseSurface {
148                status,
149                headers,
150                body: Bytes::new(),
151                timing_ns: 0,
152            },
153        }
154    }
155
156    fn location_headers() -> HeaderMap {
157        let mut h = HeaderMap::new();
158        h.insert(
159            header::LOCATION,
160            HeaderValue::from_static("https://example.com/r/2"),
161        );
162        h
163    }
164
165    #[test]
166    fn empty_baseline_returns_none() {
167        let ds = DifferentialSet {
168            baseline: vec![],
169            probe: vec![],
170            canonical: None,
171            technique: technique(),
172        };
173        assert!(ds.first_harvest_exchange().is_none());
174    }
175
176    #[test]
177    fn single_200_returns_its_status_and_headers() {
178        let mut headers = HeaderMap::new();
179        headers.insert(
180            header::CONTENT_TYPE,
181            HeaderValue::from_static("application/json"),
182        );
183        let ds = DifferentialSet {
184            baseline: vec![make_exchange(StatusCode::OK, headers.clone())],
185            probe: vec![],
186            canonical: None,
187            technique: technique(),
188        };
189        let (status, h) = ds.first_harvest_exchange().expect("should return Some");
190        assert_eq!(status, StatusCode::OK);
191        assert_eq!(
192            h.get(header::CONTENT_TYPE),
193            headers.get(header::CONTENT_TYPE)
194        );
195    }
196
197    #[test]
198    fn single_301_with_location_returns_it_when_no_2xx() {
199        let ds = DifferentialSet {
200            baseline: vec![make_exchange(
201                StatusCode::MOVED_PERMANENTLY,
202                location_headers(),
203            )],
204            probe: vec![],
205            canonical: None,
206            technique: technique(),
207        };
208        let (status, h) = ds.first_harvest_exchange().expect("should return Some");
209        assert_eq!(status, StatusCode::MOVED_PERMANENTLY);
210        assert!(h.contains_key(header::LOCATION));
211    }
212
213    #[test]
214    fn single_301_without_location_returns_none() {
215        let ds = DifferentialSet {
216            baseline: vec![make_exchange(
217                StatusCode::MOVED_PERMANENTLY,
218                HeaderMap::new(),
219            )],
220            probe: vec![],
221            canonical: None,
222            technique: technique(),
223        };
224        assert!(ds.first_harvest_exchange().is_none());
225    }
226
227    #[test]
228    fn mixed_301_with_location_then_200_returns_200() {
229        let ds = DifferentialSet {
230            baseline: vec![
231                make_exchange(StatusCode::MOVED_PERMANENTLY, location_headers()),
232                make_exchange(StatusCode::OK, HeaderMap::new()),
233            ],
234            probe: vec![],
235            canonical: None,
236            technique: technique(),
237        };
238        let (status, _) = ds.first_harvest_exchange().expect("should return Some");
239        assert_eq!(status, StatusCode::OK);
240    }
241
242    #[test]
243    fn mixed_200_then_301_with_location_returns_200() {
244        let ds = DifferentialSet {
245            baseline: vec![
246                make_exchange(StatusCode::OK, HeaderMap::new()),
247                make_exchange(StatusCode::MOVED_PERMANENTLY, location_headers()),
248            ],
249            probe: vec![],
250            canonical: None,
251            technique: technique(),
252        };
253        let (status, _) = ds.first_harvest_exchange().expect("should return Some");
254        assert_eq!(status, StatusCode::OK);
255    }
256
257    #[test]
258    fn multiple_2xx_returns_first() {
259        let mut h1 = HeaderMap::new();
260        h1.insert(header::ETAG, HeaderValue::from_static("\"abc\""));
261        let ds = DifferentialSet {
262            baseline: vec![
263                make_exchange(StatusCode::OK, h1.clone()),
264                make_exchange(StatusCode::CREATED, HeaderMap::new()),
265            ],
266            probe: vec![],
267            canonical: None,
268            technique: technique(),
269        };
270        let (status, h) = ds.first_harvest_exchange().expect("should return Some");
271        assert_eq!(status, StatusCode::OK);
272        assert_eq!(h.get(header::ETAG), h1.get(header::ETAG));
273    }
274}