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 { inner: None, refused: AtomicU64::new(0) }
42 }
43
44 pub fn online(fetch: Box<dyn Fetch>) -> Self {
46 Self { inner: Some(fetch), refused: AtomicU64::new(0) }
47 }
48
49 #[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 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#[cfg(feature = "fetch")]
78pub struct UreqFetch {
79 agent: ureq::Agent,
80}
81
82#[cfg(feature = "fetch")]
83impl UreqFetch {
84 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
129pub 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}