Skip to main content

holger_intel/
net.rs

1//! The network gate. Every byte this crate could ever pull from outside goes
2//! through [`IntelNet::get`], and an offline gate refuses every URL before any
3//! socket exists.
4
5use std::sync::atomic::{AtomicU64, Ordering};
6
7/// A network refusal or failure.
8#[derive(Debug, thiserror::Error)]
9pub enum NetError {
10    /// The gate is offline (`intel.offline = true`, or a build with no fetcher):
11    /// the URL was refused, and nothing was sent.
12    #[error("network refused: intel is offline, and {url} was not fetched")]
13    Refused { url: String },
14    /// The fetch was attempted and failed: no route, DNS, TLS, a non-200.
15    #[error("network unavailable for {url}: {why}")]
16    Unavailable { url: String, why: String },
17}
18
19impl NetError {
20    pub fn is_refused(&self) -> bool {
21        matches!(self, NetError::Refused { .. })
22    }
23}
24
25/// Something that can GET a URL.
26pub trait Fetch: Send + Sync {
27    fn get(&self, url: &str) -> Result<Vec<u8>, NetError>;
28}
29
30/// The gate itself: offline, or online over one [`Fetch`].
31pub struct IntelNet {
32    inner: Option<Box<dyn Fetch>>,
33    refused: AtomicU64,
34}
35
36impl IntelNet {
37    /// A gate that refuses every URL. There is no fetcher behind it at all, so
38    /// a refusal is not a policy check that could be skipped: there is nothing
39    /// to call.
40    pub fn offline() -> Self {
41        Self { inner: None, refused: AtomicU64::new(0) }
42    }
43
44    /// A gate over `fetch`.
45    pub fn online(fetch: Box<dyn Fetch>) -> Self {
46        Self { inner: Some(fetch), refused: AtomicU64::new(0) }
47    }
48
49    /// The online gate over `ureq` (feature `fetch`).
50    #[cfg(feature = "fetch")]
51    pub fn http() -> Self {
52        Self::online(Box::new(UreqFetch::new()))
53    }
54
55    pub fn is_offline(&self) -> bool {
56        self.inner.is_none()
57    }
58
59    /// How many URLs this gate has refused.
60    pub fn refused_count(&self) -> u64 {
61        self.refused.load(Ordering::Relaxed)
62    }
63
64    pub fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
65        match &self.inner {
66            None => {
67                self.refused.fetch_add(1, Ordering::Relaxed);
68                Err(NetError::Refused { url: url.to_string() })
69            }
70            Some(f) => f.get(url),
71        }
72    }
73}
74
75/// The online fetcher: https only, bounded time and size, no redirects, no
76/// proxy from the environment.
77#[cfg(feature = "fetch")]
78pub struct UreqFetch {
79    agent: ureq::Agent,
80}
81
82#[cfg(feature = "fetch")]
83impl UreqFetch {
84    /// The largest body accepted (an OSV PyPI dump is well under this).
85    pub const MAX_BODY: u64 = 1024 * 1024 * 1024;
86
87    pub fn new() -> Self {
88        use std::time::Duration;
89        let agent = ureq::Agent::config_builder()
90            .timeout_connect(Some(Duration::from_secs(15)))
91            .timeout_recv_response(Some(Duration::from_secs(60)))
92            .timeout_recv_body(Some(Duration::from_secs(60 * 60)))
93            .max_redirects(0)
94            .http_status_as_error(false)
95            .proxy(None)
96            .build()
97            .new_agent();
98        Self { agent }
99    }
100}
101
102#[cfg(feature = "fetch")]
103impl Default for UreqFetch {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109#[cfg(feature = "fetch")]
110impl Fetch for UreqFetch {
111    fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
112        let unavailable = |why: String| NetError::Unavailable { url: url.to_string(), why };
113        if !url.starts_with("https://") && !is_loopback_http(url) {
114            return Err(unavailable("only https:// feeds are fetched (plain http on loopback only)".into()));
115        }
116        let mut resp = self.agent.get(url).call().map_err(|e| unavailable(e.to_string()))?;
117        let status = resp.status().as_u16();
118        if status != 200 {
119            return Err(unavailable(format!("HTTP {status}")));
120        }
121        resp.body_mut()
122            .with_config()
123            .limit(Self::MAX_BODY)
124            .read_to_vec()
125            .map_err(|e| unavailable(e.to_string()))
126    }
127}
128
129/// `http://127.0.0.1…`, `http://[::1]…` or `http://localhost…`: a feed mirror on
130/// the box itself (`intel.feed_mirror`), the one plain-http source fetched.
131pub fn is_loopback_http(url: &str) -> bool {
132    let Some(rest) = url.strip_prefix("http://") else { return false };
133    let host = rest.split('/').next().unwrap_or("");
134    let host = host.rsplit_once(':').map(|(h, p)| if p.chars().all(|c| c.is_ascii_digit()) { h } else { host }).unwrap_or(host);
135    matches!(host, "127.0.0.1" | "[::1]" | "localhost")
136}
137
138#[cfg(test)]
139mod loopback_tests {
140    #[test]
141    fn only_a_loopback_host_may_be_plain_http() {
142        for ok in ["http://127.0.0.1:8080/index.txt", "http://localhost/kev.json", "http://[::1]:9/x"] {
143            assert!(super::is_loopback_http(ok), "{ok}");
144        }
145        for no in ["http://127.0.0.1.evil.example/x", "http://10.0.0.1/x", "https://127.0.0.1/x", "http://localhost.example:80/x", "ftp://localhost/x"] {
146            assert!(!super::is_loopback_http(no), "{no}");
147        }
148    }
149}