Skip to main content

gossan_origin/
types.rs

1//! Origin discovery data types — candidate origins and evidence.
2
3use serde::{Deserialize, Serialize};
4use std::net::IpAddr;
5
6/// Validation state of an origin candidate.
7#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
8pub enum ValidationState {
9    /// Candidate was discovered but not actively confirmed.
10    Speculative,
11    /// Candidate confirmed by host-header swap or 404 divergence.
12    Confirmed,
13    /// Candidate ruled out by validation (generic default page, no match).
14    Rejected,
15}
16
17/// Represents an origin server discovered behind a CDN/WAF.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
19pub struct OriginCandidate {
20    /// IP address of the candidate origin server
21    pub ip: IpAddr,
22    /// Optional explicit port. `None` means the validator falls back to
23    /// the scheme default (443 then 80). Set this when a discovery
24    /// source already knows the listening port (e.g. a Censys hit on
25    /// 8443 or a wiremock harness binding to an ephemeral port). An
26    /// explicit port is also taken as the operator's signal that they
27    /// _intend_ to probe non-routable IPs (loopback/private), so the
28    /// global-routability gate in the validator is bypassed when this
29    /// field is `Some`.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub port: Option<u16>,
32    /// Discovery technique (e.g., "ssl_cert", "dns_misconfig")
33    pub method: String,
34    /// Confidence score (0-100)
35    pub confidence: u8,
36    /// Active validation result
37    pub validated: ValidationState,
38}
39
40impl OriginCandidate {
41    /// Create a new speculative candidate.
42    pub fn new(ip: IpAddr, method: impl Into<String>, confidence: u8) -> Self {
43        Self {
44            ip,
45            port: None,
46            method: method.into(),
47            confidence,
48            validated: ValidationState::Speculative,
49        }
50    }
51
52    /// Create a new speculative candidate at an explicit port. Use this
53    /// from test harnesses (wiremock binds to an ephemeral port) and
54    /// from discovery sources that already know the listener (Censys,
55    /// Shodan, AXFR-derived A records on non-default ports).
56    pub fn new_with_port(ip: IpAddr, port: u16, method: impl Into<String>, confidence: u8) -> Self {
57        Self {
58            ip,
59            port: Some(port),
60            method: method.into(),
61            confidence,
62            validated: ValidationState::Speculative,
63        }
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use serde_json::json;
71
72    #[test]
73    fn origin_candidate_orders_by_fields() {
74        let low = OriginCandidate {
75            ip: "192.0.2.1".parse().unwrap(),
76            port: None,
77            method: "dns".into(),
78            confidence: 20,
79            validated: ValidationState::Speculative,
80        };
81        let high = OriginCandidate {
82            ip: "192.0.2.2".parse().unwrap(),
83            port: None,
84            method: "ssl".into(),
85            confidence: 90,
86            validated: ValidationState::Confirmed,
87        };
88        assert!(high > low);
89    }
90
91    #[test]
92    fn origin_candidate_serializes_cleanly() {
93        let candidate = OriginCandidate::new("192.0.2.10".parse().unwrap(), "http_header", 75);
94        let value = serde_json::to_value(candidate).unwrap();
95        assert_eq!(value["ip"], json!("192.0.2.10"));
96        assert_eq!(value["method"], json!("http_header"));
97        assert_eq!(value["confidence"], json!(75));
98        assert_eq!(value["validated"], json!("Speculative"));
99    }
100}