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}
29
30pub struct IntelNet {
32 inner: Option<Box<dyn Fetch>>,
33 refused: AtomicU64,
34}
35
36impl IntelNet {
37 pub fn offline() -> Self {
41 Self {
42 inner: None,
43 refused: AtomicU64::new(0),
44 }
45 }
46
47 pub fn online(fetch: Box<dyn Fetch>) -> Self {
49 Self {
50 inner: Some(fetch),
51 refused: AtomicU64::new(0),
52 }
53 }
54
55 #[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 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#[cfg(feature = "fetch")]
86pub struct UreqFetch {
87 agent: ureq::Agent,
88}
89
90#[cfg(feature = "fetch")]
91impl UreqFetch {
92 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
146pub 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}