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 {
42            inner: None,
43            refused: AtomicU64::new(0),
44        }
45    }
46
47    /// A gate over `fetch`.
48    pub fn online(fetch: Box<dyn Fetch>) -> Self {
49        Self {
50            inner: Some(fetch),
51            refused: AtomicU64::new(0),
52        }
53    }
54
55    /// The online gate over `ureq` (feature `fetch`).
56    #[cfg(feature = "fetch")]
57    pub fn http() -> Self {
58        Self::online(Box::new(UreqFetch::new()))
59    }
60
61    pub fn is_offline(&self) -> bool {
62        self.inner.is_none()
63    }
64
65    /// How many URLs this gate has refused.
66    pub fn refused_count(&self) -> u64 {
67        self.refused.load(Ordering::Relaxed)
68    }
69
70    pub fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
71        match &self.inner {
72            None => {
73                self.refused.fetch_add(1, Ordering::Relaxed);
74                Err(NetError::Refused {
75                    url: url.to_string(),
76                })
77            }
78            Some(f) => f.get(url),
79        }
80    }
81}
82
83/// The online fetcher: https only, bounded time and size, no redirects, no
84/// proxy from the environment.
85#[cfg(feature = "fetch")]
86pub struct UreqFetch {
87    agent: ureq::Agent,
88}
89
90#[cfg(feature = "fetch")]
91impl UreqFetch {
92    /// The largest body accepted (an OSV PyPI dump is well under this).
93    pub const MAX_BODY: u64 = 1024 * 1024 * 1024;
94
95    pub fn new() -> Self {
96        use std::time::Duration;
97        let agent = ureq::Agent::config_builder()
98            .timeout_connect(Some(Duration::from_secs(15)))
99            .timeout_recv_response(Some(Duration::from_secs(60)))
100            .timeout_recv_body(Some(Duration::from_secs(60 * 60)))
101            .max_redirects(0)
102            .http_status_as_error(false)
103            .proxy(None)
104            .build()
105            .new_agent();
106        Self { agent }
107    }
108}
109
110#[cfg(feature = "fetch")]
111impl Default for UreqFetch {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117#[cfg(feature = "fetch")]
118impl Fetch for UreqFetch {
119    fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
120        let unavailable = |why: String| NetError::Unavailable {
121            url: url.to_string(),
122            why,
123        };
124        if !url.starts_with("https://") && !is_loopback_http(url) {
125            return Err(unavailable(
126                "only https:// feeds are fetched (plain http on loopback only)".into(),
127            ));
128        }
129        let mut resp = self
130            .agent
131            .get(url)
132            .call()
133            .map_err(|e| unavailable(e.to_string()))?;
134        let status = resp.status().as_u16();
135        if status != 200 {
136            return Err(unavailable(format!("HTTP {status}")));
137        }
138        resp.body_mut()
139            .with_config()
140            .limit(Self::MAX_BODY)
141            .read_to_vec()
142            .map_err(|e| unavailable(e.to_string()))
143    }
144}
145
146/// `http://127.0.0.1…`, `http://[::1]…` or `http://localhost…`: a feed mirror on
147/// the box itself (`intel.feed_mirror`), the one plain-http source fetched.
148pub fn is_loopback_http(url: &str) -> bool {
149    let Some(rest) = url.strip_prefix("http://") else {
150        return false;
151    };
152    let host = rest.split('/').next().unwrap_or("");
153    let host = host
154        .rsplit_once(':')
155        .map(|(h, p)| {
156            if p.chars().all(|c| c.is_ascii_digit()) {
157                h
158            } else {
159                host
160            }
161        })
162        .unwrap_or(host);
163    matches!(host, "127.0.0.1" | "[::1]" | "localhost")
164}
165
166#[cfg(test)]
167mod loopback_tests {
168    #[test]
169    fn only_a_loopback_host_may_be_plain_http() {
170        for ok in [
171            "http://127.0.0.1:8080/index.txt",
172            "http://localhost/kev.json",
173            "http://[::1]:9/x",
174        ] {
175            assert!(super::is_loopback_http(ok), "{ok}");
176        }
177        for no in [
178            "http://127.0.0.1.evil.example/x",
179            "http://10.0.0.1/x",
180            "https://127.0.0.1/x",
181            "http://localhost.example:80/x",
182            "ftp://localhost/x",
183        ] {
184            assert!(!super::is_loopback_http(no), "{no}");
185        }
186    }
187}