Skip to main content

hns_browser_loopback_proxy/chromium/
event.rs

1//! Privacy-preserving proxy observation.
2//!
3//! Events intentionally have no free-form text fields. A request target,
4//! query, header, credential, payload, or backend error detail therefore
5//! cannot cross this interface accidentally.
6
7use std::fmt;
8use std::net::IpAddr;
9use std::time::Duration;
10use thiserror::Error;
11
12/// A validated, canonical ASCII DNS host or public IP literal suitable for
13/// diagnostics.
14#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct ObservedHost(String);
16
17impl ObservedHost {
18    pub fn new(host: &str) -> Result<Self, ObservedHostError> {
19        if host.is_empty() {
20            return Err(ObservedHostError::Empty);
21        }
22        if host.len() > 253 || !host.is_ascii() {
23            return Err(ObservedHostError::InvalidDnsName);
24        }
25        if let Ok(address) = host.parse::<IpAddr>() {
26            if address.to_string() == host.to_ascii_lowercase() {
27                return Ok(Self(host.to_ascii_lowercase()));
28            }
29            return Err(ObservedHostError::InvalidDnsName);
30        }
31        for label in host.split('.') {
32            if label.is_empty() || label.len() > 63 {
33                return Err(ObservedHostError::InvalidDnsName);
34            }
35            let bytes = label.as_bytes();
36            if !bytes.first().is_some_and(u8::is_ascii_alphanumeric)
37                || !bytes.last().is_some_and(u8::is_ascii_alphanumeric)
38                || !bytes
39                    .iter()
40                    .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
41            {
42                return Err(ObservedHostError::InvalidDnsName);
43            }
44        }
45        Ok(Self(host.to_ascii_lowercase()))
46    }
47
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51}
52
53impl fmt::Debug for ObservedHost {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        formatter
56            .debug_tuple("ObservedHost")
57            .field(&self.0)
58            .finish()
59    }
60}
61
62impl fmt::Display for ObservedHost {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        formatter.write_str(&self.0)
65    }
66}
67
68impl TryFrom<&str> for ObservedHost {
69    type Error = ObservedHostError;
70
71    fn try_from(value: &str) -> Result<Self, Self::Error> {
72        Self::new(value)
73    }
74}
75
76#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
77pub enum ObservedHostError {
78    #[error("observed host is empty")]
79    Empty,
80    #[error("observed host is not a canonical ASCII DNS name")]
81    InvalidDnsName,
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum ObservedMethod {
86    Get,
87    Head,
88    Post,
89    Put,
90    Delete,
91    Options,
92    Patch,
93    Connect,
94    Other,
95}
96
97impl ObservedMethod {
98    pub fn from_token(method: &str) -> Self {
99        if method.eq_ignore_ascii_case("GET") {
100            Self::Get
101        } else if method.eq_ignore_ascii_case("HEAD") {
102            Self::Head
103        } else if method.eq_ignore_ascii_case("POST") {
104            Self::Post
105        } else if method.eq_ignore_ascii_case("PUT") {
106            Self::Put
107        } else if method.eq_ignore_ascii_case("DELETE") {
108            Self::Delete
109        } else if method.eq_ignore_ascii_case("OPTIONS") {
110            Self::Options
111        } else if method.eq_ignore_ascii_case("PATCH") {
112            Self::Patch
113        } else if method.eq_ignore_ascii_case("CONNECT") {
114            Self::Connect
115        } else {
116            Self::Other
117        }
118    }
119}
120
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum LifecycleEvent {
123    Listening { port: u16 },
124    Stopping,
125    Stopped { reason: StopReason },
126}
127
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub enum StopReason {
130    Requested,
131    Cancelled,
132    ListenerFailure,
133}
134
135#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136pub enum ClientRejectionReason {
137    ActiveClientLimit,
138    AuthenticationRequired,
139    InvalidRequest,
140    Cancelled,
141    InvalidPeer,
142}
143
144#[derive(Clone, Copy, Debug, Eq, PartialEq)]
145pub enum RequestRejectionReason {
146    AuthenticationRequired,
147    InvalidRequest,
148    HostOutsideScope,
149    GlobalRateLimit,
150    HostRateLimit,
151    RequestTooLarge,
152    Cancelled,
153}
154
155#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub enum BackendFailureKind {
157    Cancelled,
158    InvalidRequest,
159    PolicyDenied,
160    Resolution,
161    TlsValidation,
162    Upstream,
163    InvalidResponse,
164    ResponseTooLarge,
165    Internal,
166}
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub enum RequestPhase {
170    Accepted,
171    Completed {
172        status_code: u16,
173        elapsed: Duration,
174    },
175    Rejected {
176        reason: RequestRejectionReason,
177    },
178    BackendFailed {
179        kind: BackendFailureKind,
180        elapsed: Duration,
181    },
182}
183
184/// Sanitized events scoped to a monotonically increasing proxy generation.
185#[derive(Clone, Debug, Eq, PartialEq)]
186pub enum ProxyEvent {
187    Lifecycle {
188        generation: u64,
189        event: LifecycleEvent,
190    },
191    ClientRejected {
192        generation: u64,
193        reason: ClientRejectionReason,
194    },
195    Request {
196        generation: u64,
197        host: ObservedHost,
198        method: ObservedMethod,
199        phase: RequestPhase,
200    },
201}
202
203pub trait ProxyObserver: Send + Sync + 'static {
204    /// Receives a privacy-bounded event. Implementations must return promptly
205    /// and must not panic; shipping profiles abort on panic.
206    fn observe(&self, event: &ProxyEvent);
207}
208
209impl<F> ProxyObserver for F
210where
211    F: Fn(&ProxyEvent) + Send + Sync + 'static,
212{
213    fn observe(&self, event: &ProxyEvent) {
214        self(event);
215    }
216}
217
218#[derive(Clone, Copy, Debug, Default)]
219pub struct NoopProxyObserver;
220
221impl ProxyObserver for NoopProxyObserver {
222    fn observe(&self, _event: &ProxyEvent) {}
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::sync::{Arc, Mutex};
229
230    #[test]
231    fn observed_host_accepts_only_dns_host_syntax() {
232        assert_eq!(
233            ObservedHost::new("MiXeD.Example")
234                .expect("valid host")
235                .as_str(),
236            "mixed.example"
237        );
238        for unsafe_host in [
239            "user:pass@example",
240            "example/path",
241            "example?query",
242            "example#fragment",
243            "example:443",
244            "example header",
245            "example%2fpath",
246        ] {
247            assert!(ObservedHost::new(unsafe_host).is_err(), "{unsafe_host}");
248        }
249    }
250
251    #[test]
252    fn unknown_method_does_not_retain_input() {
253        assert_eq!(
254            ObservedMethod::from_token("SECRET /path?query"),
255            ObservedMethod::Other
256        );
257        assert_eq!(format!("{:?}", ObservedMethod::Other), "Other");
258    }
259
260    #[test]
261    fn observer_receives_typed_sanitized_event() {
262        let observed = Arc::new(Mutex::new(Vec::new()));
263        let sink = Arc::clone(&observed);
264        let observer = move |event: &ProxyEvent| {
265            sink.lock()
266                .unwrap_or_else(|poisoned| poisoned.into_inner())
267                .push(event.clone());
268        };
269        let event = ProxyEvent::Request {
270            generation: 9,
271            host: ObservedHost::new("example").expect("valid host"),
272            method: ObservedMethod::Get,
273            phase: RequestPhase::Rejected {
274                reason: RequestRejectionReason::AuthenticationRequired,
275            },
276        };
277        observer.observe(&event);
278        assert_eq!(
279            observed
280                .lock()
281                .unwrap_or_else(|poisoned| poisoned.into_inner())
282                .as_slice(),
283            &[event]
284        );
285    }
286}