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    /// `get`, refusing a body past `max` bytes. A fetcher with no limit of its own
29    /// may take the default.
30    fn get_capped(&self, url: &str, max: u64) -> Result<Vec<u8>, NetError> {
31        let _ = max;
32        self.get(url)
33    }
34}
35
36/// The gate itself: offline, or online over one [`Fetch`].
37pub struct IntelNet {
38    inner: Option<Box<dyn Fetch>>,
39    refused: AtomicU64,
40}
41
42impl IntelNet {
43    /// A gate that refuses every URL. There is no fetcher behind it at all, so
44    /// a refusal is not a policy check that could be skipped: there is nothing
45    /// to call.
46    pub fn offline() -> Self {
47        Self {
48            inner: None,
49            refused: AtomicU64::new(0),
50        }
51    }
52
53    /// A gate over `fetch`.
54    pub fn online(fetch: Box<dyn Fetch>) -> Self {
55        Self {
56            inner: Some(fetch),
57            refused: AtomicU64::new(0),
58        }
59    }
60
61    /// The online gate over `ureq` (feature `fetch`).
62    #[cfg(feature = "fetch")]
63    pub fn http() -> Self {
64        Self::online(Box::new(UreqFetch::new()))
65    }
66
67    pub fn is_offline(&self) -> bool {
68        self.inner.is_none()
69    }
70
71    /// How many URLs this gate has refused.
72    pub fn refused_count(&self) -> u64 {
73        self.refused.load(Ordering::Relaxed)
74    }
75
76    pub fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
77        match &self.inner {
78            None => {
79                self.refused.fetch_add(1, Ordering::Relaxed);
80                Err(NetError::Refused {
81                    url: url.to_string(),
82                })
83            }
84            Some(f) => f.get(url),
85        }
86    }
87
88    /// **One feed, under that feed's own cap** ([`crate::FeedSpec::max_bytes`],
89    /// HOL-DOGFOOD-2): an OSV dump may be several GiB (Chainguard's passed 1 GiB in
90    /// 2026), NVD and KEV keep the 1 GiB bound.
91    pub fn get_feed(&self, spec: &crate::FeedSpec) -> Result<Vec<u8>, NetError> {
92        match &self.inner {
93            None => {
94                self.refused.fetch_add(1, Ordering::Relaxed);
95                Err(NetError::Refused {
96                    url: spec.uri.clone(),
97                })
98            }
99            Some(f) => f.get_capped(&spec.uri, spec.max_bytes()),
100        }
101    }
102}
103
104/// The online fetcher: https only, bounded time and size, no redirects, no
105/// proxy from the environment.
106#[cfg(feature = "fetch")]
107pub struct UreqFetch {
108    agent: ureq::Agent,
109}
110
111#[cfg(feature = "fetch")]
112impl UreqFetch {
113    /// The largest body [`Fetch::get`] accepts; a feed's own cap is
114    /// [`crate::FeedSpec::max_bytes`].
115    pub const MAX_BODY: u64 = 1024 * 1024 * 1024;
116
117    pub fn new() -> Self {
118        use std::time::Duration;
119        let agent = ureq::Agent::config_builder()
120            .timeout_connect(Some(Duration::from_secs(15)))
121            .timeout_recv_response(Some(Duration::from_secs(60)))
122            .timeout_recv_body(Some(Duration::from_secs(60 * 60)))
123            .max_redirects(0)
124            .http_status_as_error(false)
125            .proxy(None)
126            .build()
127            .new_agent();
128        Self { agent }
129    }
130}
131
132#[cfg(feature = "fetch")]
133impl Default for UreqFetch {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139#[cfg(feature = "fetch")]
140impl Fetch for UreqFetch {
141    fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
142        self.get_capped(url, Self::MAX_BODY)
143    }
144
145    fn get_capped(&self, url: &str, max: u64) -> Result<Vec<u8>, NetError> {
146        let unavailable = |why: String| NetError::Unavailable {
147            url: url.to_string(),
148            why,
149        };
150        if !url.starts_with("https://") && !is_loopback_http(url) {
151            return Err(unavailable(
152                "only https:// feeds are fetched (plain http on loopback only)".into(),
153            ));
154        }
155        let mut resp = self
156            .agent
157            .get(url)
158            .call()
159            .map_err(|e| unavailable(e.to_string()))?;
160        let status = resp.status().as_u16();
161        if status != 200 {
162            return Err(unavailable(format!("HTTP {status}")));
163        }
164        resp.body_mut()
165            .with_config()
166            .limit(max)
167            .read_to_vec()
168            .map_err(|e| unavailable(format!("{e} (this feed is capped at {max} bytes)")))
169    }
170}
171
172/// `http://127.0.0.1…`, `http://[::1]…` or `http://localhost…`: a feed mirror on
173/// the box itself (`intel.feed_mirror`), the one plain-http source fetched.
174pub fn is_loopback_http(url: &str) -> bool {
175    let Some(rest) = url.strip_prefix("http://") else {
176        return false;
177    };
178    let host = rest.split('/').next().unwrap_or("");
179    let host = host
180        .rsplit_once(':')
181        .map(|(h, p)| {
182            if p.chars().all(|c| c.is_ascii_digit()) {
183                h
184            } else {
185                host
186            }
187        })
188        .unwrap_or(host);
189    matches!(host, "127.0.0.1" | "[::1]" | "localhost")
190}
191
192#[cfg(test)]
193mod loopback_tests {
194    #[test]
195    fn only_a_loopback_host_may_be_plain_http() {
196        for ok in [
197            "http://127.0.0.1:8080/index.txt",
198            "http://localhost/kev.json",
199            "http://[::1]:9/x",
200        ] {
201            assert!(super::is_loopback_http(ok), "{ok}");
202        }
203        for no in [
204            "http://127.0.0.1.evil.example/x",
205            "http://10.0.0.1/x",
206            "https://127.0.0.1/x",
207            "http://localhost.example:80/x",
208            "ftp://localhost/x",
209        ] {
210            assert!(!super::is_loopback_http(no), "{no}");
211        }
212    }
213}
214
215#[cfg(test)]
216mod per_feed_cap_tests {
217    use super::*;
218    use crate::feeds::FeedSpec;
219    use std::sync::{Arc, Mutex};
220
221    /// Records the cap each GET was asked with.
222    struct Caps(Arc<Mutex<Vec<u64>>>);
223    impl Fetch for Caps {
224        fn get(&self, _url: &str) -> Result<Vec<u8>, NetError> {
225            self.get_capped(_url, u64::MAX)
226        }
227        fn get_capped(&self, _url: &str, max: u64) -> Result<Vec<u8>, NetError> {
228            self.0.lock().unwrap().push(max);
229            Ok(Vec::new())
230        }
231    }
232
233    /// HOL-DOGFOOD-2: the Chainguard OSV dump was 1 116 562 155 bytes on 2026-11-03, past the
234    /// one 1 GiB body limit, and the whole default refresh failed on it. Each feed declares
235    /// its own cap: an OSV dump has room to grow past 4 GiB; NVD and KEV keep 1 GiB.
236    #[test]
237    fn each_feed_is_fetched_under_its_own_cap() {
238        let seen = Arc::new(Mutex::new(Vec::new()));
239        let net = IntelNet::online(Box::new(Caps(Arc::clone(&seen))));
240        net.get_feed(&FeedSpec::osv("Chainguard")).unwrap();
241        net.get_feed(&FeedSpec::nvd("2024")).unwrap();
242        net.get_feed(&FeedSpec::kev()).unwrap();
243        let seen = seen.lock().unwrap().clone();
244        assert!(
245            seen[0] >= 4 * 1024 * 1024 * 1024,
246            "the OSV dump cap: {seen:?}"
247        );
248        assert_eq!(seen[1], 1024 * 1024 * 1024, "{seen:?}");
249        assert_eq!(seen[2], 1024 * 1024 * 1024, "{seen:?}");
250    }
251}