1use std::sync::atomic::{AtomicU64, Ordering};
6
7#[derive(Debug, thiserror::Error)]
9pub enum NetError {
10 #[error("network refused: intel is offline, and {url} was not fetched")]
13 Refused { url: String },
14 #[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
25pub trait Fetch: Send + Sync {
27 fn get(&self, url: &str) -> Result<Vec<u8>, NetError>;
28 fn get_capped(&self, url: &str, max: u64) -> Result<Vec<u8>, NetError> {
31 let _ = max;
32 self.get(url)
33 }
34}
35
36pub struct IntelNet {
38 inner: Option<Box<dyn Fetch>>,
39 refused: AtomicU64,
40}
41
42impl IntelNet {
43 pub fn offline() -> Self {
47 Self {
48 inner: None,
49 refused: AtomicU64::new(0),
50 }
51 }
52
53 pub fn online(fetch: Box<dyn Fetch>) -> Self {
55 Self {
56 inner: Some(fetch),
57 refused: AtomicU64::new(0),
58 }
59 }
60
61 #[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 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 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#[cfg(feature = "fetch")]
107pub struct UreqFetch {
108 agent: ureq::Agent,
109}
110
111#[cfg(feature = "fetch")]
112impl UreqFetch {
113 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
172pub 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 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 #[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}